From 345275167f48f65da7bf06d388519ae0913c7241 Mon Sep 17 00:00:00 2001 From: mozart Date: Tue, 7 Jul 2026 11:56:56 +0300 Subject: [PATCH 1/2] Add lazy loading for air quality card lists Show per-card shimmer placeholders while the Near You tab loads, and convert the map search sheet's browse list and the dashboard Country/All tabs from eagerly-built lists to real SliverList-based lazy loading, so large lists build on demand as the user scrolls. --- .../app/dashboard/pages/dashboard_page.dart | 119 +++++---- .../dashboard/widgets/measurements_list.dart | 13 +- .../widgets/nearby_measurement_card.dart | 60 +++++ .../app/dashboard/widgets/nearby_view.dart | 22 +- .../src/app/map/widgets/map_search_sheet.dart | 241 ++++++++++-------- 5 files changed, 268 insertions(+), 187 deletions(-) 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/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) { From 67f2e07e4e23d239131047bdb31773539c866747 Mon Sep 17 00:00:00 2001 From: mozart Date: Tue, 7 Jul 2026 12:17:03 +0300 Subject: [PATCH 2/2] Fix map markers rendering too small Air quality marker icons were rasterized at 10x10 logical pixels (vs. the helper's own 24x24 default), making them look like tiny dots. Bump to 44x44 and center-anchor the circular badge icons on their coordinate instead of the default bottom-anchor meant for teardrop pins. --- .../lib/src/app/map/utils/map_marker_builder.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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!,