diff --git a/analysis_options.yaml b/analysis_options.yaml index 78997bce..c8a428c4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,7 +1,7 @@ include: package:flutter_lints/flutter.yaml analyzer: - exclude: [build/**, lib/swagger_generated_code/**] + exclude: [build/**, lib/swagger_generated_code/**, lib/objectbox_generated_code/*.g.dart] errors: always_use_package_imports: error directives_ordering: error diff --git a/build.yaml b/build.yaml index 16cafd91..d4d52949 100644 --- a/build.yaml +++ b/build.yaml @@ -2,6 +2,10 @@ targets: $default: sources: - swaggers/** + - lib/$lib$ + # - $package$ + - lib/** + - pubspec.yaml builders: chopper_generator: options: diff --git a/lib/app/services/converters.dart b/lib/app/services/converters.dart index d346dc6a..5ca7d563 100644 --- a/lib/app/services/converters.dart +++ b/lib/app/services/converters.dart @@ -1,11 +1,16 @@ +import 'dart:convert'; + import 'package:gruene_app/app/services/enums.dart'; import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:gruene_app/features/campaigns/helper/campaign_action.dart'; import 'package:gruene_app/features/campaigns/models/doors/door_detail_model.dart'; import 'package:gruene_app/features/campaigns/models/flyer/flyer_detail_model.dart'; import 'package:gruene_app/features/campaigns/models/map_layer_model.dart'; import 'package:gruene_app/features/campaigns/models/marker_item_model.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; import 'package:gruene_app/features/campaigns/models/posters/poster_detail_model.dart'; import 'package:gruene_app/features/campaigns/models/posters/poster_list_item_model.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_update_model.dart'; import 'package:gruene_app/features/campaigns/widgets/enhanced_wheel_slider.dart'; import 'package:gruene_app/features/campaigns/widgets/text_input_field.dart'; import 'package:gruene_app/i18n/translations.g.dart'; @@ -25,3 +30,7 @@ part 'converters/poi_address_parsing.dart'; part 'converters/focus_area_parsing.dart'; part 'converters/poi_parsing.dart'; part 'converters/slider_range_parsing.dart'; +part 'converters/date_time_parsing.dart'; +part 'converters/poster_create_model_parsing.dart'; +part 'converters/poster_update_model_parsing.dart'; +part 'converters/campaign_action_parsing.dart'; diff --git a/lib/app/services/converters/campaign_action_parsing.dart b/lib/app/services/converters/campaign_action_parsing.dart new file mode 100644 index 00000000..b064f57e --- /dev/null +++ b/lib/app/services/converters/campaign_action_parsing.dart @@ -0,0 +1,25 @@ +part of '../converters.dart'; + +extension CampaignActionParsing on CampaignAction { + PosterCreateModel getSerializedAsPosterCreate() { + var data = jsonDecode(serialized!) as Map; + if (data['photo'] != null) { + data['photo'] = (data['photo'] as List).cast(); + } + data['location'] = (data['location'] as List).cast(); + + var model = PosterCreateModel.fromJson(data); + return model; + } + + PosterUpdateModel getSerializedAsPosterUpdate() { + var data = jsonDecode(serialized!) as Map; + if (data['photo'] != null) { + data['photo'] = (data['photo'] as List).cast(); + } + data['location'] = (data['location'] as List).cast(); + + var model = PosterUpdateModel.fromJson(data); + return model; + } +} diff --git a/lib/app/services/converters/date_time_parsing.dart b/lib/app/services/converters/date_time_parsing.dart new file mode 100644 index 00000000..5128f34d --- /dev/null +++ b/lib/app/services/converters/date_time_parsing.dart @@ -0,0 +1,12 @@ +part of '../converters.dart'; + +extension DateTimeParsing on DateTime { + String getDateTimeAsString() { + DateTime datetime = this; + final dateString = DateFormat(t.campaigns.poster.date_format).format(datetime); + final timeString = DateFormat(t.campaigns.poster.time_format).format(datetime); + return t.campaigns.poster.datetime_display_template + .replaceAll('{date}', dateString) + .replaceAll('{time}', timeString); + } +} diff --git a/lib/app/services/converters/poi_parsing.dart b/lib/app/services/converters/poi_parsing.dart index ed0045b9..877a537a 100644 --- a/lib/app/services/converters/poi_parsing.dart +++ b/lib/app/services/converters/poi_parsing.dart @@ -22,7 +22,7 @@ extension PoiParsing on Poi { address: poi.address.transformToAddressModel(), openedDoors: poi.house!.countOpenedDoors.toInt(), closedDoors: poi.house!.countClosedDoors.toInt(), - createdAt: _getDateTimeAsString(poi.createdAt), + createdAt: poi.createdAt.getDateTimeAsString(), ); } @@ -37,8 +37,9 @@ extension PoiParsing on Poi { imageUrl: _getImageUrl(poi), address: poi.address.transformToAddressModel(), status: poi.poster!.status.transformToModelPosterStatus(), + location: coords.transformToLatLng(), comment: poi.poster!.comment ?? '', - createdAt: _getDateTimeAsString(poi.createdAt), + createdAt: poi.createdAt.getDateTimeAsString(), ); } @@ -51,7 +52,7 @@ extension PoiParsing on Poi { id: poi.id, address: poi.address.transformToAddressModel(), flyerCount: poi.flyerSpot!.flyerCount.toInt(), - createdAt: _getDateTimeAsString(poi.createdAt), + createdAt: poi.createdAt.getDateTimeAsString(), ); } @@ -74,15 +75,7 @@ extension PoiParsing on Poi { String _getLastChangeDateTimeInfo() { final lastChange = updatedAt.toLocal(); - return _getDateTimeAsString(lastChange); - } - - String _getDateTimeAsString(DateTime lastChange) { - final lastChangeDate = DateFormat(t.campaigns.poster.date_format).format(lastChange); - final lastChangeTime = DateFormat(t.campaigns.poster.time_format).format(lastChange); - return t.campaigns.poster.datetime_display_template - .replaceAll('{date}', lastChangeDate) - .replaceAll('{time}', lastChangeTime); + return lastChange.getDateTimeAsString(); } String _getLastChangeStatus() { diff --git a/lib/app/services/converters/poi_service_type_parsing.dart b/lib/app/services/converters/poi_service_type_parsing.dart index ca6bbe2c..1fcc0cdb 100644 --- a/lib/app/services/converters/poi_service_type_parsing.dart +++ b/lib/app/services/converters/poi_service_type_parsing.dart @@ -33,4 +33,17 @@ extension PoiServiceTypeParsing on PoiServiceType { return CreatePoiType.flyerSpot; } } + + String getAsMarkerItemStatus(PosterStatus? posterStatus) { + var typeName = name; + switch (this) { + case PoiServiceType.poster: + String statusSuffix = ''; + if (posterStatus != null) statusSuffix = '_${posterStatus.name}'; + return '$typeName$statusSuffix'; + case PoiServiceType.door: + case PoiServiceType.flyer: + return typeName; + } + } } diff --git a/lib/app/services/converters/poster_create_model_parsing.dart b/lib/app/services/converters/poster_create_model_parsing.dart new file mode 100644 index 00000000..6ff162e9 --- /dev/null +++ b/lib/app/services/converters/poster_create_model_parsing.dart @@ -0,0 +1,25 @@ +part of '../converters.dart'; + +extension PosterCreateModelParsing on PosterCreateModel { + MarkerItemModel transformToVirtualMarkerItem(int temporaryId) { + return MarkerItemModel.virtual( + id: temporaryId, + status: PoiServiceType.poster.getAsMarkerItemStatus(PosterStatus.ok), + location: location, + ); + } + + PosterDetailModel transformToPosterDetailModel(int temporaryId) { + return PosterDetailModel( + id: temporaryId.toString(), + status: PosterStatus.ok, + address: address, + thumbnailUrl: null, + imageUrl: null, + location: location, + comment: '', + createdAt: '${DateTime.now().getDateTimeAsString()}*', // should mark this as preliminary + isCached: true, + ); + } +} diff --git a/lib/app/services/converters/poster_update_model_parsing.dart b/lib/app/services/converters/poster_update_model_parsing.dart new file mode 100644 index 00000000..0538ca27 --- /dev/null +++ b/lib/app/services/converters/poster_update_model_parsing.dart @@ -0,0 +1,33 @@ +part of '../converters.dart'; + +extension PosterUpdateModelParsing on PosterUpdateModel { + MarkerItemModel transformToVirtualMarkerItem() { + return MarkerItemModel.virtual( + id: int.parse(id), + status: PoiServiceType.poster.getAsMarkerItemStatus(status), + location: location, + ); + } + + PosterDetailModel transformToPosterDetailModel(int temporaryId) { + return PosterDetailModel( + id: temporaryId.toString(), + status: status, + address: address, + thumbnailUrl: null, + imageUrl: null, + location: location, + comment: comment, + createdAt: '${DateTime.now().getDateTimeAsString()}*', // should mark this as preliminary + isCached: true, + ); + } + + PosterUpdateModel mergeWith(PosterUpdateModel newPosterUpdate) { + var oldPosterUdpate = this; + + return newPosterUpdate.copyWith( + removePreviousPhotos: newPosterUpdate.removePreviousPhotos || oldPosterUdpate.removePreviousPhotos, + ); + } +} diff --git a/lib/app/services/nominatim_service.dart b/lib/app/services/nominatim_service.dart index 5c02b020..c76666cf 100644 --- a/lib/app/services/nominatim_service.dart +++ b/lib/app/services/nominatim_service.dart @@ -1,7 +1,10 @@ import 'package:gruene_app/app/geocode/nominatim.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:logger/logger.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +part 'nominatim_service.g.dart'; + class NominatimService { final Logger _logger = Logger(); @@ -22,13 +25,14 @@ class NominatimService { } } +@JsonSerializable() class AddressModel { final String street; final String city; final String zipCode; final String houseNumber; - const AddressModel({this.street = '', this.houseNumber = '', this.zipCode = '', this.city = ''}); + AddressModel({this.street = '', this.houseNumber = '', this.zipCode = '', this.city = ''}); /* * More details on how to find and categorize "places" can be found in the OSM Wiki: @@ -48,4 +52,11 @@ class AddressModel { place.address?['town']?.toString() ?? place.address?['village']?.toString() ?? ''; + + /// Connect the generated [_$AddressModelFromJson] function to the `fromJson` + /// factory. + factory AddressModel.fromJson(Map json) => _$AddressModelFromJson(json); + + /// Connect the generated [_$AddressModelToJson] function to the `toJson` method. + Map toJson() => _$AddressModelToJson(this); } diff --git a/lib/app/services/object_box.dart b/lib/app/services/object_box.dart new file mode 100644 index 00000000..60457158 --- /dev/null +++ b/lib/app/services/object_box.dart @@ -0,0 +1,27 @@ +import 'dart:async'; + +import 'package:gruene_app/objectbox_generated_code/objectbox.g.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +class ObjectBox { + static ObjectBox? _instance; + + // ObjectBox._(); + + factory ObjectBox() => _instance!; + + /// The Store of this app. + late final Store store; + + ObjectBox._create(this.store) { + // Add any additional setup code, e.g. build queries. + } + + /// Create an instance of ObjectBox to use throughout the app. + static Future init() async { + final docsDir = await getApplicationDocumentsDirectory(); + final store = await openStore(directory: p.join(docsDir.path, 'wk-cache')); + _instance = ObjectBox._create(store); + } +} diff --git a/lib/app/widgets/app_bar.dart b/lib/app/widgets/app_bar.dart index fd83698f..53de44fd 100644 --- a/lib/app/widgets/app_bar.dart +++ b/lib/app/widgets/app_bar.dart @@ -5,6 +5,7 @@ import 'package:gruene_app/app/auth/bloc/auth_bloc.dart'; import 'package:gruene_app/app/constants/routes.dart'; import 'package:gruene_app/app/theme/theme.dart'; import 'package:gruene_app/app/widgets/icon.dart'; +import 'package:gruene_app/features/campaigns/helper/campaign_action_cache.dart'; class MainAppBar extends StatelessWidget implements PreferredSizeWidget { const MainAppBar({super.key}); @@ -24,14 +25,7 @@ class MainAppBar extends StatelessWidget implements PreferredSizeWidget { backgroundColor: theme.primaryColor, centerTitle: true, actions: [ - if (currentRoute.path == Routes.campaigns.path) - IconButton( - icon: CustomIcon( - path: 'assets/icons/refresh.svg', - color: ThemeColors.background, - ), - onPressed: null, - ), + if (currentRoute.path == Routes.campaigns.path) RefreshButton(), if (currentRoute.path != Routes.settings.path && isLoggedIn) IconButton( icon: CustomIcon( @@ -47,3 +41,51 @@ class MainAppBar extends StatelessWidget implements PreferredSizeWidget { @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); } + +class RefreshButton extends StatefulWidget { + const RefreshButton({ + super.key, + }); + + @override + State createState() => _RefreshButtonState(); +} + +class _RefreshButtonState extends State { + int _currentCount = 0; + + @override + void initState() { + var campaignActionCache = CampaignActionCache(); + _currentCount = campaignActionCache.getCachedActionCount(); + + campaignActionCache.addListener(_setCurrentCounter); + super.initState(); + } + + @override + Widget build(BuildContext context) { + const maxLabelCount = 99; + var labelText = _currentCount > maxLabelCount ? '$maxLabelCount+' : _currentCount.toString(); + return IconButton( + icon: Badge( + label: Text(labelText), + isLabelVisible: _currentCount != 0, + child: CustomIcon( + path: 'assets/icons/refresh.svg', + color: ThemeColors.background, + ), + ), + onPressed: null, + ); + } + + void _setCurrentCounter() { + var campaignActionCache = CampaignActionCache(); + final newCount = campaignActionCache.getCachedActionCount(); + if (!mounted) return; + setState(() { + _currentCount = newCount; + }); + } +} diff --git a/lib/features/campaigns/helper/campaign_action.dart b/lib/features/campaigns/helper/campaign_action.dart new file mode 100644 index 00000000..75992766 --- /dev/null +++ b/lib/features/campaigns/helper/campaign_action.dart @@ -0,0 +1,65 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +// import 'package:objectbox/objectbox.dart'; + +import 'package:objectbox/objectbox.dart'; + +@Entity() +class CampaignAction { + int id; + int? poiId; + late int poiTempId; + @Transient() + CampaignActionType? actionType; + String? serialized; + + CampaignAction({ + this.id = 0, + this.poiId, + this.actionType, + this.serialized, + }) { + poiTempId = DateTime.now().millisecondsSinceEpoch; + } + + int? get actionTypeValue { + _ensureStableEnumValues(); + return actionType?.index; + } + + set actionTypeValue(int? value) { + _ensureStableEnumValues(); + if (value == null) { + actionType = null; + } else { + actionType = value >= 0 && value < CampaignActionType.values.length + ? CampaignActionType.values[value] + : CampaignActionType.unknown; + } + } + + void _ensureStableEnumValues() { + assert(CampaignActionType.unknown.index == 0); + assert(CampaignActionType.addPoster.index == 1); + assert(CampaignActionType.editPoster.index == 2); + assert(CampaignActionType.deletePoster.index == 3); + assert(CampaignActionType.addDoor.index == 4); + assert(CampaignActionType.editDoor.index == 5); + assert(CampaignActionType.deleteDoor.index == 6); + assert(CampaignActionType.addFlyer.index == 7); + assert(CampaignActionType.editFlyer.index == 8); + assert(CampaignActionType.deleteFlyer.index == 9); + } +} + +enum CampaignActionType { + unknown, + addPoster, + editPoster, + deletePoster, + addDoor, + editDoor, + deleteDoor, + addFlyer, + editFlyer, + deleteFlyer, +} diff --git a/lib/features/campaigns/helper/campaign_action_cache.dart b/lib/features/campaigns/helper/campaign_action_cache.dart new file mode 100644 index 00000000..0776f756 --- /dev/null +++ b/lib/features/campaigns/helper/campaign_action_cache.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:gruene_app/app/services/converters.dart'; +import 'package:gruene_app/app/services/object_box.dart'; +import 'package:gruene_app/features/campaigns/helper/campaign_action.dart'; +import 'package:gruene_app/features/campaigns/models/marker_item_model.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_detail_model.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_update_model.dart'; +import 'package:gruene_app/objectbox_generated_code/objectbox.g.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +class CampaignActionCache extends ChangeNotifier { + static CampaignActionCache? _instance; + + CampaignActionCache._(); + + factory CampaignActionCache() => _instance ??= CampaignActionCache._(); + + Future _addAction(CampaignAction action) async { + var box = ObjectBox().store.box(); + await box.putAsync(action); + notifyListeners(); + } + + int getCachedActionCount() { + var box = ObjectBox().store.box(); + return box.count(); + } + + Future addPosterCreate(PosterCreateModel posterCreate) async { + final action = CampaignAction( + actionType: CampaignActionType.addPoster, + serialized: jsonEncode(posterCreate.toJson()), + ); + await _addAction(action); + return posterCreate.transformToVirtualMarkerItem(action.poiTempId); + } + + Future addPosterDelete(String posterId) async { + final action = CampaignAction( + poiId: int.parse(posterId), + actionType: CampaignActionType.deletePoster, + ); + await _addAction(action); + return _getDeletePosterMarkerModel(action.poiId!); + } + + Future addPosterUpdate(PosterUpdateModel posterUpdate) async { + var actions = + (await _findActionsByPoiId(posterUpdate.id)).where((x) => x.actionType == CampaignActionType.editPoster); + var action = actions.singleOrNull; + if (action == null) { + action = CampaignAction( + poiId: int.parse(posterUpdate.id), + actionType: CampaignActionType.editPoster, + serialized: jsonEncode(posterUpdate.toJson()), + ); + } else { + // update previous edit action + var oldUpdate = action.getSerializedAsPosterUpdate(); + var newPosterUpdate = oldUpdate.mergeWith(posterUpdate); + action.serialized = jsonEncode(newPosterUpdate.toJson()); + } + + await _addAction(action); + + return posterUpdate.transformToVirtualMarkerItem(); + } + + MarkerItemModel _getDeletePosterMarkerModel(int id) { + return MarkerItemModel.virtual( + id: id, + status: 'poster_deleted', + location: LatLng(0, 0), + ); + } + + List getPosterMarkerItems() { + List markerItems = []; + var box = ObjectBox().store.box(); + var posterActions = [ + CampaignActionType.addPoster.index, + CampaignActionType.editPoster.index, + CampaignActionType.deletePoster.index, + ]; + var condition = CampaignAction_.actionTypeValue.oneOf(posterActions); + + var q = box.query(condition).order(CampaignAction_.poiTempId, flags: Order.descending); + final posterCacheList = q.build().find(); + for (var action in posterCacheList) { + if (markerItems.any((m) => m.id! == action.id)) continue; + switch (action.actionType) { + case CampaignActionType.addPoster: + var model = action.getSerializedAsPosterCreate(); + markerItems.add(model.transformToVirtualMarkerItem(action.poiTempId)); + case CampaignActionType.editPoster: + var model = action.getSerializedAsPosterUpdate(); + markerItems.add(model.transformToVirtualMarkerItem()); + case CampaignActionType.deletePoster: + var model = _getDeletePosterMarkerModel(action.poiId!); + markerItems.add(model); + case CampaignActionType.unknown: + case CampaignActionType.addDoor: + case CampaignActionType.editDoor: + case CampaignActionType.deleteDoor: + case CampaignActionType.addFlyer: + case CampaignActionType.editFlyer: + case CampaignActionType.deleteFlyer: + case null: + throw UnimplementedError(); + } + if (action.actionType == CampaignActionType.addPoster) {} + } + return markerItems; + } + + Future getPoiAsPosterDetail(String poiId) async { + var posterCacheList = await _findActionsByPoiId(poiId); + var addPosterActions = posterCacheList.where((p) => p.actionType == CampaignActionType.addPoster).toList(); + var editPosterActions = posterCacheList.where((p) => p.actionType == CampaignActionType.editPoster).toList(); + if (editPosterActions.isNotEmpty) { + var editPosterAction = editPosterActions.single; + var model = editPosterAction.getSerializedAsPosterUpdate(); + return model.transformToPosterDetailModel(int.parse(poiId)); + } else { + var addPosterAction = addPosterActions.single; + + var model = addPosterAction.getSerializedAsPosterCreate(); + return model.transformToPosterDetailModel(int.parse(poiId)); + } + } + + Future> _findActionsByPoiId(String poiId) async { + var box = ObjectBox().store.box(); + var condition = + CampaignAction_.poiId.equals(int.parse(poiId)).or(CampaignAction_.poiTempId.equals(int.parse(poiId))); + + var q = box.query(condition).order(CampaignAction_.poiTempId); + var posterCacheList = await q.build().findAsync(); + return posterCacheList; + } +} diff --git a/lib/features/campaigns/helper/map_helper.dart b/lib/features/campaigns/helper/map_helper.dart index 4e386f8e..6a7c36b5 100644 --- a/lib/features/campaigns/helper/map_helper.dart +++ b/lib/features/campaigns/helper/map_helper.dart @@ -42,4 +42,11 @@ class MapHelper { final id = feature['id'].toString(); return id; } + + static bool extractIsCachedFromFeature(Map feature) { + if (feature['properties'] == null) return false; + final properties = feature['properties'] as Map; + if (properties['is_virtual'] == null) return false; + return bool.parse(properties['is_virtual'].toString()); + } } diff --git a/lib/features/campaigns/helper/marker_item_helper.dart b/lib/features/campaigns/helper/marker_item_helper.dart index 2fdd4c23..fcb1e821 100644 --- a/lib/features/campaigns/helper/marker_item_helper.dart +++ b/lib/features/campaigns/helper/marker_item_helper.dart @@ -15,6 +15,7 @@ class MarkerItemHelper { id: markerItem.id, properties: { 'status_type': markerItem.status, + 'is_virtual': markerItem.isVirtual, }, geometry: Point(coordinates: Position(markerItem.location.longitude, markerItem.location.latitude)), ); diff --git a/lib/features/campaigns/helper/marker_item_manager.dart b/lib/features/campaigns/helper/marker_item_manager.dart index e68507f9..094dc4f7 100644 --- a/lib/features/campaigns/helper/marker_item_manager.dart +++ b/lib/features/campaigns/helper/marker_item_manager.dart @@ -1,15 +1,32 @@ import 'package:gruene_app/features/campaigns/models/marker_item_model.dart'; class MarkerItemManager { - final List loadedMarkers = []; + List loadedMarkers = []; + final List virtualMarkers = []; void addMarkers(List poiList) { - loadedMarkers.retainWhere((oldMarker) => poiList.any((newMarker) => newMarker.id != oldMarker.id)); - loadedMarkers.addAll(poiList); + // get virtual marker items and add them to cache list + var newVirtualMarkers = poiList.where((p) => p.isVirtual).toList(); + virtualMarkers.retainWhere((oldMarker) => !newVirtualMarkers.any((newMarker) => newMarker.id == oldMarker.id)); + virtualMarkers.addAll(newVirtualMarkers); + + // get marker items which are not in cache + var newStoredMarkers = poiList + .where((p) => !p.isVirtual) + .where((p) => !virtualMarkers.any((virtualMarker) => virtualMarker.id == p.id)) + .toList(); + + // remove previously loaded markers to update them + loadedMarkers.retainWhere((oldMarker) => !newStoredMarkers.any((newMarker) => newMarker.id == oldMarker.id)); + + // remove loaded markers which are also in cache + loadedMarkers.removeWhere((oldMarker) => virtualMarkers.any((cachedMarker) => cachedMarker.id == oldMarker.id)); + + loadedMarkers.addAll(newStoredMarkers); } List getMarkers() { - return loadedMarkers; + return loadedMarkers + virtualMarkers; } void removeMarker(int markerItemId) { diff --git a/lib/features/campaigns/location/location_ffi.dart b/lib/features/campaigns/location/location_ffi.dart index ecedf511..a2c00864 100644 --- a/lib/features/campaigns/location/location_ffi.dart +++ b/lib/features/campaigns/location/location_ffi.dart @@ -3,8 +3,8 @@ import 'package:gruene_app/app/constants/config.dart'; final platform = MethodChannel('${Config.appId}/location'); -/// Checks whether location services are enabled -/// without using Google Play Services +// Checks whether location services are enabled +// without using Google Play Services Future isNonGoogleLocationServiceEnabled() async { try { final bool result = await platform.invokeMethod('isLocationServiceEnabled') as bool; diff --git a/lib/features/campaigns/models/marker_item_model.dart b/lib/features/campaigns/models/marker_item_model.dart index 3febd04e..7b5bac2a 100644 --- a/lib/features/campaigns/models/marker_item_model.dart +++ b/lib/features/campaigns/models/marker_item_model.dart @@ -4,10 +4,17 @@ class MarkerItemModel { final LatLng location; final int? id; final String? status; + final bool isVirtual; const MarkerItemModel({ required this.id, required this.status, required this.location, - }); + }) : isVirtual = false; + + MarkerItemModel.virtual({ + required this.id, + this.status, + required this.location, + }) : isVirtual = true; } diff --git a/lib/features/campaigns/models/posters/poster_create_model.dart b/lib/features/campaigns/models/posters/poster_create_model.dart index adc26a72..a10d3efb 100644 --- a/lib/features/campaigns/models/posters/poster_create_model.dart +++ b/lib/features/campaigns/models/posters/poster_create_model.dart @@ -1,16 +1,65 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:typed_data'; import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +part 'poster_create_model.g.dart'; + +@JsonSerializable() class PosterCreateModel { - final AddressModel address; + AddressModel address; + + @LatLongConverter() final LatLng location; + + @Uint8ListConverter() final Uint8List? photo; - const PosterCreateModel({ + PosterCreateModel({ required this.address, this.photo, required this.location, }); + + factory PosterCreateModel.fromJson(Map json) => _$PosterCreateModelFromJson(json); + + Map toJson() => _$PosterCreateModelToJson(this); +} + +/// Converts to and from [Uint8List] and [List]<[int]>. +class Uint8ListConverter implements JsonConverter?> { + /// Create a new instance of [Uint8ListConverter]. + const Uint8ListConverter(); + + @override + Uint8List? fromJson(List? json) { + if (json == null) return null; + + return Uint8List.fromList(json); + } + + @override + List? toJson(Uint8List? object) { + if (object == null) return null; + + return object.toList(); + } +} + +/// Converts to and from [Uint8List] and [List]<[int]>. +class LatLongConverter implements JsonConverter> { + /// Create a new instance of [LatLongConverter]. + const LatLongConverter(); + + @override + LatLng fromJson(List json) { + return LatLng(json[0], json[1]); + } + + @override + List toJson(LatLng object) { + return [object.latitude, object.longitude]; + } } diff --git a/lib/features/campaigns/models/posters/poster_detail_model.dart b/lib/features/campaigns/models/posters/poster_detail_model.dart index ab0f98b0..7a4f77ec 100644 --- a/lib/features/campaigns/models/posters/poster_detail_model.dart +++ b/lib/features/campaigns/models/posters/poster_detail_model.dart @@ -1,4 +1,5 @@ import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; enum PosterStatus { ok, damaged, missing, removed } @@ -11,6 +12,8 @@ class PosterDetailModel { final String comment; final PosterStatus status; final String createdAt; + final bool isCached; + final LatLng location; PosterDetailModel({ required this.id, @@ -20,5 +23,7 @@ class PosterDetailModel { required this.status, required this.comment, required this.createdAt, + required this.location, + this.isCached = false, }); } diff --git a/lib/features/campaigns/models/posters/poster_update_model.dart b/lib/features/campaigns/models/posters/poster_update_model.dart index 61d5c9f7..66b5b35c 100644 --- a/lib/features/campaigns/models/posters/poster_update_model.dart +++ b/lib/features/campaigns/models/posters/poster_update_model.dart @@ -1,14 +1,24 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:typed_data'; import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; import 'package:gruene_app/features/campaigns/models/posters/poster_detail_model.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +part 'poster_update_model.g.dart'; + +@JsonSerializable() class PosterUpdateModel { final String id; final AddressModel address; final PosterStatus status; final String comment; + @Uint8ListConverter() final Uint8List? newPhoto; + @LatLongConverter() + final LatLng location; final bool removePreviousPhotos; PosterUpdateModel({ @@ -17,6 +27,31 @@ class PosterUpdateModel { required this.status, required this.comment, required this.removePreviousPhotos, + required this.location, this.newPhoto, }); + + factory PosterUpdateModel.fromJson(Map json) => _$PosterUpdateModelFromJson(json); + + Map toJson() => _$PosterUpdateModelToJson(this); + + PosterUpdateModel copyWith({ + String? id, + AddressModel? address, + PosterStatus? status, + String? comment, + Uint8List? newPhoto, + LatLng? location, + bool? removePreviousPhotos, + }) { + return PosterUpdateModel( + id: id ?? this.id, + address: address ?? this.address, + status: status ?? this.status, + comment: comment ?? this.comment, + newPhoto: newPhoto ?? this.newPhoto, + location: location ?? this.location, + removePreviousPhotos: removePreviousPhotos ?? this.removePreviousPhotos, + ); + } } diff --git a/lib/features/campaigns/screens/doors_screen.dart b/lib/features/campaigns/screens/doors_screen.dart index e5bc5cfd..59952db3 100644 --- a/lib/features/campaigns/screens/doors_screen.dart +++ b/lib/features/campaigns/screens/doors_screen.dart @@ -70,6 +70,7 @@ class _DoorsScreenState extends MapConsumer { onMapCreated: onMapCreated, addPOIClicked: _addPOIClicked, loadVisibleItems: loadVisibleItems, + loadCachedItems: _loadCachedItems, getMarkerImages: _getMarkerImages, onFeatureClick: _onFeatureClick, onNoFeatureClick: _onNoFeatureClick, @@ -146,4 +147,6 @@ class _DoorsScreenState extends MapConsumer { Future _saveNewAndGetMarkerItem(DoorCreateModel newDoor) async => await _grueneApiService.createNewDoor(newDoor); + + void _loadCachedItems() {} } diff --git a/lib/features/campaigns/screens/flyer_screen.dart b/lib/features/campaigns/screens/flyer_screen.dart index 83e0c87e..76181ee8 100644 --- a/lib/features/campaigns/screens/flyer_screen.dart +++ b/lib/features/campaigns/screens/flyer_screen.dart @@ -62,6 +62,7 @@ class _FlyerScreenState extends MapConsumer { onMapCreated: onMapCreated, addPOIClicked: _addPOIClicked, loadVisibleItems: loadVisibleItems, + loadCachedItems: _loadCachedItems, getMarkerImages: _getMarkerImages, onFeatureClick: _onFeatureClick, onNoFeatureClick: _onNoFeatureClick, @@ -142,4 +143,6 @@ class _FlyerScreenState extends MapConsumer { final updatedMarker = await campaignService.updateFlyer(flyerUpdate); mapController.setMarkerSource([updatedMarker]); } + + void _loadCachedItems() {} } diff --git a/lib/features/campaigns/screens/map_consumer.dart b/lib/features/campaigns/screens/map_consumer.dart index 527f9372..fea94672 100644 --- a/lib/features/campaigns/screens/map_consumer.dart +++ b/lib/features/campaigns/screens/map_consumer.dart @@ -24,7 +24,7 @@ typedef SaveNewAndGetMarkerCallback = Future Function(T); typedef GetPoiCallback = Future Function(String); typedef GetPoiDetailWidgetCallback = Widget Function(T); typedef GetPoiEditWidgetCallback = Widget Function(T); -typedef OnDeletePoiCallback = void Function(String posterId); +typedef OnDeletePoiCallback = Future Function(String posterId); abstract class MapConsumer extends State with FocusAreaInfo { late MapController mapController; @@ -146,7 +146,7 @@ abstract class MapConsumer extends State with Focus ); } - void deletePoi(String poiId) async { + Future deletePoi(String poiId) async { final id = int.parse(poiId); await campaignService.deletePoi(poiId); mapController.removeMarkerItem(id); diff --git a/lib/features/campaigns/screens/poster_edit.dart b/lib/features/campaigns/screens/poster_edit.dart index 59fb3983..35bab6dc 100644 --- a/lib/features/campaigns/screens/poster_edit.dart +++ b/lib/features/campaigns/screens/poster_edit.dart @@ -309,7 +309,7 @@ class _PosterEditState extends State with AddressExtension, ConfirmD } void _onDeletePressed() async { - widget.onDelete(widget.poster.id); + await widget.onDelete(widget.poster.id); _closeDialog(ModalEditResult.delete); } @@ -336,6 +336,7 @@ class _PosterEditState extends State with AddressExtension, ConfirmD status: _segmentedButtonSelection.isEmpty ? PosterStatus.ok : _segmentedButtonSelection.single, comment: commentTextController.text, removePreviousPhotos: _isPhotoDeleted, + location: widget.poster.location, newPhoto: reducedImage, ); await widget.onSave(updateModel); diff --git a/lib/features/campaigns/screens/posters_screen.dart b/lib/features/campaigns/screens/posters_screen.dart index 92d802e8..91919993 100644 --- a/lib/features/campaigns/screens/posters_screen.dart +++ b/lib/features/campaigns/screens/posters_screen.dart @@ -6,7 +6,9 @@ import 'package:gruene_app/app/services/enums.dart'; import 'package:gruene_app/app/services/gruene_api_campaigns_service.dart'; import 'package:gruene_app/app/services/nominatim_service.dart'; import 'package:gruene_app/app/theme/theme.dart'; +import 'package:gruene_app/features/campaigns/helper/campaign_action_cache.dart'; import 'package:gruene_app/features/campaigns/helper/campaign_constants.dart'; +import 'package:gruene_app/features/campaigns/helper/map_helper.dart'; import 'package:gruene_app/features/campaigns/helper/media_helper.dart'; import 'package:gruene_app/features/campaigns/models/marker_item_model.dart'; import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; @@ -72,6 +74,7 @@ class _PostersScreenState extends MapConsumer { onMapCreated: onMapCreated, addPOIClicked: _addPOIClicked, loadVisibleItems: loadVisibleItems, + loadCachedItems: _loadCachedItems, getMarkerImages: _getMarkerImages, onFeatureClick: _onFeatureClick, onNoFeatureClick: _onNoFeatureClick, @@ -134,8 +137,11 @@ class _PostersScreenState extends MapConsumer { ); } - Future saveNewAndGetMarkerItem(PosterCreateModel newPoster) async => - await campaignService.createNewPoster(newPoster); + Future saveNewAndGetMarkerItem(PosterCreateModel newPoster) async { + return await CampaignActionCache().addPosterCreate(newPoster); + + // return await campaignService.createNewPoster(newPoster); + } void _addPOIClicked(LatLng location) async { super.addPOIClicked( @@ -160,24 +166,29 @@ class _PostersScreenState extends MapConsumer { return poster; } + Future _getCachedPoi(String poiId) async { + final poster = await CampaignActionCache().getPoiAsPosterDetail(poiId); + return poster; + } + Widget _getEditPosterWidget(PosterDetailModel poster) { - return PosterEdit(poster: poster, onSave: _savePoster, onDelete: deletePoi); + return PosterEdit(poster: poster, onSave: _savePoster, onDelete: _deletePoster); } void _onFeatureClick(dynamic rawFeature) async { + final feature = rawFeature as Map; + final isCached = MapHelper.extractIsCachedFromFeature(feature); + getPoiDetailWidget(PosterDetailModel poster) { return PosterDetail( poi: poster, ); } - super.onFeatureClick( - rawFeature, - _getPoi, - getPoiDetailWidget, - _getEditPosterWidget, - desiredSize: Size(150, 150), - ); + var __getPoi = isCached ? _getCachedPoi : _getPoi; + + super.onFeatureClick(rawFeature, __getPoi, getPoiDetailWidget, _getEditPosterWidget, + desiredSize: Size(150, 150),); } void _onNoFeatureClick(Point point) { @@ -185,7 +196,7 @@ class _PostersScreenState extends MapConsumer { } Future _savePoster(PosterUpdateModel posterUpdate) async { - final updatedMarker = await campaignService.updatePoster(posterUpdate); + final updatedMarker = await CampaignActionCache().addPosterUpdate(posterUpdate); mapController.setMarkerSource([updatedMarker]); } @@ -214,4 +225,14 @@ class _PostersScreenState extends MapConsumer { ), ); } + + void _loadCachedItems() { + var markerItems = CampaignActionCache().getPosterMarkerItems(); + mapController.setMarkerSource(markerItems); + } + + Future _deletePoster(String posterId) async { + var markerItem = await CampaignActionCache().addPosterDelete(posterId); + mapController.setMarkerSource([markerItem]); + } } diff --git a/lib/features/campaigns/widgets/map_container.dart b/lib/features/campaigns/widgets/map_container.dart index 4ba44eee..cf5ed326 100644 --- a/lib/features/campaigns/widgets/map_container.dart +++ b/lib/features/campaigns/widgets/map_container.dart @@ -24,6 +24,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; typedef OnMapCreatedCallback = void Function(MapController controller); typedef AddPOIClickedCallback = void Function(LatLng location); typedef LoadVisibleItemsCallBack = void Function(LatLng locationSW, LatLng locationNE); +typedef LoadCachedItemsCallback = void Function(); typedef LoadDataLayersCallBack = void Function(LatLng locationSW, LatLng locationNE); typedef GetMarkerImagesCallback = Map Function(); typedef OnFeatureClickCallback = void Function(dynamic feature); @@ -36,6 +37,7 @@ class MapContainer extends StatefulWidget { final OnMapCreatedCallback? onMapCreated; final AddPOIClickedCallback? addPOIClicked; final LoadVisibleItemsCallBack? loadVisibleItems; + final LoadCachedItemsCallback? loadCachedItems; final LoadDataLayersCallBack? loadDataLayers; final GetMarkerImagesCallback? getMarkerImages; final OnFeatureClickCallback? onFeatureClick; @@ -50,6 +52,7 @@ class MapContainer extends StatefulWidget { required this.onMapCreated, required this.addPOIClicked, required this.loadVisibleItems, + required this.loadCachedItems, required this.getMarkerImages, required this.onFeatureClick, required this.onNoFeatureClick, @@ -182,10 +185,10 @@ class _MapContainerState extends State implements MapController { onMapCreated(this); } - _loadDataOnMap(); + _loadDataOnMap(init: true); } - void _loadDataOnMap() async { + void _loadDataOnMap({bool init = false}) async { final visRegion = await _controller?.getVisibleRegion(); var currentZoomLevel = _controller!.cameraPosition!.zoom; @@ -194,6 +197,10 @@ class _MapContainerState extends State implements MapController { _showAddMarker = currentZoomLevel > minimumMarkerZoomLevel; + if (init) { + final loadCachedItems = widget.loadCachedItems; + if (loadCachedItems != null) loadCachedItems(); + } final loadVisibleItems = widget.loadVisibleItems; if (loadVisibleItems != null) { loadVisibleItems(visRegion.southwest, visRegion.northeast); diff --git a/lib/features/campaigns/widgets/map_with_location.dart b/lib/features/campaigns/widgets/map_with_location.dart index 6131f19b..f9e27092 100644 --- a/lib/features/campaigns/widgets/map_with_location.dart +++ b/lib/features/campaigns/widgets/map_with_location.dart @@ -6,6 +6,7 @@ class MapWithLocation extends StatelessWidget { final OnMapCreatedCallback? onMapCreated; final AddPOIClickedCallback? addPOIClicked; final LoadVisibleItemsCallBack? loadVisibleItems; + final LoadCachedItemsCallback? loadCachedItems; final LoadDataLayersCallBack? loadDataLayers; final GetMarkerImagesCallback? getMarkerImages; final OnFeatureClickCallback? onFeatureClick; @@ -18,6 +19,7 @@ class MapWithLocation extends StatelessWidget { required this.onMapCreated, required this.addPOIClicked, required this.loadVisibleItems, + required this.loadCachedItems, required this.getMarkerImages, required this.onFeatureClick, required this.onNoFeatureClick, @@ -42,6 +44,7 @@ class MapWithLocation extends StatelessWidget { onMapCreated: onMapCreated, addPOIClicked: addPOIClicked, loadVisibleItems: loadVisibleItems, + loadCachedItems: loadCachedItems, getMarkerImages: getMarkerImages, onFeatureClick: onFeatureClick, onNoFeatureClick: onNoFeatureClick, diff --git a/lib/main.dart b/lib/main.dart index 55c9465d..24a81373 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:gruene_app/app/auth/bloc/auth_bloc.dart'; import 'package:gruene_app/app/auth/repository/auth_repository.dart'; import 'package:gruene_app/app/router.dart'; import 'package:gruene_app/app/services/gruene_api_core.dart'; +import 'package:gruene_app/app/services/object_box.dart'; import 'package:gruene_app/app/theme/theme.dart'; import 'package:gruene_app/features/mfa/bloc/mfa_bloc.dart'; import 'package:gruene_app/features/mfa/bloc/mfa_event.dart'; @@ -19,7 +20,7 @@ import 'package:gruene_app/swagger_generated_code/gruene_api.swagger.dart'; import 'package:keycloak_authenticator/api.dart'; import 'package:timeago/timeago.dart' as timeago; -void main() async { +Future main() async { await dotenv.load(fileName: '.env'); WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); @@ -37,6 +38,10 @@ void main() async { GetIt.I.registerSingleton(await createGrueneApiClient()); GetIt.I.registerFactory(MfaFactory.create); + // This is required so ObjectBox can get the application directory + // to store the database in. + WidgetsFlutterBinding.ensureInitialized(); + await ObjectBox.init(); runApp(TranslationProvider(child: const MyApp())); } diff --git a/lib/objectbox_generated_code/objectbox-model.json b/lib/objectbox_generated_code/objectbox-model.json new file mode 100644 index 00000000..f09fd1d3 --- /dev/null +++ b/lib/objectbox_generated_code/objectbox-model.json @@ -0,0 +1,54 @@ +{ + "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.", + "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.", + "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", + "entities": [ + { + "id": "1:3687273696335832918", + "lastPropertyId": "6:2592599006203626727", + "name": "CampaignAction", + "properties": [ + { + "id": "1:632207665298233006", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:1016599410622369215", + "name": "poiId", + "type": 6 + }, + { + "id": "3:7857996364901107513", + "name": "poiTempId", + "type": 6 + }, + { + "id": "4:4161217532734178868", + "name": "serialized", + "type": 9 + }, + { + "id": "6:2592599006203626727", + "name": "actionTypeValue", + "type": 6 + } + ], + "relations": [] + } + ], + "lastEntityId": "1:3687273696335832918", + "lastIndexId": "0:0", + "lastRelationId": "0:0", + "lastSequenceId": "0:0", + "modelVersion": 5, + "modelVersionParserMinimum": 5, + "retiredEntityUids": [], + "retiredIndexUids": [], + "retiredPropertyUids": [ + 6954020076197261999 + ], + "retiredRelationUids": [], + "version": 1 +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index c88f6d5a..37ba8c66 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -358,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + flat_buffers: + dependency: transitive + description: + name: flat_buffers + sha256: "380bdcba5664a718bfd4ea20a45d39e13684f5318fcd8883066a55e21f37f4c3" + url: "https://pub.dev" + source: hosted + version: "23.5.26" flutter: dependency: "direct main" description: flutter @@ -996,6 +1004,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objectbox: + dependency: "direct main" + description: + name: objectbox + sha256: ea823f4bf1d0a636e7aa50b43daabb64dd0fbd80b85a033016ccc1bc4f76f432 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + objectbox_flutter_libs: + dependency: "direct main" + description: + name: objectbox_flutter_libs + sha256: c91350bbbce5e6c2038255760b5be988faead004c814f833c2cd137445c6ae70 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + objectbox_generator: + dependency: "direct dev" + description: + name: objectbox_generator + sha256: "96da521f2cef455cd524f8854e31d64495c50711ad5f1e2cf3142a8e527bc75f" + url: "https://pub.dev" + source: hosted + version: "4.0.3" package_config: dependency: transitive description: @@ -1021,7 +1053,7 @@ packages: source: hosted version: "3.0.2" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" @@ -1037,13 +1069,13 @@ packages: source: hosted version: "1.0.2" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider - sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" path_provider_android: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fc923012..3ea81382 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,10 @@ dependencies: timeago: ^3.7.0 local_auth: ^2.3.0 image_picker: ^1.1.2 + objectbox: ^4.0.3 + objectbox_flutter_libs: any + path_provider: ^2.1.5 + path: ^1.9.0 dev_dependencies: @@ -69,12 +73,12 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^5.0.0 #--------- swagger_dart_code_generator -------------- - build_runner: ^2.3.3 + build_runner: ^2.4.13 chopper_generator: ^8.0.0 json_serializable: ^6.6.1 swagger_dart_code_generator: ^3.0.1 #---------------------------------------------------- - + objectbox_generator: any # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -95,6 +99,8 @@ flutter: - assets/maps/ - .env +objectbox: + output_dir: objectbox_generated_code # To add assets to your application, add an assets section, like this: # assets: