From 98e139850635500ae27832d80ff1a53338dca654 Mon Sep 17 00:00:00 2001 From: Christian Fiebrig Date: Mon, 3 Feb 2025 18:49:31 +0100 Subject: [PATCH] #260 update servers (#535) * #260 update servers: - use sqflite as cache database - store all item actions in cache - use virtual markers to distinct between saved and intermediate state - images are stored on device data instead of stream to reduce mem_usage - auto-discover when add + delete are done in cache (removes item from cache and never stores it) - apply door to use cache - apply flyer to use cache * 530: let refresh button rotate while flushing --- .vscode/settings.json | 3 +- analysis_options.yaml | 5 +- build.yaml | 4 + .../services/campaign_action_database.dart | 138 ++++++ lib/app/services/converters.dart | 33 +- .../converters/campaign_action_parsing.dart | 74 +++ .../converters/door_create_model_parsing.dart | 23 + .../converters/door_update_model_parsing.dart | 21 + .../flyer_create_model_parsing.dart | 22 + .../flyer_update_model_parsing.dart | 20 + .../map_string_dynamic_converter.dart | 15 + lib/app/services/converters/poi_parsing.dart | 25 +- .../converters/poi_service_type_parsing.dart | 46 ++ .../poster_create_model_parsing.dart | 25 + .../converters/poster_status_parsing.dart | 2 + .../poster_update_model_parsing.dart | 31 ++ .../services/converters/string_extension.dart | 2 + .../gruene_api_campaigns_service.dart | 156 +----- lib/app/services/gruene_api_door_service.dart | 45 ++ .../services/gruene_api_flyer_service.dart | 43 ++ .../services/gruene_api_poster_service.dart | 96 ++++ lib/app/services/nominatim_service.dart | 13 +- lib/app/widgets/app_bar.dart | 86 +++- .../campaigns/helper/campaign_action.dart | 103 ++++ .../helper/campaign_action_cache.dart | 453 ++++++++++++++++++ .../helper/campaign_action_cache_timer.dart | 22 + .../campaigns/helper/file_cache_manager.dart | 52 ++ lib/features/campaigns/helper/map_helper.dart | 7 + .../campaigns/helper/marker_item_helper.dart | 1 + .../campaigns/helper/marker_item_manager.dart | 30 +- .../campaigns/helper/media_helper.dart | 9 + .../campaigns/location/location_ffi.dart | 4 +- .../models/doors/door_create_model.dart | 10 + .../models/doors/door_detail_model.dart | 37 ++ .../models/doors/door_update_model.dart | 24 +- .../models/flyer/flyer_create_model.dart | 10 + .../models/flyer/flyer_detail_model.dart | 40 +- .../models/flyer/flyer_update_model.dart | 22 +- .../campaigns/models/marker_item_model.dart | 9 +- .../models/posters/poster_create_model.dart | 54 ++- .../models/posters/poster_detail_model.dart | 52 +- .../posters/poster_list_item_model.dart | 2 + .../models/posters/poster_update_model.dart | 43 +- lib/features/campaigns/screens/door_edit.dart | 2 + .../campaigns/screens/doors_screen.dart | 39 +- .../campaigns/screens/flyer_edit.dart | 2 + .../campaigns/screens/flyer_screen.dart | 40 +- .../campaigns/screens/map_consumer.dart | 31 +- .../screens/my_poster_list_screen.dart | 24 +- .../campaigns/screens/poster_add_screen.dart | 13 +- .../campaigns/screens/poster_detail.dart | 23 +- .../campaigns/screens/poster_edit.dart | 38 +- .../campaigns/screens/posters_screen.dart | 65 ++- .../campaigns/widgets/content_page.dart | 12 +- .../campaigns/widgets/map_container.dart | 25 +- .../widgets/map_controller_simplified.dart | 3 + .../campaigns/widgets/map_with_location.dart | 3 + lib/main.dart | 23 +- pubspec.lock | 64 ++- pubspec.yaml | 8 +- 60 files changed, 2011 insertions(+), 316 deletions(-) create mode 100644 lib/app/services/campaign_action_database.dart create mode 100644 lib/app/services/converters/campaign_action_parsing.dart create mode 100644 lib/app/services/converters/door_create_model_parsing.dart create mode 100644 lib/app/services/converters/door_update_model_parsing.dart create mode 100644 lib/app/services/converters/flyer_create_model_parsing.dart create mode 100644 lib/app/services/converters/flyer_update_model_parsing.dart create mode 100644 lib/app/services/converters/map_string_dynamic_converter.dart create mode 100644 lib/app/services/converters/poster_create_model_parsing.dart create mode 100644 lib/app/services/converters/poster_update_model_parsing.dart create mode 100644 lib/app/services/gruene_api_door_service.dart create mode 100644 lib/app/services/gruene_api_flyer_service.dart create mode 100644 lib/app/services/gruene_api_poster_service.dart create mode 100644 lib/features/campaigns/helper/campaign_action.dart create mode 100644 lib/features/campaigns/helper/campaign_action_cache.dart create mode 100644 lib/features/campaigns/helper/campaign_action_cache_timer.dart create mode 100644 lib/features/campaigns/helper/file_cache_manager.dart create mode 100644 lib/features/campaigns/widgets/map_controller_simplified.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 83610e15..b3ec0c9f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,6 @@ "circleci.filters.branchFilter": "allBranches", "circleci.persistedProjectSelection": [ "gh/verdigado/gruene_app" - ] + ], + "cSpell.enabled": false } \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index 78997bce..4c58eb9f 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,7 +1,10 @@ include: package:flutter_lints/flutter.yaml analyzer: - exclude: [build/**, lib/swagger_generated_code/**] + exclude: + - build/** + - lib/swagger_generated_code/** + - lib/**.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/campaign_action_database.dart b/lib/app/services/campaign_action_database.dart new file mode 100644 index 00000000..7a5e8729 --- /dev/null +++ b/lib/app/services/campaign_action_database.dart @@ -0,0 +1,138 @@ +import 'package:gruene_app/features/campaigns/helper/campaign_action.dart'; +import 'package:path/path.dart'; +import 'package:sqflite/sqflite.dart'; + +class CampaignActionDatabase { + static final CampaignActionDatabase instance = CampaignActionDatabase._internal(); + + static Database? _database; + + CampaignActionDatabase._internal(); + + Future get database async { + return _database ??= await _initDatabase(); + } + + Future _initDatabase() async { + final databasePath = await getDatabasesPath(); + final path = join(databasePath, 'campaign_action_db.db'); + return await openDatabase( + path, + version: 1, + onCreate: _createDatabase, + ); + } + + Future _createDatabase(Database db, _) async { + return await db.execute(''' + CREATE TABLE ${CampaignActionFields.tableName} ( + ${CampaignActionFields.id} ${CampaignActionFields.idType}, + ${CampaignActionFields.poiId} ${CampaignActionFields.intTypeNullable}, + ${CampaignActionFields.poiTempId} ${CampaignActionFields.intType}, + ${CampaignActionFields.actionType} ${CampaignActionFields.intType}, + ${CampaignActionFields.serialized} ${CampaignActionFields.textTypeNullable} + ) + '''); + } + + Future create(CampaignAction campaignAction) async { + final db = await instance.database; + final id = await db.insert(CampaignActionFields.tableName, campaignAction.toMap()); + return campaignAction.copyWith(id: id); + } + + Future> readAll() async { + final db = await instance.database; + const orderBy = '${CampaignActionFields.poiTempId} ASC, ${CampaignActionFields.id} ASC'; + final result = await db.query(CampaignActionFields.tableName, orderBy: orderBy); + return result.map((json) => CampaignAction.fromMap(json)).toList(); + } + + Future> readAllByActionType(List posterActions) async { + final db = await instance.database; + const orderBy = '${CampaignActionFields.poiTempId} ASC, ${CampaignActionFields.id} ASC'; + final result = await db.query( + CampaignActionFields.tableName, + orderBy: orderBy, + where: '${CampaignActionFields.actionType} IN (${List.filled(posterActions.length, '?').join(',')})', + whereArgs: posterActions, + ); + return result.map((json) => CampaignAction.fromMap(json)).toList(); + } + + Future> getActionsWithPoiId(String poiId) async { + final db = await instance.database; + const orderBy = '${CampaignActionFields.poiTempId} ASC, ${CampaignActionFields.id} ASC'; + final result = await db.query( + CampaignActionFields.tableName, + orderBy: orderBy, + where: '${CampaignActionFields.poiId} = ? OR ${CampaignActionFields.poiTempId} = ?', + whereArgs: [poiId, poiId], + ); + return result.map((json) => CampaignAction.fromMap(json)).toList(); + } + + Future update(CampaignAction campaignAction) async { + final db = await instance.database; + db.update( + CampaignActionFields.tableName, + campaignAction.toMap(), + where: '${CampaignActionFields.id} = ?', + whereArgs: [campaignAction.id], + ); + } + + Future updatePoiId(int oldId, int newId) async { + final db = await instance.database; + await db.update( + CampaignActionFields.tableName, + {CampaignActionFields.poiId: newId}, + where: '${CampaignActionFields.poiId} = ?', + whereArgs: [oldId], + ); + } + + Future delete(int id) async { + final db = await instance.database; + return await db.delete( + CampaignActionFields.tableName, + where: '${CampaignActionFields.id} = ?', + whereArgs: [id], + ); + } + + Future close() async { + final db = await instance.database; + db.close(); + } + + Future getCount() async { + final db = await instance.database; + int count = Sqflite.firstIntValue(await db.rawQuery('SELECT COUNT(*) FROM ${CampaignActionFields.tableName}')) ?? 0; + return count; + } + + Future actionsWithPoiIdExists(String poiId) async { + final db = await instance.database; + final result = await db.query( + CampaignActionFields.tableName, + columns: ['COUNT(*)'], + where: '${CampaignActionFields.poiId} = ? OR ${CampaignActionFields.poiTempId} = ?', + whereArgs: [poiId, poiId], + ); + return (Sqflite.firstIntValue(result) ?? 0) > 0; + } +} + +class CampaignActionFields { + static const String tableName = 'campaign_action'; + static const String idType = 'INTEGER PRIMARY KEY AUTOINCREMENT'; + static const String textTypeNullable = 'TEXT'; + static const String intType = 'INTEGER NOT NULL'; + static const String intTypeNullable = 'INTEGER'; + static const String id = '_id'; + static const String poiId = 'poiId'; + static const String poiTempId = 'poiTempId'; + static const String actionType = 'actionType'; + static const String serialized = 'serialized'; +} diff --git a/lib/app/services/converters.dart b/lib/app/services/converters.dart index 714afa38..b1a63a09 100644 --- a/lib/app/services/converters.dart +++ b/lib/app/services/converters.dart @@ -1,12 +1,21 @@ +import 'dart:convert'; + import 'package:gruene_app/app/geocode/nominatim.dart'; 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_create_model.dart'; import 'package:gruene_app/features/campaigns/models/doors/door_detail_model.dart'; +import 'package:gruene_app/features/campaigns/models/doors/door_update_model.dart'; +import 'package:gruene_app/features/campaigns/models/flyer/flyer_create_model.dart'; import 'package:gruene_app/features/campaigns/models/flyer/flyer_detail_model.dart'; +import 'package:gruene_app/features/campaigns/models/flyer/flyer_update_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'; @@ -15,17 +24,25 @@ import 'package:intl/intl.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:turf/turf.dart' as turf; -part 'converters/poi_type_parsing.dart'; +part 'converters/address_model_parsing.dart'; +part 'converters/campaign_action_parsing.dart'; +part 'converters/date_time_parsing.dart'; +part 'converters/focus_area_parsing.dart'; part 'converters/lat_lng_parsing.dart'; part 'converters/lat_lng_parsing_extended.dart'; -part 'converters/poi_service_type_parsing.dart'; -part 'converters/poi_poster_status_parsing.dart'; -part 'converters/poster_status_parsing.dart'; -part 'converters/address_model_parsing.dart'; +part 'converters/map_string_dynamic_converter.dart'; +part 'converters/place_parser.dart'; part 'converters/poi_address_parsing.dart'; -part 'converters/focus_area_parsing.dart'; part 'converters/poi_parsing.dart'; +part 'converters/poi_poster_status_parsing.dart'; +part 'converters/poi_service_type_parsing.dart'; +part 'converters/poi_type_parsing.dart'; +part 'converters/poster_create_model_parsing.dart'; +part 'converters/poster_status_parsing.dart'; +part 'converters/poster_update_model_parsing.dart'; part 'converters/slider_range_parsing.dart'; -part 'converters/place_parser.dart'; part 'converters/string_extension.dart'; -part 'converters/date_time_parsing.dart'; +part 'converters/door_update_model_parsing.dart'; +part 'converters/door_create_model_parsing.dart'; +part 'converters/flyer_create_model_parsing.dart'; +part 'converters/flyer_update_model_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..ba11c3b0 --- /dev/null +++ b/lib/app/services/converters/campaign_action_parsing.dart @@ -0,0 +1,74 @@ +part of '../converters.dart'; + +extension CampaignActionParsing on CampaignAction { + PosterCreateModel getAsPosterCreate() { + var data = jsonDecode(serialized!) as Map; + var model = PosterCreateModel.fromJson(data.convertLatLongField()); + return model; + } + + PosterUpdateModel getAsPosterUpdate() { + var data = jsonDecode(serialized!) as Map; + var model = PosterUpdateModel.fromJson(data.updateIdField(poiId!).convertLatLongField()); + + return model; + } + + DoorCreateModel getAsDoorCreate() { + var data = jsonDecode(serialized!) as Map; + var model = DoorCreateModel.fromJson(data.convertLatLongField()); + + return model; + } + + DoorUpdateModel getAsDoorUpdate() { + var data = jsonDecode(serialized!) as Map; + var model = DoorUpdateModel.fromJson(data.updateIdField(poiId!).convertLatLongField()); + + return model; + } + + FlyerCreateModel getAsFlyerCreate() { + var data = jsonDecode(serialized!) as Map; + var model = FlyerCreateModel.fromJson(data.convertLatLongField()); + + return model; + } + + FlyerUpdateModel getAsFlyerUpdate() { + var data = jsonDecode(serialized!) as Map; + var model = FlyerUpdateModel.fromJson(data.updateIdField(poiId!).convertLatLongField()); + + return model; + } + + PosterListItemModel getPosterUpdateAsPosterListItem(DateTime originalCreatedAt) { + var updateModel = getAsPosterUpdate().transformToPosterDetailModel(); + return PosterListItemModel( + id: updateModel.id, + thumbnailUrl: updateModel.thumbnailUrl, + imageUrl: updateModel.imageUrl, + address: updateModel.address, + status: updateModel.status.translatePosterStatus(), + lastChangeStatus: t.campaigns.poster.updated, + lastChangeDateTime: '${DateTime.fromMillisecondsSinceEpoch(poiTempId).getAsLocalDateTimeString()}*', + createdAt: originalCreatedAt, + isCached: true, + ); + } + + PosterListItemModel getPosterCreateAsPosterListItem() { + var createModel = getAsPosterCreate().transformToPosterDetailModel(poiTempId.toString()); + return PosterListItemModel( + id: createModel.id, + thumbnailUrl: createModel.thumbnailUrl, + imageUrl: createModel.imageUrl, + address: createModel.address, + status: createModel.status.translatePosterStatus(), + lastChangeStatus: t.campaigns.poster.updated, + lastChangeDateTime: '${DateTime.fromMillisecondsSinceEpoch(poiTempId).getAsLocalDateTimeString()}*', + createdAt: DateTime.fromMillisecondsSinceEpoch(poiTempId), + isCached: true, + ); + } +} diff --git a/lib/app/services/converters/door_create_model_parsing.dart b/lib/app/services/converters/door_create_model_parsing.dart new file mode 100644 index 00000000..234e7367 --- /dev/null +++ b/lib/app/services/converters/door_create_model_parsing.dart @@ -0,0 +1,23 @@ +part of '../converters.dart'; + +extension DoorCreateModelParsing on DoorCreateModel { + DoorDetailModel transformToDoorDetailModel(String temporaryId) { + return DoorDetailModel( + id: temporaryId, + address: address, + closedDoors: closedDoors, + openedDoors: openedDoors, + location: location, + createdAt: '${DateTime.now().getAsLocalDateTimeString()}*', // should mark this as preliminary + isCached: true, + ); + } + + MarkerItemModel transformToVirtualMarkerItem(int temporaryId) { + return MarkerItemModel.virtual( + id: temporaryId, + status: PoiServiceType.door.name, + location: location, + ); + } +} diff --git a/lib/app/services/converters/door_update_model_parsing.dart b/lib/app/services/converters/door_update_model_parsing.dart new file mode 100644 index 00000000..c28ce7a5 --- /dev/null +++ b/lib/app/services/converters/door_update_model_parsing.dart @@ -0,0 +1,21 @@ +part of '../converters.dart'; + +extension DoorUpdateModelParsing on DoorUpdateModel { + DoorDetailModel transformToDoorDetailModel() { + var newDoorDetail = oldDoorDetail.copyWith( + address: address, + closedDoors: closedDoors, + openedDoors: openedDoors, + isCached: true, + ); + return newDoorDetail; + } + + MarkerItemModel transformToVirtualMarkerItem() { + return MarkerItemModel.virtual( + id: int.parse(id), + status: PoiServiceType.door.name, + location: location, + ); + } +} diff --git a/lib/app/services/converters/flyer_create_model_parsing.dart b/lib/app/services/converters/flyer_create_model_parsing.dart new file mode 100644 index 00000000..cf3a940f --- /dev/null +++ b/lib/app/services/converters/flyer_create_model_parsing.dart @@ -0,0 +1,22 @@ +part of '../converters.dart'; + +extension FlyerCreateModelParsing on FlyerCreateModel { + FlyerDetailModel transformToFlyerDetailModel(String temporaryId) { + return FlyerDetailModel( + id: temporaryId, + address: address, + flyerCount: flyerCount, + location: location, + createdAt: '${DateTime.now().getAsLocalDateTimeString()}*', // should mark this as preliminary + isCached: true, + ); + } + + MarkerItemModel transformToVirtualMarkerItem(int temporaryId) { + return MarkerItemModel.virtual( + id: temporaryId, + status: PoiServiceType.flyer.name, + location: location, + ); + } +} diff --git a/lib/app/services/converters/flyer_update_model_parsing.dart b/lib/app/services/converters/flyer_update_model_parsing.dart new file mode 100644 index 00000000..bc168368 --- /dev/null +++ b/lib/app/services/converters/flyer_update_model_parsing.dart @@ -0,0 +1,20 @@ +part of '../converters.dart'; + +extension FlyerUpdateModelParsing on FlyerUpdateModel { + FlyerDetailModel transformToFlyerDetailModel() { + var newFlyerDetail = oldFlyerDetail.copyWith( + address: address, + flyerCount: flyerCount, + isCached: true, + ); + return newFlyerDetail; + } + + MarkerItemModel transformToVirtualMarkerItem() { + return MarkerItemModel.virtual( + id: int.parse(id), + status: PoiServiceType.flyer.name, + location: location, + ); + } +} diff --git a/lib/app/services/converters/map_string_dynamic_converter.dart b/lib/app/services/converters/map_string_dynamic_converter.dart new file mode 100644 index 00000000..7e40a2b9 --- /dev/null +++ b/lib/app/services/converters/map_string_dynamic_converter.dart @@ -0,0 +1,15 @@ +part of '../converters.dart'; + +extension MapStringDynamicConverter on Map { + Map convertLatLongField({String fieldName = 'location'}) { + if (containsKey(fieldName) && this[fieldName] is List) { + this[fieldName] = (this[fieldName] as List).cast().toList(); + } + return this; + } + + Map updateIdField(int id) { + this['id'] = id.toString(); + return this; + } +} diff --git a/lib/app/services/converters/poi_parsing.dart b/lib/app/services/converters/poi_parsing.dart index 7c548706..74dc342d 100644 --- a/lib/app/services/converters/poi_parsing.dart +++ b/lib/app/services/converters/poi_parsing.dart @@ -22,7 +22,8 @@ extension PoiParsing on Poi { address: poi.address.transformToAddressModel(), openedDoors: poi.house!.countOpenedDoors.toInt(), closedDoors: poi.house!.countClosedDoors.toInt(), - createdAt: _getUtcDateTimeAsLocalDateTimeString(poi.createdAt), + location: poi.coords.transformToLatLng(), + createdAt: poi.createdAt.getAsLocalDateTimeString(), ); } @@ -37,8 +38,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: _getUtcDateTimeAsLocalDateTimeString(poi.createdAt), + createdAt: poi.createdAt.getAsLocalDateTimeString(), ); } @@ -51,7 +53,8 @@ extension PoiParsing on Poi { id: poi.id, address: poi.address.transformToAddressModel(), flyerCount: poi.flyerSpot!.flyerCount.toInt(), - createdAt: _getUtcDateTimeAsLocalDateTimeString(poi.createdAt), + createdAt: poi.createdAt.getAsLocalDateTimeString(), + location: coords.transformToLatLng(), ); } @@ -67,25 +70,11 @@ extension PoiParsing on Poi { address: poi.address.transformToAddressModel(), status: poi.poster!.status.translatePosterStatus(), lastChangeStatus: poi._getLastChangeStatus(), - lastChangeDateTime: poi._getLastChangeDateTimeInfo(), + lastChangeDateTime: poi.updatedAt.getAsLocalDateTimeString(), createdAt: poi.createdAt, ); } - String _getLastChangeDateTimeInfo() { - return _getUtcDateTimeAsLocalDateTimeString(updatedAt); - } - - String _getUtcDateTimeAsLocalDateTimeString(DateTime utcTime) { - final localTime = utcTime.toLocal(); - - final lastChangeDate = DateFormat(t.campaigns.poster.date_format).format(localTime); - final lastChangeTime = DateFormat(t.campaigns.poster.time_format).format(localTime); - return t.campaigns.poster.datetime_display_template - .replaceAll('{date}', lastChangeDate) - .replaceAll('{time}', lastChangeTime); - } - String _getLastChangeStatus() { return createdAt == updatedAt ? t.campaigns.poster.created : t.campaigns.poster.updated; } diff --git a/lib/app/services/converters/poi_service_type_parsing.dart b/lib/app/services/converters/poi_service_type_parsing.dart index ca6bbe2c..233804b1 100644 --- a/lib/app/services/converters/poi_service_type_parsing.dart +++ b/lib/app/services/converters/poi_service_type_parsing.dart @@ -33,4 +33,50 @@ 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; + } + } + + CampaignActionType getCacheDeleteAction() { + switch (this) { + case PoiServiceType.poster: + return CampaignActionType.deletePoster; + case PoiServiceType.door: + return CampaignActionType.deleteDoor; + case PoiServiceType.flyer: + return CampaignActionType.deleteFlyer; + } + } + + CampaignActionType getCacheEditAction() { + switch (this) { + case PoiServiceType.poster: + return CampaignActionType.editPoster; + case PoiServiceType.door: + return CampaignActionType.editDoor; + case PoiServiceType.flyer: + return CampaignActionType.editFlyer; + } + } + + CampaignActionType getCacheAddAction() { + switch (this) { + case PoiServiceType.poster: + return CampaignActionType.addPoster; + case PoiServiceType.door: + return CampaignActionType.addDoor; + case PoiServiceType.flyer: + return CampaignActionType.addFlyer; + } + } } 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..00e08c31 --- /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(String temporaryId) { + return PosterDetailModel( + id: temporaryId, + status: PosterStatus.ok, + address: address, + thumbnailUrl: imageFileLocation, + imageUrl: imageFileLocation, + location: location, + comment: '', + createdAt: '${DateTime.now().getAsLocalDateTimeString()}*', // should mark this as preliminary + isCached: true, + ); + } +} diff --git a/lib/app/services/converters/poster_status_parsing.dart b/lib/app/services/converters/poster_status_parsing.dart index 2b861a4b..29a73f14 100644 --- a/lib/app/services/converters/poster_status_parsing.dart +++ b/lib/app/services/converters/poster_status_parsing.dart @@ -9,4 +9,6 @@ extension PosterStatusParsing on PosterStatus { PosterStatus.removed => PoiPosterStatus.removed, }; } + + String translatePosterStatus() => transformToPoiPosterStatus().translatePosterStatus(); } 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..95d5e866 --- /dev/null +++ b/lib/app/services/converters/poster_update_model_parsing.dart @@ -0,0 +1,31 @@ +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() { + var newPosterDetail = oldPosterDetail.copyWith( + status: status, + address: address, + thumbnailUrl: newImageFileLocation ?? (!removePreviousPhotos ? oldPosterDetail.thumbnailUrl : null), + imageUrl: newImageFileLocation ?? (!removePreviousPhotos ? oldPosterDetail.imageUrl : null), + comment: comment, + isCached: true, + ); + return newPosterDetail; + } + + PosterUpdateModel mergeWith(PosterUpdateModel newPosterUpdate) { + var oldPosterUdpate = this; + + return newPosterUpdate.copyWith( + removePreviousPhotos: newPosterUpdate.removePreviousPhotos || oldPosterUdpate.removePreviousPhotos, + ); + } +} diff --git a/lib/app/services/converters/string_extension.dart b/lib/app/services/converters/string_extension.dart index 7ae756a0..7576590c 100644 --- a/lib/app/services/converters/string_extension.dart +++ b/lib/app/services/converters/string_extension.dart @@ -10,4 +10,6 @@ extension StringExtension on String { if (text.trim().isEmpty) return this; return '$this\n$text'; } + + bool isNetworkImageUrl() => Uri.parse(this).hasScheme; } diff --git a/lib/app/services/gruene_api_campaigns_service.dart b/lib/app/services/gruene_api_campaigns_service.dart index 2b72e1ad..3d00a383 100644 --- a/lib/app/services/gruene_api_campaigns_service.dart +++ b/lib/app/services/gruene_api_campaigns_service.dart @@ -1,29 +1,15 @@ import 'dart:async'; -import 'package:chopper/chopper.dart' as chopper; import 'package:flutter/foundation.dart'; import 'package:get_it/get_it.dart'; import 'package:gruene_app/app/services/converters.dart'; import 'package:gruene_app/app/services/enums.dart'; -import 'package:gruene_app/features/campaigns/models/doors/door_create_model.dart'; -import 'package:gruene_app/features/campaigns/models/doors/door_detail_model.dart'; -import 'package:gruene_app/features/campaigns/models/doors/door_update_model.dart'; -import 'package:gruene_app/features/campaigns/models/flyer/flyer_create_model.dart'; -import 'package:gruene_app/features/campaigns/models/flyer/flyer_detail_model.dart'; -import 'package:gruene_app/features/campaigns/models/flyer/flyer_update_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/swagger_generated_code/gruene_api.swagger.dart'; -import 'package:http/http.dart' as http; -import 'package:http_parser/http_parser.dart'; -import 'package:intl/intl.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class GrueneApiCampaignsService { +abstract class GrueneApiCampaignsService { late GrueneApi grueneApi; final PoiServiceType poiType; @@ -52,73 +38,7 @@ class GrueneApiCampaignsService { return getPoisResult.body!.data.map((layerItem) => layerItem.transformToMapLayer()).toList(); } - Future createNewPoster(PosterCreateModel newPoster) async { - final requestParam = CreatePoi( - coords: newPoster.location.transformToGeoJsonCoords(), - type: poiType.transformToApiCreateType(), - address: newPoster.address.transformToPoiAddress(), - ); - // saving POI - final newPoiResponse = await grueneApi.v1CampaignsPoisPost(body: requestParam); - - if (newPoiResponse.error == null && newPoster.photo != null) { - // saving Photo along with POI - var poiId = newPoiResponse.body!.id; - - await _storeNewPhoto(poiId, newPoster.photo!); - } - - return newPoiResponse.body!.transformToMarkerItem(); - } - - Future createNewDoor(DoorCreateModel newDoor) async { - final requestParam = CreatePoi( - coords: newDoor.location.transformToGeoJsonCoords(), - type: poiType.transformToApiCreateType(), - address: newDoor.address.transformToPoiAddress(), - house: PoiHouse( - countOpenedDoors: newDoor.openedDoors.toDouble(), - countClosedDoors: newDoor.closedDoors.toDouble(), - ), - ); - // saving POI - final newPoiResponse = await grueneApi.v1CampaignsPoisPost(body: requestParam); - - return newPoiResponse.body!.transformToMarkerItem(); - } - - Future createNewFlyer(FlyerCreateModel newFlyer) async { - final requestParam = CreatePoi( - coords: newFlyer.location.transformToGeoJsonCoords(), - type: poiType.transformToApiCreateType(), - address: newFlyer.address.transformToPoiAddress(), - flyerSpot: PoiFlyerSpot( - flyerCount: newFlyer.flyerCount.toDouble(), - ), - ); - // saving POI - final newPoiResponse = await grueneApi.v1CampaignsPoisPost(body: requestParam); - - return newPoiResponse.body!.transformToMarkerItem(); - } - - Future getPoiAsPosterDetail(String poiId) async { - return _getPoi(poiId, (p) => p.transformPoiToPosterDetail()); - } - - Future getPoiAsPosterListItem(String poiId) { - return _getPoi(poiId, (p) => p.transformToPosterListItem()); - } - - Future getPoiAsDoorDetail(String poiId) { - return _getPoi(poiId, (p) => p.transformPoiToDoorDetail()); - } - - Future getPoiAsFlyerDetail(String poiId) { - return _getPoi(poiId, (p) => p.transformPoiToFlyerDetail()); - } - - Future _getPoi(String poiId, T Function(Poi) transform) async { + Future getPoi(String poiId, T Function(Poi) transform) async { final poiResponse = await grueneApi.v1CampaignsPoisPoiIdGet(poiId: poiId); return transform(poiResponse.body!); } @@ -127,76 +47,4 @@ class GrueneApiCampaignsService { // ignore: unused_local_variable final deletePoiResponse = await grueneApi.v1CampaignsPoisPoiIdDelete(poiId: poiId); } - - Future updatePoster(PosterUpdateModel posterUpdate) async { - var dtoUpdate = UpdatePoi( - address: posterUpdate.address.transformToPoiAddress(), - poster: PoiPoster( - status: posterUpdate.status.transformToPoiPosterStatus(), - comment: posterUpdate.comment.isEmpty ? null : posterUpdate.comment, - ), - ); - var updatePoiResponse = await grueneApi.v1CampaignsPoisPoiIdPut(poiId: posterUpdate.id, body: dtoUpdate); - - if (posterUpdate.newPhoto != null || posterUpdate.removePreviousPhotos) { - for (var photo in updatePoiResponse.body!.photos) { - updatePoiResponse = await grueneApi.v1CampaignsPoisPoiIdPhotosPhotoIdDelete( - poiId: posterUpdate.id, - photoId: photo.id, - ); - } - } - if (posterUpdate.newPhoto != null) { - updatePoiResponse = await _storeNewPhoto(posterUpdate.id, posterUpdate.newPhoto!); - } - - return updatePoiResponse.body!.transformToMarkerItem(); - } - - Future updateDoor(DoorUpdateModel doorUpdate) async { - var dtoUpdate = UpdatePoi( - address: doorUpdate.address.transformToPoiAddress(), - house: PoiHouse( - countOpenedDoors: doorUpdate.openedDoors.toDouble(), - countClosedDoors: doorUpdate.closedDoors.toDouble(), - ), - ); - var updatePoiResponse = await grueneApi.v1CampaignsPoisPoiIdPut(poiId: doorUpdate.id, body: dtoUpdate); - - return updatePoiResponse.body!.transformToMarkerItem(); - } - - Future updateFlyer(FlyerUpdateModel flyerUpdate) async { - var dtoUpdate = UpdatePoi( - address: flyerUpdate.address.transformToPoiAddress(), - flyerSpot: PoiFlyerSpot( - flyerCount: flyerUpdate.flyerCount.toDouble(), - ), - ); - var updatePoiResponse = await grueneApi.v1CampaignsPoisPoiIdPut(poiId: flyerUpdate.id, body: dtoUpdate); - - return updatePoiResponse.body!.transformToMarkerItem(); - } - - Future> _storeNewPhoto(String poiId, Uint8List photo) async { - var timeStamp = DateFormat('yyMMdd_HHmmss').format(DateTime.now()); - // ignore: unused_local_variable - final savePoiPhotoResponse = await grueneApi.v1CampaignsPoisPoiIdPhotosPost( - poiId: poiId, - image: http.MultipartFile.fromBytes( - 'image', - photo, - filename: 'poi_${poiId}_$timeStamp.jpg', - contentType: MediaType('image', 'jpeg'), - ), - ); - return savePoiPhotoResponse; - } - - Future> getMyPosters() async { - final getPoisType = poiType.transformToApiSelfGetType(); - - final getPoisResult = await grueneApi.v1CampaignsPoisSelfGet(type: getPoisType); - return getPoisResult.body!.data.map((p) => p.transformToPosterListItem()).toList(); - } } diff --git a/lib/app/services/gruene_api_door_service.dart b/lib/app/services/gruene_api_door_service.dart new file mode 100644 index 00000000..bb30084d --- /dev/null +++ b/lib/app/services/gruene_api_door_service.dart @@ -0,0 +1,45 @@ +import 'package:gruene_app/app/services/converters.dart'; +import 'package:gruene_app/app/services/enums.dart'; +import 'package:gruene_app/app/services/gruene_api_campaigns_service.dart'; +import 'package:gruene_app/features/campaigns/models/doors/door_create_model.dart'; +import 'package:gruene_app/features/campaigns/models/doors/door_detail_model.dart'; +import 'package:gruene_app/features/campaigns/models/doors/door_update_model.dart'; +import 'package:gruene_app/features/campaigns/models/marker_item_model.dart'; +import 'package:gruene_app/swagger_generated_code/gruene_api.swagger.dart'; + +class GrueneApiDoorService extends GrueneApiCampaignsService { + GrueneApiDoorService() : super(poiType: PoiServiceType.door); + + Future createNewDoor(DoorCreateModel newDoor) async { + final requestParam = CreatePoi( + coords: newDoor.location.transformToGeoJsonCoords(), + type: poiType.transformToApiCreateType(), + address: newDoor.address.transformToPoiAddress(), + house: PoiHouse( + countOpenedDoors: newDoor.openedDoors.toDouble(), + countClosedDoors: newDoor.closedDoors.toDouble(), + ), + ); + // saving POI + final newPoiResponse = await grueneApi.v1CampaignsPoisPost(body: requestParam); + + return newPoiResponse.body!.transformToMarkerItem(); + } + + Future updateDoor(DoorUpdateModel doorUpdate) async { + var dtoUpdate = UpdatePoi( + address: doorUpdate.address.transformToPoiAddress(), + house: PoiHouse( + countOpenedDoors: doorUpdate.openedDoors.toDouble(), + countClosedDoors: doorUpdate.closedDoors.toDouble(), + ), + ); + var updatePoiResponse = await grueneApi.v1CampaignsPoisPoiIdPut(poiId: doorUpdate.id, body: dtoUpdate); + + return updatePoiResponse.body!.transformToMarkerItem(); + } + + Future getPoiAsDoorDetail(String poiId) { + return getPoi(poiId, (p) => p.transformPoiToDoorDetail()); + } +} diff --git a/lib/app/services/gruene_api_flyer_service.dart b/lib/app/services/gruene_api_flyer_service.dart new file mode 100644 index 00000000..755490d1 --- /dev/null +++ b/lib/app/services/gruene_api_flyer_service.dart @@ -0,0 +1,43 @@ +import 'package:gruene_app/app/services/converters.dart'; +import 'package:gruene_app/app/services/enums.dart'; +import 'package:gruene_app/app/services/gruene_api_campaigns_service.dart'; +import 'package:gruene_app/features/campaigns/models/flyer/flyer_create_model.dart'; +import 'package:gruene_app/features/campaigns/models/flyer/flyer_detail_model.dart'; +import 'package:gruene_app/features/campaigns/models/flyer/flyer_update_model.dart'; +import 'package:gruene_app/features/campaigns/models/marker_item_model.dart'; +import 'package:gruene_app/swagger_generated_code/gruene_api.swagger.dart'; + +class GrueneApiFlyerService extends GrueneApiCampaignsService { + GrueneApiFlyerService() : super(poiType: PoiServiceType.flyer); + + Future createNewFlyer(FlyerCreateModel newFlyer) async { + final requestParam = CreatePoi( + coords: newFlyer.location.transformToGeoJsonCoords(), + type: poiType.transformToApiCreateType(), + address: newFlyer.address.transformToPoiAddress(), + flyerSpot: PoiFlyerSpot( + flyerCount: newFlyer.flyerCount.toDouble(), + ), + ); + // saving POI + final newPoiResponse = await grueneApi.v1CampaignsPoisPost(body: requestParam); + + return newPoiResponse.body!.transformToMarkerItem(); + } + + Future getPoiAsFlyerDetail(String poiId) { + return getPoi(poiId, (p) => p.transformPoiToFlyerDetail()); + } + + Future updateFlyer(FlyerUpdateModel flyerUpdate) async { + var dtoUpdate = UpdatePoi( + address: flyerUpdate.address.transformToPoiAddress(), + flyerSpot: PoiFlyerSpot( + flyerCount: flyerUpdate.flyerCount.toDouble(), + ), + ); + var updatePoiResponse = await grueneApi.v1CampaignsPoisPoiIdPut(poiId: flyerUpdate.id, body: dtoUpdate); + + return updatePoiResponse.body!.transformToMarkerItem(); + } +} diff --git a/lib/app/services/gruene_api_poster_service.dart b/lib/app/services/gruene_api_poster_service.dart new file mode 100644 index 00000000..88b6ddb5 --- /dev/null +++ b/lib/app/services/gruene_api_poster_service.dart @@ -0,0 +1,96 @@ +import 'package:chopper/chopper.dart' as chopper; +import 'package:get_it/get_it.dart'; +import 'package:gruene_app/app/services/converters.dart'; +import 'package:gruene_app/app/services/enums.dart'; +import 'package:gruene_app/app/services/gruene_api_campaigns_service.dart'; +import 'package:gruene_app/features/campaigns/helper/file_cache_manager.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/swagger_generated_code/gruene_api.swagger.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:intl/intl.dart'; + +class GrueneApiPosterService extends GrueneApiCampaignsService { + GrueneApiPosterService() : super(poiType: PoiServiceType.poster); + + Future createNewPoster(PosterCreateModel newPoster) async { + final requestParam = CreatePoi( + coords: newPoster.location.transformToGeoJsonCoords(), + type: poiType.transformToApiCreateType(), + address: newPoster.address.transformToPoiAddress(), + ); + // saving POI + final newPoiResponse = await grueneApi.v1CampaignsPoisPost(body: requestParam); + + if (newPoiResponse.error == null && newPoster.imageFileLocation != null) { + // saving Photo along with POI + var poiId = newPoiResponse.body!.id; + + await _storeNewPhoto(poiId, newPoster.imageFileLocation!); + } + + return newPoiResponse.body!.transformToMarkerItem(); + } + + Future updatePoster(PosterUpdateModel posterUpdate) async { + var dtoUpdate = UpdatePoi( + address: posterUpdate.address.transformToPoiAddress(), + poster: PoiPoster( + status: posterUpdate.status.transformToPoiPosterStatus(), + comment: posterUpdate.comment.isEmpty ? null : posterUpdate.comment, + ), + ); + var updatePoiResponse = await grueneApi.v1CampaignsPoisPoiIdPut(poiId: posterUpdate.id, body: dtoUpdate); + + if (posterUpdate.newImageFileLocation != null || posterUpdate.removePreviousPhotos) { + for (var photo in updatePoiResponse.body!.photos) { + updatePoiResponse = await grueneApi.v1CampaignsPoisPoiIdPhotosPhotoIdDelete( + poiId: posterUpdate.id, + photoId: photo.id, + ); + } + } + if (posterUpdate.newImageFileLocation != null) { + updatePoiResponse = await _storeNewPhoto(posterUpdate.id, posterUpdate.newImageFileLocation!); + } + + return updatePoiResponse.body!.transformToMarkerItem(); + } + + Future> _storeNewPhoto(String poiId, String imageFileLocation) async { + var timeStamp = DateFormat('yyMMdd_HHmmss').format(DateTime.now()); + var fileManager = GetIt.I(); + var photo = await fileManager.retrieveFileData(imageFileLocation); + // ignore: unused_local_variable + final savePoiPhotoResponse = await grueneApi.v1CampaignsPoisPoiIdPhotosPost( + poiId: poiId, + image: http.MultipartFile.fromBytes( + 'image', + photo, + filename: 'poi_${poiId}_$timeStamp.jpg', + contentType: MediaType('image', 'jpeg'), + ), + ); + fileManager.deleteFile(imageFileLocation); + return savePoiPhotoResponse; + } + + Future getPoiAsPosterDetail(String poiId) async { + return getPoi(poiId, (p) => p.transformPoiToPosterDetail()); + } + + Future getPoiAsPosterListItem(String poiId) { + return getPoi(poiId, (p) => p.transformToPosterListItem()); + } + + Future> getMyPosters() async { + final getPoisType = poiType.transformToApiSelfGetType(); + + final getPoisResult = await grueneApi.v1CampaignsPoisSelfGet(type: getPoisType); + return getPoisResult.body!.data.map((p) => p.transformToPosterListItem()).toList(); + } +} diff --git a/lib/app/services/nominatim_service.dart b/lib/app/services/nominatim_service.dart index a91e0b91..3b6447e9 100644 --- a/lib/app/services/nominatim_service.dart +++ b/lib/app/services/nominatim_service.dart @@ -1,9 +1,12 @@ import 'package:flutter/foundation.dart'; import 'package:gruene_app/app/geocode/nominatim.dart'; import 'package:gruene_app/app/services/converters.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(); @@ -103,13 +106,14 @@ class SearchResultItem { displayName = place.getAddress(); } +@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: @@ -123,4 +127,11 @@ class AddressModel { houseNumber = place.getHouseNumber(), zipCode = place.getZipCode(), city = cityOverride ?? place.getCityOrVillage(); + + /// 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/widgets/app_bar.dart b/lib/app/widgets/app_bar.dart index 61ff5aa0..c2da615b 100644 --- a/lib/app/widgets/app_bar.dart +++ b/lib/app/widgets/app_bar.dart @@ -1,10 +1,15 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; 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'; +import 'package:simple_animations/simple_animations.dart'; class MainAppBar extends StatelessWidget implements PreferredSizeWidget { const MainAppBar({super.key}); @@ -24,14 +29,7 @@ class MainAppBar extends StatelessWidget implements PreferredSizeWidget { backgroundColor: isLoggedIn ? theme.primaryColor : theme.colorScheme.surfaceDim, 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 +45,75 @@ 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; + final campaignActionCache = GetIt.I(); + bool _animateIcon = false; + + @override + void initState() { + campaignActionCache.getCachedActionCount().then((value) { + setState(() { + _currentCount = value; + }); + }); + campaignActionCache.addListener(_setCurrentCounter); + super.initState(); + } + + @override + Widget build(BuildContext context) { + const maxLabelCount = 99; + var labelText = _currentCount > maxLabelCount ? '$maxLabelCount+' : _currentCount.toString(); + + getIcon() => CustomIcon( + path: 'assets/icons/refresh.svg', + color: ThemeColors.background, + ); + + var iconAnimated = LoopAnimationBuilder( + tween: Tween(begin: 0.0, end: 2 * pi), // 0° to 360° (2π) + duration: const Duration(seconds: 2), // for 2 seconds per iteration + + builder: (context, value, _) { + return Transform.rotate( + angle: value, // use value + child: getIcon(), + ); + }, + ); + + return IconButton( + onPressed: _flushCachedData, + icon: Badge( + label: Text(labelText), + isLabelVisible: _currentCount != 0, + child: _animateIcon ? iconAnimated : getIcon(), + ), + ); + } + + void _setCurrentCounter() async { + final newCount = await campaignActionCache.getCachedActionCount(); + final isFlushing = campaignActionCache.isFlushing; + if (!mounted) return; + setState(() { + _currentCount = newCount; + _animateIcon = isFlushing; + }); + } + + void _flushCachedData() { + campaignActionCache.flushCache(); + } +} diff --git a/lib/features/campaigns/helper/campaign_action.dart b/lib/features/campaigns/helper/campaign_action.dart new file mode 100644 index 00000000..b9998116 --- /dev/null +++ b/lib/features/campaigns/helper/campaign_action.dart @@ -0,0 +1,103 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +import 'package:gruene_app/app/services/campaign_action_database.dart'; + +class CampaignAction { + int? id; + int? poiId; + late int poiTempId; + CampaignActionType? actionType; + String? serialized; + + CampaignAction({ + this.id, + 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); + } + + Map toMap() { + return { + CampaignActionFields.id: id, + CampaignActionFields.poiId: poiId, + CampaignActionFields.poiTempId: poiTempId, + CampaignActionFields.actionType: actionType!.index, + CampaignActionFields.serialized: serialized, + }; + } + + factory CampaignAction.fromMap(Map map) { + return CampaignAction( + id: map[CampaignActionFields.id] as int, + poiId: map[CampaignActionFields.poiId] != null ? map[CampaignActionFields.poiId] as int : null, + actionType: map[CampaignActionFields.actionType] != null + ? CampaignActionType.values[map[CampaignActionFields.actionType] as int] + : null, + serialized: map['serialized'] != null ? map['serialized'] as String : null, + )..poiTempId = map[CampaignActionFields.poiTempId] as int; + } + + String toJson() => json.encode(toMap()); + + factory CampaignAction.fromJson(String source) => CampaignAction.fromMap(json.decode(source) as Map); + + CampaignAction copyWith({ + int? id, + int? poiId, + int? poiTempId, + CampaignActionType? actionType, + String? serialized, + }) { + return CampaignAction( + id: id ?? this.id, + poiId: poiId ?? this.poiId, + actionType: actionType ?? this.actionType, + serialized: serialized ?? this.serialized, + )..poiTempId = poiTempId ?? this.poiTempId; + } +} + +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..0e36d07b --- /dev/null +++ b/lib/features/campaigns/helper/campaign_action_cache.dart @@ -0,0 +1,453 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:gruene_app/app/services/campaign_action_database.dart'; +import 'package:gruene_app/app/services/converters.dart'; +import 'package:gruene_app/app/services/enums.dart'; +import 'package:gruene_app/app/services/gruene_api_door_service.dart'; +import 'package:gruene_app/app/services/gruene_api_flyer_service.dart'; +import 'package:gruene_app/app/services/gruene_api_poster_service.dart'; +import 'package:gruene_app/features/campaigns/helper/campaign_action.dart'; +import 'package:gruene_app/features/campaigns/helper/media_helper.dart'; +import 'package:gruene_app/features/campaigns/models/doors/door_create_model.dart'; +import 'package:gruene_app/features/campaigns/models/doors/door_detail_model.dart'; +import 'package:gruene_app/features/campaigns/models/doors/door_update_model.dart'; +import 'package:gruene_app/features/campaigns/models/flyer/flyer_create_model.dart'; +import 'package:gruene_app/features/campaigns/models/flyer/flyer_detail_model.dart'; +import 'package:gruene_app/features/campaigns/models/flyer/flyer_update_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/map_controller_simplified.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +class CampaignActionCache extends ChangeNotifier { + static CampaignActionCache? _instance; + static bool _isflushing = false; + var campaignActionDatabase = CampaignActionDatabase.instance; + + MapControllerSimplified? _currentMapController; + + CampaignActionCache._(); + + factory CampaignActionCache() => _instance ??= CampaignActionCache._(); + + bool get isFlushing => _isflushing; + + Future isCached(String poiId) async { + return campaignActionDatabase.actionsWithPoiIdExists(poiId); + } + + Future _appendActionToCache(CampaignAction action) async { + await campaignActionDatabase.create(action); + notifyListeners(); + } + + Future _updateAction(CampaignAction action) async { + await campaignActionDatabase.update(action); + } + + Future getCachedActionCount() { + return campaignActionDatabase.getCount(); + } + + Future storeNewPoi(PoiServiceType poiType, dynamic poiCreate) async { + switch (poiType) { + case PoiServiceType.poster: + return await _addCreateAction( + poiType: poiType, + poi: poiCreate as PosterCreateModel, + getJson: (poi) => poi.toJson(), + getMarker: (poi, tempId) => poi.transformToVirtualMarkerItem(tempId), + ); + case PoiServiceType.door: + return await _addCreateAction( + poiType: poiType, + poi: poiCreate as DoorCreateModel, + getJson: (poi) => poi.toJson(), + getMarker: (poi, tempId) => poi.transformToVirtualMarkerItem(tempId), + ); + case PoiServiceType.flyer: + return await _addCreateAction( + poiType: poiType, + poi: poiCreate as FlyerCreateModel, + getJson: (poi) => poi.toJson(), + getMarker: (poi, tempId) => poi.transformToVirtualMarkerItem(tempId), + ); + } + } + + Future _addCreateAction({ + required PoiServiceType poiType, + required T poi, + required Map Function(T) getJson, + required MarkerItemModel Function(T, int) getMarker, + }) async { + final action = CampaignAction( + actionType: poiType.getCacheAddAction(), + serialized: jsonEncode(getJson(poi)), + ); + await _appendActionToCache(action); + return getMarker(poi, action.poiTempId); + } + + Future deletePoi(PoiServiceType poiType, String poiId) async { + final action = CampaignAction( + poiId: int.parse(poiId), + actionType: poiType.getCacheDeleteAction(), + ); + + var poiCacheList = await _findActionsByPoiId(poiId); + var addActions = poiCacheList.where((p) => p.actionType == poiType.getCacheAddAction()).toList(); + if (addActions.isNotEmpty) { + // create_action is in cache + for (var action in poiCacheList) { + campaignActionDatabase.delete(action.id!); + } + notifyListeners(); + } else { + await _appendActionToCache(action); + } + return _getDeleteMarkerModel(poiType, action.poiId!); + } + + Future updatePoi(PoiServiceType poiType, dynamic poi) async { + switch (poiType) { + case PoiServiceType.poster: + return await _addUpdateAction( + poiType: poiType, + poi: poi as PosterUpdateModel, + getId: (poi) => poi.id, + getJson: (poi) => poi.toJson(), + mergeUpdates: (action, poiUpdate) => action.getAsPosterUpdate().mergeWith(poiUpdate), + getMarker: (poi) => poi.transformToVirtualMarkerItem(), + ); + case PoiServiceType.door: + return await _addUpdateAction( + poiType: poiType, + poi: poi as DoorUpdateModel, + getId: (poi) => poi.id, + getJson: (poi) => poi.toJson(), + mergeUpdates: (action, poiUpdate) => poiUpdate, + getMarker: (poi) => poi.transformToVirtualMarkerItem(), + ); + case PoiServiceType.flyer: + return await _addUpdateAction( + poiType: poiType, + poi: poi as FlyerUpdateModel, + getId: (poi) => poi.id, + getJson: (poi) => poi.toJson(), + mergeUpdates: (action, poiUpdate) => poiUpdate, + getMarker: (poi) => poi.transformToVirtualMarkerItem(), + ); + } + } + + Future _addUpdateAction({ + required PoiServiceType poiType, + required T poi, + required String Function(T) getId, + required Map Function(T) getJson, + required T Function(CampaignAction, T) mergeUpdates, + required MarkerItemModel Function(T) getMarker, + }) async { + var actions = (await _findActionsByPoiId(getId(poi))).where((x) => x.actionType == poiType.getCacheEditAction()); + var action = actions.singleOrNull; + if (action == null) { + action = CampaignAction( + poiId: int.parse(getId(poi)), + actionType: poiType.getCacheEditAction(), + serialized: jsonEncode(getJson(poi)), + ); + await _appendActionToCache(action); + } else { + // update previous edit action + var newPoiUpdate = mergeUpdates(action, poi); + action.serialized = jsonEncode(getJson(newPoiUpdate)); + await _updateAction(action); + } + + return getMarker(poi); + } + + MarkerItemModel _getDeleteMarkerModel(PoiServiceType poiType, int id) { + return MarkerItemModel.virtual( + id: id, + status: '${poiType.name}_deleted', + location: LatLng(0, 0), + ); + } + + Future> getMarkerItems(PoiServiceType poiType) async { + List markerItems = []; + var posterActions = [ + poiType.getCacheAddAction().index, + poiType.getCacheEditAction().index, + poiType.getCacheDeleteAction().index, + ]; + final posterCacheList = await campaignActionDatabase.readAllByActionType(posterActions); + for (var action in posterCacheList) { + if (markerItems.any((m) => m.id! == action.id)) continue; + switch (action.actionType) { + case CampaignActionType.addPoster: + var model = action.getAsPosterCreate(); + markerItems.add(model.transformToVirtualMarkerItem(action.poiTempId)); + case CampaignActionType.editPoster: + var model = action.getAsPosterUpdate(); + markerItems.add(model.transformToVirtualMarkerItem()); + case CampaignActionType.deletePoster: + var model = _getDeleteMarkerModel(PoiServiceType.poster, action.poiId!); + markerItems.add(model); + + case CampaignActionType.addDoor: + var model = action.getAsDoorCreate(); + markerItems.add(model.transformToVirtualMarkerItem(action.poiTempId)); + case CampaignActionType.editDoor: + var model = action.getAsDoorUpdate(); + markerItems.add(model.transformToVirtualMarkerItem()); + case CampaignActionType.deleteDoor: + var model = _getDeleteMarkerModel(PoiServiceType.door, action.poiId!); + markerItems.add(model); + + case CampaignActionType.addFlyer: + var model = action.getAsFlyerCreate(); + markerItems.add(model.transformToVirtualMarkerItem(action.poiTempId)); + case CampaignActionType.editFlyer: + var model = action.getAsFlyerUpdate(); + markerItems.add(model.transformToVirtualMarkerItem()); + case CampaignActionType.deleteFlyer: + var model = _getDeleteMarkerModel(PoiServiceType.flyer, action.poiId!); + markerItems.add(model); + + case CampaignActionType.unknown: + case null: + throw UnimplementedError(); + } + } + return markerItems; + } + + Future getPoiAsPosterDetail(String poiId) async { + var detailModel = await _getPoiDetail( + poiId: poiId, + addActionFilter: CampaignActionType.addPoster, + editActionFilter: CampaignActionType.editPoster, + transformEditAction: (action) => action.getAsPosterUpdate().transformToPosterDetailModel(), + transformAddAction: (action) => action.getAsPosterCreate().transformToPosterDetailModel(poiId), + ); + return detailModel; + } + + Future getPoiAsDoorDetail(String poiId) async { + var detailModel = await _getPoiDetail( + poiId: poiId, + addActionFilter: CampaignActionType.addDoor, + editActionFilter: CampaignActionType.editDoor, + transformEditAction: (action) => action.getAsDoorUpdate().transformToDoorDetailModel(), + transformAddAction: (action) => action.getAsDoorCreate().transformToDoorDetailModel(poiId), + ); + return detailModel; + } + + Future getPoiAsFlyerDetail(String poiId) async { + var detailModel = await _getPoiDetail( + poiId: poiId, + addActionFilter: CampaignActionType.addFlyer, + editActionFilter: CampaignActionType.editFlyer, + transformEditAction: (action) => action.getAsFlyerUpdate().transformToFlyerDetailModel(), + transformAddAction: (action) => action.getAsFlyerCreate().transformToFlyerDetailModel(poiId), + ); + return detailModel; + } + + Future _getPoiDetail({ + required String poiId, + required CampaignActionType addActionFilter, + required CampaignActionType editActionFilter, + required T Function(CampaignAction) transformEditAction, + required T Function(CampaignAction) transformAddAction, + }) async { + var cacheList = await _findActionsByPoiId(poiId); + var editActions = cacheList.where((p) => p.actionType == editActionFilter).toList(); + if (editActions.isNotEmpty) { + var editAction = editActions.single; + return transformEditAction(editAction); + } else { + var addActions = cacheList.where((p) => p.actionType == addActionFilter).toList(); + var addAction = addActions.single; + return transformAddAction(addAction); + } + } + + Future> _findActionsByPoiId(String poiId) async { + var posterCacheList = campaignActionDatabase.getActionsWithPoiId(poiId); + return posterCacheList; + } + + void flushCache() async { + if (_isflushing) return; + try { + _isflushing = true; + notifyListeners(); + + var posterApiService = GetIt.I(); + var doorApiService = GetIt.I(); + var flyerApiService = GetIt.I(); + final allActions = await campaignActionDatabase.readAll(); + + for (int i = 0; i < allActions.length; i++) { + var action = allActions[i]; + switch (action.actionType) { + case CampaignActionType.addPoster: + var model = action.getAsPosterCreate(); + var newPosterMarker = await posterApiService.createNewPoster(model); + await _updateIdsInCache( + oldId: action.poiTempId, + newId: newPosterMarker.id!, + startIndex: i + 1, + allActions: allActions, + ); + campaignActionDatabase.delete(action.id!); + + case CampaignActionType.editPoster: + var model = action.getAsPosterUpdate(); + await posterApiService.updatePoster(model); + campaignActionDatabase.delete(action.id!); + + case CampaignActionType.addDoor: + var model = action.getAsDoorCreate(); + var newDoorMarker = await doorApiService.createNewDoor(model); + await _updateIdsInCache( + oldId: action.poiTempId, + newId: newDoorMarker.id!, + startIndex: i + 1, + allActions: allActions, + ); + campaignActionDatabase.delete(action.id!); + + case CampaignActionType.editDoor: + var model = action.getAsDoorUpdate(); + await doorApiService.updateDoor(model); + campaignActionDatabase.delete(action.id!); + + case CampaignActionType.addFlyer: + var model = action.getAsFlyerCreate(); + var newDoorMarker = await flyerApiService.createNewFlyer(model); + await _updateIdsInCache( + oldId: action.poiTempId, + newId: newDoorMarker.id!, + startIndex: i + 1, + allActions: allActions, + ); + campaignActionDatabase.delete(action.id!); + case CampaignActionType.editFlyer: + var model = action.getAsFlyerUpdate(); + await flyerApiService.updateFlyer(model); + campaignActionDatabase.delete(action.id!); + + case CampaignActionType.deleteDoor: + case CampaignActionType.deletePoster: + case CampaignActionType.deleteFlyer: + await posterApiService.deletePoi(action.poiId!.toString()); + campaignActionDatabase.delete(action.id!); + + case CampaignActionType.unknown: + case null: + throw UnimplementedError(); + } + notifyListeners(); + } + } finally { + if (await getCachedActionCount() == 0) { + MediaHelper.removeAllFiles(); + } + if (_currentMapController != null) { + _currentMapController!.resetMarkerItems(); + } + _isflushing = false; + notifyListeners(); + } + } + + Future _updateIdsInCache({ + required int oldId, + required int newId, + required List allActions, + int startIndex = 0, + }) async { + await campaignActionDatabase.updatePoiId(oldId, newId); + for (var j = startIndex; j < allActions.length; j++) { + if (allActions[j].poiId == null) continue; + if (allActions[j].poiId! == oldId) { + allActions[j] = allActions[j].copyWith(poiId: newId); + } + } + } + + Future replaceAndFillUpMyPosterList(List myPosters) async { + for (var i = 0; i < myPosters.length; i++) { + final currentPoster = myPosters[i]; + var posterCacheList = await _findActionsByPoiId(currentPoster.id); + var deletePosterActions = posterCacheList.where((p) => p.actionType == CampaignActionType.deletePoster).toList(); + if (deletePosterActions.isNotEmpty) { + myPosters.remove(currentPoster); + i--; + continue; + } + var editPosterActions = posterCacheList.where((p) => p.actionType == CampaignActionType.editPoster).toList(); + if (editPosterActions.isNotEmpty) { + var editPosterAction = editPosterActions.single; + var posterListItem = editPosterAction.getPosterUpdateAsPosterListItem(currentPoster.createdAt); + myPosters[i] = posterListItem; + } + } + + var newPosterCacheList = await campaignActionDatabase.readAllByActionType([CampaignActionType.addPoster.index]); + for (var newPoster in newPosterCacheList) { + var posterCacheList = await _findActionsByPoiId(newPoster.poiTempId.toString()); + var deletePosterActions = posterCacheList.where((p) => p.actionType == CampaignActionType.deletePoster).toList(); + if (deletePosterActions.isNotEmpty) { + continue; + } + var editPosterActions = posterCacheList.where((p) => p.actionType == CampaignActionType.editPoster).toList(); + if (editPosterActions.isNotEmpty) { + var editPosterAction = editPosterActions.single; + var posterListItem = + editPosterAction.getPosterUpdateAsPosterListItem(DateTime.fromMillisecondsSinceEpoch(newPoster.poiTempId)); + myPosters.add(posterListItem); + continue; + } + var addPosterActions = posterCacheList.where((p) => p.actionType == CampaignActionType.addPoster).toList(); + if (addPosterActions.isNotEmpty) { + var addPosterAction = addPosterActions.single; + var posterListItem = addPosterAction.getPosterCreateAsPosterListItem(); + myPosters.add(posterListItem); + continue; + } + } + } + + Future getPoiAsPosterListItem(String id, {DateTime? createdAt}) async { + var posterCacheList = await _findActionsByPoiId(id); + var editPosterActions = posterCacheList.where((p) => p.actionType == CampaignActionType.editPoster).toList(); + if (editPosterActions.isNotEmpty) { + var editPosterAction = editPosterActions.single; + var posterListItem = editPosterAction.getPosterUpdateAsPosterListItem(createdAt ?? DateTime.now()); + return posterListItem; + } + var addPosterActions = posterCacheList.where((p) => p.actionType == CampaignActionType.addPoster).toList(); + if (addPosterActions.isNotEmpty) { + var addPosterAction = addPosterActions.single; + var posterListItem = addPosterAction.getPosterCreateAsPosterListItem(); + return posterListItem; + } + throw UnimplementedError(); + } + + void setCurrentMapController(MapControllerSimplified controller) { + _currentMapController = controller; + } +} diff --git a/lib/features/campaigns/helper/campaign_action_cache_timer.dart b/lib/features/campaigns/helper/campaign_action_cache_timer.dart new file mode 100644 index 00000000..69cbf211 --- /dev/null +++ b/lib/features/campaigns/helper/campaign_action_cache_timer.dart @@ -0,0 +1,22 @@ +import 'dart:async'; +import 'package:get_it/get_it.dart'; +import 'package:gruene_app/app/auth/repository/auth_repository.dart'; +import 'package:gruene_app/features/campaigns/helper/campaign_action_cache.dart'; + +class CampaignActionCacheTimer { + late Timer timer; + + CampaignActionCacheTimer() { + timer = Timer.periodic(Duration(minutes: 5), (timer) => _flushData()); + + // initial flush + Future.delayed(Duration(seconds: 5), () => Timer.run(_flushData)); + } + + void _flushData() async { + var authRepo = AuthRepository(); + if (await authRepo.getAccessToken() != null) { + GetIt.I().flushCache(); + } + } +} diff --git a/lib/features/campaigns/helper/file_cache_manager.dart b/lib/features/campaigns/helper/file_cache_manager.dart new file mode 100644 index 00000000..62561dfc --- /dev/null +++ b/lib/features/campaigns/helper/file_cache_manager.dart @@ -0,0 +1,52 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +class FileManager { + static const String storeDirName = 'wk-campaign-file-store'; + + Future storeFile(String filename, Uint8List data) async { + var fullFileName = await _getFullFileName(filename); + File file = await File(fullFileName).create(recursive: true); + await file.writeAsBytes(data); + return fullFileName; + } + + Future _getStore() async { + final docsDir = await getApplicationDocumentsDirectory(); + return p.join(docsDir.path, storeDirName); + } + + Future _getFullFileName(String filename) async { + return p.join(await _getStore(), p.basename(filename)); + } + + void deleteFile(String filename) async { + var fullFileName = await _getFullFileName(filename); + var file = File(fullFileName); + if (await file.exists()) { + await file.delete(); + } + } + + Future retrieveFileData(String filename) async { + var fullFileName = await _getFullFileName(filename); + var file = File(fullFileName); + return file.readAsBytes(); + } + + Future clearAllFiles() async { + var dir = Directory(await _getStore()); + if (!(await dir.exists())) return; + var allFiles = await dir.list().toList(); + for (var file in allFiles) { + var stat = await file.stat(); + // delete all files older than 30 days, assuming these are outdated + if (stat.modified.add(Duration(days: 30)).millisecondsSinceEpoch < DateTime.now().millisecondsSinceEpoch) { + file.delete(); + } + } + } +} 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..f018c767 100644 --- a/lib/features/campaigns/helper/marker_item_manager.dart +++ b/lib/features/campaigns/helper/marker_item_manager.dart @@ -1,18 +1,40 @@ 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) { loadedMarkers.retainWhere((item) => item.id == null || item.id != markerItemId); } + + void resetAllMarkers() { + loadedMarkers.clear(); + virtualMarkers.clear(); + } } diff --git a/lib/features/campaigns/helper/media_helper.dart b/lib/features/campaigns/helper/media_helper.dart index da7ca95f..390270eb 100644 --- a/lib/features/campaigns/helper/media_helper.dart +++ b/lib/features/campaigns/helper/media_helper.dart @@ -6,6 +6,7 @@ import 'package:get_it/get_it.dart'; import 'package:gruene_app/app/theme/theme.dart'; import 'package:gruene_app/features/campaigns/helper/app_settings.dart'; import 'package:gruene_app/features/campaigns/helper/enums.dart'; +import 'package:gruene_app/features/campaigns/helper/file_cache_manager.dart'; import 'package:gruene_app/i18n/translations.g.dart'; import 'package:image/image.dart' as image_lib; import 'package:image_picker/image_picker.dart'; @@ -146,4 +147,12 @@ class MediaHelper { }, ); } + + static Future storeImage(Uint8List imageData) async { + return GetIt.I().storeFile('${DateTime.now().millisecondsSinceEpoch}.jpg', imageData); + } + + static void removeAllFiles() { + GetIt.I().clearAllFiles(); + } } 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/doors/door_create_model.dart b/lib/features/campaigns/models/doors/door_create_model.dart index 1cce9bea..53898941 100644 --- a/lib/features/campaigns/models/doors/door_create_model.dart +++ b/lib/features/campaigns/models/doors/door_create_model.dart @@ -1,7 +1,13 @@ import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +part 'door_create_model.g.dart'; + +@JsonSerializable() class DoorCreateModel { + @LatLongConverter() final LatLng location; final AddressModel address; final int openedDoors; @@ -13,4 +19,8 @@ class DoorCreateModel { required this.openedDoors, required this.closedDoors, }); + + factory DoorCreateModel.fromJson(Map json) => _$DoorCreateModelFromJson(json); + + Map toJson() => _$DoorCreateModelToJson(this); } diff --git a/lib/features/campaigns/models/doors/door_detail_model.dart b/lib/features/campaigns/models/doors/door_detail_model.dart index ca4bfe0e..ad15754a 100644 --- a/lib/features/campaigns/models/doors/door_detail_model.dart +++ b/lib/features/campaigns/models/doors/door_detail_model.dart @@ -1,5 +1,13 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'package:gruene_app/app/services/converters.dart'; import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +part 'door_detail_model.g.dart'; + +@JsonSerializable() class DoorDetailModel { String id; @@ -7,6 +15,9 @@ class DoorDetailModel { final int openedDoors; final int closedDoors; final String createdAt; + @LatLongConverter() + final LatLng location; + final bool isCached; DoorDetailModel({ required this.id, @@ -14,5 +25,31 @@ class DoorDetailModel { required this.openedDoors, required this.closedDoors, required this.createdAt, + required this.location, + this.isCached = false, }); + + factory DoorDetailModel.fromJson(Map json) => _$DoorDetailModelFromJson(json.convertLatLongField()); + + Map toJson() => _$DoorDetailModelToJson(this); + + DoorDetailModel copyWith({ + String? id, + AddressModel? address, + int? openedDoors, + int? closedDoors, + String? createdAt, + LatLng? location, + bool? isCached, + }) { + return DoorDetailModel( + id: id ?? this.id, + address: address ?? this.address, + openedDoors: openedDoors ?? this.openedDoors, + closedDoors: closedDoors ?? this.closedDoors, + createdAt: createdAt ?? this.createdAt, + location: location ?? this.location, + isCached: isCached ?? this.isCached, + ); + } } diff --git a/lib/features/campaigns/models/doors/door_update_model.dart b/lib/features/campaigns/models/doors/door_update_model.dart index 1b3e712f..b6bf5c6d 100644 --- a/lib/features/campaigns/models/doors/door_update_model.dart +++ b/lib/features/campaigns/models/doors/door_update_model.dart @@ -1,15 +1,31 @@ import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:gruene_app/features/campaigns/models/doors/door_detail_model.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +part 'door_update_model.g.dart'; + +@JsonSerializable() class DoorUpdateModel { - int openedDoors; - int closedDoors; - String id; - AddressModel address; + final int openedDoors; + final int closedDoors; + final String id; + final AddressModel address; + @LatLongConverter() + final LatLng location; + final DoorDetailModel oldDoorDetail; DoorUpdateModel({ required this.id, required this.address, required this.openedDoors, required this.closedDoors, + required this.oldDoorDetail, + required this.location, }); + + factory DoorUpdateModel.fromJson(Map json) => _$DoorUpdateModelFromJson(json); + + Map toJson() => _$DoorUpdateModelToJson(this); } diff --git a/lib/features/campaigns/models/flyer/flyer_create_model.dart b/lib/features/campaigns/models/flyer/flyer_create_model.dart index 7f39f199..21caab49 100644 --- a/lib/features/campaigns/models/flyer/flyer_create_model.dart +++ b/lib/features/campaigns/models/flyer/flyer_create_model.dart @@ -1,7 +1,13 @@ import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +part 'flyer_create_model.g.dart'; + +@JsonSerializable() class FlyerCreateModel { + @LatLongConverter() final LatLng location; final AddressModel address; @@ -9,4 +15,8 @@ class FlyerCreateModel { final int flyerCount; FlyerCreateModel({required this.location, required this.address, required this.flyerCount}); + + factory FlyerCreateModel.fromJson(Map json) => _$FlyerCreateModelFromJson(json); + + Map toJson() => _$FlyerCreateModelToJson(this); } diff --git a/lib/features/campaigns/models/flyer/flyer_detail_model.dart b/lib/features/campaigns/models/flyer/flyer_detail_model.dart index 5b5fb644..a79ab825 100644 --- a/lib/features/campaigns/models/flyer/flyer_detail_model.dart +++ b/lib/features/campaigns/models/flyer/flyer_detail_model.dart @@ -1,18 +1,52 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'package:gruene_app/app/services/converters.dart'; import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +part 'flyer_detail_model.g.dart'; + +@JsonSerializable() class FlyerDetailModel { final String id; - final AddressModel address; - final int flyerCount; - final String createdAt; + final bool isCached; + + @LatLongConverter() + final LatLng location; FlyerDetailModel({ required this.id, required this.address, required this.flyerCount, required this.createdAt, + required this.location, + this.isCached = false, }); + + factory FlyerDetailModel.fromJson(Map json) => + _$FlyerDetailModelFromJson(json.convertLatLongField()); + + Map toJson() => _$FlyerDetailModelToJson(this); + + FlyerDetailModel copyWith({ + String? id, + AddressModel? address, + int? flyerCount, + String? createdAt, + bool? isCached, + LatLng? location, + }) { + return FlyerDetailModel( + id: id ?? this.id, + address: address ?? this.address, + flyerCount: flyerCount ?? this.flyerCount, + createdAt: createdAt ?? this.createdAt, + isCached: isCached ?? this.isCached, + location: location ?? this.location, + ); + } } diff --git a/lib/features/campaigns/models/flyer/flyer_update_model.dart b/lib/features/campaigns/models/flyer/flyer_update_model.dart index e07ffe09..17be5a45 100644 --- a/lib/features/campaigns/models/flyer/flyer_update_model.dart +++ b/lib/features/campaigns/models/flyer/flyer_update_model.dart @@ -1,9 +1,29 @@ import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:gruene_app/features/campaigns/models/flyer/flyer_detail_model.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +part 'flyer_update_model.g.dart'; + +@JsonSerializable() class FlyerUpdateModel { final String id; final AddressModel address; final int flyerCount; + @LatLongConverter() + final LatLng location; + final FlyerDetailModel oldFlyerDetail; + + FlyerUpdateModel({ + required this.id, + required this.address, + required this.flyerCount, + required this.location, + required this.oldFlyerDetail, + }); + + factory FlyerUpdateModel.fromJson(Map json) => _$FlyerUpdateModelFromJson(json); - FlyerUpdateModel({required this.id, required this.address, required this.flyerCount}); + Map toJson() => _$FlyerUpdateModelToJson(this); } 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..eea5eafa 100644 --- a/lib/features/campaigns/models/posters/poster_create_model.dart +++ b/lib/features/campaigns/models/posters/poster_create_model.dart @@ -1,16 +1,62 @@ 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; - final Uint8List? photo; - const PosterCreateModel({ + final String? imageFileLocation; + + PosterCreateModel({ required this.address, - this.photo, + this.imageFileLocation, 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(); + } +} + +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..7bcbd5cc 100644 --- a/lib/features/campaigns/models/posters/poster_detail_model.dart +++ b/lib/features/campaigns/models/posters/poster_detail_model.dart @@ -1,7 +1,23 @@ +import 'package:gruene_app/app/services/converters.dart'; import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; -enum PosterStatus { ok, damaged, missing, removed } +part 'poster_detail_model.g.dart'; +enum PosterStatus { + @JsonValue(100) + ok, + @JsonValue(200) + damaged, + @JsonValue(300) + missing, + @JsonValue(400) + removed, +} + +@JsonSerializable() class PosterDetailModel { String id; String? thumbnailUrl; @@ -11,6 +27,9 @@ class PosterDetailModel { final String comment; final PosterStatus status; final String createdAt; + final bool isCached; + @LatLongConverter() + final LatLng location; PosterDetailModel({ required this.id, @@ -20,5 +39,36 @@ class PosterDetailModel { required this.status, required this.comment, required this.createdAt, + required this.location, + this.isCached = false, }); + + PosterDetailModel copyWith({ + String? id, + String? thumbnailUrl, + String? imageUrl, + AddressModel? address, + String? comment, + PosterStatus? status, + String? createdAt, + bool? isCached, + LatLng? location, + }) { + return PosterDetailModel( + id: id ?? this.id, + thumbnailUrl: thumbnailUrl ?? this.thumbnailUrl, + imageUrl: imageUrl ?? this.imageUrl, + address: address ?? this.address, + comment: comment ?? this.comment, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + isCached: isCached ?? this.isCached, + location: location ?? this.location, + ); + } + + factory PosterDetailModel.fromJson(Map json) => + _$PosterDetailModelFromJson(json.convertLatLongField()); + + Map toJson() => _$PosterDetailModelToJson(this); } diff --git a/lib/features/campaigns/models/posters/poster_list_item_model.dart b/lib/features/campaigns/models/posters/poster_list_item_model.dart index e475c7dc..32e1f0fd 100644 --- a/lib/features/campaigns/models/posters/poster_list_item_model.dart +++ b/lib/features/campaigns/models/posters/poster_list_item_model.dart @@ -9,6 +9,7 @@ class PosterListItemModel { final String lastChangeStatus; final String lastChangeDateTime; final DateTime createdAt; + final bool isCached; const PosterListItemModel({ required this.id, @@ -19,5 +20,6 @@ class PosterListItemModel { required this.lastChangeStatus, required this.lastChangeDateTime, required this.createdAt, + 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..5d8ac1cb 100644 --- a/lib/features/campaigns/models/posters/poster_update_model.dart +++ b/lib/features/campaigns/models/posters/poster_update_model.dart @@ -1,15 +1,22 @@ -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; - final Uint8List? newPhoto; + final String? newImageFileLocation; + @LatLongConverter() + final LatLng location; final bool removePreviousPhotos; + final PosterDetailModel oldPosterDetail; PosterUpdateModel({ required this.id, @@ -17,6 +24,34 @@ class PosterUpdateModel { required this.status, required this.comment, required this.removePreviousPhotos, - this.newPhoto, + required this.location, + this.newImageFileLocation, + required this.oldPosterDetail, }); + + factory PosterUpdateModel.fromJson(Map json) => _$PosterUpdateModelFromJson(json); + + Map toJson() => _$PosterUpdateModelToJson(this); + + PosterUpdateModel copyWith({ + String? id, + AddressModel? address, + PosterStatus? status, + String? comment, + String? newImageFileLocation, + LatLng? location, + bool? removePreviousPhotos, + PosterDetailModel? oldPosterDetail, + }) { + return PosterUpdateModel( + id: id ?? this.id, + address: address ?? this.address, + status: status ?? this.status, + comment: comment ?? this.comment, + newImageFileLocation: newImageFileLocation ?? this.newImageFileLocation, + location: location ?? this.location, + removePreviousPhotos: removePreviousPhotos ?? this.removePreviousPhotos, + oldPosterDetail: oldPosterDetail ?? this.oldPosterDetail, + ); + } } diff --git a/lib/features/campaigns/screens/door_edit.dart b/lib/features/campaigns/screens/door_edit.dart index 2c2174c3..ae854214 100644 --- a/lib/features/campaigns/screens/door_edit.dart +++ b/lib/features/campaigns/screens/door_edit.dart @@ -153,6 +153,8 @@ class _DoorEditState extends State with AddressExtension, DoorValidato address: getAddress(), openedDoors: validationResult.openedDoors, closedDoors: validationResult.closedDoors, + location: widget.door.location, + oldDoorDetail: widget.door, ); await widget.onSave(updateModel); _closeDialog(); diff --git a/lib/features/campaigns/screens/doors_screen.dart b/lib/features/campaigns/screens/doors_screen.dart index fff9ffc1..b5ceb071 100644 --- a/lib/features/campaigns/screens/doors_screen.dart +++ b/lib/features/campaigns/screens/doors_screen.dart @@ -1,14 +1,15 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; 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/gruene_api_door_service.dart'; import 'package:gruene_app/app/services/nominatim_service.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/models/doors/door_create_model.dart'; import 'package:gruene_app/features/campaigns/models/doors/door_detail_model.dart'; import 'package:gruene_app/features/campaigns/models/doors/door_update_model.dart'; -import 'package:gruene_app/features/campaigns/models/marker_item_model.dart'; import 'package:gruene_app/features/campaigns/screens/door_edit.dart'; import 'package:gruene_app/features/campaigns/screens/doors_add_screen.dart'; import 'package:gruene_app/features/campaigns/screens/doors_detail.dart'; @@ -25,7 +26,8 @@ class DoorsScreen extends StatefulWidget { State createState() => _DoorsScreenState(); } -class _DoorsScreenState extends MapConsumer { +class _DoorsScreenState extends MapConsumer { + static const _poiType = PoiServiceType.door; final Map> doorsExclusions = >{ t.campaigns.filters.focusAreas: [t.campaigns.filters.visited_areas], t.campaigns.filters.visited_areas: [t.campaigns.filters.focusAreas], @@ -33,12 +35,12 @@ class _DoorsScreenState extends MapConsumer { late List doorsFilter; - final GrueneApiCampaignsService _grueneApiService = GrueneApiCampaignsService(poiType: PoiServiceType.door); + final _grueneApiService = GetIt.I(); - _DoorsScreenState() : super(); + _DoorsScreenState() : super(_poiType); @override - GrueneApiCampaignsService get campaignService => _grueneApiService; + GrueneApiDoorService get campaignService => _grueneApiService; @override void initState() { @@ -70,6 +72,7 @@ class _DoorsScreenState extends MapConsumer { onMapCreated: onMapCreated, addPOIClicked: _addPOIClicked, loadVisibleItems: loadVisibleItems, + loadCachedItems: loadCachedItems, getMarkerImages: _getMarkerImages, onFeatureClick: _onFeatureClick, onNoFeatureClick: _onNoFeatureClick, @@ -98,7 +101,7 @@ class _DoorsScreenState extends MapConsumer { location, null, _getAddScreen, - _saveNewAndGetMarkerItem, + saveNewAndGetMarkerItem, ); } @@ -109,11 +112,21 @@ class _DoorsScreenState extends MapConsumer { } void _onFeatureClick(dynamic rawFeature) async { + final feature = rawFeature as Map; + final isCached = MapHelper.extractIsCachedFromFeature(feature); + getPoi(String poiId) async { final door = await campaignService.getPoiAsDoorDetail(poiId); return door; } + getCachedPoi(String poiId) async { + final door = await campaignActionCache.getPoiAsDoorDetail(poiId); + return door; + } + + var getPoiFromCacheOrApi = isCached ? getCachedPoi : getPoi; + getPoiDetail(DoorDetailModel door) { return DoorsDetail( poi: door, @@ -121,12 +134,12 @@ class _DoorsScreenState extends MapConsumer { } getEditPoiWidget(DoorDetailModel door) { - return DoorEdit(door: door, onSave: _saveDoor, onDelete: deletePoi); + return DoorEdit(door: door, onSave: savePoi, onDelete: deletePoi); } super.onFeatureClick( rawFeature, - getPoi, + getPoiFromCacheOrApi, getPoiDetail, getEditPoiWidget, desiredSize: Size(145, 110), @@ -137,18 +150,10 @@ class _DoorsScreenState extends MapConsumer { showFocusAreaInfoAtPoint(point); } - Future _saveDoor(DoorUpdateModel doorUpdate) async { - final updatedMarker = await campaignService.updateDoor(doorUpdate); - mapController.setMarkerSource([updatedMarker]); - } - DoorsAddScreen _getAddScreen(LatLng location, AddressModel? address, Object? additionalData) { return DoorsAddScreen( location: location, address: address!, ); } - - Future _saveNewAndGetMarkerItem(DoorCreateModel newDoor) async => - await _grueneApiService.createNewDoor(newDoor); } diff --git a/lib/features/campaigns/screens/flyer_edit.dart b/lib/features/campaigns/screens/flyer_edit.dart index 570cfcef..977c6d3d 100644 --- a/lib/features/campaigns/screens/flyer_edit.dart +++ b/lib/features/campaigns/screens/flyer_edit.dart @@ -133,6 +133,8 @@ class _FlyerEditState extends State with AddressExtension, FlyerValid id: widget.flyer.id, address: getAddress(), flyerCount: validationResult.flyerCount, + oldFlyerDetail: widget.flyer, + location: widget.flyer.location, ); await widget.onSave(updateModel); _closeDialog(); diff --git a/lib/features/campaigns/screens/flyer_screen.dart b/lib/features/campaigns/screens/flyer_screen.dart index 152d93de..e561088a 100644 --- a/lib/features/campaigns/screens/flyer_screen.dart +++ b/lib/features/campaigns/screens/flyer_screen.dart @@ -1,14 +1,15 @@ import 'dart:math'; import 'package:flutter/widgets.dart'; +import 'package:get_it/get_it.dart'; 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/gruene_api_flyer_service.dart'; import 'package:gruene_app/app/services/nominatim_service.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/models/flyer/flyer_create_model.dart'; import 'package:gruene_app/features/campaigns/models/flyer/flyer_detail_model.dart'; import 'package:gruene_app/features/campaigns/models/flyer/flyer_update_model.dart'; -import 'package:gruene_app/features/campaigns/models/marker_item_model.dart'; import 'package:gruene_app/features/campaigns/screens/flyer_add_screen.dart'; import 'package:gruene_app/features/campaigns/screens/flyer_detail.dart'; import 'package:gruene_app/features/campaigns/screens/flyer_edit.dart'; @@ -25,12 +26,13 @@ class FlyerScreen extends StatefulWidget { State createState() => _FlyerScreenState(); } -class _FlyerScreenState extends MapConsumer { - final GrueneApiCampaignsService _grueneApiService = GrueneApiCampaignsService(poiType: PoiServiceType.flyer); +class _FlyerScreenState extends MapConsumer { + static const _poiType = PoiServiceType.flyer; + final _grueneApiService = GetIt.I(); late List flyerFilter; - _FlyerScreenState() : super(); + _FlyerScreenState() : super(_poiType); @override void initState() { @@ -62,6 +64,7 @@ class _FlyerScreenState extends MapConsumer { onMapCreated: onMapCreated, addPOIClicked: _addPOIClicked, loadVisibleItems: loadVisibleItems, + loadCachedItems: loadCachedItems, getMarkerImages: _getMarkerImages, onFeatureClick: _onFeatureClick, onNoFeatureClick: _onNoFeatureClick, @@ -90,7 +93,7 @@ class _FlyerScreenState extends MapConsumer { location, null, _getAddScreen, - _saveNewAndGetMarkerItem, + saveNewAndGetMarkerItem, ); } @@ -101,11 +104,21 @@ class _FlyerScreenState extends MapConsumer { } void _onFeatureClick(dynamic rawFeature) async { + final feature = rawFeature as Map; + final isCached = MapHelper.extractIsCachedFromFeature(feature); + getPoi(String poiId) async { final flyer = await campaignService.getPoiAsFlyerDetail(poiId); return flyer; } + getCachedPoi(String poiId) async { + final flyer = await campaignActionCache.getPoiAsFlyerDetail(poiId); + return flyer; + } + + var getPoiFromCacheOrApi = isCached ? getCachedPoi : getPoi; + getPoiDetail(FlyerDetailModel flyer) { return FlyerDetail( poi: flyer, @@ -113,12 +126,12 @@ class _FlyerScreenState extends MapConsumer { } getEditPoiWidget(FlyerDetailModel flyer) { - return FlyerEdit(flyer: flyer, onSave: _saveFlyer, onDelete: deletePoi); + return FlyerEdit(flyer: flyer, onSave: savePoi, onDelete: deletePoi); } super.onFeatureClick( rawFeature, - getPoi, + getPoiFromCacheOrApi, getPoiDetail, getEditPoiWidget, desiredSize: Size(150, 92), @@ -130,7 +143,7 @@ class _FlyerScreenState extends MapConsumer { } @override - GrueneApiCampaignsService get campaignService => _grueneApiService; + GrueneApiFlyerService get campaignService => _grueneApiService; FlyerAddScreen _getAddScreen(LatLng location, AddressModel? address, Object? additionalData) { return FlyerAddScreen( @@ -138,13 +151,4 @@ class _FlyerScreenState extends MapConsumer { address: address!, ); } - - Future _saveNewAndGetMarkerItem(FlyerCreateModel newFlyer) async { - return await campaignService.createNewFlyer(newFlyer); - } - - Future _saveFlyer(FlyerUpdateModel flyerUpdate) async { - final updatedMarker = await campaignService.updateFlyer(flyerUpdate); - mapController.setMarkerSource([updatedMarker]); - } } diff --git a/lib/features/campaigns/screens/map_consumer.dart b/lib/features/campaigns/screens/map_consumer.dart index 68fccbc3..11d19e0f 100644 --- a/lib/features/campaigns/screens/map_consumer.dart +++ b/lib/features/campaigns/screens/map_consumer.dart @@ -3,9 +3,11 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +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/enums.dart'; import 'package:gruene_app/features/campaigns/helper/map_helper.dart'; @@ -25,9 +27,10 @@ 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 poiId); -abstract class MapConsumer extends State with FocusAreaInfo, SearchMixin { +abstract class MapConsumer extends State + with FocusAreaInfo, SearchMixin { late MapController mapController; final NominatimService _nominatimService = GetIt.I(); @@ -37,8 +40,10 @@ abstract class MapConsumer extends State with Focus final _minZoomFocusAreaLayer = 11.0; ScaffoldFeatureController? _lastInfoSnackBar; String? _lastFocusAreaId; + final campaignActionCache = GetIt.I(); + final PoiServiceType poiType; - MapConsumer(); + MapConsumer(this.poiType); GrueneApiCampaignsService get campaignService; @@ -148,10 +153,9 @@ abstract class MapConsumer extends State with Focus ); } - void deletePoi(String poiId) async { - final id = int.parse(poiId); - await campaignService.deletePoi(poiId); - mapController.removeMarkerItem(id); + Future deletePoi(String poiId) async { + var markerItem = await campaignActionCache.deletePoi(poiType, poiId); + mapController.setMarkerSource([markerItem]); } void addMapLayersForContext(MapLibreMapController mapLibreController) async { @@ -333,4 +337,17 @@ abstract class MapConsumer extends State with Focus void navigateMapTo(LatLng location) { mapController.navigateMapTo(location); } + + void loadCachedItems() async { + var markerItems = await campaignActionCache.getMarkerItems(poiType); + mapController.setMarkerSource(markerItems); + } + + Future savePoi(PoiUpdateType poiUpdate) async { + final updatedMarker = await campaignActionCache.updatePoi(poiType, poiUpdate); + mapController.setMarkerSource([updatedMarker]); + } + + Future saveNewAndGetMarkerItem(PoiCreateType newPoi) async => + await campaignActionCache.storeNewPoi(poiType, newPoi); } diff --git a/lib/features/campaigns/screens/my_poster_list_screen.dart b/lib/features/campaigns/screens/my_poster_list_screen.dart index fa0a9cec..7707c49f 100644 --- a/lib/features/campaigns/screens/my_poster_list_screen.dart +++ b/lib/features/campaigns/screens/my_poster_list_screen.dart @@ -1,4 +1,7 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:gruene_app/app/services/converters.dart'; import 'package:gruene_app/app/theme/theme.dart'; import 'package:gruene_app/features/campaigns/helper/campaign_constants.dart'; import 'package:gruene_app/features/campaigns/helper/enums.dart'; @@ -40,6 +43,7 @@ class _MyPosterListScreenState extends State { return Container( padding: EdgeInsets.all(16), child: Column( + mainAxisAlignment: MainAxisAlignment.start, children: [ Container( height: 52, @@ -119,7 +123,7 @@ class _MyPosterListScreenState extends State { child: FittedBox( fit: BoxFit.cover, child: FutureBuilder( - future: Future.delayed(Duration.zero, () => myPoster.thumbnailUrl), + future: Future.delayed(Duration.zero, () => (thumbnailUrl: myPoster.thumbnailUrl)), builder: (context, snapshot) { if (!snapshot.hasData && !snapshot.hasError) { return Image.asset(CampaignConstants.dummyImageAssetName); @@ -127,10 +131,14 @@ class _MyPosterListScreenState extends State { return GestureDetector( onTap: () => _showPictureFullView(myPoster.imageUrl!), - child: FadeInImage.assetNetwork( - placeholder: CampaignConstants.dummyImageAssetName, - image: snapshot.data!, - ), + child: snapshot.data!.thumbnailUrl!.isNetworkImageUrl() + ? FadeInImage.assetNetwork( + placeholder: CampaignConstants.dummyImageAssetName, + image: snapshot.data!.thumbnailUrl!, + ) + : Image.file( + File(snapshot.data!.thumbnailUrl!), + ), ); }, ), @@ -202,8 +210,10 @@ class _MyPosterListScreenState extends State { } void _showPictureFullView(String imageUrl) { - ImageProvider imageProvider = NetworkImage(imageUrl); - MediaHelper.showPictureInFullView(context, imageProvider); + MediaHelper.showPictureInFullView( + context, + imageUrl.isNetworkImageUrl() ? NetworkImage(imageUrl) : FileImage(File(imageUrl)), + ); } void _editPoster(PosterListItemModel myPoster) async { diff --git a/lib/features/campaigns/screens/poster_add_screen.dart b/lib/features/campaigns/screens/poster_add_screen.dart index eef2937d..7ea126c3 100644 --- a/lib/features/campaigns/screens/poster_add_screen.dart +++ b/lib/features/campaigns/screens/poster_add_screen.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:gruene_app/app/services/nominatim_service.dart'; import 'package:gruene_app/app/theme/theme.dart'; @@ -193,16 +192,22 @@ class _PostersAddState extends State with AddressExtension { if (!localContext.mounted) return; final reducedImage = await MediaHelper.resizeAndReduceImageFile(_currentPhoto); - _saveAndReturn(reducedImage); + String? fileLocation; + if (reducedImage != null) { + fileLocation = await MediaHelper.storeImage(reducedImage); + var exists = await File(fileLocation).exists(); + debugPrint(exists.toString()); + } + _saveAndReturn(fileLocation); } - void _saveAndReturn(Uint8List? reducedImage) { + void _saveAndReturn(String? fileLocation) { Navigator.maybePop( context, PosterCreateModel( location: widget.location, address: getAddress(), - photo: reducedImage, + imageFileLocation: fileLocation, ), ); } diff --git a/lib/features/campaigns/screens/poster_detail.dart b/lib/features/campaigns/screens/poster_detail.dart index 0d358853..4778d070 100644 --- a/lib/features/campaigns/screens/poster_detail.dart +++ b/lib/features/campaigns/screens/poster_detail.dart @@ -1,4 +1,7 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:gruene_app/app/services/converters.dart'; import 'package:gruene_app/features/campaigns/helper/campaign_constants.dart'; import 'package:gruene_app/features/campaigns/models/posters/poster_detail_model.dart'; import 'package:gruene_app/features/campaigns/widgets/address_field_detail.dart'; @@ -18,16 +21,24 @@ class PosterDetail extends StatelessWidget { ), Expanded( child: FutureBuilder( - future: Future.delayed(Duration.zero, () => poi.thumbnailUrl), + future: Future.delayed( + Duration.zero, + () => poi.thumbnailUrl == null ? null : (thumbnailUrl: poi.thumbnailUrl), + ), builder: (context, snapshot) { if (!snapshot.hasData && !snapshot.hasError) { return Image.asset(CampaignConstants.dummyImageAssetName); } - - return FadeInImage.assetNetwork( - placeholder: CampaignConstants.dummyImageAssetName, - image: snapshot.data!, - ); + if (snapshot.data!.thumbnailUrl!.isNetworkImageUrl()) { + return FadeInImage.assetNetwork( + placeholder: CampaignConstants.dummyImageAssetName, + image: snapshot.data!.thumbnailUrl!, + ); + } else { + return Image.file( + File(snapshot.data!.thumbnailUrl!), + ); + } }, ), ), diff --git a/lib/features/campaigns/screens/poster_edit.dart b/lib/features/campaigns/screens/poster_edit.dart index 43e72b01..74d5be88 100644 --- a/lib/features/campaigns/screens/poster_edit.dart +++ b/lib/features/campaigns/screens/poster_edit.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:gruene_app/app/services/converters.dart'; import 'package:gruene_app/app/theme/theme.dart'; import 'package:gruene_app/features/campaigns/helper/campaign_constants.dart'; import 'package:gruene_app/features/campaigns/helper/enums.dart'; @@ -317,7 +318,10 @@ class _PosterEditState extends State with AddressExtension, ConfirmD } if (_isPhotoDeleted) return getDummyAsset(); return FutureBuilder( - future: Future.delayed(Duration.zero, () => widget.poster.imageUrl), + future: Future.delayed( + Duration.zero, + () => widget.poster.imageUrl == null ? null : (imageUrl: widget.poster.imageUrl), + ), builder: (context, snapshot) { if (!snapshot.hasData && !snapshot.hasError) { return getDummyAsset(); @@ -325,18 +329,23 @@ class _PosterEditState extends State with AddressExtension, ConfirmD return GestureDetector( onTap: _showPictureFullView, - child: FadeInImage.assetNetwork( - placeholder: CampaignConstants.dummyImageAssetName, - image: snapshot.data!, - fit: BoxFit.cover, - ), + child: snapshot.data!.imageUrl!.isNetworkImageUrl() + ? FadeInImage.assetNetwork( + placeholder: CampaignConstants.dummyImageAssetName, + image: snapshot.data!.imageUrl!, + fit: BoxFit.cover, + ) + : Image.file( + File(snapshot.data!.imageUrl!), + fit: BoxFit.cover, + ), ); }, ); } void _onDeletePressed() async { - widget.onDelete(widget.poster.id); + await widget.onDelete(widget.poster.id); _closeDialog(ModalEditResult.delete); } @@ -356,13 +365,22 @@ class _PosterEditState extends State with AddressExtension, ConfirmD () => MediaHelper.resizeAndReduceImageFile(_currentPhoto), ); + String? fileLocation; + if (reducedImage != null) { + fileLocation = await MediaHelper.storeImage(reducedImage); + var exists = await File(fileLocation).exists(); + debugPrint(exists.toString()); + } + final updateModel = PosterUpdateModel( id: widget.poster.id, address: getAddress(), status: _segmentedButtonSelection.isEmpty ? PosterStatus.ok : _segmentedButtonSelection.single, comment: commentTextController.text, removePreviousPhotos: _isPhotoDeleted, - newPhoto: reducedImage, + location: widget.poster.location, + newImageFileLocation: fileLocation, + oldPosterDetail: widget.poster, ); await widget.onSave(updateModel); @@ -431,8 +449,10 @@ class _PosterEditState extends State with AddressExtension, ConfirmD ImageProvider imageProvider; if (_currentPhoto != null) { imageProvider = FileImage(_currentPhoto!); - } else { + } else if (widget.poster.imageUrl!.isNetworkImageUrl()) { imageProvider = NetworkImage(widget.poster.imageUrl!); + } else { + imageProvider = FileImage(File(widget.poster.imageUrl!)); } MediaHelper.showPictureInFullView(context, imageProvider); } diff --git a/lib/features/campaigns/screens/posters_screen.dart b/lib/features/campaigns/screens/posters_screen.dart index 2252838a..21ac1588 100644 --- a/lib/features/campaigns/screens/posters_screen.dart +++ b/lib/features/campaigns/screens/posters_screen.dart @@ -2,15 +2,17 @@ import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; 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/gruene_api_poster_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_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'; 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/screens/map_consumer.dart'; import 'package:gruene_app/features/campaigns/screens/my_poster_list_screen.dart'; @@ -31,15 +33,16 @@ class PostersScreen extends StatefulWidget { State createState() => _PostersScreenState(); } -class _PostersScreenState extends MapConsumer { - final GrueneApiCampaignsService _grueneApiService = GrueneApiCampaignsService(poiType: PoiServiceType.poster); +class _PostersScreenState extends MapConsumer { + static const _poiType = PoiServiceType.poster; + final _grueneApiService = GetIt.I(); late List postersFilter; - _PostersScreenState() : super(); + _PostersScreenState() : super(_poiType); @override - GrueneApiCampaignsService get campaignService => _grueneApiService; + GrueneApiPosterService get campaignService => _grueneApiService; @override void initState() { @@ -72,6 +75,7 @@ class _PostersScreenState extends MapConsumer { onMapCreated: onMapCreated, addPOIClicked: _addPOIClicked, loadVisibleItems: loadVisibleItems, + loadCachedItems: loadCachedItems, getMarkerImages: _getMarkerImages, onFeatureClick: _onFeatureClick, onNoFeatureClick: _onNoFeatureClick, @@ -135,9 +139,6 @@ class _PostersScreenState extends MapConsumer { ); } - Future saveNewAndGetMarkerItem(PosterCreateModel newPoster) async => - await campaignService.createNewPoster(newPoster); - void _addPOIClicked(LatLng location) async { super.addPOIClicked( location, @@ -161,20 +162,38 @@ class _PostersScreenState extends MapConsumer { return poster; } + Future _getCachedPoi(String poiId) async { + final poster = await campaignActionCache.getPoiAsPosterDetail(poiId); + return poster; + } + + Future _getPoiFromCacheOrApi(String poiId) async { + if (await campaignActionCache.isCached(poiId)) { + return _getCachedPoi(poiId); + } else { + return _getPoi(poiId); + } + } + Widget _getEditPosterWidget(PosterDetailModel poster) { - return PosterEdit(poster: poster, onSave: _savePoster, onDelete: deletePoi); + return PosterEdit(poster: poster, onSave: savePoi, onDelete: deletePoi); } void _onFeatureClick(dynamic rawFeature) async { + final feature = rawFeature as Map; + final isCached = MapHelper.extractIsCachedFromFeature(feature); + getPoiDetailWidget(PosterDetailModel poster) { return PosterDetail( poi: poster, ); } + var getPoiFromCacheOrApi = isCached ? _getCachedPoi : _getPoi; + super.onFeatureClick( rawFeature, - _getPoi, + getPoiFromCacheOrApi, getPoiDetailWidget, _getEditPosterWidget, desiredSize: Size(150, 150), @@ -185,15 +204,10 @@ class _PostersScreenState extends MapConsumer { showFocusAreaInfoAtPoint(point); } - Future _savePoster(PosterUpdateModel posterUpdate) async { - final updatedMarker = await campaignService.updatePoster(posterUpdate); - mapController.setMarkerSource([updatedMarker]); - } - void showMyPosters() async { final theme = Theme.of(context); - final myPosters = await campaignService.getMyPosters(); + final myPosters = await getAllMyPosters(); myPosters.sort((b, a) => a.createdAt.compareTo(b.createdAt)); var navState = getNavState(); @@ -204,15 +218,28 @@ class _PostersScreenState extends MapConsumer { return ContentPage( contentBackgroundColor: theme.colorScheme.surfaceDim, title: getCurrentRoute().name ?? '', + alignment: Alignment.topCenter, child: MyPosterListScreen( myPosters: myPosters, - getPoi: _getPoi, + getPoi: _getPoiFromCacheOrApi, getPoiEdit: _getEditPosterWidget, - reloadPosterListItem: (id) => campaignService.getPoiAsPosterListItem(id), + reloadPosterListItem: _getPosterListItem, ), ); }, ), ); } + + Future> getAllMyPosters() async { + var myPosters = await campaignService.getMyPosters(); + await campaignActionCache.replaceAndFillUpMyPosterList(myPosters); + return myPosters; + } + + Future _getPosterListItem(String id) async { + return await campaignActionCache.isCached(id) + ? campaignActionCache.getPoiAsPosterListItem(id) + : campaignService.getPoiAsPosterListItem(id); + } } diff --git a/lib/features/campaigns/widgets/content_page.dart b/lib/features/campaigns/widgets/content_page.dart index cea8bd36..9e8277c4 100644 --- a/lib/features/campaigns/widgets/content_page.dart +++ b/lib/features/campaigns/widgets/content_page.dart @@ -7,7 +7,15 @@ class ContentPage extends StatelessWidget { final Color? contentBackgroundColor; - const ContentPage({super.key, required this.title, required this.child, this.contentBackgroundColor}); + final Alignment alignment; + + const ContentPage({ + super.key, + required this.title, + required this.child, + this.contentBackgroundColor, + this.alignment = Alignment.center, + }); @override Widget build(BuildContext context) { @@ -15,7 +23,7 @@ class ContentPage extends StatelessWidget { return Scaffold( backgroundColor: contentBackgroundColor ?? theme.colorScheme.secondary, body: Align( - alignment: Alignment.center, + alignment: alignment, child: SingleChildScrollView(child: child), ), appBar: CustomAppBar( diff --git a/lib/features/campaigns/widgets/map_container.dart b/lib/features/campaigns/widgets/map_container.dart index 42bebc82..a18038c5 100644 --- a/lib/features/campaigns/widgets/map_container.dart +++ b/lib/features/campaigns/widgets/map_container.dart @@ -7,6 +7,7 @@ import 'package:get_it/get_it.dart'; import 'package:gruene_app/app/constants/config.dart'; import 'package:gruene_app/app/theme/theme.dart'; import 'package:gruene_app/features/campaigns/helper/app_settings.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/map_layer_manager.dart'; @@ -20,12 +21,14 @@ import 'package:gruene_app/features/campaigns/models/marker_item_model.dart'; import 'package:gruene_app/features/campaigns/widgets/attribution_dialog.dart'; import 'package:gruene_app/features/campaigns/widgets/location_button.dart'; import 'package:gruene_app/features/campaigns/widgets/map_controller.dart'; +import 'package:gruene_app/features/campaigns/widgets/map_controller_simplified.dart'; import 'package:gruene_app/i18n/translations.g.dart'; 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); @@ -38,6 +41,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; @@ -52,6 +56,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, @@ -66,11 +71,12 @@ class MapContainer extends StatefulWidget { State createState() => _MapContainerState(); } -class _MapContainerState extends State implements MapController { +class _MapContainerState extends State implements MapController, MapControllerSimplified { MapLibreMapController? _controller; final MarkerItemManager _markerItemManager = MarkerItemManager(); final MapLayerDataManager _mapLayerManager = MapLayerDataManager(); final appSettings = GetIt.I(); + final campaignActionCache = GetIt.I(); bool _isMapInitialized = false; bool _permissionGiven = false; @@ -184,10 +190,12 @@ class _MapContainerState extends State implements MapController { onMapCreated(this); } - _loadDataOnMap(); + campaignActionCache.setCurrentMapController(this); + + _loadDataOnMap(init: true); } - void _loadDataOnMap() async { + void _loadDataOnMap({bool init = false}) async { final visRegion = await _controller?.getVisibleRegion(); var currentZoomLevel = _controller!.cameraPosition!.zoom; @@ -196,6 +204,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); @@ -672,6 +684,13 @@ class _MapContainerState extends State implements MapController { duration: Duration(seconds: 1), ); } + + @override + void resetMarkerItems() { + if (!mounted) return; + _markerItemManager.resetAllMarkers(); + _loadDataOnMap(init: true); + } } class MyTriangle extends CustomClipper { diff --git a/lib/features/campaigns/widgets/map_controller_simplified.dart b/lib/features/campaigns/widgets/map_controller_simplified.dart new file mode 100644 index 00000000..8d6669f4 --- /dev/null +++ b/lib/features/campaigns/widgets/map_controller_simplified.dart @@ -0,0 +1,3 @@ +abstract class MapControllerSimplified { + void resetMarkerItems(); +} 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 f961a24a..00a8c111 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -10,12 +12,18 @@ 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_campaigns_statistics_service.dart'; import 'package:gruene_app/app/services/gruene_api_core.dart'; +import 'package:gruene_app/app/services/gruene_api_door_service.dart'; +import 'package:gruene_app/app/services/gruene_api_flyer_service.dart'; +import 'package:gruene_app/app/services/gruene_api_poster_service.dart'; import 'package:gruene_app/app/services/ip_service.dart'; import 'package:gruene_app/app/services/nominatim_service.dart'; import 'package:gruene_app/app/services/secure_storage_service.dart'; import 'package:gruene_app/app/theme/theme.dart'; import 'package:gruene_app/app/widgets/clean_layout.dart'; import 'package:gruene_app/features/campaigns/helper/app_settings.dart'; +import 'package:gruene_app/features/campaigns/helper/campaign_action_cache.dart'; +import 'package:gruene_app/features/campaigns/helper/campaign_action_cache_timer.dart'; +import 'package:gruene_app/features/campaigns/helper/file_cache_manager.dart'; import 'package:gruene_app/features/mfa/bloc/mfa_bloc.dart'; import 'package:gruene_app/features/mfa/bloc/mfa_event.dart'; import 'package:gruene_app/features/mfa/domain/mfa_factory.dart'; @@ -24,7 +32,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'); WidgetsFlutterBinding.ensureInitialized(); final locale = await LocaleSettings.useDeviceLocale(); @@ -46,8 +54,19 @@ void main() async { // Warning: The gruene api singleton depends on the auth repository which depends on the authenticator singleton // Therefore this should be last GetIt.I.registerSingleton(await createGrueneApiClient()); - GetIt.I.registerSingleton(GrueneApiCampaignsStatisticsService()); GetIt.I.registerFactory(() => NominatimService(countryCode: t.campaigns.search.country_code)); + GetIt.I.registerSingleton(CampaignActionCache()); + GetIt.I.registerSingleton(CampaignActionCacheTimer()); + GetIt.I.registerSingleton(FileManager()); + + GetIt.I.registerFactory(() => GrueneApiPosterService()); + GetIt.I.registerFactory(() => GrueneApiDoorService()); + GetIt.I.registerFactory(() => GrueneApiFlyerService()); + GetIt.I.registerFactory(() => GrueneApiCampaignsStatisticsService()); + + WidgetsFlutterBinding.ensureInitialized(); + + // setupCachePeriodicFlushing(); runApp(TranslationProvider(child: const MyApp())); } diff --git a/pubspec.lock b/pubspec.lock index c88f6d5a..e79f447d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1021,7 +1021,7 @@ packages: source: hosted version: "3.0.2" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" @@ -1037,13 +1037,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: @@ -1204,6 +1204,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + simple_animations: + dependency: "direct main" + description: + name: simple_animations + sha256: "1ea7b93fb98e2a611b6865d632de55607b766328d14700143353129ee0559d3a" + url: "https://pub.dev" + source: hosted + version: "5.0.2" sky_engine: dependency: transitive description: flutter @@ -1257,6 +1265,46 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + url: "https://pub.dev" + source: hosted + version: "2.5.4+6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" + url: "https://pub.dev" + source: hosted + version: "2.4.1+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -1305,6 +1353,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.4" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ec193a75..643810d6 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 + path_provider: ^2.1.5 + path: ^1.9.0 + sqflite: ^2.4.1 + simple_animations: ^5.0.2 dev_dependencies: @@ -69,13 +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 #---------------------------------------------------- - # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -96,7 +99,6 @@ flutter: - assets/maps/ - .env - # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg