diff --git a/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart b/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart index 514730d12d..9b6d6b68f7 100644 --- a/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart +++ b/src/mobile/lib/src/app/dashboard/pages/dashboard_page.dart @@ -144,9 +144,7 @@ class _DashboardPageState extends State with UiLoggy { }, ), ), - SliverToBoxAdapter( - child: _buildContentForCurrentView(isGuest: isGuest), - ), + _buildContentForCurrentView(isGuest: isGuest), ], ), ), @@ -180,56 +178,58 @@ class _DashboardPageState extends State with UiLoggy { return BlocBuilder( builder: (context, state) { if (state is DashboardLoading && state.previousState == null) { - return DashboardLoadingPage(); + return const SliverToBoxAdapter(child: DashboardLoadingPage()); } if (state is DashboardLoadingError && !state.hasCache) { final isOffline = state.isOffline; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - isOffline ? Icons.cloud_off : Icons.error_outline, - size: 64, - color: Colors.grey, - ), - SizedBox(height: 16), - TranslatedText( - isOffline - ? "Couldn't connect to the internet" - : "Couldn't load air quality data", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Theme.of(context).textTheme.headlineMedium?.color, + return SliverToBoxAdapter( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + isOffline ? Icons.cloud_off : Icons.error_outline, + size: 64, + color: Colors.grey, ), - ), - SizedBox(height: 8), - TranslatedText( - isOffline - ? "Please check your connection and try again" - : "Something went wrong. Please try again later", - style: TextStyle( - fontSize: 16, - color: Theme.of(context).textTheme.bodyMedium?.color, + SizedBox(height: 16), + TranslatedText( + isOffline + ? "Couldn't connect to the internet" + : "Couldn't load air quality data", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Theme.of(context).textTheme.headlineMedium?.color, + ), ), - ), - SizedBox(height: 24), - ElevatedButton.icon( - onPressed: () { - context - .read() - .add(LoadDashboard(forceRefresh: true)); - }, - icon: Icon(Icons.refresh), - label: TranslatedText('Try Again'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryColor, - foregroundColor: Colors.white, + SizedBox(height: 8), + TranslatedText( + isOffline + ? "Please check your connection and try again" + : "Something went wrong. Please try again later", + style: TextStyle( + fontSize: 16, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), ), - ), - ], + SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () { + context + .read() + .add(LoadDashboard(forceRefresh: true)); + }, + icon: Icon(Icons.refresh), + label: TranslatedText('Try Again'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryColor, + foregroundColor: Colors.white, + ), + ), + ], + ), ), ); } @@ -244,16 +244,21 @@ class _DashboardPageState extends State with UiLoggy { 'User has ${state.selectedLocationIds.length} selected locations'); } - return MyPlacesView( - userPreferences: state.userPreferences, + return SliverToBoxAdapter( + child: MyPlacesView( + userPreferences: state.userPreferences, + ), ); case DashboardView.nearYou: - return NearbyView( - onNavigateToFavorites: () => setView(DashboardView.favorites), - onExploreCities: isGuest - ? () => setView(DashboardView.explore) - : null, + return SliverToBoxAdapter( + child: NearbyView( + onNavigateToFavorites: () => + setView(DashboardView.favorites), + onExploreCities: isGuest + ? () => setView(DashboardView.explore) + : null, + ), ); case DashboardView.country: @@ -265,8 +270,10 @@ class _DashboardPageState extends State with UiLoggy { return MeasurementsList(measurements: countryMeasurements); case DashboardView.explore: - return ExploreCountriesView( - measurements: state.response.measurements ?? [], + return SliverToBoxAdapter( + child: ExploreCountriesView( + measurements: state.response.measurements ?? [], + ), ); default: @@ -277,7 +284,7 @@ class _DashboardPageState extends State with UiLoggy { } } - return DashboardLoadingPage(); + return const SliverToBoxAdapter(child: DashboardLoadingPage()); }, ); } diff --git a/src/mobile/lib/src/app/dashboard/widgets/measurements_list.dart b/src/mobile/lib/src/app/dashboard/widgets/measurements_list.dart index 123491fb59..4bfa2eeb47 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/measurements_list.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/measurements_list.dart @@ -12,14 +12,11 @@ class MeasurementsList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.builder( - physics: const NeverScrollableScrollPhysics(), - itemCount: measurements.length, - shrinkWrap: true, - padding: EdgeInsets.zero, // Remove any default padding - itemBuilder: (context, index) { - return AnalyticsCard(measurements[index]); - } + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => AnalyticsCard(measurements[index]), + childCount: measurements.length, + ), ); } } \ No newline at end of file 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 0cd17ab104..8c65363f94 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 @@ -3,10 +3,70 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:loggy/loggy.dart'; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; import 'package:airqo/src/app/dashboard/pages/forecast_overview_page.dart'; +import 'package:airqo/src/app/shared/widgets/loading_widget.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'; +/// Shimmer skeleton matching [NearbyMeasurementCard]'s layout, shown per-slot +/// while nearby measurements are still being resolved (location + dashboard +/// fetch), so the "Near You" tab doesn't show a single blocking spinner. +class NearbyMeasurementCardLoader extends StatelessWidget { + const NearbyMeasurementCardLoader({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: AppSurfaceColors.elevatedCardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.only(left: 16, right: 16, bottom: 16, top: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShimmerText(width: 140, height: 20), + SizedBox(height: 8), + ShimmerText(width: 100, height: 12), + ], + ), + ), + ], + ), + ), + Divider(thickness: .5, color: Theme.of(context).dividerColor), + Padding( + padding: + const EdgeInsets.only(left: 16, right: 16, bottom: 16, top: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShimmerText(width: 70, height: 14), + SizedBox(height: 8), + ShimmerText(width: 90, height: 30), + ], + ), + ShimmerContainer(height: 86, width: 86, borderRadius: 100), + ], + ), + ), + ], + ), + ); + } +} + class NearbyMeasurementCard extends StatelessWidget with UiLoggy { final Measurement measurement; final String? fallbackLocationName; 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 151d7b0aad..081369ecad 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/nearby_view.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/nearby_view.dart @@ -455,22 +455,12 @@ class _NearbyViewState extends State with UiLoggy { } if (isLoading && _nearbyMeasurementsWithDistance.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 120), - CircularProgressIndicator(color: AppColors.primaryColor), - const SizedBox(height: 16), - TranslatedText( - "Getting your location...", - style: TextStyle( - fontSize: 16, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ), - ], - ), + return ListView.builder( + itemCount: _maxNearbyLocations, + padding: const EdgeInsets.only(top: 8, bottom: 16), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) => const NearbyMeasurementCardLoader(), ); } diff --git a/src/mobile/lib/src/app/map/utils/map_marker_builder.dart b/src/mobile/lib/src/app/map/utils/map_marker_builder.dart index 7d8fbe1f5b..fd07c8f255 100644 --- a/src/mobile/lib/src/app/map/utils/map_marker_builder.dart +++ b/src/mobile/lib/src/app/map/utils/map_marker_builder.dart @@ -7,6 +7,11 @@ import 'package:flutter/painting.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; class MapMarkerBuilder { + /// Rasterized marker icon size, in logical pixels. The air quality badge + /// SVGs are 96x96 circular icons — 10x10 (the previous value) rendered + /// as little more than a dot on the map. + static const Size _markerIconSize = Size(44, 44); + Future> buildMarkers({ required List measurements, required ValueChanged onMeasurementTap, @@ -42,8 +47,11 @@ class MapMarkerBuilder { onTap: () => onTap(measurement), icon: await bitmapDescriptorFromSvgAsset( resolvedPath, - const Size(10, 10), + _markerIconSize, ), + // These icons are circular badges, not teardrop pins, so center the + // icon on the coordinate instead of the default bottom-anchor. + anchor: const Offset(0.5, 0.5), position: LatLng( measurement.siteDetails!.approximateLatitude!, measurement.siteDetails!.approximateLongitude!, 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 bf06678889..42075374fb 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 @@ -99,26 +99,26 @@ class _MapSearchSheetState extends State { controller: widget.scrollController, slivers: [ SliverToBoxAdapter(child: _buildSearchHeader()), - SliverToBoxAdapter( - child: BlocBuilder( - builder: (context, placesState) { - final trimmed = widget.searchController.text.trim(); - if (trimmed.isEmpty) { - return _BrowseList( - isDark: widget.isDark, - selectedCountry: _selectedCountry, - countries: _activeCountries, - measurements: _browseMeasurements, - hasUserPosition: widget.hasUserPosition, - onCountrySelected: (country) { - setState(() => _selectedCountry = country); - }, - onMeasurementSelected: widget.onMeasurementSelected, - ); - } + BlocBuilder( + builder: (context, placesState) { + final trimmed = widget.searchController.text.trim(); + if (trimmed.isEmpty) { + return _BrowseList( + isDark: widget.isDark, + selectedCountry: _selectedCountry, + countries: _activeCountries, + measurements: _browseMeasurements, + hasUserPosition: widget.hasUserPosition, + onCountrySelected: (country) { + setState(() => _selectedCountry = country); + }, + onMeasurementSelected: widget.onMeasurementSelected, + ); + } - if (placesState is SearchLoading) { - return const Padding( + if (placesState is SearchLoading) { + return const SliverToBoxAdapter( + child: Padding( padding: EdgeInsets.all(16), child: Column(children: [ GooglePlacesLoader(), @@ -127,33 +127,35 @@ class _MapSearchSheetState extends State { SizedBox(height: 12), GooglePlacesLoader(), ]), - ); - } + ), + ); + } - if (placesState is SearchLoaded) { - return _SearchResults( + if (placesState is SearchLoaded) { + return SliverToBoxAdapter( + child: _SearchResults( state: placesState, isDark: widget.isDark, localSearchResults: widget.localSearchResults, query: widget.searchController.text.trim(), onMeasurementSelected: widget.onMeasurementSelected, onPlaceSelected: widget.onPlaceSelected, - ); - } - - return _BrowseList( - isDark: widget.isDark, - selectedCountry: _selectedCountry, - countries: _activeCountries, - measurements: _browseMeasurements, - hasUserPosition: widget.hasUserPosition, - onCountrySelected: (country) { - setState(() => _selectedCountry = country); - }, - onMeasurementSelected: widget.onMeasurementSelected, + ), ); - }, - ), + } + + return _BrowseList( + isDark: widget.isDark, + selectedCountry: _selectedCountry, + countries: _activeCountries, + measurements: _browseMeasurements, + hasUserPosition: widget.hasUserPosition, + onCountrySelected: (country) { + setState(() => _selectedCountry = country); + }, + onMeasurementSelected: widget.onMeasurementSelected, + ); + }, ), ], ), @@ -215,44 +217,55 @@ class _BrowseList extends StatelessWidget { @override Widget build(BuildContext context) { if (measurements.isEmpty && countries.isEmpty) { - return const SizedBox.shrink(); + return const SliverToBoxAdapter(child: SizedBox.shrink()); } final labelColor = isDark ? AppColors.boldHeadlineColor2 : AppColors.boldHeadlineColor3; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 6), - _CountryChips( - isDark: isDark, - countries: countries, - selectedCountry: selectedCountry, - onSelected: onCountrySelected, + + return SliverMainAxisGroup( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 8), + sliver: SliverToBoxAdapter( + child: _CountryChips( + isDark: isDark, + countries: countries, + selectedCountry: selectedCountry, + onSelected: onCountrySelected, + ), ), - const SizedBox(height: 8), - if (measurements.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 16), + ), + if (measurements.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: Center( child: TranslatedText( 'No locations available', style: TextStyle(color: labelColor, fontSize: 13), ), ), - ) - else - ..._measurementRows( - context: context, - measurements: measurements, - isDark: isDark, - onSelected: onMeasurementSelected, ), - const SizedBox(height: 20), - ], - ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _measurementRow( + context: context, + measurement: measurements[index], + isDark: isDark, + onSelected: onMeasurementSelected, + showDivider: index < measurements.length - 1, + ), + childCount: measurements.length, + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 20)), + ], ); } } @@ -475,55 +488,69 @@ List _measurementRows({ }) { return measurements.asMap().entries.map((entry) { final i = entry.key; - final measurement = entry.value; - final pm25 = measurement.pm25?.value; - final aqLevel = pm25 == null ? null : mapAqLevelFromPm25(pm25); final showDivider = i < measurements.length - 1 || addTrailingDivider; + return _measurementRow( + context: context, + measurement: entry.value, + isDark: isDark, + onSelected: onSelected, + showDivider: showDivider, + ); + }).toList(); +} - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: () => onSelected(measurement), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - children: [ - SvgPicture.asset( - aqLevel?.asset ?? - 'assets/images/shared/airquality_indicators/unavailable.svg', - width: 20, - height: 20, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - measurement.siteDetails?.searchName ?? - measurement.siteDetails?.name ?? - '—', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppTextColors.headline(context), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, +Widget _measurementRow({ + required BuildContext context, + required Measurement measurement, + required bool isDark, + required ValueChanged onSelected, + required bool showDivider, +}) { + final pm25 = measurement.pm25?.value; + final aqLevel = pm25 == null ? null : mapAqLevelFromPm25(pm25); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () => onSelected(measurement), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + SvgPicture.asset( + aqLevel?.asset ?? + 'assets/images/shared/airquality_indicators/unavailable.svg', + width: 20, + height: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + measurement.siteDetails?.searchName ?? + measurement.siteDetails?.name ?? + '—', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTextColors.headline(context), ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - _AqCategoryChip( - label: pm25 == null - ? 'No data' - : (measurement.aqiCategory ?? ''), - color: aqLevel?.color ?? Colors.grey, - ), - ], - ), + ), + _AqCategoryChip( + label: + pm25 == null ? 'No data' : (measurement.aqiCategory ?? ''), + color: aqLevel?.color ?? Colors.grey, + ), + ], ), ), - if (showDivider) _rowDivider(isDark), - ], - ); - }).toList(); + ), + if (showDivider) _rowDivider(isDark), + ], + ); } TextStyle _sectionLabelStyle(bool isDark) {