diff --git a/analysis_options.yaml b/analysis_options.yaml index 78997bce..c8a428c4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,7 +1,7 @@ include: package:flutter_lints/flutter.yaml analyzer: - exclude: [build/**, lib/swagger_generated_code/**] + exclude: [build/**, lib/swagger_generated_code/**, lib/objectbox_generated_code/*.g.dart] errors: always_use_package_imports: error directives_ordering: error diff --git a/build.yaml b/build.yaml index 16cafd91..d4d52949 100644 --- a/build.yaml +++ b/build.yaml @@ -2,6 +2,10 @@ targets: $default: sources: - swaggers/** + - lib/$lib$ + # - $package$ + - lib/** + - pubspec.yaml builders: chopper_generator: options: diff --git a/lib/app/services/campaign_action_database.dart b/lib/app/services/campaign_action_database.dart new file mode 100644 index 00000000..24657f84 --- /dev/null +++ b/lib/app/services/campaign_action_database.dart @@ -0,0 +1,133 @@ +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'); + // await File(path).delete(); + 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 read(int id) async { + // final db = await instance.database; + // final maps = await db.query( + // CampaignActionFields.tableName, + // columns: CampaignActionFields.values, + // where: '${CampaignActionFields.id} = ?', + // whereArgs: [id], + // ); + + // if (maps.isNotEmpty) { + // return NoteModel.fromJson(maps.first); + // } else { + // throw Exception('ID $id not found'); + // } + // } + + Future> readAll() async { + final db = await instance.database; + const orderBy = '${CampaignActionFields.poiTempId} DESC'; + 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} DESC'; + 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} DESC'; + 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(NoteModel note) async { + // final db = await instance.database; + // return db.update( + // CampaignActionFields.tableName, + // note.toJson(), + // where: '${CampaignActionFields.id} = ?', + // whereArgs: [note.id], + // ); + // } + + 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; + } +} + +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 d346dc6a..5ca7d563 100644 --- a/lib/app/services/converters.dart +++ b/lib/app/services/converters.dart @@ -1,11 +1,16 @@ +import 'dart:convert'; + import 'package:gruene_app/app/services/enums.dart'; import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:gruene_app/features/campaigns/helper/campaign_action.dart'; import 'package:gruene_app/features/campaigns/models/doors/door_detail_model.dart'; import 'package:gruene_app/features/campaigns/models/flyer/flyer_detail_model.dart'; import 'package:gruene_app/features/campaigns/models/map_layer_model.dart'; import 'package:gruene_app/features/campaigns/models/marker_item_model.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; import 'package:gruene_app/features/campaigns/models/posters/poster_detail_model.dart'; import 'package:gruene_app/features/campaigns/models/posters/poster_list_item_model.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_update_model.dart'; import 'package:gruene_app/features/campaigns/widgets/enhanced_wheel_slider.dart'; import 'package:gruene_app/features/campaigns/widgets/text_input_field.dart'; import 'package:gruene_app/i18n/translations.g.dart'; @@ -25,3 +30,7 @@ part 'converters/poi_address_parsing.dart'; part 'converters/focus_area_parsing.dart'; part 'converters/poi_parsing.dart'; part 'converters/slider_range_parsing.dart'; +part 'converters/date_time_parsing.dart'; +part 'converters/poster_create_model_parsing.dart'; +part 'converters/poster_update_model_parsing.dart'; +part 'converters/campaign_action_parsing.dart'; diff --git a/lib/app/services/converters/campaign_action_parsing.dart b/lib/app/services/converters/campaign_action_parsing.dart new file mode 100644 index 00000000..5b99ff79 --- /dev/null +++ b/lib/app/services/converters/campaign_action_parsing.dart @@ -0,0 +1,25 @@ +part of '../converters.dart'; + +extension CampaignActionParsing on CampaignAction { + PosterCreateModel getSerializedAsPosterCreate() { + var data = jsonDecode(serialized!) as Map; + if (data['photo'] != null) { + data['photo'] = (data['photo'] as List).cast(); + } + data['location'] = (data['location'] as List).cast(); + + var model = PosterCreateModel.fromJson(data); + return model; + } + + PosterUpdateModel getSerializedAsPosterUpdate() { + var data = jsonDecode(serialized!) as Map; + if (data['newPhoto'] != null) { + data['newPhoto'] = (data['newPhoto'] as List).cast(); + } + data['location'] = (data['location'] as List).cast(); + + var model = PosterUpdateModel.fromJson(data); + return model; + } +} diff --git a/lib/app/services/converters/date_time_parsing.dart b/lib/app/services/converters/date_time_parsing.dart new file mode 100644 index 00000000..f6ca8f6f --- /dev/null +++ b/lib/app/services/converters/date_time_parsing.dart @@ -0,0 +1,13 @@ +part of '../converters.dart'; + +extension DateTimeParsing on DateTime { + String getAsLocalDateTimeString() { + DateTime utcDateTime = this; + DateTime localDateTime = utcDateTime.toLocal(); + final dateString = DateFormat(t.campaigns.poster.date_format).format(localDateTime); + final timeString = DateFormat(t.campaigns.poster.time_format).format(localDateTime); + return t.campaigns.poster.datetime_display_template + .replaceAll('{date}', dateString) + .replaceAll('{time}', timeString); + } +} diff --git a/lib/app/services/converters/poi_parsing.dart b/lib/app/services/converters/poi_parsing.dart index 7c548706..56b33fcb 100644 --- a/lib/app/services/converters/poi_parsing.dart +++ b/lib/app/services/converters/poi_parsing.dart @@ -22,7 +22,7 @@ extension PoiParsing on Poi { address: poi.address.transformToAddressModel(), openedDoors: poi.house!.countOpenedDoors.toInt(), closedDoors: poi.house!.countClosedDoors.toInt(), - createdAt: _getUtcDateTimeAsLocalDateTimeString(poi.createdAt), + createdAt: poi.createdAt.getAsLocalDateTimeString(), ); } @@ -37,8 +37,9 @@ extension PoiParsing on Poi { imageUrl: _getImageUrl(poi), address: poi.address.transformToAddressModel(), status: poi.poster!.status.transformToModelPosterStatus(), + location: coords.transformToLatLng(), comment: poi.poster!.comment ?? '', - createdAt: _getUtcDateTimeAsLocalDateTimeString(poi.createdAt), + createdAt: poi.createdAt.getAsLocalDateTimeString(), ); } @@ -51,7 +52,7 @@ extension PoiParsing on Poi { id: poi.id, address: poi.address.transformToAddressModel(), flyerCount: poi.flyerSpot!.flyerCount.toInt(), - createdAt: _getUtcDateTimeAsLocalDateTimeString(poi.createdAt), + createdAt: poi.createdAt.getAsLocalDateTimeString(), ); } @@ -67,25 +68,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..1fcc0cdb 100644 --- a/lib/app/services/converters/poi_service_type_parsing.dart +++ b/lib/app/services/converters/poi_service_type_parsing.dart @@ -33,4 +33,17 @@ extension PoiServiceTypeParsing on PoiServiceType { return CreatePoiType.flyerSpot; } } + + String getAsMarkerItemStatus(PosterStatus? posterStatus) { + var typeName = name; + switch (this) { + case PoiServiceType.poster: + String statusSuffix = ''; + if (posterStatus != null) statusSuffix = '_${posterStatus.name}'; + return '$typeName$statusSuffix'; + case PoiServiceType.door: + case PoiServiceType.flyer: + return typeName; + } + } } diff --git a/lib/app/services/converters/poster_create_model_parsing.dart b/lib/app/services/converters/poster_create_model_parsing.dart new file mode 100644 index 00000000..7f2314a0 --- /dev/null +++ b/lib/app/services/converters/poster_create_model_parsing.dart @@ -0,0 +1,25 @@ +part of '../converters.dart'; + +extension PosterCreateModelParsing on PosterCreateModel { + MarkerItemModel transformToVirtualMarkerItem(int temporaryId) { + return MarkerItemModel.virtual( + id: temporaryId, + status: PoiServiceType.poster.getAsMarkerItemStatus(PosterStatus.ok), + location: location, + ); + } + + PosterDetailModel transformToPosterDetailModel(int temporaryId) { + return PosterDetailModel( + id: temporaryId.toString(), + status: PosterStatus.ok, + address: address, + thumbnailUrl: 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_update_model_parsing.dart b/lib/app/services/converters/poster_update_model_parsing.dart new file mode 100644 index 00000000..f910b84c --- /dev/null +++ b/lib/app/services/converters/poster_update_model_parsing.dart @@ -0,0 +1,33 @@ +part of '../converters.dart'; + +extension PosterUpdateModelParsing on PosterUpdateModel { + MarkerItemModel transformToVirtualMarkerItem() { + return MarkerItemModel.virtual( + id: int.parse(id), + status: PoiServiceType.poster.getAsMarkerItemStatus(status), + location: location, + ); + } + + PosterDetailModel transformToPosterDetailModel(int temporaryId) { + return PosterDetailModel( + id: temporaryId.toString(), + status: status, + address: address, + thumbnailUrl: null, + imageUrl: null, + location: location, + comment: comment, + createdAt: '${DateTime.now().getAsLocalDateTimeString()}*', // should mark this as preliminary + isCached: true, + ); + } + + PosterUpdateModel mergeWith(PosterUpdateModel newPosterUpdate) { + var oldPosterUdpate = this; + + return newPosterUpdate.copyWith( + removePreviousPhotos: newPosterUpdate.removePreviousPhotos || oldPosterUdpate.removePreviousPhotos, + ); + } +} diff --git a/lib/app/services/gruene_api_campaigns_service.dart b/lib/app/services/gruene_api_campaigns_service.dart index 2b72e1ad..ddaa79c1 100644 --- a/lib/app/services/gruene_api_campaigns_service.dart +++ b/lib/app/services/gruene_api_campaigns_service.dart @@ -1,6 +1,5 @@ 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'; @@ -13,14 +12,9 @@ import 'package:gruene_app/features/campaigns/models/flyer/flyer_detail_model.da 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 { @@ -52,25 +46,6 @@ 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(), @@ -128,31 +103,6 @@ class GrueneApiCampaignsService { 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(), @@ -178,21 +128,6 @@ class GrueneApiCampaignsService { 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(); 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..d51fc87d --- /dev/null +++ b/lib/app/services/gruene_api_poster_service.dart @@ -0,0 +1,96 @@ +import 'dart:typed_data'; + +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_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.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 _storeNewPhoto2(posterUpdate.id, posterUpdate.newPhoto!); + } + + return updatePoiResponse.body!.transformToMarkerItem(); + } + + Future> _storeNewPhoto2(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> _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; + } +} diff --git a/lib/app/services/nominatim_service.dart b/lib/app/services/nominatim_service.dart index 5c02b020..c76666cf 100644 --- a/lib/app/services/nominatim_service.dart +++ b/lib/app/services/nominatim_service.dart @@ -1,7 +1,10 @@ import 'package:gruene_app/app/geocode/nominatim.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:logger/logger.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +part 'nominatim_service.g.dart'; + class NominatimService { final Logger _logger = Logger(); @@ -22,13 +25,14 @@ class NominatimService { } } +@JsonSerializable() class AddressModel { final String street; final String city; final String zipCode; final String houseNumber; - const AddressModel({this.street = '', this.houseNumber = '', this.zipCode = '', this.city = ''}); + AddressModel({this.street = '', this.houseNumber = '', this.zipCode = '', this.city = ''}); /* * More details on how to find and categorize "places" can be found in the OSM Wiki: @@ -48,4 +52,11 @@ class AddressModel { place.address?['town']?.toString() ?? place.address?['village']?.toString() ?? ''; + + /// Connect the generated [_$AddressModelFromJson] function to the `fromJson` + /// factory. + factory AddressModel.fromJson(Map json) => _$AddressModelFromJson(json); + + /// Connect the generated [_$AddressModelToJson] function to the `toJson` method. + Map toJson() => _$AddressModelToJson(this); } diff --git a/lib/app/widgets/app_bar.dart b/lib/app/widgets/app_bar.dart index fd83698f..fdbd6b87 100644 --- a/lib/app/widgets/app_bar.dart +++ b/lib/app/widgets/app_bar.dart @@ -1,10 +1,12 @@ 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'; class MainAppBar extends StatelessWidget implements PreferredSizeWidget { const MainAppBar({super.key}); @@ -24,14 +26,7 @@ class MainAppBar extends StatelessWidget implements PreferredSizeWidget { backgroundColor: theme.primaryColor, centerTitle: true, actions: [ - if (currentRoute.path == Routes.campaigns.path) - IconButton( - icon: CustomIcon( - path: 'assets/icons/refresh.svg', - color: ThemeColors.background, - ), - onPressed: null, - ), + if (currentRoute.path == Routes.campaigns.path) RefreshButton(), if (currentRoute.path != Routes.settings.path && isLoggedIn) IconButton( icon: CustomIcon( @@ -47,3 +42,57 @@ 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(); + + @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(); + return IconButton( + icon: Badge( + label: Text(labelText), + isLabelVisible: _currentCount != 0, + child: CustomIcon( + path: 'assets/icons/refresh.svg', + color: ThemeColors.background, + ), + ), + onPressed: _flushCachedData, + ); + } + + void _setCurrentCounter() async { + final newCount = await campaignActionCache.getCachedActionCount(); + if (!mounted) return; + setState(() { + _currentCount = newCount; + }); + } + + void _flushCachedData() { + campaignActionCache.flushCachedItems(); + } +} diff --git a/lib/features/campaigns/helper/campaign_action.dart b/lib/features/campaigns/helper/campaign_action.dart new file mode 100644 index 00000000..f927d2b6 --- /dev/null +++ b/lib/features/campaigns/helper/campaign_action.dart @@ -0,0 +1,99 @@ +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, + String? serialized, + }) { + return CampaignAction( + id: id ?? this.id, + poiId: poiId ?? this.poiId, + 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..6ce11d3e --- /dev/null +++ b/lib/features/campaigns/helper/campaign_action_cache.dart @@ -0,0 +1,163 @@ +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/gruene_api_poster_service.dart'; +import 'package:gruene_app/features/campaigns/helper/campaign_action.dart'; +import 'package:gruene_app/features/campaigns/models/marker_item_model.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_detail_model.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_update_model.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +class CampaignActionCache extends ChangeNotifier { + static CampaignActionCache? _instance; + var campaignActionDatabase = CampaignActionDatabase.instance; + + CampaignActionCache._(); + + factory CampaignActionCache() => _instance ??= CampaignActionCache._(); + + Future _addAction(CampaignAction action) async { + await campaignActionDatabase.create(action); + notifyListeners(); + } + + Future getCachedActionCount() { + return campaignActionDatabase.getCount(); + } + + Future addPosterCreate(PosterCreateModel posterCreate) async { + final action = CampaignAction( + actionType: CampaignActionType.addPoster, + serialized: jsonEncode(posterCreate.toJson()), + ); + await _addAction(action); + return posterCreate.transformToVirtualMarkerItem(action.poiTempId); + } + + Future addPosterDelete(String posterId) async { + final action = CampaignAction( + poiId: int.parse(posterId), + actionType: CampaignActionType.deletePoster, + ); + await _addAction(action); + return _getDeletePosterMarkerModel(action.poiId!); + } + + Future addPosterUpdate(PosterUpdateModel posterUpdate) async { + var actions = + (await _findActionsByPoiId(posterUpdate.id)).where((x) => x.actionType == CampaignActionType.editPoster); + var action = actions.singleOrNull; + if (action == null) { + action = CampaignAction( + poiId: int.parse(posterUpdate.id), + actionType: CampaignActionType.editPoster, + serialized: jsonEncode(posterUpdate.toJson()), + ); + } else { + // update previous edit action + var oldUpdate = action.getSerializedAsPosterUpdate(); + var newPosterUpdate = oldUpdate.mergeWith(posterUpdate); + action.serialized = jsonEncode(newPosterUpdate.toJson()); + } + + await _addAction(action); + + return posterUpdate.transformToVirtualMarkerItem(); + } + + MarkerItemModel _getDeletePosterMarkerModel(int id) { + return MarkerItemModel.virtual( + id: id, + status: 'poster_deleted', + location: LatLng(0, 0), + ); + } + + Future> getPosterMarkerItems() async { + List markerItems = []; + var posterActions = [ + CampaignActionType.addPoster.index, + CampaignActionType.editPoster.index, + CampaignActionType.deletePoster.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.getSerializedAsPosterCreate(); + markerItems.add(model.transformToVirtualMarkerItem(action.poiTempId)); + case CampaignActionType.editPoster: + var model = action.getSerializedAsPosterUpdate(); + markerItems.add(model.transformToVirtualMarkerItem()); + case CampaignActionType.deletePoster: + var model = _getDeletePosterMarkerModel(action.poiId!); + markerItems.add(model); + case CampaignActionType.unknown: + case CampaignActionType.addDoor: + case CampaignActionType.editDoor: + case CampaignActionType.deleteDoor: + case CampaignActionType.addFlyer: + case CampaignActionType.editFlyer: + case CampaignActionType.deleteFlyer: + case null: + throw UnimplementedError(); + } + if (action.actionType == CampaignActionType.addPoster) {} + } + return markerItems; + } + + Future getPoiAsPosterDetail(String poiId) async { + var posterCacheList = await _findActionsByPoiId(poiId); + var addPosterActions = posterCacheList.where((p) => p.actionType == CampaignActionType.addPoster).toList(); + var editPosterActions = posterCacheList.where((p) => p.actionType == CampaignActionType.editPoster).toList(); + if (editPosterActions.isNotEmpty) { + var editPosterAction = editPosterActions.single; + var model = editPosterAction.getSerializedAsPosterUpdate(); + return model.transformToPosterDetailModel(int.parse(poiId)); + } else { + var addPosterAction = addPosterActions.single; + + var model = addPosterAction.getSerializedAsPosterCreate(); + return model.transformToPosterDetailModel(int.parse(poiId)); + } + } + + Future> _findActionsByPoiId(String poiId) async { + var posterCacheList = campaignActionDatabase.getActionsWithPoiId(poiId); + return posterCacheList; + } + + void flushCachedItems() async { + var posterApiService = GetIt.I(); + final allActions = await campaignActionDatabase.readAll(); + + for (var action in allActions) { + switch (action.actionType) { + case CampaignActionType.addPoster: + var model = action.getSerializedAsPosterCreate(); + await posterApiService.createNewPoster(model); + campaignActionDatabase.delete(action.id!); + case CampaignActionType.editPoster: + case CampaignActionType.deletePoster: + await posterApiService.deletePoi(action.poiId!.toString()); + campaignActionDatabase.delete(action.id!); + case CampaignActionType.addDoor: + case CampaignActionType.editDoor: + case CampaignActionType.deleteDoor: + case CampaignActionType.addFlyer: + case CampaignActionType.editFlyer: + case CampaignActionType.deleteFlyer: + case CampaignActionType.unknown: + case null: + throw UnimplementedError(); + } + } + notifyListeners(); + } +} 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..04ab56d4 --- /dev/null +++ b/lib/features/campaigns/helper/file_cache_manager.dart @@ -0,0 +1,45 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +class FileManager { + 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 _getFullFileName(String filename) async { + final docsDir = await getApplicationDocumentsDirectory(); + var storeDirName = 'wk-campaign-file-store'; + return p.join(docsDir.path, storeDirName, 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(); + } +} + +// class FileCacheManager { +// static const key = 'wk-cache-manager'; +// static CacheManager instance = CacheManager( +// Config( +// key, +// stalePeriod: const Duration(days: 7), +// maxNrOfCacheObjects: 200, +// ), +// ); +// } diff --git a/lib/features/campaigns/helper/map_helper.dart b/lib/features/campaigns/helper/map_helper.dart index 4e386f8e..6a7c36b5 100644 --- a/lib/features/campaigns/helper/map_helper.dart +++ b/lib/features/campaigns/helper/map_helper.dart @@ -42,4 +42,11 @@ class MapHelper { final id = feature['id'].toString(); return id; } + + static bool extractIsCachedFromFeature(Map feature) { + if (feature['properties'] == null) return false; + final properties = feature['properties'] as Map; + if (properties['is_virtual'] == null) return false; + return bool.parse(properties['is_virtual'].toString()); + } } diff --git a/lib/features/campaigns/helper/marker_item_helper.dart b/lib/features/campaigns/helper/marker_item_helper.dart index 2fdd4c23..fcb1e821 100644 --- a/lib/features/campaigns/helper/marker_item_helper.dart +++ b/lib/features/campaigns/helper/marker_item_helper.dart @@ -15,6 +15,7 @@ class MarkerItemHelper { id: markerItem.id, properties: { 'status_type': markerItem.status, + 'is_virtual': markerItem.isVirtual, }, geometry: Point(coordinates: Position(markerItem.location.longitude, markerItem.location.latitude)), ); diff --git a/lib/features/campaigns/helper/marker_item_manager.dart b/lib/features/campaigns/helper/marker_item_manager.dart index e68507f9..094dc4f7 100644 --- a/lib/features/campaigns/helper/marker_item_manager.dart +++ b/lib/features/campaigns/helper/marker_item_manager.dart @@ -1,15 +1,32 @@ import 'package:gruene_app/features/campaigns/models/marker_item_model.dart'; class MarkerItemManager { - final List loadedMarkers = []; + List loadedMarkers = []; + final List virtualMarkers = []; void addMarkers(List poiList) { - loadedMarkers.retainWhere((oldMarker) => poiList.any((newMarker) => newMarker.id != oldMarker.id)); - loadedMarkers.addAll(poiList); + // get virtual marker items and add them to cache list + var newVirtualMarkers = poiList.where((p) => p.isVirtual).toList(); + virtualMarkers.retainWhere((oldMarker) => !newVirtualMarkers.any((newMarker) => newMarker.id == oldMarker.id)); + virtualMarkers.addAll(newVirtualMarkers); + + // get marker items which are not in cache + var newStoredMarkers = poiList + .where((p) => !p.isVirtual) + .where((p) => !virtualMarkers.any((virtualMarker) => virtualMarker.id == p.id)) + .toList(); + + // remove previously loaded markers to update them + loadedMarkers.retainWhere((oldMarker) => !newStoredMarkers.any((newMarker) => newMarker.id == oldMarker.id)); + + // remove loaded markers which are also in cache + loadedMarkers.removeWhere((oldMarker) => virtualMarkers.any((cachedMarker) => cachedMarker.id == oldMarker.id)); + + loadedMarkers.addAll(newStoredMarkers); } List getMarkers() { - return loadedMarkers; + return loadedMarkers + virtualMarkers; } void removeMarker(int markerItemId) { diff --git a/lib/features/campaigns/helper/media_helper.dart b/lib/features/campaigns/helper/media_helper.dart index 028a7d7d..cb908abd 100644 --- a/lib/features/campaigns/helper/media_helper.dart +++ b/lib/features/campaigns/helper/media_helper.dart @@ -2,8 +2,10 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:gruene_app/app/theme/theme.dart'; import 'package:gruene_app/features/campaigns/helper/enums.dart'; +import 'package:gruene_app/features/campaigns/helper/file_cache_manager.dart'; import 'package:image/image.dart' as image_lib; import 'package:image_picker/image_picker.dart'; import 'package:photo_view/photo_view.dart'; @@ -108,4 +110,8 @@ class MediaHelper { } return null; } + + static Future storeImage(Uint8List imageData) async { + return GetIt.I().storeFile('${DateTime.now().millisecondsSinceEpoch}.jpg', imageData); + } } diff --git a/lib/features/campaigns/location/location_ffi.dart b/lib/features/campaigns/location/location_ffi.dart index ecedf511..a2c00864 100644 --- a/lib/features/campaigns/location/location_ffi.dart +++ b/lib/features/campaigns/location/location_ffi.dart @@ -3,8 +3,8 @@ import 'package:gruene_app/app/constants/config.dart'; final platform = MethodChannel('${Config.appId}/location'); -/// Checks whether location services are enabled -/// without using Google Play Services +// Checks whether location services are enabled +// without using Google Play Services Future isNonGoogleLocationServiceEnabled() async { try { final bool result = await platform.invokeMethod('isLocationServiceEnabled') as bool; diff --git a/lib/features/campaigns/models/marker_item_model.dart b/lib/features/campaigns/models/marker_item_model.dart index 3febd04e..7b5bac2a 100644 --- a/lib/features/campaigns/models/marker_item_model.dart +++ b/lib/features/campaigns/models/marker_item_model.dart @@ -4,10 +4,17 @@ class MarkerItemModel { final LatLng location; final int? id; final String? status; + final bool isVirtual; const MarkerItemModel({ required this.id, required this.status, required this.location, - }); + }) : isVirtual = false; + + MarkerItemModel.virtual({ + required this.id, + this.status, + required this.location, + }) : isVirtual = true; } diff --git a/lib/features/campaigns/models/posters/poster_create_model.dart b/lib/features/campaigns/models/posters/poster_create_model.dart index adc26a72..9dc11f0f 100644 --- a/lib/features/campaigns/models/posters/poster_create_model.dart +++ b/lib/features/campaigns/models/posters/poster_create_model.dart @@ -1,16 +1,64 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:typed_data'; import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +part 'poster_create_model.g.dart'; + +@JsonSerializable() class PosterCreateModel { - final AddressModel address; + AddressModel address; + + @LatLongConverter() final LatLng location; - 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(); + } +} + +/// Converts to and from [Uint8List] and [List]<[int]>. +class LatLongConverter implements JsonConverter> { + /// Create a new instance of [LatLongConverter]. + const LatLongConverter(); + + @override + LatLng fromJson(List json) { + return LatLng(json[0], json[1]); + } + + @override + List toJson(LatLng object) { + return [object.latitude, object.longitude]; + } } diff --git a/lib/features/campaigns/models/posters/poster_detail_model.dart b/lib/features/campaigns/models/posters/poster_detail_model.dart index ab0f98b0..7a4f77ec 100644 --- a/lib/features/campaigns/models/posters/poster_detail_model.dart +++ b/lib/features/campaigns/models/posters/poster_detail_model.dart @@ -1,4 +1,5 @@ import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; enum PosterStatus { ok, damaged, missing, removed } @@ -11,6 +12,8 @@ class PosterDetailModel { final String comment; final PosterStatus status; final String createdAt; + final bool isCached; + final LatLng location; PosterDetailModel({ required this.id, @@ -20,5 +23,7 @@ class PosterDetailModel { required this.status, required this.comment, required this.createdAt, + required this.location, + this.isCached = false, }); } diff --git a/lib/features/campaigns/models/posters/poster_update_model.dart b/lib/features/campaigns/models/posters/poster_update_model.dart index 61d5c9f7..66b5b35c 100644 --- a/lib/features/campaigns/models/posters/poster_update_model.dart +++ b/lib/features/campaigns/models/posters/poster_update_model.dart @@ -1,14 +1,24 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:typed_data'; import 'package:gruene_app/app/services/nominatim_service.dart'; +import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; import 'package:gruene_app/features/campaigns/models/posters/poster_detail_model.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +part 'poster_update_model.g.dart'; + +@JsonSerializable() class PosterUpdateModel { final String id; final AddressModel address; final PosterStatus status; final String comment; + @Uint8ListConverter() final Uint8List? newPhoto; + @LatLongConverter() + final LatLng location; final bool removePreviousPhotos; PosterUpdateModel({ @@ -17,6 +27,31 @@ class PosterUpdateModel { required this.status, required this.comment, required this.removePreviousPhotos, + required this.location, this.newPhoto, }); + + factory PosterUpdateModel.fromJson(Map json) => _$PosterUpdateModelFromJson(json); + + Map toJson() => _$PosterUpdateModelToJson(this); + + PosterUpdateModel copyWith({ + String? id, + AddressModel? address, + PosterStatus? status, + String? comment, + Uint8List? newPhoto, + LatLng? location, + bool? removePreviousPhotos, + }) { + return PosterUpdateModel( + id: id ?? this.id, + address: address ?? this.address, + status: status ?? this.status, + comment: comment ?? this.comment, + newPhoto: newPhoto ?? this.newPhoto, + location: location ?? this.location, + removePreviousPhotos: removePreviousPhotos ?? this.removePreviousPhotos, + ); + } } diff --git a/lib/features/campaigns/screens/doors_screen.dart b/lib/features/campaigns/screens/doors_screen.dart index e5bc5cfd..59952db3 100644 --- a/lib/features/campaigns/screens/doors_screen.dart +++ b/lib/features/campaigns/screens/doors_screen.dart @@ -70,6 +70,7 @@ class _DoorsScreenState extends MapConsumer { onMapCreated: onMapCreated, addPOIClicked: _addPOIClicked, loadVisibleItems: loadVisibleItems, + loadCachedItems: _loadCachedItems, getMarkerImages: _getMarkerImages, onFeatureClick: _onFeatureClick, onNoFeatureClick: _onNoFeatureClick, @@ -146,4 +147,6 @@ class _DoorsScreenState extends MapConsumer { Future _saveNewAndGetMarkerItem(DoorCreateModel newDoor) async => await _grueneApiService.createNewDoor(newDoor); + + void _loadCachedItems() {} } diff --git a/lib/features/campaigns/screens/flyer_screen.dart b/lib/features/campaigns/screens/flyer_screen.dart index 83e0c87e..76181ee8 100644 --- a/lib/features/campaigns/screens/flyer_screen.dart +++ b/lib/features/campaigns/screens/flyer_screen.dart @@ -62,6 +62,7 @@ class _FlyerScreenState extends MapConsumer { onMapCreated: onMapCreated, addPOIClicked: _addPOIClicked, loadVisibleItems: loadVisibleItems, + loadCachedItems: _loadCachedItems, getMarkerImages: _getMarkerImages, onFeatureClick: _onFeatureClick, onNoFeatureClick: _onNoFeatureClick, @@ -142,4 +143,6 @@ class _FlyerScreenState extends MapConsumer { final updatedMarker = await campaignService.updateFlyer(flyerUpdate); mapController.setMarkerSource([updatedMarker]); } + + void _loadCachedItems() {} } diff --git a/lib/features/campaigns/screens/map_consumer.dart b/lib/features/campaigns/screens/map_consumer.dart index 527f9372..fea94672 100644 --- a/lib/features/campaigns/screens/map_consumer.dart +++ b/lib/features/campaigns/screens/map_consumer.dart @@ -24,7 +24,7 @@ typedef SaveNewAndGetMarkerCallback = Future Function(T); typedef GetPoiCallback = Future Function(String); typedef GetPoiDetailWidgetCallback = Widget Function(T); typedef GetPoiEditWidgetCallback = Widget Function(T); -typedef OnDeletePoiCallback = void Function(String posterId); +typedef OnDeletePoiCallback = Future Function(String posterId); abstract class MapConsumer extends State with FocusAreaInfo { late MapController mapController; @@ -146,7 +146,7 @@ abstract class MapConsumer extends State with Focus ); } - void deletePoi(String poiId) async { + Future deletePoi(String poiId) async { final id = int.parse(poiId); await campaignService.deletePoi(poiId); mapController.removeMarkerItem(id); diff --git a/lib/features/campaigns/screens/poster_add_screen.dart b/lib/features/campaigns/screens/poster_add_screen.dart index b0a79bf7..6420e006 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'; @@ -173,17 +172,22 @@ class _PostersAddState extends State with AddressExtension, Pos if (!validatePoster(_currentPhoto, context)) 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..a684f478 100644 --- a/lib/features/campaigns/screens/poster_detail.dart +++ b/lib/features/campaigns/screens/poster_detail.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:gruene_app/features/campaigns/helper/campaign_constants.dart'; import 'package:gruene_app/features/campaigns/models/posters/poster_detail_model.dart'; @@ -18,16 +20,25 @@ class PosterDetail extends StatelessWidget { ), Expanded( child: FutureBuilder( - future: Future.delayed(Duration.zero, () => poi.thumbnailUrl), + future: Future.delayed( + Duration.zero, + () => poi.thumbnailUrl == null ? null : (isCached: poi.isCached, 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!.isCached) { + return Image.file( + File(snapshot.data!.thumbnailUrl!), + ); + } else { + return FadeInImage.assetNetwork( + placeholder: CampaignConstants.dummyImageAssetName, + image: snapshot.data!.thumbnailUrl!, + ); + } }, ), ), diff --git a/lib/features/campaigns/screens/poster_edit.dart b/lib/features/campaigns/screens/poster_edit.dart index 59fb3983..b6987f58 100644 --- a/lib/features/campaigns/screens/poster_edit.dart +++ b/lib/features/campaigns/screens/poster_edit.dart @@ -290,7 +290,12 @@ 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 + : (isCached: widget.poster.isCached, imageUrl: widget.poster.imageUrl), + ), builder: (context, snapshot) { if (!snapshot.hasData && !snapshot.hasError) { return getDummyAsset(); @@ -298,18 +303,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!.isCached + ? Image.file( + File(snapshot.data!.imageUrl!), + fit: BoxFit.cover, + ) + : FadeInImage.assetNetwork( + placeholder: CampaignConstants.dummyImageAssetName, + image: snapshot.data!.imageUrl!, + fit: BoxFit.cover, + ), ); }, ); } void _onDeletePressed() async { - widget.onDelete(widget.poster.id); + await widget.onDelete(widget.poster.id); _closeDialog(ModalEditResult.delete); } @@ -336,6 +346,7 @@ class _PosterEditState extends State with AddressExtension, ConfirmD status: _segmentedButtonSelection.isEmpty ? PosterStatus.ok : _segmentedButtonSelection.single, comment: commentTextController.text, removePreviousPhotos: _isPhotoDeleted, + location: widget.poster.location, newPhoto: reducedImage, ); await widget.onSave(updateModel); @@ -405,6 +416,8 @@ class _PosterEditState extends State with AddressExtension, ConfirmD ImageProvider imageProvider; if (_currentPhoto != null) { imageProvider = FileImage(_currentPhoto!); + } else if (widget.poster.isCached) { + imageProvider = FileImage(File(widget.poster.imageUrl!)); } else { imageProvider = NetworkImage(widget.poster.imageUrl!); } diff --git a/lib/features/campaigns/screens/posters_screen.dart b/lib/features/campaigns/screens/posters_screen.dart index 92d802e8..17092247 100644 --- a/lib/features/campaigns/screens/posters_screen.dart +++ b/lib/features/campaigns/screens/posters_screen.dart @@ -2,11 +2,14 @@ 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/nominatim_service.dart'; import 'package:gruene_app/app/theme/theme.dart'; +import 'package:gruene_app/features/campaigns/helper/campaign_action_cache.dart'; import 'package:gruene_app/features/campaigns/helper/campaign_constants.dart'; +import 'package:gruene_app/features/campaigns/helper/map_helper.dart'; import 'package:gruene_app/features/campaigns/helper/media_helper.dart'; import 'package:gruene_app/features/campaigns/models/marker_item_model.dart'; import 'package:gruene_app/features/campaigns/models/posters/poster_create_model.dart'; @@ -33,6 +36,7 @@ class PostersScreen extends StatefulWidget { class _PostersScreenState extends MapConsumer { final GrueneApiCampaignsService _grueneApiService = GrueneApiCampaignsService(poiType: PoiServiceType.poster); + final campaignActionCache = GetIt.I(); late List postersFilter; @@ -72,6 +76,7 @@ class _PostersScreenState extends MapConsumer { onMapCreated: onMapCreated, addPOIClicked: _addPOIClicked, loadVisibleItems: loadVisibleItems, + loadCachedItems: _loadCachedItems, getMarkerImages: _getMarkerImages, onFeatureClick: _onFeatureClick, onNoFeatureClick: _onNoFeatureClick, @@ -134,8 +139,11 @@ class _PostersScreenState extends MapConsumer { ); } - Future saveNewAndGetMarkerItem(PosterCreateModel newPoster) async => - await campaignService.createNewPoster(newPoster); + Future saveNewAndGetMarkerItem(PosterCreateModel newPoster) async { + return await campaignActionCache.addPosterCreate(newPoster); + + // return await campaignService.createNewPoster(newPoster); + } void _addPOIClicked(LatLng location) async { super.addPOIClicked( @@ -160,20 +168,30 @@ class _PostersScreenState extends MapConsumer { return poster; } + Future _getCachedPoi(String poiId) async { + final poster = await campaignActionCache.getPoiAsPosterDetail(poiId); + return poster; + } + Widget _getEditPosterWidget(PosterDetailModel poster) { - return PosterEdit(poster: poster, onSave: _savePoster, onDelete: deletePoi); + return PosterEdit(poster: poster, onSave: _savePoster, onDelete: _deletePoster); } void _onFeatureClick(dynamic rawFeature) async { + final feature = rawFeature as Map; + final isCached = MapHelper.extractIsCachedFromFeature(feature); + getPoiDetailWidget(PosterDetailModel poster) { return PosterDetail( poi: poster, ); } + var getPoiFromCacheOrApi = isCached ? _getCachedPoi : _getPoi; + super.onFeatureClick( rawFeature, - _getPoi, + getPoiFromCacheOrApi, getPoiDetailWidget, _getEditPosterWidget, desiredSize: Size(150, 150), @@ -185,7 +203,7 @@ class _PostersScreenState extends MapConsumer { } Future _savePoster(PosterUpdateModel posterUpdate) async { - final updatedMarker = await campaignService.updatePoster(posterUpdate); + final updatedMarker = await campaignActionCache.addPosterUpdate(posterUpdate); mapController.setMarkerSource([updatedMarker]); } @@ -214,4 +232,14 @@ class _PostersScreenState extends MapConsumer { ), ); } + + void _loadCachedItems() async { + var markerItems = await campaignActionCache.getPosterMarkerItems(); + mapController.setMarkerSource(markerItems); + } + + Future _deletePoster(String posterId) async { + var markerItem = await campaignActionCache.addPosterDelete(posterId); + mapController.setMarkerSource([markerItem]); + } } diff --git a/lib/features/campaigns/widgets/map_container.dart b/lib/features/campaigns/widgets/map_container.dart index 7cc21b75..230736e1 100644 --- a/lib/features/campaigns/widgets/map_container.dart +++ b/lib/features/campaigns/widgets/map_container.dart @@ -26,6 +26,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; typedef OnMapCreatedCallback = void Function(MapController controller); typedef AddPOIClickedCallback = void Function(LatLng location); typedef LoadVisibleItemsCallBack = void Function(LatLng locationSW, LatLng locationNE); +typedef LoadCachedItemsCallback = void Function(); typedef LoadDataLayersCallBack = void Function(LatLng locationSW, LatLng locationNE); typedef GetMarkerImagesCallback = Map Function(); typedef OnFeatureClickCallback = void Function(dynamic feature); @@ -38,6 +39,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 +54,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, @@ -186,10 +189,10 @@ class _MapContainerState extends State implements MapController { onMapCreated(this); } - _loadDataOnMap(); + _loadDataOnMap(init: true); } - void _loadDataOnMap() async { + void _loadDataOnMap({bool init = false}) async { final visRegion = await _controller?.getVisibleRegion(); var currentZoomLevel = _controller!.cameraPosition!.zoom; @@ -198,6 +201,10 @@ class _MapContainerState extends State implements MapController { _showAddMarker = currentZoomLevel > minimumMarkerZoomLevel; + if (init) { + final loadCachedItems = widget.loadCachedItems; + if (loadCachedItems != null) loadCachedItems(); + } final loadVisibleItems = widget.loadVisibleItems; if (loadVisibleItems != null) { loadVisibleItems(visRegion.southwest, visRegion.northeast); diff --git a/lib/features/campaigns/widgets/map_with_location.dart b/lib/features/campaigns/widgets/map_with_location.dart index 6131f19b..f9e27092 100644 --- a/lib/features/campaigns/widgets/map_with_location.dart +++ b/lib/features/campaigns/widgets/map_with_location.dart @@ -6,6 +6,7 @@ class MapWithLocation extends StatelessWidget { final OnMapCreatedCallback? onMapCreated; final AddPOIClickedCallback? addPOIClicked; final LoadVisibleItemsCallBack? loadVisibleItems; + final LoadCachedItemsCallback? loadCachedItems; final LoadDataLayersCallBack? loadDataLayers; final GetMarkerImagesCallback? getMarkerImages; final OnFeatureClickCallback? onFeatureClick; @@ -18,6 +19,7 @@ class MapWithLocation extends StatelessWidget { required this.onMapCreated, required this.addPOIClicked, required this.loadVisibleItems, + required this.loadCachedItems, required this.getMarkerImages, required this.onFeatureClick, required this.onNoFeatureClick, @@ -42,6 +44,7 @@ class MapWithLocation extends StatelessWidget { onMapCreated: onMapCreated, addPOIClicked: addPOIClicked, loadVisibleItems: loadVisibleItems, + loadCachedItems: loadCachedItems, getMarkerImages: getMarkerImages, onFeatureClick: onFeatureClick, onNoFeatureClick: onNoFeatureClick, diff --git a/lib/main.dart b/lib/main.dart index 4e1d6989..9c0d14c9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,8 +10,11 @@ import 'package:gruene_app/app/auth/bloc/auth_bloc.dart'; import 'package:gruene_app/app/auth/repository/auth_repository.dart'; import 'package:gruene_app/app/router.dart'; import 'package:gruene_app/app/services/gruene_api_core.dart'; +import 'package:gruene_app/app/services/gruene_api_poster_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_session_settings.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'; @@ -20,7 +23,7 @@ import 'package:gruene_app/swagger_generated_code/gruene_api.swagger.dart'; import 'package:keycloak_authenticator/api.dart'; import 'package:timeago/timeago.dart' as timeago; -void main() async { +Future main() async { await dotenv.load(fileName: '.env'); WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); @@ -38,7 +41,15 @@ void main() async { GetIt.I.registerSingleton(await createGrueneApiClient()); GetIt.I.registerSingleton(CampaignSessionSettings()); + GetIt.I.registerSingleton(CampaignActionCache()); + // GetIt.I.registerSingleton(FileCacheManager.instance); + GetIt.I.registerSingleton(FileManager()); + GetIt.I.registerFactory(MfaFactory.create); + GetIt.I.registerFactory(() => GrueneApiPosterService()); + // This is required so ObjectBox can get the application directory + // to store the database in. + WidgetsFlutterBinding.ensureInitialized(); runApp(TranslationProvider(child: const MyApp())); } diff --git a/pubspec.lock b/pubspec.lock index c88f6d5a..25e51bf9 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: @@ -1257,6 +1257,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 +1345,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 fc923012..23fa41cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,9 @@ 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 dev_dependencies: @@ -69,13 +72,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 @@ -95,7 +97,6 @@ flutter: - assets/maps/ - .env - # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg