diff --git a/assets/symbols/posters/poster.png b/assets/symbols/posters/poster.png index 0c2771e9..cc3cc889 100644 Binary files a/assets/symbols/posters/poster.png and b/assets/symbols/posters/poster.png differ diff --git a/assets/symbols/posters/poster_damaged.png b/assets/symbols/posters/poster_damaged.png index c250990f..bee1ee97 100644 Binary files a/assets/symbols/posters/poster_damaged.png and b/assets/symbols/posters/poster_damaged.png differ diff --git a/assets/symbols/posters/poster_missing.png b/assets/symbols/posters/poster_missing.png new file mode 100644 index 00000000..3de3a132 Binary files /dev/null and b/assets/symbols/posters/poster_missing.png differ diff --git a/assets/symbols/posters/poster_removed.png b/assets/symbols/posters/poster_removed.png index ed2fba9c..3a6ed661 100644 Binary files a/assets/symbols/posters/poster_removed.png and b/assets/symbols/posters/poster_removed.png differ diff --git a/assets/symbols/posters/poster_to_be_moved.png b/assets/symbols/posters/poster_to_be_moved.png new file mode 100644 index 00000000..1ab347f5 Binary files /dev/null and b/assets/symbols/posters/poster_to_be_moved.png differ diff --git a/assets/symbols/posters/svg_inverted/poster_damaged.svg b/assets/symbols/posters/svg_inverted/poster_damaged.svg new file mode 100644 index 00000000..3e7c6995 --- /dev/null +++ b/assets/symbols/posters/svg_inverted/poster_damaged.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/symbols/posters/svg_inverted/poster_missing.svg b/assets/symbols/posters/svg_inverted/poster_missing.svg new file mode 100644 index 00000000..1f01d088 --- /dev/null +++ b/assets/symbols/posters/svg_inverted/poster_missing.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/symbols/posters/svg_inverted/poster_ok.svg b/assets/symbols/posters/svg_inverted/poster_ok.svg new file mode 100644 index 00000000..2b40be69 --- /dev/null +++ b/assets/symbols/posters/svg_inverted/poster_ok.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/symbols/posters/svg_inverted/poster_removed.svg b/assets/symbols/posters/svg_inverted/poster_removed.svg new file mode 100644 index 00000000..5135581e --- /dev/null +++ b/assets/symbols/posters/svg_inverted/poster_removed.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/symbols/posters/svg_inverted/poster_tobemoved.svg b/assets/symbols/posters/svg_inverted/poster_tobemoved.svg new file mode 100644 index 00000000..9c136533 --- /dev/null +++ b/assets/symbols/posters/svg_inverted/poster_tobemoved.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/app/services/converters/poi_poster_status_parsing.dart b/lib/app/services/converters/poi_poster_status_parsing.dart index 371f6cb8..6c66605e 100644 --- a/lib/app/services/converters/poi_poster_status_parsing.dart +++ b/lib/app/services/converters/poi_poster_status_parsing.dart @@ -7,6 +7,7 @@ extension PoiPosterStatusParsing on PoiPosterStatus { PoiPosterStatus.damaged => PosterStatus.damaged, PoiPosterStatus.missing => PosterStatus.missing, PoiPosterStatus.removed => PosterStatus.removed, + PoiPosterStatus.toBeMoved => PosterStatus.toBeMoved, PoiPosterStatus.swaggerGeneratedUnknown => throw UnimplementedError(), }; } @@ -17,6 +18,7 @@ extension PoiPosterStatusParsing on PoiPosterStatus { PoiPosterStatus.damaged => t.campaigns.poster.status.damaged.label, PoiPosterStatus.removed => t.campaigns.poster.status.removed.label, PoiPosterStatus.missing => t.campaigns.poster.status.missing.label, + PoiPosterStatus.toBeMoved => t.campaigns.poster.status.to_be_moved.label, PoiPosterStatus.swaggerGeneratedUnknown => throw UnimplementedError(), }; } diff --git a/lib/app/services/converters/poster_status_parsing.dart b/lib/app/services/converters/poster_status_parsing.dart index 29a73f14..bf73b635 100644 --- a/lib/app/services/converters/poster_status_parsing.dart +++ b/lib/app/services/converters/poster_status_parsing.dart @@ -7,6 +7,7 @@ extension PosterStatusParsing on PosterStatus { PosterStatus.damaged => PoiPosterStatus.damaged, PosterStatus.missing => PoiPosterStatus.missing, PosterStatus.removed => PoiPosterStatus.removed, + PosterStatus.toBeMoved => PoiPosterStatus.toBeMoved, }; } diff --git a/lib/app/services/gruene_api_campaigns_statistics_service.dart b/lib/app/services/gruene_api_campaigns_statistics_service.dart index 768d246f..515e854a 100644 --- a/lib/app/services/gruene_api_campaigns_statistics_service.dart +++ b/lib/app/services/gruene_api_campaigns_statistics_service.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; -import 'package:gruene_app/features/campaigns/models/statistics/campaign_statistics.dart'; +import 'package:gruene_app/features/campaigns/models/statistics/campaign_statistics_model.dart'; import 'package:gruene_app/features/campaigns/models/statistics/campaign_statistics_set.dart'; import 'package:gruene_app/swagger_generated_code/gruene_api.swagger.dart'; @@ -11,7 +11,7 @@ class GrueneApiCampaignsStatisticsService { grueneApi = GetIt.I(); } - Future getStatistics() async { + Future getStatistics() async { try { var statResult = await grueneApi.v1CampaignsStatisticsGet(); return statResult.body!.asCampaignStatistics(); @@ -23,9 +23,9 @@ class GrueneApiCampaignsStatisticsService { } } -extension StatisticsParser on Statistics { - CampaignStatistics asCampaignStatistics() { - return CampaignStatistics( +extension StatisticsParser on CampaignStatistics { + CampaignStatisticsModel asCampaignStatistics() { + return CampaignStatisticsModel( flyerStats: flyer.asStatisticsSet(), houseStats: house.asStatisticsSet(), posterStats: poster.asStatisticsSet(), diff --git a/lib/features/campaigns/helper/campaign_constants.dart b/lib/features/campaigns/helper/campaign_constants.dart index 1e6c0051..a1c7f0da 100644 --- a/lib/features/campaigns/helper/campaign_constants.dart +++ b/lib/features/campaigns/helper/campaign_constants.dart @@ -6,7 +6,9 @@ class CampaignConstants { static const flyerAssetName = 'assets/symbols/flyer/flyer.png'; static const posterOkAssetName = 'assets/symbols/posters/poster.png'; static const posterDamagedAssetName = 'assets/symbols/posters/poster_damaged.png'; + static const posterMissingAssetName = 'assets/symbols/posters/poster_missing.png'; static const posterRemovedAssetName = 'assets/symbols/posters/poster_removed.png'; + static const posterToBeMovedAssetName = 'assets/symbols/posters/poster_to_be_moved.png'; static const addMarkerAssetName = 'assets/symbols/add_marker.svg'; static const markerSourceName = 'markers'; diff --git a/lib/features/campaigns/helper/campaign_session_settings.dart b/lib/features/campaigns/helper/campaign_session_settings.dart index 5d4bbf1a..488149b0 100644 --- a/lib/features/campaigns/helper/campaign_session_settings.dart +++ b/lib/features/campaigns/helper/campaign_session_settings.dart @@ -1,12 +1,12 @@ import 'package:gruene_app/app/services/nominatim_service.dart'; -import 'package:gruene_app/features/campaigns/models/statistics/campaign_statistics.dart'; +import 'package:gruene_app/features/campaigns/models/statistics/campaign_statistics_model.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; class CampaignSessionSettings { LatLng? lastPosition; double? lastZoomLevel; - CampaignStatistics? recentStatistics; + CampaignStatisticsModel? recentStatistics; DateTime? recentStatisticsFetchTimestamp; bool imageConsentConfirmed = false; diff --git a/lib/features/campaigns/helper/enums.dart b/lib/features/campaigns/helper/enums.dart index 760a24bf..88571490 100644 --- a/lib/features/campaigns/helper/enums.dart +++ b/lib/features/campaigns/helper/enums.dart @@ -1,3 +1,5 @@ enum ModalEditResult { cancel, save, delete } +enum ModalDetailResult { close, edit } + enum ImageType { jpeg, png } diff --git a/lib/features/campaigns/helper/poster_status.dart b/lib/features/campaigns/helper/poster_status.dart index 7cd3af28..c80100eb 100644 --- a/lib/features/campaigns/helper/poster_status.dart +++ b/lib/features/campaigns/helper/poster_status.dart @@ -2,21 +2,26 @@ import 'package:gruene_app/features/campaigns/models/posters/poster_detail_model import 'package:gruene_app/i18n/translations.g.dart'; class PosterStatusHelper { - static List<(PosterStatus, String, String)> getPosterStatusOptions = <(PosterStatus, String, String)>[ + static List<(PosterStatus, String)> getPosterStatusList = <(PosterStatus status, String label)>[ + ( + PosterStatus.ok, + t.campaigns.poster.status.ok.label, + ), ( PosterStatus.damaged, t.campaigns.poster.status.damaged.label, - t.campaigns.poster.status.damaged.hint, ), ( PosterStatus.missing, t.campaigns.poster.status.missing.label, - t.campaigns.poster.status.missing.hint, + ), + ( + PosterStatus.toBeMoved, + t.campaigns.poster.status.to_be_moved.label, ), ( PosterStatus.removed, t.campaigns.poster.status.removed.label, - t.campaigns.poster.status.removed.hint, ), ]; } diff --git a/lib/features/campaigns/models/posters/poster_detail_model.dart b/lib/features/campaigns/models/posters/poster_detail_model.dart index 7bcbd5cc..414f1b04 100644 --- a/lib/features/campaigns/models/posters/poster_detail_model.dart +++ b/lib/features/campaigns/models/posters/poster_detail_model.dart @@ -15,6 +15,8 @@ enum PosterStatus { missing, @JsonValue(400) removed, + @JsonValue(500) + toBeMoved, } @JsonSerializable() diff --git a/lib/features/campaigns/models/statistics/campaign_statistics.dart b/lib/features/campaigns/models/statistics/campaign_statistics_model.dart similarity index 79% rename from lib/features/campaigns/models/statistics/campaign_statistics.dart rename to lib/features/campaigns/models/statistics/campaign_statistics_model.dart index 766cb4b4..1ea65652 100644 --- a/lib/features/campaigns/models/statistics/campaign_statistics.dart +++ b/lib/features/campaigns/models/statistics/campaign_statistics_model.dart @@ -1,9 +1,9 @@ import 'package:gruene_app/features/campaigns/models/statistics/campaign_statistics_set.dart'; -class CampaignStatistics { +class CampaignStatisticsModel { final CampaignStatisticsSet flyerStats, houseStats, posterStats; - const CampaignStatistics({ + const CampaignStatisticsModel({ required this.flyerStats, required this.houseStats, required this.posterStats, diff --git a/lib/features/campaigns/screens/map_consumer.dart b/lib/features/campaigns/screens/map_consumer.dart index 11d19e0f..365a8cf7 100644 --- a/lib/features/campaigns/screens/map_consumer.dart +++ b/lib/features/campaigns/screens/map_consumer.dart @@ -114,22 +114,44 @@ abstract class MapConsumer getPoiDetail, GetPoiEditWidgetCallback getPoiEdit, { Size desiredSize = const Size(100, 100), + bool useBottomSheet = false, }) async { final feature = rawFeature as Map; final poiId = MapHelper.extractPoiIdFromFeature(feature); U poi = await getPoi(poiId); final poiDetailWidget = getPoiDetail(poi); - var popupWidget = SizedBox( - height: desiredSize.height, - width: desiredSize.width, - child: poiDetailWidget, - ); - final coord = MapHelper.extractLatLngFromFeature(feature); - mapController.showMapPopover( - coord, - popupWidget, - () => _editPoi(() => getPoiEdit(poi)), - desiredSize, + if (useBottomSheet) { + await mapController.setFocusToMarkerItem(rawFeature); + var result = await showDetailBottomSheet(poiDetailWidget); + if (result != null && result == ModalDetailResult.edit) { + _editPoi(() => getPoiEdit(poi)); + } + await mapController.unsetFocusToMarkerItem(); + } else { + var popupWidget = SizedBox( + height: desiredSize.height, + width: desiredSize.width, + child: poiDetailWidget, + ); + final coord = MapHelper.extractLatLngFromFeature(feature); + mapController.showMapPopover( + coord, + popupWidget, + () => _editPoi(() => getPoiEdit(poi)), + desiredSize, + ); + } + } + + Future showDetailBottomSheet(Widget poiDetailWidget) async { + final theme = Theme.of(context); + return await showModalBottomSheet( + isScrollControlled: false, + isDismissible: true, + barrierColor: Colors.transparent, + context: context, + backgroundColor: theme.colorScheme.surface, + builder: (context) => poiDetailWidget, ); } diff --git a/lib/features/campaigns/screens/poster_detail.dart b/lib/features/campaigns/screens/poster_detail.dart index 4778d070..b784c884 100644 --- a/lib/features/campaigns/screens/poster_detail.dart +++ b/lib/features/campaigns/screens/poster_detail.dart @@ -1,10 +1,14 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.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'; import 'package:gruene_app/features/campaigns/models/posters/poster_detail_model.dart'; -import 'package:gruene_app/features/campaigns/widgets/address_field_detail.dart'; +import 'package:gruene_app/features/campaigns/widgets/close_edit_widget.dart'; +import 'package:gruene_app/i18n/translations.g.dart'; class PosterDetail extends StatelessWidget { final PosterDetailModel poi; @@ -13,36 +17,105 @@ class PosterDetail extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - AddressFieldDetail( - street: poi.address.street, - houseNumber: poi.address.houseNumber, - ), - Expanded( - child: FutureBuilder( - future: Future.delayed( - Duration.zero, - () => poi.thumbnailUrl == null ? null : (thumbnailUrl: poi.thumbnailUrl), + getStatusColor() { + switch (poi.status) { + case PosterStatus.ok: + return ThemeColors.secondary; + case PosterStatus.damaged: + case PosterStatus.missing: + case PosterStatus.toBeMoved: + return Colors.red; + case PosterStatus.removed: + return ThemeColors.textDisabled; + } + } + + getStatusText() { + return switch (poi.status) { + PosterStatus.ok => t.campaigns.poster.status.ok.description, + PosterStatus.damaged => t.campaigns.poster.status.damaged.description, + PosterStatus.missing => t.campaigns.poster.status.missing.description, + PosterStatus.toBeMoved => t.campaigns.poster.status.to_be_moved.description, + PosterStatus.removed => t.campaigns.poster.status.removed.description, + }; + } + + var theme = Theme.of(context); + return GestureDetector( + onTap: () => _closeDialog(context, result: ModalDetailResult.edit), + child: SizedBox( + height: 250, + child: Column( + children: [ + Container( + padding: EdgeInsets.all(16), + child: CloseEditWidget( + onClose: () => _closeDialog(context), + onEdit: () => _closeDialog(context, result: ModalDetailResult.edit), + ), + ), + Row( + children: [ + Container( + padding: EdgeInsets.only(left: 12, bottom: 12), + height: 150, + width: 120, + child: FutureBuilder( + 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); + } + if (snapshot.data!.thumbnailUrl!.isNetworkImageUrl()) { + return FadeInImage.assetNetwork( + placeholder: CampaignConstants.dummyImageAssetName, + image: snapshot.data!.thumbnailUrl!, + ); + } else { + return Image.file( + File(snapshot.data!.thumbnailUrl!), + ); + } + }, + ), + ), + SizedBox(width: 12), + Text( + '${poi.address.street} ${poi.address.houseNumber}\n${poi.address.zipCode} ${poi.address.city}', + style: theme.textTheme.labelLarge!.copyWith(color: ThemeColors.text), + ), + ], ), - builder: (context, snapshot) { - if (!snapshot.hasData && !snapshot.hasError) { - return Image.asset(CampaignConstants.dummyImageAssetName); - } - if (snapshot.data!.thumbnailUrl!.isNetworkImageUrl()) { - return FadeInImage.assetNetwork( - placeholder: CampaignConstants.dummyImageAssetName, - image: snapshot.data!.thumbnailUrl!, - ); - } else { - return Image.file( - File(snapshot.data!.thumbnailUrl!), - ); - } - }, - ), + Expanded( + child: Container( + color: getStatusColor(), + padding: EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + SizedBox(width: 12), + SvgPicture.asset( + 'assets/symbols/posters/svg_inverted/poster_${poi.status.name.toLowerCase()}.svg', + height: 18, + ), + SizedBox(width: 12), + Text( + getStatusText(), + style: theme.textTheme.labelLarge!.copyWith(color: ThemeColors.background), + ), + ], + ), + ), + ), + ], ), - ], + ), ); } + + void _closeDialog(BuildContext context, {ModalDetailResult result = ModalDetailResult.close}) { + Navigator.maybePop(context, result); + } } diff --git a/lib/features/campaigns/screens/poster_edit.dart b/lib/features/campaigns/screens/poster_edit.dart index 74d5be88..de02689c 100644 --- a/lib/features/campaigns/screens/poster_edit.dart +++ b/lib/features/campaigns/screens/poster_edit.dart @@ -37,8 +37,6 @@ class PosterEdit extends StatefulWidget { } class _PosterEditState extends State with AddressExtension, ConfirmDelete { - Set _segmentedButtonSelection = {}; - @override TextEditingController streetTextController = TextEditingController(); @override @@ -65,8 +63,8 @@ class _PosterEditState extends State with AddressExtension, ConfirmD void initState() { setAddress(widget.poster.address); commentTextController.text = widget.poster.comment; - if (widget.poster.status != PosterStatus.ok) _segmentedButtonSelection = {widget.poster.status}; _isPhotoDeleted = (widget.poster.imageUrl == null); + _selectedPosterStatus = widget.poster.status; super.initState(); } @@ -74,7 +72,6 @@ class _PosterEditState extends State with AddressExtension, ConfirmD @override Widget build(BuildContext context) { final theme = Theme.of(context); - final buttonStyle = _getSegmentedButtonStyle(theme); final currentSize = MediaQuery.of(context).size; final lightBorderColor = ThemeColors.textLight; var imageRowHeight = 130.0; @@ -218,51 +215,16 @@ class _PosterEditState extends State with AddressExtension, ConfirmD ), Container( padding: EdgeInsets.symmetric(vertical: 6), - child: Row( + height: 217, + child: Column( children: [ - Expanded( - child: Column( - children: [ - Align( - alignment: Alignment.center, - child: SegmentedButton( - multiSelectionEnabled: false, - emptySelectionAllowed: true, - showSelectedIcon: false, - selected: _segmentedButtonSelection, - onSelectionChanged: (Set newSelection) { - setState(() { - _segmentedButtonSelection = newSelection; - }); - }, - segments: PosterStatusHelper.getPosterStatusOptions.map>( - ((PosterStatus, String, String) posterStatusContext) { - return ButtonSegment( - value: posterStatusContext.$1, - label: Text(posterStatusContext.$2), - ); - }).toList(), - style: buttonStyle, - ), - ), - SizedBox( - height: 25, - child: Text( - _getCurrentPosterStatusHint(), - style: theme.textTheme.labelMedium!.apply( - color: ThemeColors.textDisabled, - ), - ), - ), - ], - ), - ), + ...PosterStatusHelper.getPosterStatusList.map(_getRadioItem), ], ), ), Container( padding: EdgeInsets.symmetric(vertical: 6), - height: 140, + height: 148, child: Row( children: [ Expanded( @@ -375,7 +337,7 @@ class _PosterEditState extends State with AddressExtension, ConfirmD final updateModel = PosterUpdateModel( id: widget.poster.id, address: getAddress(), - status: _segmentedButtonSelection.isEmpty ? PosterStatus.ok : _segmentedButtonSelection.single, + status: _selectedPosterStatus, comment: commentTextController.text, removePreviousPhotos: _isPhotoDeleted, location: widget.poster.location, @@ -387,54 +349,6 @@ class _PosterEditState extends State with AddressExtension, ConfirmD _closeDialog(ModalEditResult.save); } - ButtonStyle _getSegmentedButtonStyle(ThemeData theme) { - final WidgetStateProperty segmentedButtonBackgroundColor = WidgetStateProperty.resolveWith( - (Set states) { - if (states.contains(WidgetState.selected)) { - return ThemeColors.secondary; - } - return ThemeColors.background; - }, - ); - final WidgetStateProperty segmentedButtonForegroundColor = WidgetStateProperty.resolveWith( - (Set states) { - if (states.contains(WidgetState.selected)) { - return ThemeColors.background; - } - return ThemeColors.text; - }, - ); - - final WidgetStateProperty segmentedButtonTextStyle = WidgetStateProperty.resolveWith( - (Set states) { - if (states.contains(WidgetState.selected)) { - return theme.textTheme.labelMedium!.apply( - color: Colors.red, - ); - } - return theme.textTheme.labelMedium; - }, - ); - - return ButtonStyle( - textStyle: segmentedButtonTextStyle, //WidgetStatePropertyAll(theme.textTheme.labelMedium), - backgroundColor: segmentedButtonBackgroundColor, - foregroundColor: segmentedButtonForegroundColor, - side: WidgetStatePropertyAll(BorderSide(color: ThemeColors.secondary)), - ); - } - - String _getCurrentPosterStatusHint() { - if (_segmentedButtonSelection.isEmpty) return ''; - return _segmentedButtonSelection.map( - (selected) { - return PosterStatusHelper.getPosterStatusOptions - .firstWhere(((PosterStatus, String, String) posterStatusContext) => posterStatusContext.$1 == selected) - .$3; - }, - ).join('; '); - } - void _acquireNewPhoto() async { final photo = await MediaHelper.acquirePhoto(context); @@ -491,4 +405,24 @@ class _PosterEditState extends State with AddressExtension, ConfirmD } bool get _hasPhoto => _currentPhoto != null || (widget.poster.imageUrl != null && !_isPhotoDeleted); + + PosterStatus _selectedPosterStatus = PosterStatus.ok; + + Widget _getRadioItem((PosterStatus, String) item) { + var theme = Theme.of(context); + return RadioListTile( + value: item.$1, + groupValue: _selectedPosterStatus, + onChanged: (value) => setState(() { + _selectedPosterStatus = value!; + }), + fillColor: WidgetStatePropertyAll(ThemeColors.primary), + title: Text( + item.$2, + style: theme.textTheme.bodyMedium, + ), + visualDensity: VisualDensity(vertical: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } } diff --git a/lib/features/campaigns/screens/posters_screen.dart b/lib/features/campaigns/screens/posters_screen.dart index 21ac1588..77c581de 100644 --- a/lib/features/campaigns/screens/posters_screen.dart +++ b/lib/features/campaigns/screens/posters_screen.dart @@ -152,8 +152,9 @@ class _PostersScreenState extends MapConsumer().campaign.recentStatisticsFetchTimestamp ?? DateTime.now(); return SingleChildScrollView( child: Container( @@ -73,7 +73,7 @@ class StatisticsScreen extends StatelessWidget { ); } - Widget _getBadgeBox(CampaignStatistics statistics, BuildContext context, ThemeData theme) { + Widget _getBadgeBox(CampaignStatisticsModel statistics, BuildContext context, ThemeData theme) { var mediaQuery = MediaQuery.of(context); return Container( padding: EdgeInsets.all(16), @@ -107,7 +107,7 @@ class StatisticsScreen extends StatelessWidget { ); } - List _getBadges(CampaignStatistics statistics, ThemeData theme) { + List _getBadges(CampaignStatisticsModel statistics, ThemeData theme) { return [ _getBadgeRow(t.campaigns.statistic.recorded_doors, statistics.houseStats.own.toInt(), theme), _getBadgeRow(t.campaigns.statistic.recorded_posters, statistics.posterStats.own.toInt(), theme), @@ -269,7 +269,7 @@ class StatisticsScreen extends StatelessWidget { ); } - Future _loadStatistics() async { + Future _loadStatistics() async { var campaignSettings = GetIt.I().campaign; if (campaignSettings.recentStatistics != null && diff --git a/lib/features/campaigns/widgets/close_edit_widget.dart b/lib/features/campaigns/widgets/close_edit_widget.dart new file mode 100644 index 00000000..b1bcb0f8 --- /dev/null +++ b/lib/features/campaigns/widgets/close_edit_widget.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:gruene_app/i18n/translations.g.dart'; + +class CloseEditWidget extends StatelessWidget { + final void Function()? onEdit; + final void Function() onClose; + + const CloseEditWidget({ + super.key, + this.onEdit, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + GestureDetector( + onTap: onClose, + child: Icon(Icons.close), + ), + _getSaveAction(theme), + ], + ); + } + + Widget _getSaveAction(ThemeData theme) { + if (onEdit == null) return SizedBox.shrink(); + return Expanded( + child: GestureDetector( + onTap: onEdit, + child: Text( + t.common.actions.edit, + textAlign: TextAlign.right, + style: theme.textTheme.bodyLarge, + ), + ), + ); + } +} diff --git a/lib/features/campaigns/widgets/map_container.dart b/lib/features/campaigns/widgets/map_container.dart index a18038c5..9cfcb5be 100644 --- a/lib/features/campaigns/widgets/map_container.dart +++ b/lib/features/campaigns/widgets/map_container.dart @@ -24,6 +24,7 @@ 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'; +import 'package:turf/turf.dart' as turf; typedef OnMapCreatedCallback = void Function(MapController controller); typedef AddPOIClickedCallback = void Function(LatLng location); @@ -96,6 +97,8 @@ class _MapContainerState extends State implements MapController, M bool _showAddMarker = true; + bool _isInFocusMode = false; + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -115,7 +118,7 @@ class _MapContainerState extends State implements MapController, M height: 0, width: 0, ); - if (popups.isEmpty && _showAddMarker) { + if (popups.isEmpty && _showAddMarker & !_isInFocusMode) { addMarker = Center( child: Container( padding: EdgeInsets.only( @@ -318,7 +321,15 @@ class _MapContainerState extends State implements MapController, M CampaignConstants.markerLayerName, const SymbolLayerProperties( iconImage: ['get', 'status_type'], - iconSize: 2, + iconSize: [ + Expressions.interpolate, + ['linear'], + [Expressions.zoom], + 11, + 1, + 16, + 2, + ], iconAllowOverlap: true, ), enableInteraction: false, @@ -328,6 +339,25 @@ class _MapContainerState extends State implements MapController, M ['has', 'point_count'], ], ); + + // add selected map layers + await _controller!.addGeoJsonSource( + '${CampaignConstants.markerSourceName}_selected', + MarkerItemHelper.transformListToGeoJson([]).toJson(), + ); + + await _controller!.addSymbolLayer( + '${CampaignConstants.markerSourceName}_selected', + '${CampaignConstants.markerLayerName}_selected', + const SymbolLayerProperties( + iconImage: ['get', 'status_type'], + iconSize: 3, + iconAllowOverlap: true, + ), + enableInteraction: false, + minzoom: minZoomMarkerItems, + ); + // init context layers re-directed to context screens widget.addMapLayersForContext!(_controller!); } @@ -415,6 +445,44 @@ class _MapContainerState extends State implements MapController, M ); } + @override + Future setFocusToMarkerItem(Map feature) async { + // removes the add_marker + setState(() { + _isInFocusMode = true; + }); + // align map to show feature in center area + final coord = MapHelper.extractLatLngFromFeature(feature); + await moveMapIfItemIsOnBorder(coord, Size(150, 150)); + // set opacity of marker layer + await _controller!.setLayerProperties( + CampaignConstants.markerLayerName, + SymbolLayerProperties(iconOpacity: 0.2), + ); + // set data for '_selected layer' + var featureObject = turf.Feature.fromJson(feature); + turf.FeatureCollection collection = turf.FeatureCollection(features: [featureObject]); + await _controller!.setGeoJsonSource( + '${CampaignConstants.markerSourceName}_selected', + collection.toJson(), + ); + } + + @override + Future unsetFocusToMarkerItem() async { + setState(() { + _isInFocusMode = false; + }); + await _controller!.setLayerProperties( + CampaignConstants.markerLayerName, + SymbolLayerProperties(iconOpacity: 1), + ); + await _controller!.setGeoJsonSource( + '${CampaignConstants.markerSourceName}_selected', + turf.FeatureCollection(features: []).toJson(), + ); + } + Future moveMapIfItemIsOnBorder(LatLng itemCoordinate, Size desiredSize) async { final mediaQuery = MediaQuery.of(context); final currentSize = mediaQuery.size; diff --git a/lib/features/campaigns/widgets/map_controller.dart b/lib/features/campaigns/widgets/map_controller.dart index 464245ed..72e505fc 100644 --- a/lib/features/campaigns/widgets/map_controller.dart +++ b/lib/features/campaigns/widgets/map_controller.dart @@ -43,4 +43,7 @@ abstract class MapController { void toggleInfoForMissingMapFeatures(bool enable); void navigateMapTo(LatLng location); + + Future setFocusToMarkerItem(Map feature); + Future unsetFocusToMarkerItem(); } diff --git a/lib/i18n/app_de.json b/lib/i18n/app_de.json index facd9b07..e00bcf1a 100644 --- a/lib/i18n/app_de.json +++ b/lib/i18n/app_de.json @@ -64,16 +64,24 @@ "info_poster_guidelines": "Wusstest Du, dass es je nach Region unterschiedliche gesetzliche Vorgaben und Regeln für das Aufhängen von Plakaten gibt? Bitte wende Dich an Deinen Orts- oder Kreisverband für weitere Informationen, bevor Du Plakate aufhängst!", "status": { "damaged": { - "label": "Beschädigt", - "hint": "Durch Fremdeinwirkung beschädigt" + "label": "Beschädigt (Als zu ersetzen melden)", + "description": "Plakat ist beschädigt, bitte ersetzen!" }, "missing": { "label": "Verschollen", - "hint": "Durch Fremdeinwirkung entfernt" + "description": "Plakat ist verschollen, bitte ersetzen!" }, "removed": { "label": "Abgehängt", - "hint": "Dauerhaft entfernt" + "description": "Plakat wurde abgehängt." + }, + "to_be_moved": { + "label": "Umzuhängen", + "description": "Plakat hängt falsch, bitte umhängen!" + }, + "ok": { + "label": "Plakat hängt", + "description": "Plakat hängt, kein Handlungsbedarf." } }, "comment": { diff --git a/pubspec.yaml b/pubspec.yaml index 643810d6..e2446dcb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -94,6 +94,7 @@ flutter: - assets/graphics/placeholders/ - assets/symbols/ - assets/symbols/posters/ + - assets/symbols/posters/svg_inverted/ - assets/symbols/doors/ - assets/symbols/flyer/ - assets/maps/ diff --git a/swaggers/gruene-api.yaml b/swaggers/gruene-api.yaml index 6c767d53..c1768980 100644 --- a/swaggers/gruene-api.yaml +++ b/swaggers/gruene-api.yaml @@ -331,7 +331,6 @@ paths: security: - api_key: [] - bearer: [] - - oauth2: [] post: operationId: createProfile summary: Create user profile @@ -356,7 +355,6 @@ paths: security: - api_key: [] - bearer: [] - - oauth2: [] /v1/profiles/self: get: operationId: getOwnProfile @@ -378,7 +376,6 @@ paths: security: - api_key: [] - bearer: [] - - oauth2: [] /v1/profiles/{profileId}: get: operationId: getProfile @@ -405,7 +402,6 @@ paths: security: - api_key: [] - bearer: [] - - oauth2: [] put: operationId: updateProfile summary: Update user profile @@ -442,7 +438,6 @@ paths: security: - api_key: [] - bearer: [] - - oauth2: [] delete: operationId: deleteProfile summary: Delete user profile @@ -468,7 +463,6 @@ paths: security: - api_key: [] - bearer: [] - - oauth2: [] /v1/profiles/{profileId}/image: put: operationId: updateProfileImage @@ -482,7 +476,8 @@ paths: - profileImage properties: profileImage: - type: file + type: string + format: binary parameters: - name: profileId required: true @@ -505,7 +500,6 @@ paths: security: - api_key: [] - bearer: [] - - oauth2: [] delete: operationId: deleteProfileImage summary: Delete user profile image @@ -531,7 +525,6 @@ paths: security: - api_key: [] - bearer: [] - - oauth2: [] /v1/profile-tags: get: operationId: findProfileTags @@ -918,7 +911,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] get: operationId: findAreas summary: Find Areas @@ -942,7 +934,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] /v1/campaigns/areas/self: get: operationId: findOwnAreas @@ -967,7 +958,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] /v1/campaigns/areas/{areaId}: get: operationId: getArea @@ -993,7 +983,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] put: operationId: updateArea summary: Update an Area @@ -1024,7 +1013,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] delete: operationId: deleteArea summary: Delete an Area @@ -1049,7 +1037,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] /v1/campaigns/polling-stations: post: operationId: createPollingStation @@ -1205,7 +1192,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] get: operationId: findPois summary: Find POIs @@ -1240,7 +1226,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] /v1/campaigns/pois/self: get: operationId: findOwnPois @@ -1276,7 +1261,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] /v1/campaigns/pois/{poiId}: get: operationId: getPoi @@ -1302,7 +1286,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] put: operationId: updatePoi summary: Update a POI @@ -1333,7 +1316,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] delete: operationId: deletePoi summary: Delete a POI @@ -1358,7 +1340,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] /v1/campaigns/pois/{poiId}/photos: post: operationId: addPoiPhoto @@ -1372,7 +1353,8 @@ paths: - image properties: image: - type: file + type: string + format: binary parameters: - name: poiId required: true @@ -1394,7 +1376,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] /v1/campaigns/pois/{poiId}/photos/{photoId}: delete: operationId: deletePoiPhoto @@ -1425,7 +1406,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] /v1/campaigns/routes: post: operationId: createRoute @@ -1592,7 +1572,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] get: operationId: findExperienceAreas summary: Find ExperienceAreas @@ -1616,7 +1595,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] /v1/campaigns/experience-areas/{experienceAreaId}: get: operationId: getExperienceArea @@ -1642,7 +1620,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] put: operationId: updateExperienceArea summary: Update a ExperienceArea @@ -1673,7 +1650,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] delete: operationId: deleteExperienceArea summary: Delete a ExperienceArea @@ -1698,7 +1674,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] /v1/campaigns/focus-areas: post: operationId: createFocusArea @@ -1723,7 +1698,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] get: operationId: findFocusAreas summary: Find FocusAreas @@ -1747,7 +1721,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] /v1/campaigns/focus-areas/{focusAreaId}: get: operationId: getFocusArea @@ -1773,7 +1746,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] put: operationId: updateFocusArea summary: Update a FocusArea @@ -1804,7 +1776,6 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] delete: operationId: deleteFocusArea summary: Delete a FocusArea @@ -1829,11 +1800,10 @@ paths: - campaigns security: - bearer: [] - - oauth2: [] /v1/campaigns/statistics: get: - operationId: getStatistics - summary: Get statistics + operationId: getCampaignStatistics + summary: Get campaign statistics parameters: [] responses: '200': @@ -1841,7 +1811,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Statistics' + $ref: '#/components/schemas/CampaignStatistics' '401': description: '' tags: @@ -1899,7 +1869,6 @@ paths: - news security: - bearer: [] - - oauth2: [] /v1/news/{newsId}: get: operationId: getNews @@ -1925,7 +1894,31 @@ paths: - news security: - bearer: [] - - oauth2: [] + /v1/client-info: + get: + operationId: ClientInfoController_getClientInfo + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ClientInfo' + /v1/gnetz-applications: + get: + operationId: findGnetzApplications + summary: Find GNetz Applications + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/FindGnetzApplicationsResponse' + tags: + - gnetz-applications /health: get: tags: @@ -2427,14 +2420,17 @@ components: items: type: string purposes: - description: Purposes associated with email - examples: - - - privat - - - grüne - - - dienstlich - - - Postanschrift - - - Rechnungsanschrift - - - Lieferanschrift + description: |- + Purposes associated with email + Occuring values: + - privat + - grüne + - dienstlich + - Postanschrift + - Rechnungsanschrift + - Lieferanschrift + example: + - privat type: array items: type: string @@ -2623,6 +2619,19 @@ components: - tags - roles - achievements + OffsetPaginationMeta: + type: object + properties: + count: + type: number + total: + type: number + offset: + type: number + limit: + type: number + hasNext: + type: boolean FindProfilesResponse: type: object properties: @@ -2631,20 +2640,7 @@ components: items: $ref: '#/components/schemas/PublicProfile' meta: - type: object - properties: - count: - required: true - type: number - offset: - required: true - type: number - limit: - required: true - type: number - hasNext: - required: true - type: boolean + $ref: '#/components/schemas/OffsetPaginationMeta' required: - data - meta @@ -2871,20 +2867,7 @@ components: items: $ref: '#/components/schemas/ProfileTag' meta: - type: object - properties: - count: - required: true - type: number - total: - required: true - type: number - offset: - required: true - type: number - limit: - required: true - type: number + $ref: '#/components/schemas/OffsetPaginationMeta' required: - data - meta @@ -2906,6 +2889,11 @@ components: required: - id - username + KeysetPaginationMeta: + type: object + properties: + cursorNext: + type: string FindOffboardingUsersResponse: type: object properties: @@ -2914,11 +2902,7 @@ components: items: $ref: '#/components/schemas/OffboardingUserInfo' meta: - type: object - properties: - cursorNext: - required: false - type: string + $ref: '#/components/schemas/KeysetPaginationMeta' required: - data - meta @@ -2953,20 +2937,7 @@ components: items: $ref: '#/components/schemas/Division' meta: - type: object - properties: - count: - required: true - type: number - total: - required: true - type: number - offset: - required: true - type: number - limit: - required: true - type: number + $ref: '#/components/schemas/OffsetPaginationMeta' required: - data - meta @@ -3107,20 +3078,7 @@ components: items: $ref: '#/components/schemas/Role' meta: - type: object - properties: - count: - required: true - type: number - total: - required: true - type: number - offset: - required: true - type: number - limit: - required: true - type: number + $ref: '#/components/schemas/OffsetPaginationMeta' required: - data - meta @@ -3132,20 +3090,7 @@ components: items: $ref: '#/components/schemas/RoleTag' meta: - type: object - properties: - count: - required: true - type: number - total: - required: true - type: number - offset: - required: true - type: number - limit: - required: true - type: number + $ref: '#/components/schemas/OffsetPaginationMeta' required: - data - meta @@ -3157,20 +3102,7 @@ components: items: $ref: '#/components/schemas/RoleCategory' meta: - type: object - properties: - count: - required: true - type: number - total: - required: true - type: number - offset: - required: true - type: number - limit: - required: true - type: number + $ref: '#/components/schemas/OffsetPaginationMeta' required: - data - meta @@ -3200,27 +3132,26 @@ components: Polygon: type: object properties: - type: - type: string - description: Type of the polygon - example: Polygon - default: Polygon coordinates: type: array + description: |- + Coordinates of the polygon + Must follow the GeoJSON standard items: - name: coordinates type: array items: - required: true - description: |- - Coordinates of the polygon - Must follow the GeoJSON standard type: array items: type: number + format: double + type: + type: string + description: Type of the polygon + example: Polygon + default: Polygon required: - - type - coordinates + - type UpdateArea: type: object properties: @@ -3424,6 +3355,7 @@ components: - DAMAGED - REMOVED - MISSING + - TO_BE_MOVED type: string comment: type: string @@ -3851,7 +3783,7 @@ components: - division - state - germany - Statistics: + CampaignStatistics: type: object properties: poster: @@ -3932,6 +3864,122 @@ components: $ref: '#/components/schemas/News' required: - data + ClientInfo: + type: object + properties: + clientIp: + type: string + clientIpVersion: + enum: + - ivp4 + - ipv6 + type: string + required: + - clientIp + - clientIpVersion + GnetzApplicationCategory: + type: object + properties: + slug: + type: string + description: Category slug + example: beteiligung + title: + type: string + description: Category title + example: Beteiligung + order: + type: number + description: The category order used for display purposes + example: 1 + required: + - slug + - title + - order + GnetzApplication: + type: object + properties: + shortDescription: + type: object + additionalProperties: + type: string + example: + de_DE: Beschreibung in Deutsch + en_US: description in english + description: + type: object + additionalProperties: + type: string + example: + de_DE: Beschreibung in Deutsch + en_US: description in english + slug: + type: string + description: Application slug + example: wolke + url: + type: string + nullable: true + description: URL to the application + example: https://wolke.netzbegruenung.de + title: + type: string + description: Application title + example: Wolke + documentation: + type: string + nullable: true + description: URL to the documentation + example: https://netz.gruene.de/de/wissenswerk/2024-08/wolke + googleStore: + type: string + nullable: true + description: URL to google play store + example: https://play.google.com/store/apps/details?id=com.nextcloud.client + appleStore: + type: string + nullable: true + description: URL to apple store + example: https://apps.apple.com/us/app/nextcloud/id1125420102 + supportMail: + type: string + nullable: true + description: support email + example: support@example.com + icon: + type: string + nullable: true + description: icon as svg + example: >- + + categories: + description: Application categories + type: array + items: + $ref: '#/components/schemas/GnetzApplicationCategory' + required: + - shortDescription + - description + - slug + - url + - title + - documentation + - googleStore + - appleStore + - supportMail + - icon + - categories + FindGnetzApplicationsResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/GnetzApplication' + required: + - data HealthCheckResponse: type: object properties: