diff --git a/app/lib/controller/collections_controller.dart b/app/lib/controller/collections_controller.dart index 5b6b56a2..70b49a92 100644 --- a/app/lib/controller/collections_controller.dart +++ b/app/lib/controller/collections_controller.dart @@ -10,8 +10,10 @@ import 'package:nc_photos/controller/files_controller.dart'; import 'package:nc_photos/controller/server_controller.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/adapter.dart'; import 'package:nc_photos/entity/collection/util.dart'; import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/collection_item/new_item.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/sharee.dart'; @@ -25,6 +27,7 @@ import 'package:nc_photos/use_case/collection/remove_collections.dart'; import 'package:nc_photos/use_case/collection/share_collection.dart'; import 'package:nc_photos/use_case/collection/unshare_collection.dart'; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_collection/np_collection.dart'; import 'package:np_common/or_null.dart'; import 'package:np_common/type.dart'; import 'package:rxdart/rxdart.dart'; @@ -208,7 +211,19 @@ class CollectionsController { knownItems: (item?.items.isEmpty ?? true) ? null : item!.items, ); }); - _updateCollection(c, items); + final newItems = await items?.asyncMap((e) { + if (e is NewCollectionItem) { + try { + return CollectionAdapter.of(_c, account, c).adaptToNewItem(e); + } catch (e, stackTrace) { + _log.severe("[edit] Failed to adapt new item: $e", e, stackTrace); + return Future.value(null); + } + } else { + return Future.value(e); + } + }); + _updateCollection(c, newItems?.whereNotNull().toList()); } catch (e, stackTrace) { _dataStreamController.addError(e, stackTrace); } diff --git a/app/lib/entity/album/item.dart b/app/lib/entity/album/item.dart index cbfa97c5..23d2520a 100644 --- a/app/lib/entity/album/item.dart +++ b/app/lib/entity/album/item.dart @@ -5,6 +5,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/type.dart'; +import 'package:np_gps_map/np_gps_map.dart'; import 'package:np_string/np_string.dart'; import 'package:to_string/to_string.dart'; @@ -30,6 +31,9 @@ abstract class AlbumItem with EquatableMixin { case AlbumLabelItem._type: return AlbumLabelItem.fromJson( content.cast(), addedBy, addedAt); + case AlbumMapItem._type: + return AlbumMapItem.fromJson( + content.cast(), addedBy, addedAt); default: _log.shout("[fromJson] Unknown type: $type"); throw ArgumentError.value(type, "type"); @@ -42,6 +46,8 @@ abstract class AlbumItem with EquatableMixin { return AlbumFileItem._type; } else if (this is AlbumLabelItem) { return AlbumLabelItem._type; + } else if (this is AlbumMapItem) { + return AlbumMapItem._type; } else { throw StateError("Unknwon subtype"); } @@ -181,3 +187,60 @@ class AlbumLabelItem extends AlbumItem { static const _type = "label"; } + +@toString +class AlbumMapItem extends AlbumItem { + AlbumMapItem({ + required super.addedBy, + required super.addedAt, + required this.location, + }); + + factory AlbumMapItem.fromJson( + JsonObj json, CiString addedBy, DateTime addedAt) { + return AlbumMapItem( + addedBy: addedBy, + addedAt: addedAt, + location: CameraPosition.fromJson(json["location"]), + ); + } + + @override + String toString() => _$toString(); + + @override + JsonObj toContentJson() { + return { + "location": location.toJson(), + }; + } + + @override + bool compareServerIdentity(AlbumItem other) => + other is AlbumMapItem && + location == other.location && + addedBy == other.addedBy && + addedAt == other.addedAt; + + AlbumMapItem copyWith({ + CiString? addedBy, + DateTime? addedAt, + CameraPosition? location, + }) { + return AlbumMapItem( + addedBy: addedBy ?? this.addedBy, + addedAt: addedAt ?? this.addedAt, + location: location ?? this.location, + ); + } + + @override + List get props => [ + ...super.props, + location, + ]; + + final CameraPosition location; + + static const _type = "map"; +} diff --git a/app/lib/entity/album/item.g.dart b/app/lib/entity/album/item.g.dart index 12bb28f5..5a0fa1ae 100644 --- a/app/lib/entity/album/item.g.dart +++ b/app/lib/entity/album/item.g.dart @@ -78,3 +78,10 @@ extension _$AlbumLabelItemToString on AlbumLabelItem { return "AlbumLabelItem {addedBy: $addedBy, addedAt: $addedAt, text: $text}"; } } + +extension _$AlbumMapItemToString on AlbumMapItem { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "AlbumMapItem {addedBy: $addedBy, addedAt: $addedAt, location: $location}"; + } +} diff --git a/app/lib/entity/collection.dart b/app/lib/entity/collection.dart index 2a805a30..181f3a94 100644 --- a/app/lib/entity/collection.dart +++ b/app/lib/entity/collection.dart @@ -88,6 +88,8 @@ enum CollectionCapability { rename, // text labels labelItem, + // maps + mapItem, // set the cover image manualCover, // share the collection with other user on the same server diff --git a/app/lib/entity/collection/adapter/album.dart b/app/lib/entity/collection/adapter/album.dart index a57608bd..6093a0b9 100644 --- a/app/lib/entity/collection/adapter/album.dart +++ b/app/lib/entity/collection/adapter/album.dart @@ -54,6 +54,8 @@ class CollectionAlbumAdapter implements CollectionAdapter { return CollectionFileItemAlbumAdapter(i); } else if (i is AlbumLabelItem) { return CollectionLabelItemAlbumAdapter(i); + } else if (i is AlbumMapItem) { + return CollectionMapItemAlbumAdapter(i); } else { _log.shout("[listItem] Unknown item type: ${i.runtimeType}"); throw UnimplementedError("Unknown item type: ${i.runtimeType}"); @@ -100,6 +102,12 @@ class CollectionAlbumAdapter implements CollectionAdapter { addedAt: e.createdAt, text: e.text, ); + } else if (e is NewCollectionMapItem) { + return AlbumMapItem( + addedBy: account.userId, + addedAt: e.createdAt, + location: e.location, + ); } else { _log.severe("[edit] Unsupported type: ${e.runtimeType}"); return null; @@ -248,6 +256,16 @@ class CollectionAlbumAdapter implements CollectionAdapter { .reversed .firstWhere((e) => e.text == original.text); return CollectionLabelItemAlbumAdapter(item); + } else if (original is NewCollectionMapItem) { + final item = AlbumStaticProvider.of(_provider.album) + .items + .whereType() + .sorted((a, b) => a.addedAt.compareTo(b.addedAt)) + .reversed + .firstWhere((e) => + e.location == original.location && + e.addedAt == original.createdAt); + return CollectionMapItemAlbumAdapter(item); } else { throw UnsupportedError("Unsupported type: ${original.runtimeType}"); } diff --git a/app/lib/entity/collection/adapter/nc_album.dart b/app/lib/entity/collection/adapter/nc_album.dart index facceb00..f2600c9d 100644 --- a/app/lib/entity/collection/adapter/nc_album.dart +++ b/app/lib/entity/collection/adapter/nc_album.dart @@ -14,7 +14,6 @@ import 'package:nc_photos/entity/collection_item/new_item.dart'; import 'package:nc_photos/entity/collection_item/util.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/nc_album.dart'; -import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/use_case/find_file_descriptor.dart'; import 'package:nc_photos/use_case/nc_album/add_file_to_nc_album.dart'; import 'package:nc_photos/use_case/nc_album/edit_nc_album.dart'; @@ -98,16 +97,16 @@ class CollectionNcAlbumAdapter _log.warning( "[edit] Nextcloud album does not support editing item or sort"); } - final newItems = items?.run((items) => items - .map((e) => e is CollectionFileItem ? e.file : null) - .whereNotNull() - .toList()); + // final newItems = items?.run((items) => items + // .map((e) => e is CollectionFileItem ? e.file : null) + // .whereNotNull() + // .toList()); final newAlbum = await EditNcAlbum(_c)( account, _provider.album, name: name, - items: newItems, - itemSort: itemSort, + // items: newItems, + // itemSort: itemSort, ); return collection.copyWith( name: name, diff --git a/app/lib/entity/collection/content_provider/album.dart b/app/lib/entity/collection/content_provider/album.dart index 9a077dbb..d45f9e99 100644 --- a/app/lib/entity/collection/content_provider/album.dart +++ b/app/lib/entity/collection/content_provider/album.dart @@ -56,6 +56,7 @@ class CollectionAlbumProvider CollectionCapability.manualItem, CollectionCapability.manualSort, CollectionCapability.labelItem, + CollectionCapability.mapItem, CollectionCapability.share, ], ]; @@ -66,6 +67,7 @@ class CollectionAlbumProvider if (album.provider is AlbumStaticProvider) ...[ CollectionCapability.manualItem, CollectionCapability.labelItem, + CollectionCapability.mapItem, ], ]; diff --git a/app/lib/entity/collection/exporter.dart b/app/lib/entity/collection/exporter.dart index 899669dc..729cab13 100644 --- a/app/lib/entity/collection/exporter.dart +++ b/app/lib/entity/collection/exporter.dart @@ -59,6 +59,12 @@ class CollectionExporter { addedAt: clock.now().toUtc(), text: e.text, ); + } else if (e is CollectionMapItem) { + return AlbumMapItem( + addedBy: account.userId, + addedAt: clock.now().toUtc(), + location: e.location, + ); } else { return null; } diff --git a/app/lib/entity/collection_item.dart b/app/lib/entity/collection_item.dart index d8b5e5a2..da54e15e 100644 --- a/app/lib/entity/collection_item.dart +++ b/app/lib/entity/collection_item.dart @@ -1,4 +1,5 @@ import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:np_gps_map/np_gps_map.dart'; /// An item in a [Collection] abstract class CollectionItem { @@ -24,3 +25,13 @@ abstract class CollectionLabelItem implements CollectionItem { Object get id; String get text; } + +abstract class CollectionMapItem implements CollectionItem { + const CollectionMapItem(); + + /// An object used to identify this instance + /// + /// [id] should be unique and stable + Object get id; + CameraPosition get location; +} diff --git a/app/lib/entity/collection_item/album_item_adapter.dart b/app/lib/entity/collection_item/album_item_adapter.dart index 59fd77be..6e979759 100644 --- a/app/lib/entity/collection_item/album_item_adapter.dart +++ b/app/lib/entity/collection_item/album_item_adapter.dart @@ -1,6 +1,7 @@ import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:np_gps_map/np_gps_map.dart'; import 'package:to_string/to_string.dart'; part 'album_item_adapter.g.dart'; @@ -11,6 +12,8 @@ mixin AlbumAdaptedCollectionItem on CollectionItem { return CollectionFileItemAlbumAdapter(item); } else if (item is AlbumLabelItem) { return CollectionLabelItemAlbumAdapter(item); + } else if (item is AlbumMapItem) { + return CollectionMapItemAlbumAdapter(item); } else { throw ArgumentError("Unknown type: ${item.runtimeType}"); } @@ -64,3 +67,23 @@ class CollectionLabelItemAlbumAdapter extends CollectionLabelItem final AlbumLabelItem item; } + +@toString +class CollectionMapItemAlbumAdapter extends CollectionMapItem + with AlbumAdaptedCollectionItem { + const CollectionMapItemAlbumAdapter(this.item); + + @override + String toString() => _$toString(); + + @override + Object get id => item.addedAt; + + @override + CameraPosition get location => item.location; + + @override + AlbumItem get albumItem => item; + + final AlbumMapItem item; +} diff --git a/app/lib/entity/collection_item/album_item_adapter.g.dart b/app/lib/entity/collection_item/album_item_adapter.g.dart index 18769b88..0c7e47cf 100644 --- a/app/lib/entity/collection_item/album_item_adapter.g.dart +++ b/app/lib/entity/collection_item/album_item_adapter.g.dart @@ -21,3 +21,11 @@ extension _$CollectionLabelItemAlbumAdapterToString return "CollectionLabelItemAlbumAdapter {item: $item}"; } } + +extension _$CollectionMapItemAlbumAdapterToString + on CollectionMapItemAlbumAdapter { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "CollectionMapItemAlbumAdapter {item: $item}"; + } +} diff --git a/app/lib/entity/collection_item/new_item.dart b/app/lib/entity/collection_item/new_item.dart index 9d75fd05..5ffbe456 100644 --- a/app/lib/entity/collection_item/new_item.dart +++ b/app/lib/entity/collection_item/new_item.dart @@ -1,5 +1,6 @@ import 'package:nc_photos/entity/collection_item.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:np_gps_map/np_gps_map.dart'; import 'package:to_string/to_string.dart'; part 'new_item.g.dart'; @@ -47,3 +48,23 @@ class NewCollectionLabelItem implements CollectionLabelItem, NewCollectionItem { final DateTime createdAt; } + +/// A new [CollectionMapItem] +/// +/// This class is for marking an intermediate item that has recently been added +/// but not necessarily persisted yet to the provider of this collection +@toString +class NewCollectionMapItem implements CollectionMapItem, NewCollectionItem { + const NewCollectionMapItem(this.location, this.createdAt); + + @override + String toString() => _$toString(); + + @override + Object get id => createdAt; + + @override + final CameraPosition location; + + final DateTime createdAt; +} diff --git a/app/lib/entity/collection_item/new_item.g.dart b/app/lib/entity/collection_item/new_item.g.dart index b22ab8c7..e33518a3 100644 --- a/app/lib/entity/collection_item/new_item.g.dart +++ b/app/lib/entity/collection_item/new_item.g.dart @@ -19,3 +19,10 @@ extension _$NewCollectionLabelItemToString on NewCollectionLabelItem { return "NewCollectionLabelItem {text: $text, createdAt: $createdAt}"; } } + +extension _$NewCollectionMapItemToString on NewCollectionMapItem { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "NewCollectionMapItem {location: $location, createdAt: $createdAt}"; + } +} diff --git a/app/lib/gps_map_util.dart b/app/lib/gps_map_util.dart index 467679f2..3121343d 100644 --- a/app/lib/gps_map_util.dart +++ b/app/lib/gps_map_util.dart @@ -1,4 +1,6 @@ +import 'package:android_intent_plus/android_intent.dart'; import 'package:np_gps_map/np_gps_map.dart'; +import 'package:np_platform_util/np_platform_util.dart'; extension GpsMapProviderExtension on GpsMapProvider { String toUserString() { @@ -10,3 +12,14 @@ extension GpsMapProviderExtension on GpsMapProvider { } } } + +void launchExternalMap(CameraPosition location) { + if (getRawPlatform() == NpPlatform.android) { + final intent = AndroidIntent( + action: "action_view", + data: Uri.encodeFull( + "geo:${location.center.latitude},${location.center.longitude}?z=${location.zoom}"), + ); + intent.launch(); + } +} diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index bfd3e57d..0f703d68 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1521,6 +1521,11 @@ "@customizeButtonsUnsupportedWarning": { "description": "Some button can't be removed. This message will be shown instead when user try to do so" }, + "placePickerTitle": "Pick a place", + "albumAddMapTooltip": "Add map", + "@albumAddMapTooltip": { + "description": "Add a map that display between photos to an album" + }, "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 04bc7157..262fcee0 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -268,6 +268,8 @@ "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -286,7 +288,9 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", - "customizeButtonsUnsupportedWarning" + "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip" ], "de": [ @@ -297,7 +301,9 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", - "customizeButtonsUnsupportedWarning" + "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip" ], "el": [ @@ -453,7 +459,9 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", - "customizeButtonsUnsupportedWarning" + "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip" ], "es": [ @@ -464,7 +472,9 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", - "customizeButtonsUnsupportedWarning" + "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip" ], "fi": [ @@ -511,7 +521,9 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", - "customizeButtonsUnsupportedWarning" + "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip" ], "fr": [ @@ -558,7 +570,9 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", - "customizeButtonsUnsupportedWarning" + "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip" ], "it": [ @@ -610,7 +624,9 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", - "customizeButtonsUnsupportedWarning" + "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip" ], "nl": [ @@ -999,6 +1015,8 @@ "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip", "errorUnauthenticated", "errorDisconnected", "errorLocked", @@ -1057,7 +1075,9 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", - "customizeButtonsUnsupportedWarning" + "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip" ], "pt": [ @@ -1124,7 +1144,9 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", - "customizeButtonsUnsupportedWarning" + "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip" ], "ru": [ @@ -1171,7 +1193,9 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", - "customizeButtonsUnsupportedWarning" + "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip" ], "tr": [ @@ -1182,7 +1206,9 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", - "customizeButtonsUnsupportedWarning" + "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip" ], "zh": [ @@ -1260,7 +1286,9 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", - "customizeButtonsUnsupportedWarning" + "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip" ], "zh_Hant": [ @@ -1432,6 +1460,8 @@ "livePhotoTooltip", "dragAndDropRearrangeButtons", "customizeCollectionsNavBarDescription", - "customizeButtonsUnsupportedWarning" + "customizeButtonsUnsupportedWarning", + "placePickerTitle", + "albumAddMapTooltip" ] } diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index 16b5c6a4..7f5b1073 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -36,11 +36,13 @@ import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/pref.dart'; import 'package:nc_photos/exception_event.dart'; import 'package:nc_photos/flutter_util.dart' as flutter_util; +import 'package:nc_photos/gps_map_util.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/np_api_util.dart'; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/session_storage.dart'; import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/stream_util.dart'; import 'package:nc_photos/widget/album_share_outlier_browser.dart'; import 'package:nc_photos/widget/app_intermediate_circular_progress_indicator.dart'; import 'package:nc_photos/widget/collection_picker.dart'; @@ -52,6 +54,7 @@ import 'package:nc_photos/widget/network_thumbnail.dart'; import 'package:nc_photos/widget/page_visibility_mixin.dart'; import 'package:nc_photos/widget/photo_list_item.dart'; import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util; +import 'package:nc_photos/widget/place_picker/place_picker.dart'; import 'package:nc_photos/widget/selectable_item_list.dart'; import 'package:nc_photos/widget/selection_app_bar.dart'; import 'package:nc_photos/widget/share_collection_dialog.dart'; @@ -62,12 +65,14 @@ import 'package:nc_photos/widget/viewer.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/or_null.dart'; import 'package:np_datetime/np_datetime.dart'; +import 'package:np_gps_map/np_gps_map.dart'; import 'package:np_ui/np_ui.dart'; import 'package:to_string/to_string.dart'; part 'collection_browser.g.dart'; part 'collection_browser/app_bar.dart'; part 'collection_browser/bloc.dart'; +part 'collection_browser/item_view.dart'; part 'collection_browser/state_event.dart'; part 'collection_browser/type.dart'; part 'collection_browser/view.dart'; @@ -412,7 +417,7 @@ class _WrappedCollectionBrowserState extends State<_WrappedCollectionBrowser> typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; typedef _BlocListener = BlocListener<_Bloc, _State>; typedef _BlocListenerT = BlocListenerT<_Bloc, _State, T>; -// typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; +typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; extension on BuildContext { _Bloc get bloc => read<_Bloc>(); diff --git a/app/lib/widget/collection_browser.g.dart b/app/lib/widget/collection_browser.g.dart index eeacd3b0..afe44c02 100644 --- a/app/lib/widget/collection_browser.g.dart +++ b/app/lib/widget/collection_browser.g.dart @@ -219,6 +219,13 @@ extension _$_AddLabelToCollectionToString on _AddLabelToCollection { } } +extension _$_AddMapToCollectionToString on _AddMapToCollection { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_AddMapToCollection {location: $location}"; + } +} + extension _$_EditSortToString on _EditSort { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/app/lib/widget/collection_browser/app_bar.dart b/app/lib/widget/collection_browser/app_bar.dart index 9fe15dc7..85802dbd 100644 --- a/app/lib/widget/collection_browser/app_bar.dart +++ b/app/lib/widget/collection_browser/app_bar.dart @@ -364,6 +364,12 @@ class _EditAppBar extends StatelessWidget { tooltip: L10n.global().albumAddTextTooltip, onPressed: () => _onAddTextPressed(context), ), + if (capabilitiesAdapter.isPermitted(CollectionCapability.mapItem)) + IconButton( + icon: const Icon(Icons.map_outlined), + tooltip: L10n.global().albumAddMapTooltip, + onPressed: () => _onAddMapPressed(context), + ), if (capabilitiesAdapter.isPermitted(CollectionCapability.sort)) IconButton( icon: const Icon(Icons.sort_by_alpha_outlined), @@ -387,6 +393,15 @@ class _EditAppBar extends StatelessWidget { context.read<_Bloc>().add(_AddLabelToCollection(result)); } + Future _onAddMapPressed(BuildContext context) async { + final result = await Navigator.of(context) + .pushNamed(PlacePicker.routeName); + if (result == null) { + return; + } + context.read<_Bloc>().add(_AddMapToCollection(result)); + } + Future _onSortPressed(BuildContext context) async { final current = context .read<_Bloc>() diff --git a/app/lib/widget/collection_browser/bloc.dart b/app/lib/widget/collection_browser/bloc.dart index 0d963912..3ed4a0e8 100644 --- a/app/lib/widget/collection_browser/bloc.dart +++ b/app/lib/widget/collection_browser/bloc.dart @@ -30,6 +30,7 @@ class _Bloc extends Bloc<_Event, _State> on<_BeginEdit>(_onBeginEdit); on<_EditName>(_onEditName); on<_AddLabelToCollection>(_onAddLabelToCollection); + on<_AddMapToCollection>(_onAddMapToCollection); on<_EditSort>(_onEditSort); on<_EditManualSort>(_onEditManualSort); on<_TransformEditItems>(_onTransformEditItems); @@ -210,6 +211,17 @@ class _Bloc extends Bloc<_Event, _State> )); } + void _onAddMapToCollection(_AddMapToCollection ev, Emitter<_State> emit) { + _log.info(ev); + assert(isCollectionCapabilityPermitted(CollectionCapability.mapItem)); + emit(state.copyWith( + editItems: [ + NewCollectionMapItem(ev.location, clock.now().toUtc()), + ...state.editItems ?? state.items, + ], + )); + } + void _onEditSort(_EditSort ev, Emitter<_State> emit) { _log.info(ev); final result = _transformItems(state.editItems ?? state.items, ev.sort); @@ -497,22 +509,23 @@ class _Bloc extends Bloc<_Event, _State> "[_transformItems] Unsupported file format: ${item.file.fdMime}"); } } else if (item is CollectionLabelItem) { - if (state.isEditMode) { - transformed.add(_EditLabelListItem( - original: item, - id: item.id, - text: item.text, - onEditPressed: () { - // TODO - }, - )); - } else { - transformed.add(_LabelItem( - original: item, - id: item.id, - text: item.text, - )); - } + transformed.add(_LabelItem( + original: item, + id: item.id, + text: item.text, + onEditPressed: () { + // TODO + }, + )); + } else if (item is CollectionMapItem) { + transformed.add(_MapItem( + original: item, + id: item.id, + location: item.location, + onEditPressed: () { + // TODO + }, + )); } } return _TransformResult( diff --git a/app/lib/widget/collection_browser/item_view.dart b/app/lib/widget/collection_browser/item_view.dart new file mode 100644 index 00000000..95b69c1d --- /dev/null +++ b/app/lib/widget/collection_browser/item_view.dart @@ -0,0 +1,87 @@ +part of '../collection_browser.dart'; + +class _LabelView extends StatelessWidget { + const _LabelView({ + required this.text, + }); + + @override + Widget build(BuildContext context) { + return PhotoListLabel(text: text); + } + + final String text; +} + +class _EditLabelView extends StatelessWidget { + const _EditLabelView({ + required this.text, + required this.onEditPressed, + }); + + @override + Widget build(BuildContext context) { + return PhotoListLabelEdit( + text: text, + onEditPressed: onEditPressed, + ); + } + + final String text; + final VoidCallback? onEditPressed; +} + +class _MapView extends StatelessWidget { + const _MapView({ + required this.location, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ValueStreamBuilderEx( + stream: context.read().gpsMapProvider, + builder: StreamWidgetBuilder.value( + (context, gpsMapProvider) => StaticMap( + key: Key(location.toString()), + providerHint: gpsMapProvider, + location: location, + onTap: onTap, + ), + ), + ); + } + + final CameraPosition location; + final VoidCallback? onTap; +} + +class _EditMapView extends StatelessWidget { + const _EditMapView({ + required this.location, + required this.onEditPressed, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + AbsorbPointer( + absorbing: true, + child: _MapView(location: location), + ), + Positioned( + top: 8, + right: 8, + child: FloatingActionButton.small( + onPressed: onEditPressed, + child: const Icon(Icons.edit_outlined), + ), + ), + ], + ); + } + + final CameraPosition location; + final VoidCallback? onEditPressed; +} diff --git a/app/lib/widget/collection_browser/state_event.dart b/app/lib/widget/collection_browser/state_event.dart index 32e97dd1..cca6ee1a 100644 --- a/app/lib/widget/collection_browser/state_event.dart +++ b/app/lib/widget/collection_browser/state_event.dart @@ -174,6 +174,16 @@ class _AddLabelToCollection implements _Event { final String label; } +@toString +class _AddMapToCollection implements _Event { + const _AddMapToCollection(this.location); + + @override + String toString() => _$toString(); + + final CameraPosition location; +} + @toString class _EditSort implements _Event { const _EditSort(this.sort); diff --git a/app/lib/widget/collection_browser/type.dart b/app/lib/widget/collection_browser/type.dart index 7e7d70a0..84427acf 100644 --- a/app/lib/widget/collection_browser/type.dart +++ b/app/lib/widget/collection_browser/type.dart @@ -8,6 +8,7 @@ abstract class _Item implements SelectableItemMetadata, DraggableItemMetadata { Widget buildWidget(BuildContext context); Widget? buildDragFeedbackWidget(BuildContext context) => null; + Size? dragFeedbackWidgetSize() => null; } /// Items backed by an actual [CollectionItem] @@ -101,8 +102,12 @@ class _LabelItem extends _ActualItem { required super.original, required this.id, required this.text, + required this.onEditPressed, }); + @override + bool get isDraggable => true; + @override bool operator ==(Object other) => identical(this, other) || (other is _LabelItem && id == other.id); @@ -115,39 +120,80 @@ class _LabelItem extends _ActualItem { @override Widget buildWidget(BuildContext context) { - return PhotoListLabel( - text: text, + return _BlocSelector( + selector: (state) => state.isEditMode, + builder: (context, isEditMode) => isEditMode + ? _EditLabelView( + text: text, + onEditPressed: onEditPressed, + ) + : _LabelView(text: text), ); } + @override + Widget? buildDragFeedbackWidget(BuildContext context) { + return _LabelView(text: text); + } + final Object id; final String text; + final VoidCallback? onEditPressed; } -class _EditLabelListItem extends _LabelItem { - const _EditLabelListItem({ +class _MapItem extends _ActualItem { + const _MapItem({ required super.original, - required super.id, - required super.text, + required this.id, + required this.location, required this.onEditPressed, }); @override bool get isDraggable => true; + @override + bool operator ==(Object other) => + identical(this, other) || (other is _MapItem && id == other.id); + + @override + int get hashCode => id.hashCode; + + @override + StaggeredTile get staggeredTile => const StaggeredTile.extent(99, 256); + @override Widget buildWidget(BuildContext context) { - return PhotoListLabelEdit( - text: text, - onEditPressed: onEditPressed, + return _BlocSelector( + selector: (state) => state.isEditMode, + builder: (context, isEditMode) => isEditMode + ? _EditMapView( + location: location, + onEditPressed: onEditPressed, + ) + : _MapView( + location: location, + onTap: () { + launchExternalMap(location); + }, + ), ); } @override Widget? buildDragFeedbackWidget(BuildContext context) { - return super.buildWidget(context); + return Icon( + Icons.place, + color: Theme.of(context).colorScheme.primary, + size: 48, + ); } + @override + Size? dragFeedbackWidgetSize() => const Size.square(48); + + final Object id; + final CameraPosition location; final VoidCallback? onEditPressed; } diff --git a/app/lib/widget/collection_browser/view.dart b/app/lib/widget/collection_browser/view.dart index 1aad5f01..f38b2f87 100644 --- a/app/lib/widget/collection_browser/view.dart +++ b/app/lib/widget/collection_browser/view.dart @@ -120,6 +120,8 @@ class _EditContentList extends StatelessWidget { itemDragFeedbackBuilder: (context, _, item) => item.buildDragFeedbackWidget(context) ?? item.buildWidget(context), + itemDragFeedbackSize: (_, item) => + item.dragFeedbackWidgetSize(), staggeredTileBuilder: (_, item) => item.staggeredTile, onDragResult: (results) { context.addEvent(_EditManualSort(results)); diff --git a/app/lib/widget/draggable_item_list.dart b/app/lib/widget/draggable_item_list.dart index 2f604ad7..6b6d6676 100644 --- a/app/lib/widget/draggable_item_list.dart +++ b/app/lib/widget/draggable_item_list.dart @@ -24,6 +24,7 @@ class DraggableItemList required this.maxCrossAxisExtent, required this.itemBuilder, required this.itemDragFeedbackBuilder, + this.itemDragFeedbackSize, required this.staggeredTileBuilder, this.onDragResult, this.onDraggingChanged, @@ -38,6 +39,7 @@ class DraggableItemList itemBuilder; final Widget? Function(BuildContext context, int index, T metadata)? itemDragFeedbackBuilder; + final Size? Function(int index, T metadata)? itemDragFeedbackSize; final StaggeredTile? Function(int index, T metadata) staggeredTileBuilder; /// Called when an item is dropped to a new place @@ -63,9 +65,9 @@ class _DraggableItemListState if (meta.isDraggable) { return my.Draggable<_DraggableData>( data: _DraggableData(i, meta), - feedback: SizedBox( - width: widget.maxCrossAxisExtent * .65, - height: widget.maxCrossAxisExtent * .65, + feedback: SizedBox.fromSize( + size: widget.itemDragFeedbackSize?.call(i, meta) ?? + Size.square(widget.maxCrossAxisExtent * .65), child: widget.itemDragFeedbackBuilder?.call(context, i, meta), ), onDropBefore: (data) => _onMoved(data.index, i, true), diff --git a/app/lib/widget/my_app.dart b/app/lib/widget/my_app.dart index 76b21bd2..fb7a920f 100644 --- a/app/lib/widget/my_app.dart +++ b/app/lib/widget/my_app.dart @@ -38,6 +38,7 @@ import 'package:nc_photos/widget/image_enhancer.dart'; import 'package:nc_photos/widget/local_file_viewer.dart'; import 'package:nc_photos/widget/map_browser.dart'; import 'package:nc_photos/widget/people_browser.dart'; +import 'package:nc_photos/widget/place_picker/place_picker.dart'; import 'package:nc_photos/widget/places_browser.dart'; import 'package:nc_photos/widget/result_viewer.dart'; import 'package:nc_photos/widget/root_picker.dart'; @@ -213,6 +214,7 @@ class _WrappedAppState extends State<_WrappedApp> ArchiveBrowser.routeName: ArchiveBrowser.buildRoute, TrustedCertManager.routeName: TrustedCertManager.buildRoute, MapBrowser.routeName: MapBrowser.buildRoute, + PlacePicker.routeName: PlacePicker.buildRoute, }; Route? _onGenerateRoute(RouteSettings settings) { diff --git a/app/lib/widget/place_picker/bloc.dart b/app/lib/widget/place_picker/bloc.dart new file mode 100644 index 00000000..74ae7c87 --- /dev/null +++ b/app/lib/widget/place_picker/bloc.dart @@ -0,0 +1,21 @@ +part of 'place_picker.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> with BlocLogger { + _Bloc() : super(_State.init()) { + on<_SetPosition>(_onSetPosition); + } + + @override + String get tag => _log.fullName; + + @override + bool Function(dynamic, dynamic)? get shouldLog => (currentState, nextState) { + return currentState.position == nextState.position; + }; + + void _onSetPosition(_SetPosition ev, _Emitter emit) { + // _log.info(ev); + emit(state.copyWith(position: ev.value)); + } +} diff --git a/app/lib/widget/place_picker/place_picker.dart b/app/lib/widget/place_picker/place_picker.dart new file mode 100644 index 00000000..d950c2b6 --- /dev/null +++ b/app/lib/widget/place_picker/place_picker.dart @@ -0,0 +1,93 @@ +import 'package:copy_with/copy_with.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc_util.dart'; +import 'package:nc_photos/controller/pref_controller.dart'; +import 'package:nc_photos/stream_util.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_gps_map/np_gps_map.dart'; +import 'package:to_string/to_string.dart'; + +part 'bloc.dart'; +part 'place_picker.g.dart'; +part 'state_event.dart'; + +class PlacePicker extends StatelessWidget { + static const routeName = "/place-picker"; + + static Route buildRoute(RouteSettings settings) => + MaterialPageRoute( + builder: (_) => const PlacePicker(), + settings: settings, + ); + + const PlacePicker({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => _Bloc(), + child: const _WrappedPlacePicker(), + ); + } +} + +@npLog +class _WrappedPlacePicker extends StatelessWidget { + const _WrappedPlacePicker(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(L10n.global().placePickerTitle), + leading: IconButton( + onPressed: () { + final position = context.state.position; + _log.info("[build] Position picked: $position"); + Navigator.of(context).pop(position); + }, + icon: const Icon(Icons.check_outlined), + ), + ), + body: const _BodyView(), + ); + } +} + +class _BodyView extends StatelessWidget { + const _BodyView(); + + @override + Widget build(BuildContext context) { + final prevPosition = + context.read().mapBrowserPrevPositionValue; + return ValueStreamBuilderEx( + stream: context.read().gpsMapProvider, + builder: StreamWidgetBuilder.value( + (context, gpsMapProvider) => PlacePickerView( + providerHint: gpsMapProvider, + initialPosition: prevPosition ?? const MapCoord(0, 0), + initialZoom: prevPosition == null ? 2.5 : 10, + onCameraMove: (position) { + context.addEvent(_SetPosition(position)); + }, + ), + ), + ); + } +} + +// typedef _BlocBuilder = BlocBuilder<_Bloc, _State>; +// typedef _BlocListener = BlocListener<_Bloc, _State>; +// typedef _BlocListenerT = BlocListenerT<_Bloc, _State, T>; +// typedef _BlocSelector = BlocSelector<_Bloc, _State, T>; +typedef _Emitter = Emitter<_State>; + +extension on BuildContext { + _Bloc get bloc => read<_Bloc>(); + _State get state => bloc.state; + void addEvent(_Event event) => bloc.add(event); +} diff --git a/app/lib/widget/place_picker/place_picker.g.dart b/app/lib/widget/place_picker/place_picker.g.dart new file mode 100644 index 00000000..f8d17d3d --- /dev/null +++ b/app/lib/widget/place_picker/place_picker.g.dart @@ -0,0 +1,73 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'place_picker.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_StateCopyWithWorker { + _State call({CameraPosition? position}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call({dynamic position = copyWithNull}) { + return _State( + position: position == copyWithNull + ? that.position + : position as CameraPosition?); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_WrappedPlacePickerNpLog on _WrappedPlacePicker { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("widget.place_picker.place_picker._WrappedPlacePicker"); +} + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.place_picker.place_picker._Bloc"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_StateToString on _State { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_State {position: $position}"; + } +} + +extension _$_SetPositionToString on _SetPosition { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SetPosition {value: $value}"; + } +} diff --git a/app/lib/widget/place_picker/state_event.dart b/app/lib/widget/place_picker/state_event.dart new file mode 100644 index 00000000..ea405ac4 --- /dev/null +++ b/app/lib/widget/place_picker/state_event.dart @@ -0,0 +1,28 @@ +part of 'place_picker.dart'; + +@genCopyWith +@toString +class _State { + const _State({ + this.position, + }); + + factory _State.init() => const _State(); + + @override + String toString() => _$toString(); + + final CameraPosition? position; +} + +abstract class _Event {} + +@toString +class _SetPosition implements _Event { + const _SetPosition(this.value); + + @override + String toString() => _$toString(); + + final CameraPosition value; +} diff --git a/app/lib/widget/viewer_detail_pane.dart b/app/lib/widget/viewer_detail_pane.dart index 4b509065..1aa4e5b1 100644 --- a/app/lib/widget/viewer_detail_pane.dart +++ b/app/lib/widget/viewer_detail_pane.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:android_intent_plus/android_intent.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; @@ -19,6 +18,7 @@ import 'package:nc_photos/entity/exif_extension.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/gps_map_util.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/platform/features.dart' as features; @@ -355,11 +355,13 @@ class _ViewerDetailPaneState extends State { height: 256, child: ValueStreamBuilder( stream: context.read().gpsMapProvider, - builder: (context, gpsMapProvider) => GpsMap( + builder: (context, gpsMapProvider) => StaticMap( providerHint: gpsMapProvider.requireData, - center: _gps!, - zoom: 16, - onTap: _onMapTap, + location: CameraPosition(center: _gps!, zoom: 16), + onTap: () => launchExternalMap(CameraPosition( + center: _gps!, + zoom: 16, + )), ), ), ), @@ -493,16 +495,6 @@ class _ViewerDetailPaneState extends State { SetAsHandler(c, context: context).setAsFile(widget.account, _file!); } - void _onMapTap() { - if (getRawPlatform() == NpPlatform.android) { - final intent = AndroidIntent( - action: "action_view", - data: Uri.encodeFull("geo:${_gps!.latitude},${_gps!.longitude}?z=16"), - ); - intent.launch(); - } - } - void _onDateTimeTap(BuildContext context) { assert(_file != null); showDialog( diff --git a/np_gps_map/lib/np_gps_map.dart b/np_gps_map/lib/np_gps_map.dart index 11e5edf1..979ba137 100644 --- a/np_gps_map/lib/np_gps_map.dart +++ b/np_gps_map/lib/np_gps_map.dart @@ -1,6 +1,7 @@ library np_gps_map; -export 'src/gps_map.dart'; export 'src/interactive_map.dart'; -export 'src/map_coord.dart'; +export 'src/place_picker.dart'; +export 'src/static_map.dart'; +export 'src/type.dart'; export 'src/util.dart' show initGpsMap; diff --git a/np_gps_map/lib/src/interactive_map.dart b/np_gps_map/lib/src/interactive_map.dart index d44ed04e..c3fdc027 100644 --- a/np_gps_map/lib/src/interactive_map.dart +++ b/np_gps_map/lib/src/interactive_map.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:np_gps_map/src/gps_map.dart'; import 'package:np_gps_map/src/interactive_map/google.dart'; import 'package:np_gps_map/src/interactive_map/osm.dart'; -import 'package:np_gps_map/src/map_coord.dart'; +import 'package:np_gps_map/src/type.dart'; import 'package:np_gps_map/src/util.dart'; import 'package:np_platform_util/np_platform_util.dart'; @@ -31,6 +30,7 @@ class InteractiveMap extends StatelessWidget { this.googleClusterBuilder, this.contentPadding, this.onMapCreated, + this.onCameraMove, }); @override @@ -45,6 +45,7 @@ class InteractiveMap extends StatelessWidget { clusterBuilder: osmClusterBuilder, contentPadding: contentPadding, onMapCreated: onMapCreated, + onCameraMove: onCameraMove, ); } else { return GoogleInteractiveMap( @@ -55,6 +56,7 @@ class InteractiveMap extends StatelessWidget { clusterBuilder: googleClusterBuilder, contentPadding: contentPadding, onMapCreated: onMapCreated, + onCameraMove: onCameraMove, ); } } @@ -70,4 +72,5 @@ class InteractiveMap extends StatelessWidget { final OsmClusterBuilder? osmClusterBuilder; final EdgeInsets? contentPadding; final void Function(InteractiveMapController controller)? onMapCreated; + final void Function(CameraPosition position)? onCameraMove; } diff --git a/np_gps_map/lib/src/interactive_map/google.dart b/np_gps_map/lib/src/interactive_map/google.dart index 9da9b182..97d8d54a 100644 --- a/np_gps_map/lib/src/interactive_map/google.dart +++ b/np_gps_map/lib/src/interactive_map/google.dart @@ -5,7 +5,7 @@ import 'package:google_maps_cluster_manager/google_maps_cluster_manager.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:np_common/object_util.dart'; import 'package:np_gps_map/src/interactive_map.dart'; -import 'package:np_gps_map/src/map_coord.dart'; +import 'package:np_gps_map/src/type.dart' as type; typedef GoogleClusterBuilder = FutureOr Function( BuildContext context, List dataPoints); @@ -20,18 +20,20 @@ class GoogleInteractiveMap extends StatefulWidget { this.onClusterTap, this.contentPadding, this.onMapCreated, + this.onCameraMove, }); @override State createState() => _GoogleInteractiveMapState(); - final MapCoord? initialPosition; + final type.MapCoord? initialPosition; final double? initialZoom; final List? dataPoints; final GoogleClusterBuilder? clusterBuilder; final void Function(List dataPoints)? onClusterTap; final EdgeInsets? contentPadding; final void Function(InteractiveMapController controller)? onMapCreated; + final void Function(type.CameraPosition position)? onCameraMove; } class _GoogleInteractiveMapState extends State { @@ -57,7 +59,14 @@ class _GoogleInteractiveMapState extends State { const CameraPosition(target: LatLng(0, 0)), markers: _markers, onMapCreated: _onMapCreated, - onCameraMove: _clusterManager.onCameraMove, + onCameraMove: (position) { + _clusterManager.onCameraMove(position); + widget.onCameraMove?.call(type.CameraPosition( + center: position.target.toMapCoord(), + zoom: position.zoom, + rotation: position.bearing, + )); + }, onCameraIdle: _clusterManager.updateMap, padding: widget.contentPadding ?? EdgeInsets.zero, ); @@ -120,7 +129,7 @@ class _ParentController implements InteractiveMapController { const _ParentController(this.controller); @override - void setPosition(MapCoord position) { + void setPosition(type.MapCoord position) { controller .animateCamera(CameraUpdate.newLatLngZoom(position.toLatLng(), 10)); } @@ -128,7 +137,7 @@ class _ParentController implements InteractiveMapController { final GoogleMapController controller; } -extension on MapCoord { +extension on type.MapCoord { LatLng toLatLng() => LatLng(latitude, longitude); } diff --git a/np_gps_map/lib/src/interactive_map/osm.dart b/np_gps_map/lib/src/interactive_map/osm.dart index 8f5215b2..2bc31c21 100644 --- a/np_gps_map/lib/src/interactive_map/osm.dart +++ b/np_gps_map/lib/src/interactive_map/osm.dart @@ -7,7 +7,7 @@ import 'package:flutter_map_marker_cluster/flutter_map_marker_cluster.dart'; import 'package:latlong2/latlong.dart'; import 'package:np_common/object_util.dart'; import 'package:np_gps_map/src/interactive_map.dart'; -import 'package:np_gps_map/src/map_coord.dart'; +import 'package:np_gps_map/src/type.dart'; import 'package:rxdart/rxdart.dart'; typedef OsmClusterBuilder = Widget Function( @@ -23,6 +23,7 @@ class OsmInteractiveMap extends StatefulWidget { this.onClusterTap, this.contentPadding, this.onMapCreated, + this.onCameraMove, }); @override @@ -35,6 +36,7 @@ class OsmInteractiveMap extends StatefulWidget { final void Function(List dataPoints)? onClusterTap; final EdgeInsets? contentPadding; final void Function(InteractiveMapController controller)? onMapCreated; + final void Function(CameraPosition position)? onCameraMove; } class _OsmInteractiveMapState extends State { @@ -47,6 +49,11 @@ class _OsmInteractiveMapState extends State { widget.onMapCreated?.call(_parentController!); _subscriptions.add(_controller.mapEventStream.listen((ev) { _mapRotationRadSubject.add(ev.camera.rotationRad); + widget.onCameraMove?.call(CameraPosition( + center: ev.camera.center.toMapCoord(), + zoom: ev.camera.zoom, + rotation: (360 - ev.camera.rotation) % 360, + )); })); } }); diff --git a/np_gps_map/lib/src/map_coord.dart b/np_gps_map/lib/src/map_coord.dart deleted file mode 100644 index ca35a1df..00000000 --- a/np_gps_map/lib/src/map_coord.dart +++ /dev/null @@ -1,9 +0,0 @@ -class MapCoord { - const MapCoord(this.latitude, this.longitude); - - @override - String toString() => "MapCoord {latitude: $latitude, longitude: $longitude}"; - - final double latitude; - final double longitude; -} diff --git a/np_gps_map/lib/src/native/google_gps_map.dart b/np_gps_map/lib/src/native/google_gps_map.dart index 67f4d872..16abef2a 100644 --- a/np_gps_map/lib/src/native/google_gps_map.dart +++ b/np_gps_map/lib/src/native/google_gps_map.dart @@ -1,18 +1,17 @@ import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; -import 'package:np_gps_map/src/map_coord.dart'; +import 'package:np_gps_map/src/type.dart' as type; class GoogleGpsMap extends StatelessWidget { const GoogleGpsMap({ super.key, - required this.center, - required this.zoom, + required this.location, this.onTap, }); @override Widget build(BuildContext context) { - final centerLl = LatLng(center.latitude, center.longitude); + final center = LatLng(location.center.latitude, location.center.longitude); return GoogleMap( compassEnabled: false, mapToolbarEnabled: false, @@ -25,13 +24,14 @@ class GoogleGpsMap extends StatelessWidget { buildingsEnabled: false, // liteModeEnabled: true, initialCameraPosition: CameraPosition( - target: centerLl, - zoom: zoom, + target: center, + zoom: location.zoom, + bearing: location.rotation, ), markers: { Marker( markerId: const MarkerId("at"), - position: centerLl, + position: center, // for some reason, GoogleMap's onTap is not triggered if // tapped on top of the marker onTap: onTap, @@ -46,8 +46,7 @@ class GoogleGpsMap extends StatelessWidget { ); } - final MapCoord center; - final double zoom; + final type.CameraPosition location; final VoidCallback? onTap; } diff --git a/np_gps_map/lib/src/osm_gps_map.dart b/np_gps_map/lib/src/osm_gps_map.dart index ba56f8a5..b2089e18 100644 --- a/np_gps_map/lib/src/osm_gps_map.dart +++ b/np_gps_map/lib/src/osm_gps_map.dart @@ -1,36 +1,30 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; -import 'package:np_gps_map/src/map_coord.dart'; -import 'package:url_launcher/url_launcher_string.dart'; +import 'package:np_gps_map/src/type.dart'; class OsmGpsMap extends StatelessWidget { const OsmGpsMap({ super.key, - required this.center, - required this.zoom, + required this.location, this.onTap, }); @override Widget build(BuildContext context) { const double pinSize = 48; - final centerLl = LatLng(center.latitude, center.longitude); + final center = LatLng(location.center.latitude, location.center.longitude); return GestureDetector( - onTap: () { - launchUrlString( - "https://www.openstreetmap.org/?mlat=${center.latitude}&mlon=${center.longitude}#map=${zoom.toInt()}/${center.latitude}/${center.longitude}", - mode: LaunchMode.externalApplication, - ); - }, + onTap: onTap, behavior: HitTestBehavior.opaque, // IgnorePointer is needed to prevent FlutterMap absorbing all pointer // events child: IgnorePointer( child: FlutterMap( options: MapOptions( - initialCenter: centerLl, - initialZoom: zoom, + initialCenter: center, + initialZoom: location.zoom, + initialRotation: (360 - location.rotation) % 360, interactionOptions: const InteractionOptions( flags: InteractiveFlag.none, ), @@ -40,11 +34,12 @@ class OsmGpsMap extends StatelessWidget { urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", ), MarkerLayer( + rotate: true, markers: [ Marker( width: pinSize, height: pinSize, - point: centerLl, + point: center, alignment: Alignment.topCenter, child: const Image( image: AssetImage( @@ -62,7 +57,6 @@ class OsmGpsMap extends StatelessWidget { ); } - final MapCoord center; - final double zoom; + final CameraPosition location; final void Function()? onTap; } diff --git a/np_gps_map/lib/src/place_picker.dart b/np_gps_map/lib/src/place_picker.dart new file mode 100644 index 00000000..5a2bda2d --- /dev/null +++ b/np_gps_map/lib/src/place_picker.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:np_gps_map/src/interactive_map.dart'; +import 'package:np_gps_map/src/type.dart'; + +class PlacePickerView extends StatelessWidget { + const PlacePickerView({ + super.key, + required this.providerHint, + this.initialPosition, + this.initialZoom, + this.contentPadding, + this.onCameraMove, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + InteractiveMap( + providerHint: providerHint, + initialPosition: initialPosition, + initialZoom: initialZoom, + contentPadding: contentPadding, + onCameraMove: onCameraMove, + ), + Positioned.fill( + child: Transform.translate( + // 48(height) / 2 + offset: const Offset(0, -24), + child: Center( + child: Image.asset("packages/np_gps_map/assets/gps_map_pin.png"), + ), + ), + ), + ], + ); + } + + final GpsMapProvider providerHint; + final MapCoord? initialPosition; + final double? initialZoom; + final EdgeInsets? contentPadding; + final void Function(CameraPosition position)? onCameraMove; +} diff --git a/np_gps_map/lib/src/gps_map.dart b/np_gps_map/lib/src/static_map.dart similarity index 69% rename from np_gps_map/lib/src/gps_map.dart rename to np_gps_map/lib/src/static_map.dart index fa6f7feb..b78750eb 100644 --- a/np_gps_map/lib/src/gps_map.dart +++ b/np_gps_map/lib/src/static_map.dart @@ -1,23 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:np_gps_map/src/map_coord.dart'; import 'package:np_gps_map/src/native/google_gps_map.dart' if (dart.library.html) 'package:np_gps_map/src/web/google_gps_map.dart'; import 'package:np_gps_map/src/osm_gps_map.dart'; +import 'package:np_gps_map/src/type.dart'; import 'package:np_gps_map/src/util.dart'; import 'package:np_platform_util/np_platform_util.dart'; -enum GpsMapProvider { - google, - osm, - ; -} - -class GpsMap extends StatelessWidget { - const GpsMap({ +class StaticMap extends StatelessWidget { + const StaticMap({ super.key, required this.providerHint, - required this.center, - required this.zoom, + required this.location, this.onTap, }); @@ -26,14 +19,12 @@ class GpsMap extends StatelessWidget { if (providerHint == GpsMapProvider.osm || (getRawPlatform() == NpPlatform.android && !isNewGMapsRenderer())) { return OsmGpsMap( - center: center, - zoom: zoom, + location: location, onTap: onTap, ); } else { return GoogleGpsMap( - center: center, - zoom: zoom, + location: location, onTap: onTap, ); } @@ -43,8 +34,6 @@ class GpsMap extends StatelessWidget { /// actual choice may be different depending on the runtime environment final GpsMapProvider providerHint; - /// A pair of latitude and longitude coordinates, stored as degrees - final MapCoord center; - final double zoom; + final CameraPosition location; final void Function()? onTap; } diff --git a/np_gps_map/lib/src/type.dart b/np_gps_map/lib/src/type.dart new file mode 100644 index 00000000..4607f58c --- /dev/null +++ b/np_gps_map/lib/src/type.dart @@ -0,0 +1,80 @@ +import 'package:equatable/equatable.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart' as gmap; +import 'package:latlong2/latlong.dart'; +import 'package:np_common/type.dart'; + +enum GpsMapProvider { + google, + osm, + ; +} + +/// A pair of latitude and longitude coordinates, stored as degrees +class MapCoord { + const MapCoord(this.latitude, this.longitude); + + MapCoord.fromJson(JsonObj json) + : latitude = json["lat"], + longitude = json["lng"]; + + @override + String toString() => "MapCoord {latitude: $latitude, longitude: $longitude}"; + + JsonObj toJson() => { + "lat": latitude, + "lng": longitude, + }; + + final double latitude; + final double longitude; +} + +extension GLatLngExtension on gmap.LatLng { + MapCoord toMapCoord() => MapCoord(latitude, longitude); +} + +extension LatLngExtension on LatLng { + MapCoord toMapCoord() => MapCoord(latitude, longitude); +} + +class CameraPosition with EquatableMixin { + const CameraPosition({ + required this.center, + required this.zoom, + this.rotation = 0, + }); + + factory CameraPosition.fromJson(JsonObj json) { + return CameraPosition( + center: MapCoord.fromJson(json["center"]), + zoom: json["zoom"], + rotation: json["rotation"], + ); + } + + @override + String toString() => "CameraPosition {" + "center: $center, " + "zoom: $zoom, " + "rotation: $rotation, " + "}"; + + JsonObj toJson() { + return { + "center": center.toJson(), + "zoom": zoom, + "rotation": rotation, + }; + } + + @override + List get props => [center, zoom, rotation]; + + final MapCoord center; + final double zoom; + // The camera's bearing in degrees, measured clockwise from north. + // + // A bearing of 0.0, the default, means the camera points north. + // A bearing of 90.0 means the camera points east. + final double rotation; +} diff --git a/np_gps_map/pubspec.yaml b/np_gps_map/pubspec.yaml index 042798a4..ed824288 100644 --- a/np_gps_map/pubspec.yaml +++ b/np_gps_map/pubspec.yaml @@ -9,6 +9,7 @@ environment: flutter: ">=3.19.0" dependencies: + equatable: ^2.0.5 flutter: sdk: flutter flutter_map: ^6.1.0 @@ -25,7 +26,6 @@ dependencies: np_platform_util: path: ../np_platform_util rxdart: ^0.27.7 - url_launcher: ^6.1.11 dependency_overrides: google_maps_flutter_android: 2.7.0