diff --git a/src/mobile/lib/src/app/dashboard/pages/location_selection/components/swipeable_analytics_card.dart b/src/mobile/lib/src/app/dashboard/pages/location_selection/components/swipeable_analytics_card.dart index bbea95a383..0e7445dfda 100644 --- a/src/mobile/lib/src/app/dashboard/pages/location_selection/components/swipeable_analytics_card.dart +++ b/src/mobile/lib/src/app/dashboard/pages/location_selection/components/swipeable_analytics_card.dart @@ -209,6 +209,18 @@ class _SwipeableAnalyticsCardState extends State return getAppAqiCategoryColor(measurement.aqiCategory ?? ''); } + Widget _chevron(BuildContext context) { + return SvgPicture.asset( + 'assets/icons/chevron-right.svg', + width: 20, + height: 20, + colorFilter: ColorFilter.mode( + AppTextColors.modalCloseIcon(context), + BlendMode.srcIn, + ), + ); + } + void _handleRemove() { final String siteId = widget.measurement.siteId ?? ''; @@ -234,6 +246,8 @@ class _SwipeableAnalyticsCardState extends State @override Widget build(BuildContext context) { + final locationColor = AppTextColors.muted(context); + return Stack( clipBehavior: Clip.none, children: [ @@ -327,7 +341,7 @@ class _SwipeableAnalyticsCardState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( @@ -356,6 +370,10 @@ class _SwipeableAnalyticsCardState extends State 'assets/images/shared/location_pin.svg', width: 14, height: 14, + colorFilter: ColorFilter.mode( + locationColor, + BlendMode.srcIn, + ), ), const SizedBox(width: 4), Expanded( @@ -363,11 +381,7 @@ class _SwipeableAnalyticsCardState extends State _getLocationDescription(widget.measurement), style: TextStyle( fontSize: 14, - color: Theme.of(context) - .textTheme - .bodyMedium - ?.color - ?.withValues(alpha: 0.7), + color: locationColor, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -378,6 +392,10 @@ class _SwipeableAnalyticsCardState extends State ], ), ), + Padding( + padding: const EdgeInsets.only(left: 8, top: 2), + child: _chevron(context), + ), ], ), ], diff --git a/src/mobile/lib/src/app/dashboard/widgets/analytics_card.dart b/src/mobile/lib/src/app/dashboard/widgets/analytics_card.dart index 768fca8a9a..239dc3e6f3 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/analytics_card.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/analytics_card.dart @@ -55,6 +55,18 @@ class AnalyticsCard extends StatelessWidget with UiLoggy { return getAppAqiCategoryColor(measurement.aqiCategory ?? ''); } + Widget _chevron(BuildContext context) { + return SvgPicture.asset( + 'assets/icons/chevron-right.svg', + width: 20, + height: 20, + colorFilter: ColorFilter.mode( + AppTextColors.modalCloseIcon(context), + BlendMode.srcIn, + ), + ); + } + @override Widget build(BuildContext context) { final locationColor = AppTextColors.muted(context); @@ -75,7 +87,7 @@ class AnalyticsCard extends StatelessWidget with UiLoggy { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( @@ -104,6 +116,10 @@ class AnalyticsCard extends StatelessWidget with UiLoggy { 'assets/images/shared/location_pin.svg', width: 14, height: 14, + colorFilter: ColorFilter.mode( + locationColor, + BlendMode.srcIn, + ), ), SizedBox(width: 4), Expanded( @@ -122,6 +138,10 @@ class AnalyticsCard extends StatelessWidget with UiLoggy { ], ), ), + Padding( + padding: const EdgeInsets.only(left: 8, top: 2), + child: _chevron(context), + ), ], ), ], diff --git a/src/mobile/lib/src/app/dashboard/widgets/nearby_measurement_card.dart b/src/mobile/lib/src/app/dashboard/widgets/nearby_measurement_card.dart index 41debd24a2..0cd17ab104 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/nearby_measurement_card.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/nearby_measurement_card.dart @@ -58,8 +58,22 @@ class NearbyMeasurementCard extends StatelessWidget with UiLoggy { return getAppAqiCategoryColor(measurement.aqiCategory ?? ''); } + Widget _chevron(BuildContext context) { + return SvgPicture.asset( + 'assets/icons/chevron-right.svg', + width: 20, + height: 20, + colorFilter: ColorFilter.mode( + AppTextColors.modalCloseIcon(context), + BlendMode.srcIn, + ), + ); + } + @override Widget build(BuildContext context) { + final locationColor = AppTextColors.muted(context); + return InkWell( onTap: () => _openForecast(context), child: Container( @@ -75,7 +89,7 @@ class NearbyMeasurementCard extends StatelessWidget with UiLoggy { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( @@ -104,6 +118,10 @@ class NearbyMeasurementCard extends StatelessWidget with UiLoggy { 'assets/images/shared/location_pin.svg', width: 14, height: 14, + colorFilter: ColorFilter.mode( + locationColor, + BlendMode.srcIn, + ), ), SizedBox(width: 4), Expanded( @@ -111,11 +129,7 @@ class NearbyMeasurementCard extends StatelessWidget with UiLoggy { _getLocationDescription(measurement), style: TextStyle( fontSize: 14, - color: Theme.of(context) - .textTheme - .bodyMedium - ?.color - ?.withValues(alpha: 0.7), + color: locationColor, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -126,6 +140,10 @@ class NearbyMeasurementCard extends StatelessWidget with UiLoggy { ], ), ), + Padding( + padding: const EdgeInsets.only(left: 8, top: 2), + child: _chevron(context), + ), ], ), ], diff --git a/src/mobile/lib/src/app/dashboard/widgets/nearby_view.dart b/src/mobile/lib/src/app/dashboard/widgets/nearby_view.dart index 3c2fcd2c7d..45016eca23 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/nearby_view.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/nearby_view.dart @@ -564,7 +564,6 @@ class _NearbyViewState extends State with UiLoggy { final entry = _nearbyMeasurementsWithDistance[index]; return NearbyMeasurementCard( measurement: entry.key, - // distance: entry.value, ); }, ), diff --git a/src/mobile/lib/src/app/map/pages/map_page.dart b/src/mobile/lib/src/app/map/pages/map_page.dart index a87fd977b3..f8d5ac87cd 100644 --- a/src/mobile/lib/src/app/map/pages/map_page.dart +++ b/src/mobile/lib/src/app/map/pages/map_page.dart @@ -7,6 +7,7 @@ import 'package:airqo/src/app/map/controllers/map_camera_controller.dart'; import 'package:airqo/src/app/map/utils/map_marker_builder.dart'; import 'package:airqo/src/app/map/widgets/map_aq_card_layer.dart'; import 'package:airqo/src/app/map/widgets/map_controls.dart'; +import 'package:airqo/src/app/map/widgets/map_controls_tour.dart'; import 'package:airqo/src/app/map/widgets/map_error_view.dart'; import 'package:airqo/src/app/map/widgets/map_loading_view.dart'; import 'package:airqo/src/app/map/widgets/map_overlay_controls.dart'; @@ -20,6 +21,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:geolocator/geolocator.dart'; import 'package:loggy/loggy.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class MapScreen extends StatefulWidget { const MapScreen({super.key}); @@ -53,6 +55,13 @@ class _MapScreenState extends State String? _mapStyleJson; int _markerBuildSeq = 0; + static const String _tourSeenKey = 'map_controls_tour_seen'; + final GlobalKey _layersControlKey = GlobalKey(); + final GlobalKey _locateControlKey = GlobalKey(); + final GlobalKey _zoomControlKey = GlobalKey(); + bool _showControlsTour = false; + bool _locationResolved = false; + static const LatLng _center = LatLng(0.347596, 32.582520); static const double _sheetPeekSize = 0.13; static const double _sheetMidSize = 0.44; @@ -122,18 +131,19 @@ class _MapScreenState extends State void _showForecastModal(Measurement measurement) { _hideTextInputOverlay(); - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; _hideTextInputOverlay(); - Future.delayed(const Duration(milliseconds: 48), () { - if (!mounted) return; - _hideTextInputOverlay(); - ForecastOverviewPage.showForMeasurement( - context, - measurement: measurement, - ); - }); + await Future.delayed(const Duration(milliseconds: 48)); + if (!mounted) return; + _hideTextInputOverlay(); + await ForecastOverviewPage.showForMeasurement( + context, + measurement: measurement, + ); + if (!mounted) return; + _hideTextInputOverlay(); }); } @@ -202,7 +212,56 @@ class _MapScreenState extends State _cameraController.snapToPosition(userPosition!); } catch (e) { loggy.error('Failed to get user location: $e'); + } finally { + if (mounted) { + setState(() => _locationResolved = true); + _maybeShowControlsTour(); + } + } + } + + Future _dismissControlsTour() async { + setState(() => _showControlsTour = false); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_tourSeenKey, true); + } + + void _maybeShowControlsTour() { + if (_showControlsTour || !_locationResolved) return; + SharedPreferences.getInstance().then((prefs) { + final seen = prefs.getBool(_tourSeenKey) ?? false; + if (!seen && mounted) setState(() => _showControlsTour = true); + }); + } + + List _controlsTourSteps() { + final steps = [ + MapControlTourStep( + targetKey: _layersControlKey, + icon: Icons.layers_outlined, + title: 'Map style', + subtitle: 'Switch between standard, satellite, and other map views.', + ), + ]; + if (userPosition != null) { + steps.add( + MapControlTourStep( + targetKey: _locateControlKey, + icon: Icons.my_location, + title: 'Your location', + subtitle: 'Center the map on where you are right now.', + ), + ); } + steps.add( + MapControlTourStep( + targetKey: _zoomControlKey, + icon: Icons.add, + title: 'Zoom in and out', + subtitle: 'Use + and − to zoom the map closer or further away.', + ), + ); + return steps; } void _updateNearbyMeasurements() { @@ -413,6 +472,9 @@ class _MapScreenState extends State Positioned.fill( child: MapOverlayControls( isDark: isDark, + layersKey: _layersControlKey, + locateKey: _locateControlKey, + zoomKey: _zoomControlKey, onLayersTap: _openMapStylePicker, onLocateTap: userPosition != null ? () => _cameraController.snapToPosition(userPosition!) @@ -423,8 +485,10 @@ class _MapScreenState extends State ), Positioned( top: 50, - left: 10, - child: MapAqLegend(isDark: isDark), + left: mapControlSideInset, + child: MapControlSideSlot( + child: MapAqLegend(isDark: isDark), + ), ), DraggableScrollableSheet( controller: _sheetController, @@ -455,6 +519,11 @@ class _MapScreenState extends State onViewForecast: () => _showForecastModal(_selectedCardMeasurement!), ), + if (_showControlsTour) + MapControlsTour( + steps: _controlsTourSteps(), + onDismiss: _dismissControlsTour, + ), ], ); } diff --git a/src/mobile/lib/src/app/map/widgets/map_air_quality_card.dart b/src/mobile/lib/src/app/map/widgets/map_air_quality_card.dart index c0ad9b47e4..a1765edb5c 100644 --- a/src/mobile/lib/src/app/map/widgets/map_air_quality_card.dart +++ b/src/mobile/lib/src/app/map/widgets/map_air_quality_card.dart @@ -1,28 +1,37 @@ -import 'dart:ui' as ui; - import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; -import 'package:airqo/src/app/map/utils/map_aq_presentation.dart'; import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:airqo/src/meta/utils/forecast_utils.dart'; import 'package:airqo/src/meta/utils/utils.dart'; String _locationDescription(Measurement measurement) { - final s = measurement.siteDetails; - if (s == null) return ''; + final siteDetails = measurement.siteDetails; + if (siteDetails == null) return ''; + final parts = []; - if (s.city?.isNotEmpty == true) { - parts.add(s.city!); - } else if (s.town?.isNotEmpty == true) { - parts.add(s.town!); + + if (siteDetails.city?.isNotEmpty == true) { + parts.add(siteDetails.city!); + } else if (siteDetails.town?.isNotEmpty == true) { + parts.add(siteDetails.town!); } - if (s.region?.isNotEmpty == true) { - parts.add(s.region!); - } else if (s.county?.isNotEmpty == true) { - parts.add(s.county!); + + if (siteDetails.region?.isNotEmpty == true) { + parts.add(siteDetails.region!); + } else if (siteDetails.county?.isNotEmpty == true) { + parts.add(siteDetails.county!); } - if (s.country?.isNotEmpty == true) parts.add(s.country!); - return parts.join(', '); + + if (siteDetails.country?.isNotEmpty == true) { + parts.add(siteDetails.country!); + } + + return parts.isNotEmpty + ? parts.join(', ') + : siteDetails.locationName ?? + siteDetails.formattedName ?? + ''; } class MapAirQualityCard extends StatelessWidget { @@ -38,9 +47,7 @@ class MapAirQualityCard extends StatelessWidget { }); Color _aqiColor() { - final pmValue = measurement.pm25?.value; - if (pmValue == null) return Colors.grey; - return mapAqLevelFromPm25(pmValue).color; + return getAppAqiCategoryColor(measurement.aqiCategory ?? ''); } @override @@ -48,203 +55,244 @@ class MapAirQualityCard extends StatelessWidget { final isDark = Theme.of(context).brightness == Brightness.dark; final pmValue = measurement.pm25?.value; final aqColor = _aqiColor(); - final aqLabel = - pmValue == null ? 'No data' : (measurement.aqiCategory ?? ''); + final aqLabel = pmValue == null + ? 'No data' + : (measurement.aqiCategory?.isNotEmpty == true + ? measurement.aqiCategory! + : 'Unknown'); + String? aqiIconPath; + if (pmValue != null) { + final path = getAirQualityIcon(measurement, pmValue); + aqiIconPath = path.isNotEmpty + ? path + : 'assets/images/shared/airquality_indicators/unavailable.svg'; + } final locationText = _locationDescription(measurement); - final dynamicAqIconPath = - pmValue == null ? null : getAirQualityIcon(measurement, pmValue); - final aqIconPath = pmValue == null - ? 'assets/images/shared/airquality_indicators/unavailable.svg' - : dynamicAqIconPath?.isNotEmpty == true - ? dynamicAqIconPath - : mapAqLevelFromPm25(pmValue).asset; + final locationName = measurement.siteDetails?.searchName ?? + measurement.siteDetails?.name ?? + '—'; - return ClipRRect( - borderRadius: BorderRadius.circular(12), - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - decoration: AppSurfaceColors.elevatedCardDecoration(context), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container(height: 3, color: aqColor), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - measurement.siteDetails?.searchName ?? - measurement.siteDetails?.name ?? - '—', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Theme.of(context) - .textTheme - .headlineSmall - ?.color, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (locationText.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - locationText, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w400, - color: isDark - ? AppColors.boldHeadlineColor2 - : AppColors.boldHeadlineColor3, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), + return Container( + decoration: AppSurfaceColors.elevatedCardDecoration(context), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + locationName, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Theme.of(context) + .textTheme + .headlineSmall + ?.color, ), - GestureDetector( - onTap: onDismiss, - behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 0, 8), - child: Icon( - Icons.close, - size: 18, - color: isDark - ? AppColors.boldHeadlineColor2 - : AppColors.boldHeadlineColor3, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (locationText.isNotEmpty) ...[ + const SizedBox(height: 2), + Row( + children: [ + SvgPicture.asset( + 'assets/images/shared/location_pin.svg', + width: 12, + height: 12, ), - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (aqIconPath != null) - SvgPicture.asset( - aqIconPath, - width: 36, - height: 36, - ) - else - const Icon(Icons.help_outline, - size: 36, color: Colors.grey), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - aqLabel, + const SizedBox(width: 4), + Expanded( + child: Text( + locationText, style: TextStyle( fontSize: 12, - fontWeight: FontWeight.w700, - color: aqColor, + color: AppTextColors.muted(context), ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - pmValue != null - ? pmValue.toStringAsFixed(1) - : '—', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w800, - color: Theme.of(context) - .textTheme - .titleLarge - ?.color, - ), - ), - const SizedBox(width: 4), - Padding( - padding: const EdgeInsets.only(bottom: 3), - child: Text( - 'µg/m³ PM2.5', - style: TextStyle( - fontSize: 11, - color: isDark - ? AppColors.boldHeadlineColor2 - : AppColors.boldHeadlineColor3, - ), - ), - ), - ], - ), - ], - ), + ), + ], ), ], + ], + ), + ), + GestureDetector( + onTap: onDismiss, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 0, 0), + child: Icon( + Icons.close, + size: 18, + color: AppTextColors.modalCloseIcon(context), ), - const SizedBox(height: 14), - GestureDetector( - onTap: onViewForecast, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 11), - decoration: BoxDecoration( - color: isDark - ? AppColors.primaryColor.withValues(alpha: 0.14) - : AppColors.primaryColor.withValues(alpha: 0.07), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.primaryColor.withValues( - alpha: isDark ? 0.22 : 0.14, + ), + ), + ], + ), + ), + Divider( + thickness: 0.5, + height: 1, + color: Theme.of(context).dividerColor, + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SvgPicture.asset( + isDark + ? 'assets/images/shared/pm_rating.svg' + : 'assets/images/shared/pm_rating_white.svg', + height: 14, ), - ), + Text( + ' PM2.5', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context) + .textTheme + .headlineSmall + ?.color, + ), + ), + ], ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text( + pmValue != null + ? pmValue.toStringAsFixed(1) + : '—', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + color: Theme.of(context) + .textTheme + .titleLarge + ?.color, + ), + ), Padding( - padding: const EdgeInsets.only(left: 14), + padding: const EdgeInsets.only(bottom: 2), child: Text( - 'view forecast', + ' μg/m³', style: TextStyle( - fontSize: 13, + fontSize: 11, fontWeight: FontWeight.w600, - color: AppColors.primaryColor, + color: Theme.of(context) + .textTheme + .titleLarge + ?.color, ), ), ), - Padding( - padding: const EdgeInsets.only(right: 14), - child: Icon( - Icons.arrow_forward_ios, - size: 13, - color: AppColors.primaryColor, - ), - ), ], ), - ), + ], ), + if (aqiIconPath != null) + SvgPicture.asset( + aqiIconPath, + width: 36, + height: 36, + ) + else + const Icon( + Icons.help_outline, + size: 36, + color: Colors.grey, + ), ], ), - ), - ], + const SizedBox(height: 10), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: aqColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + aqLabel, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: aqColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: onViewForecast, + style: OutlinedButton.styleFrom( + foregroundColor: AppTextColors.headline(context), + backgroundColor: AppSurfaceColors.card(context), + side: AppSurfaceColors.borderSide(context), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + textStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + const Text('View forecast'), + const SizedBox(width: 4), + SvgPicture.asset( + 'assets/icons/chevron-right.svg', + width: 14, + height: 14, + colorFilter: ColorFilter.mode( + AppTextColors.headline(context), + BlendMode.srcIn, + ), + ), + ], + ), + ), + ), + ], + ), ), - ), + ], ), ); } diff --git a/src/mobile/lib/src/app/map/widgets/map_controls.dart b/src/mobile/lib/src/app/map/widgets/map_controls.dart index cb01019947..e853be6739 100644 --- a/src/mobile/lib/src/app/map/widgets/map_controls.dart +++ b/src/mobile/lib/src/app/map/widgets/map_controls.dart @@ -3,6 +3,51 @@ import 'package:airqo/src/meta/utils/colors.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; +const _mapControlTapSize = 56.0; +const _mapControlVisualSize = 44.0; +const _mapControlIconSize = 20.0; +const _mapZoomVisualHeight = 88.0; +const _mapZoomTapHeight = 96.0; + +/// Visual distance from the screen edge to map control pills. +const mapControlVisualEdgeInset = 12.0; + +/// Position for the 56px tap wrapper so the 44px pill sits [mapControlVisualEdgeInset] from the edge. +const mapControlSideInset = + mapControlVisualEdgeInset - (_mapControlTapSize - _mapControlVisualSize) / 2; + +/// Centers a 44px control inside the shared 56px tap column width. +class MapControlSideSlot extends StatelessWidget { + const MapControlSideSlot({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: _mapControlTapSize, + child: Center(child: child), + ); + } +} + +BoxDecoration _mapControlDecoration({ + required Color background, + required BorderRadius borderRadius, +}) { + return BoxDecoration( + color: background, + borderRadius: borderRadius, + boxShadow: const [ + BoxShadow( + color: Color(0x33536A87), + offset: Offset(0, 2), + blurRadius: 5, + ), + ], + ); +} + class MapIconButton extends StatelessWidget { final IconData icon; final bool isDark; @@ -24,30 +69,25 @@ class MapIconButton extends StatelessWidget { : (isDark ? AppColors.darkHighlight.withValues(alpha: 0.94) : Colors.white.withValues(alpha: 0.95)); - final iconColor = - filled ? Colors.white : Theme.of(context).textTheme.bodyMedium?.color; + final iconColor = filled + ? Colors.white + : AppTextColors.modalCloseIcon(context); - return SizedBox( - width: 48, - height: 48, - child: Center( - child: GestureDetector( - onTap: onTap, + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + width: _mapControlTapSize, + height: _mapControlTapSize, + child: Center( child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(8), - boxShadow: const [ - BoxShadow( - color: Color(0x33536A87), - offset: Offset(0, 2), - blurRadius: 5, - ), - ], + width: _mapControlVisualSize, + height: _mapControlVisualSize, + decoration: _mapControlDecoration( + background: bg, + borderRadius: BorderRadius.circular(10), ), - child: Icon(icon, size: 16, color: iconColor), + child: Icon(icon, size: _mapControlIconSize, color: iconColor), ), ), ), @@ -72,64 +112,55 @@ class MapZoomGroup extends StatelessWidget { final bg = isDark ? AppColors.darkHighlight.withValues(alpha: 0.94) : Colors.white.withValues(alpha: 0.95); - final iconColor = Theme.of(context).textTheme.bodyMedium?.color; + final iconColor = AppTextColors.modalCloseIcon(context); final divider = Colors.grey.withValues(alpha: 0.25); - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - boxShadow: const [ - BoxShadow( - color: Color(0x33536A87), - offset: Offset(0, 2), - blurRadius: 5, - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 48, - height: 48, - child: Center( - child: GestureDetector( - onTap: onZoomIn, - child: Container( - width: 32, - height: 24, - decoration: BoxDecoration( - color: bg, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(8)), - border: - Border(bottom: BorderSide(color: divider, width: 0.8)), + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: (details) { + if (details.localPosition.dy < _mapZoomTapHeight / 2) { + onZoomIn(); + } else { + onZoomOut(); + } + }, + child: SizedBox( + width: _mapControlTapSize, + height: _mapZoomTapHeight, + child: Center( + child: Container( + width: _mapControlVisualSize, + height: _mapZoomVisualHeight, + decoration: _mapControlDecoration( + background: bg, + borderRadius: BorderRadius.circular(10), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + Expanded( + child: Center( + child: Icon( + Icons.add, + size: _mapControlIconSize, + color: iconColor, + ), ), - child: Icon(Icons.add, size: 16, color: iconColor), ), - ), - ), - ), - SizedBox( - width: 48, - height: 48, - child: Center( - child: GestureDetector( - onTap: onZoomOut, - child: Container( - width: 32, - height: 24, - decoration: BoxDecoration( - color: bg, - borderRadius: - const BorderRadius.vertical(bottom: Radius.circular(8)), + Container(height: 0.8, color: divider), + Expanded( + child: Center( + child: Icon( + Icons.remove, + size: _mapControlIconSize, + color: iconColor, + ), ), - child: Icon(Icons.remove, size: 16, color: iconColor), ), - ), + ], ), ), - ], + ), ), ); } @@ -173,21 +204,16 @@ class MapAqLegend extends StatelessWidget { @override Widget build(BuildContext context) { + final bg = isDark + ? AppColors.darkHighlight.withValues(alpha: 0.94) + : Colors.white.withValues(alpha: 0.95); + return Container( - width: 40, - padding: const EdgeInsets.symmetric(vertical: 7), - decoration: BoxDecoration( - color: isDark - ? AppColors.darkHighlight.withValues(alpha: 0.94) - : Colors.white.withValues(alpha: 0.95), - borderRadius: BorderRadius.circular(20), - boxShadow: const [ - BoxShadow( - color: Color(0x33536A87), - offset: Offset(0, 2), - blurRadius: 5, - ), - ], + width: _mapControlVisualSize, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: _mapControlDecoration( + background: bg, + borderRadius: BorderRadius.circular(10), ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/src/mobile/lib/src/app/map/widgets/map_controls_tour.dart b/src/mobile/lib/src/app/map/widgets/map_controls_tour.dart new file mode 100644 index 0000000000..7366bdf49a --- /dev/null +++ b/src/mobile/lib/src/app/map/widgets/map_controls_tour.dart @@ -0,0 +1,368 @@ +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; + +class MapControlTourStep { + const MapControlTourStep({ + required this.targetKey, + required this.icon, + required this.title, + required this.subtitle, + }); + + final GlobalKey targetKey; + final IconData icon; + final String title; + final String subtitle; +} + +/// Full-screen guided tour that spotlights map controls on the right edge. +/// +/// Shown once per install; [steps] can omit the locate step when location is +/// unavailable. +class MapControlsTour extends StatefulWidget { + const MapControlsTour({ + super.key, + required this.steps, + required this.onDismiss, + }); + + final List steps; + final VoidCallback onDismiss; + + @override + State createState() => _MapControlsTourState(); +} + +class _MapControlsTourState extends State + with SingleTickerProviderStateMixin { + int _stepIndex = 0; + Rect? _targetRect; + late AnimationController _fade; + late Animation _opacity; + + MapControlTourStep get _step => widget.steps[_stepIndex]; + bool get _isLastStep => _stepIndex >= widget.steps.length - 1; + + @override + void initState() { + super.initState(); + _fade = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + ); + _opacity = CurvedAnimation(parent: _fade, curve: Curves.easeOut); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _measureTarget(); + _fade.forward(); + }); + } + + @override + void dispose() { + _fade.dispose(); + super.dispose(); + } + + void _measureTarget() { + final ctx = _step.targetKey.currentContext; + if (ctx == null) return; + final box = ctx.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + final topLeft = box.localToGlobal(Offset.zero); + if (mounted) { + setState(() { + _targetRect = topLeft & box.size; + }); + } + } + + void _advance() { + if (_isLastStep) { + _fade.reverse().then((_) { + if (mounted) widget.onDismiss(); + }); + return; + } + + _fade.reverse().then((_) { + if (!mounted) return; + setState(() { + _stepIndex++; + _targetRect = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + _measureTarget(); + _fade.forward(); + }); + }); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final targetRect = _targetRect; + + return FadeTransition( + opacity: _opacity, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _advance, + child: Stack( + children: [ + if (targetRect != null) + CustomPaint( + size: MediaQuery.of(context).size, + painter: _SpotlightPainter(spotlight: targetRect), + ) + else + Container(color: Colors.black54), + if (targetRect != null) + _TooltipBubble( + targetRect: targetRect, + isDark: isDark, + step: _step, + stepIndex: _stepIndex, + stepCount: widget.steps.length, + isLastStep: _isLastStep, + ), + ], + ), + ), + ); + } +} + +class _SpotlightPainter extends CustomPainter { + final Rect spotlight; + static const double _pad = 8; + static const double _radius = 12; + + const _SpotlightPainter({required this.spotlight}); + + Rect get _expanded => Rect.fromLTRB( + spotlight.left - _pad, + spotlight.top - _pad, + spotlight.right + _pad, + spotlight.bottom + _pad, + ); + + @override + void paint(Canvas canvas, Size size) { + final expanded = _expanded; + canvas.saveLayer(Offset.zero & size, Paint()); + + canvas.drawRect( + Offset.zero & size, + Paint()..color = Colors.black.withValues(alpha: 0.68), + ); + + canvas.drawRRect( + RRect.fromRectAndRadius(expanded, const Radius.circular(_radius)), + Paint()..blendMode = BlendMode.clear, + ); + + canvas.restore(); + + canvas.drawRRect( + RRect.fromRectAndRadius(expanded, const Radius.circular(_radius)), + Paint() + ..color = Colors.white.withValues(alpha: 0.25) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, + ); + } + + @override + bool shouldRepaint(_SpotlightPainter old) => old.spotlight != spotlight; +} + +class _TooltipBubble extends StatelessWidget { + final Rect targetRect; + final bool isDark; + final MapControlTourStep step; + final int stepIndex; + final int stepCount; + final bool isLastStep; + + static const double _arrowW = 10.0; + static const double _hPad = 16.0; + static const double _maxBubbleWidth = 280.0; + + const _TooltipBubble({ + required this.targetRect, + required this.isDark, + required this.step, + required this.stepIndex, + required this.stepCount, + required this.isLastStep, + }); + + @override + Widget build(BuildContext context) { + final padding = MediaQuery.paddingOf(context); + final screen = MediaQuery.sizeOf(context); + final bubbleBg = isDark ? AppColors.darkHighlight : Colors.white; + final textColor = isDark ? Colors.white : const Color(0xFF1A1D23); + final subColor = + isDark ? AppColors.boldHeadlineColor2 : AppColors.boldHeadlineColor3; + + final bubbleWidth = (screen.width - + padding.horizontal - + _hPad * 2 - + _arrowW - + 12) + .clamp(220.0, _maxBubbleWidth); + final left = (targetRect.left - bubbleWidth - _arrowW - 12).clamp( + _hPad + padding.left, + screen.width - bubbleWidth - _hPad - padding.right, + ); + + final bubbleTop = (targetRect.center.dy - 72).clamp( + padding.top + _hPad + 8, + screen.height * 0.55, + ); + + final arrowTop = (targetRect.center.dy - bubbleTop - 10) + .clamp(18.0, 120.0); + + return Stack( + children: [ + Positioned( + left: left, + top: bubbleTop, + width: bubbleWidth, + child: Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + color: bubbleBg, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.primaryColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + step.icon, + size: 20, + color: AppColors.primaryColor, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + step.title, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: textColor, + height: 1.2, + ), + ), + const SizedBox(height: 4), + Text( + step.subtitle, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: subColor, + height: 1.45, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + Text( + '${stepIndex + 1} of $stepCount', + style: TextStyle( + fontSize: 11, + color: subColor, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Text( + isLastStep ? 'Tap anywhere to dismiss' : 'Tap anywhere to continue', + style: TextStyle( + fontSize: 11, + color: subColor, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + Icon( + isLastStep + ? Icons.keyboard_arrow_down_rounded + : Icons.keyboard_arrow_right_rounded, + size: 14, + color: subColor, + ), + ], + ), + ], + ), + ), + ), + Positioned( + left: left + bubbleWidth, + top: bubbleTop + arrowTop, + child: CustomPaint( + size: const Size(_arrowW, 20), + painter: _ArrowPainter(color: bubbleBg, pointingRight: true), + ), + ), + ], + ); + } +} + +class _ArrowPainter extends CustomPainter { + final Color color; + final bool pointingRight; + + const _ArrowPainter({required this.color, this.pointingRight = false}); + + @override + void paint(Canvas canvas, Size size) { + final path = pointingRight + ? (Path() + ..moveTo(0, size.height * 0.25) + ..lineTo(size.width, size.height / 2) + ..lineTo(0, size.height * 0.75) + ..close()) + : (Path() + ..moveTo(0, size.height) + ..lineTo(size.width / 2, 0) + ..lineTo(size.width, size.height) + ..close()); + canvas.drawPath(path, Paint()..color = color); + } + + @override + bool shouldRepaint(_ArrowPainter old) => + old.color != color || old.pointingRight != pointingRight; +} diff --git a/src/mobile/lib/src/app/map/widgets/map_overlay_controls.dart b/src/mobile/lib/src/app/map/widgets/map_overlay_controls.dart index e6da2be60f..afd7943929 100644 --- a/src/mobile/lib/src/app/map/widgets/map_overlay_controls.dart +++ b/src/mobile/lib/src/app/map/widgets/map_overlay_controls.dart @@ -9,6 +9,9 @@ class MapOverlayControls extends StatelessWidget { required this.onZoomIn, required this.onZoomOut, this.onLocateTap, + this.layersKey, + this.locateKey, + this.zoomKey, }); final bool isDark; @@ -16,6 +19,9 @@ class MapOverlayControls extends StatelessWidget { final VoidCallback? onLocateTap; final VoidCallback onZoomIn; final VoidCallback onZoomOut; + final GlobalKey? layersKey; + final GlobalKey? locateKey; + final GlobalKey? zoomKey; @override Widget build(BuildContext context) { @@ -23,21 +29,23 @@ class MapOverlayControls extends StatelessWidget { children: [ Positioned( top: 50, - right: 12, + right: mapControlSideInset, child: MapIconButton( + key: layersKey, icon: Icons.layers_outlined, isDark: isDark, onTap: onLayersTap, ), ), Positioned( - top: 90, - right: 12, + top: 112, + right: mapControlSideInset, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (onLocateTap != null) ...[ MapIconButton( + key: locateKey, icon: Icons.my_location, isDark: isDark, filled: true, @@ -46,6 +54,7 @@ class MapOverlayControls extends StatelessWidget { const SizedBox(height: 6), ], MapZoomGroup( + key: zoomKey, isDark: isDark, onZoomIn: onZoomIn, onZoomOut: onZoomOut, diff --git a/src/mobile/lib/src/app/map/widgets/map_search_sheet.dart b/src/mobile/lib/src/app/map/widgets/map_search_sheet.dart index 8a2a6d7b4b..bf06678889 100644 --- a/src/mobile/lib/src/app/map/widgets/map_search_sheet.dart +++ b/src/mobile/lib/src/app/map/widgets/map_search_sheet.dart @@ -334,7 +334,7 @@ class _SearchResults extends StatelessWidget { style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: Theme.of(context).textTheme.bodyLarge?.color, + color: AppTextColors.headline(context), ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -504,7 +504,7 @@ List _measurementRows({ style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: Theme.of(context).textTheme.bodyLarge?.color, + color: AppTextColors.headline(context), ), maxLines: 1, overflow: TextOverflow.ellipsis,