diff --git a/src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart b/src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart index deb54aec54..87e7ed4405 100644 --- a/src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart +++ b/src/mobile/lib/src/app/dashboard/pages/forecast_overview_page.dart @@ -1,3 +1,4 @@ +import 'package:airqo/src/app/dashboard/widgets/air_quality_share_sheet.dart'; import 'package:airqo/src/app/dashboard/bloc/forecast/forecast_bloc.dart'; import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; import 'package:airqo/src/app/dashboard/models/forecast_guidance.dart'; @@ -86,6 +87,7 @@ class _ForecastOverviewPageState extends State { ForecastTimeScope _timeScope = ForecastTimeScope.daily; ScrollController? _scrollController; final _todayStr = DateFormat('yyyy-MM-dd').format(DateTime.now()); + final GlobalKey _shareButtonKey = GlobalKey(); @override void initState() { @@ -150,6 +152,23 @@ class _ForecastOverviewPageState extends State { } } + Future _openShareSheet() async { + final measurement = widget.measurement; + if (measurement == null) return; + + final renderObject = + _shareButtonKey.currentContext?.findRenderObject() as RenderBox?; + final shareOrigin = renderObject == null + ? null + : renderObject.localToGlobal(Offset.zero) & renderObject.size; + + await showAirQualityShareSheet( + context, + measurement: measurement, + sharePositionOrigin: shareOrigin, + ); + } + @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; @@ -216,14 +235,32 @@ class _ForecastOverviewPageState extends State { ], ), ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: Icon( - Icons.close, - color: AppTextColors.modalCloseIcon(context), + if (widget.measurement != null) + IconButton( + key: _shareButtonKey, + onPressed: _openShareSheet, + tooltip: 'Share', + icon: SvgPicture.asset( + 'assets/icons/share-icon.svg', + width: 20, + height: 20, + semanticsLabel: 'Share', + colorFilter: ColorFilter.mode( + AppTextColors.modalCloseIcon(context), + BlendMode.srcIn, + ), + ), + visualDensity: VisualDensity.compact, + ) + else + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: Icon( + Icons.close, + color: AppTextColors.modalCloseIcon(context), + ), + visualDensity: VisualDensity.compact, ), - visualDensity: VisualDensity.compact, - ), ], ), ), @@ -322,15 +359,13 @@ class _ForecastOverviewPageState extends State { final idx = _selectedDayIndex.clamp(0, forecasts.length - 1); final day = forecasts[idx]; final selectedDateLabel = DateFormat('EEEE, MMMM d').format(day.time); - final hourEntries = - hourlyEntriesForDate(state.hourlyResponse, day.time); + final hourEntries = hourlyEntriesForDate(state.hourlyResponse, day.time); _syncHourIndex(hourEntries, day.time); final hourIdx = hourEntries.isEmpty ? 0 : _selectedHourIndex.clamp(0, hourEntries.length - 1); - final selectedHour = - hourEntries.isNotEmpty ? hourEntries[hourIdx] : null; + final selectedHour = hourEntries.isNotEmpty ? hourEntries[hourIdx] : null; return SingleChildScrollView( controller: scrollController, @@ -447,9 +482,7 @@ class _ForecastOverviewPageState extends State { mainAxisSize: MainAxisSize.min, children: [ Icon( - isNetwork - ? Icons.wifi_off_rounded - : Icons.error_outline_rounded, + isNetwork ? Icons.wifi_off_rounded : Icons.error_outline_rounded, size: 56, color: AppTextColors.muted(context), ), diff --git a/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_sheet.dart b/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_sheet.dart new file mode 100644 index 0000000000..24973d7b03 --- /dev/null +++ b/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_sheet.dart @@ -0,0 +1,185 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; +import 'package:airqo/src/app/dashboard/widgets/air_quality_share_card.dart'; +import 'package:airqo/src/app/shared/services/air_quality_share_service.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +Future showAirQualityShareSheet( + BuildContext context, { + required Measurement measurement, + String? fallbackLocationName, + Rect? sharePositionOrigin, +}) { + return showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: Colors.transparent, + builder: (_) => AirQualityShareSheet( + measurement: measurement, + fallbackLocationName: fallbackLocationName, + sharePositionOrigin: sharePositionOrigin, + ), + ); +} + +class AirQualityShareSheet extends StatefulWidget { + final Measurement measurement; + final String? fallbackLocationName; + final Rect? sharePositionOrigin; + + const AirQualityShareSheet({ + super.key, + required this.measurement, + this.fallbackLocationName, + this.sharePositionOrigin, + }); + + @override + State createState() => _AirQualityShareSheetState(); +} + +class _AirQualityShareSheetState extends State { + final GlobalKey _cardKey = GlobalKey(); + bool _isSharingCard = false; + + Future _shareCard() async { + if (_isSharingCard) return; + + setState(() { + _isSharingCard = true; + }); + + try { + final imageBytes = await _captureCard(); + if (imageBytes == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Could not prepare the share card. Please try again.'), + ), + ); + return; + } + + await AirQualityShareService.shareMeasurementCard( + imageBytes, + widget.measurement, + fallbackLocationName: widget.fallbackLocationName, + sharePositionOrigin: widget.sharePositionOrigin, + ); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not share the card. Please try again.'), + ), + ); + } finally { + if (mounted) { + setState(() { + _isSharingCard = false; + }); + } + } + } + + Future _captureCard() async { + final pixelRatio = + MediaQuery.of(context).devicePixelRatio.clamp(2.0, 3.0).toDouble(); + + await Future.delayed(const Duration(milliseconds: 16)); + + final boundary = + _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) return null; + + final image = await boundary.toImage(pixelRatio: pixelRatio); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + + return byteData?.buffer.asUint8List(); + } + + @override + Widget build(BuildContext context) { + final textColor = Theme.of(context).textTheme.headlineSmall?.color; + final subtitleColor = + Theme.of(context).textTheme.bodyMedium?.color?.withValues(alpha: 0.72); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(28), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Share card', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: textColor, + ), + ), + const SizedBox(height: 6), + Text( + 'Preview the card before sending it.', + style: TextStyle( + fontSize: 14, + color: subtitleColor, + ), + ), + const SizedBox(height: 16), + RepaintBoundary( + key: _cardKey, + child: AirQualityShareCard( + measurement: widget.measurement, + fallbackLocationName: widget.fallbackLocationName, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isSharingCard ? null : _shareCard, + icon: _isSharingCard + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.ios_share_outlined), + label: Text( + _isSharingCard ? 'Preparing card...' : 'Share card', + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryColor, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart b/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart index f40febf651..b2ee3d9d83 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart @@ -1,21 +1,14 @@ -import 'dart:typed_data'; -import 'dart:ui' as ui; - import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; -import 'package:airqo/src/app/dashboard/widgets/air_quality_share_card.dart'; import 'package:airqo/src/app/dashboard/pages/forecast_overview_page.dart'; import 'package:airqo/src/app/dashboard/utils/measurement_location_utils.dart'; +import 'package:airqo/src/app/dashboard/widgets/air_quality_share_sheet.dart'; import 'package:airqo/src/app/dashboard/widgets/expanded_analytics_card.dart'; import 'package:airqo/src/app/dashboard/widgets/analytics_forecast_widget.dart'; -import 'package:airqo/src/app/shared/services/air_quality_share_service.dart'; import 'package:airqo/src/meta/utils/colors.dart'; import 'package:airqo/src/app/shared/widgets/translated_text.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter_svg/flutter_svg.dart'; -enum _ShareAction { quickText, card } - class AnalyticsSpecifics extends StatefulWidget { final Measurement measurement; final String? fallbackLocationName; @@ -29,7 +22,6 @@ class AnalyticsSpecifics extends StatefulWidget { class _AnalyticsSpecificsState extends State { double containerHeight = 90; bool expanded = false; - bool _isSharing = false; final GlobalKey _shareButtonKey = GlobalKey(); @override @@ -85,111 +77,14 @@ class _AnalyticsSpecificsState extends State { ? null : renderObject.localToGlobal(Offset.zero) & renderObject.size; - final selectedAction = await showModalBottomSheet<_ShareAction>( - context: context, - useRootNavigator: true, - showDragHandle: true, - backgroundColor: Theme.of(context).cardColor, - builder: (sheetContext) { - final textColor = Theme.of(sheetContext).textTheme.headlineSmall?.color; - final subtitleColor = Theme.of(sheetContext) - .textTheme - .bodyMedium - ?.color - ?.withValues(alpha: 0.72); - - return SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Share air quality', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: textColor, - ), - ), - const SizedBox(height: 6), - Text( - 'Choose a quick text update or a richer share card.', - style: TextStyle( - fontSize: 14, - color: subtitleColor, - ), - ), - const SizedBox(height: 18), - _ShareOptionTile( - icon: Icons.textsms_outlined, - title: 'Share quick text', - subtitle: 'Fast, lightweight, and works well in chats.', - onTap: () => Navigator.of(sheetContext).pop( - _ShareAction.quickText, - ), - ), - const SizedBox(height: 12), - _ShareOptionTile( - icon: Icons.photo_outlined, - title: 'Share card', - subtitle: 'A cleaner visual snapshot of this location.', - onTap: () => Navigator.of(sheetContext).pop( - _ShareAction.card, - ), - ), - ], - ), - ), - ); - }, - ); - - if (!mounted || selectedAction == null) return; - - if (selectedAction == _ShareAction.quickText) { - await _shareQuickText(shareOrigin); - return; - } - - await showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - showDragHandle: true, - backgroundColor: Colors.transparent, - builder: (_) => _ShareCardSheet( - measurement: widget.measurement, - fallbackLocationName: widget.fallbackLocationName, - sharePositionOrigin: shareOrigin, - ), + await showAirQualityShareSheet( + context, + measurement: widget.measurement, + fallbackLocationName: widget.fallbackLocationName, + sharePositionOrigin: shareOrigin, ); } - Future _shareQuickText(Rect? shareOrigin) async { - if (_isSharing) return; - - setState(() { - _isSharing = true; - }); - - try { - await AirQualityShareService.shareMeasurement( - widget.measurement, - fallbackLocationName: widget.fallbackLocationName, - sharePositionOrigin: shareOrigin, - ); - } finally { - if (mounted) { - setState(() { - _isSharing = false; - }); - } - } - } - @override Widget build(BuildContext context) { final nameColor = Theme.of(context).textTheme.headlineSmall?.color; @@ -230,16 +125,16 @@ class _AnalyticsSpecificsState extends State { children: [ TextButton.icon( key: _shareButtonKey, - onPressed: _isSharing ? null : _shareAirQuality, - icon: _isSharing - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.share_outlined, size: 18), + onPressed: _shareAirQuality, + icon: SvgPicture.asset( + 'assets/icons/share-icon.svg', + width: 18, + height: 18, + colorFilter: const ColorFilter.mode( + AppColors.primaryColor, + BlendMode.srcIn, + ), + ), label: const TranslatedText('Share'), style: TextButton.styleFrom( foregroundColor: AppColors.primaryColor, @@ -299,8 +194,7 @@ class _AnalyticsSpecificsState extends State { siteId: widget.measurement.siteDetails!.id!, siteName: measurementDisplayName( widget.measurement, - fallbackLocationName: - widget.fallbackLocationName, + fallbackLocationName: widget.fallbackLocationName, ), locationDescription: measurementLocationDescription( widget.measurement, @@ -341,224 +235,3 @@ class _AnalyticsSpecificsState extends State { ); } } - -class _ShareOptionTile extends StatelessWidget { - final IconData icon; - final String title; - final String subtitle; - final VoidCallback onTap; - - const _ShareOptionTile({ - required this.icon, - required this.title, - required this.subtitle, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final subtitleColor = - Theme.of(context).textTheme.bodyMedium?.color?.withValues(alpha: 0.72); - - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(20), - child: Ink( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Theme.of(context).dividerColor), - color: Theme.of(context).scaffoldBackgroundColor, - ), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: AppColors.primaryColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(14), - ), - child: Icon( - icon, - color: AppColors.primaryColor, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: TextStyle( - fontSize: 13, - color: subtitleColor, - ), - ), - ], - ), - ), - const Icon(Icons.chevron_right), - ], - ), - ), - ); - } -} - -class _ShareCardSheet extends StatefulWidget { - final Measurement measurement; - final String? fallbackLocationName; - final Rect? sharePositionOrigin; - - const _ShareCardSheet({ - required this.measurement, - required this.fallbackLocationName, - required this.sharePositionOrigin, - }); - - @override - State<_ShareCardSheet> createState() => _ShareCardSheetState(); -} - -class _ShareCardSheetState extends State<_ShareCardSheet> { - final GlobalKey _cardKey = GlobalKey(); - bool _isSharingCard = false; - - Future _shareCard() async { - if (_isSharingCard) return; - - setState(() { - _isSharingCard = true; - }); - - try { - final imageBytes = await _captureCard(); - if (imageBytes == null) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: - Text('Could not prepare the share card. Please try again.'), - ), - ); - return; - } - - await AirQualityShareService.shareMeasurementCard( - imageBytes, - widget.measurement, - fallbackLocationName: widget.fallbackLocationName, - sharePositionOrigin: widget.sharePositionOrigin, - ); - } finally { - if (mounted) { - setState(() { - _isSharingCard = false; - }); - } - } - } - - Future _captureCard() async { - final pixelRatio = - MediaQuery.of(context).devicePixelRatio.clamp(2.0, 3.0).toDouble(); - - await Future.delayed(const Duration(milliseconds: 16)); - - final boundary = - _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; - if (boundary == null) return null; - - final image = await boundary.toImage(pixelRatio: pixelRatio); - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - - return byteData?.buffer.asUint8List(); - } - - @override - Widget build(BuildContext context) { - final textColor = Theme.of(context).textTheme.headlineSmall?.color; - final subtitleColor = - Theme.of(context).textTheme.bodyMedium?.color?.withValues(alpha: 0.72); - - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(28), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Share card', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: textColor, - ), - ), - const SizedBox(height: 6), - Text( - 'Preview the card before sending it.', - style: TextStyle( - fontSize: 14, - color: subtitleColor, - ), - ), - const SizedBox(height: 16), - RepaintBoundary( - key: _cardKey, - child: AirQualityShareCard( - measurement: widget.measurement, - fallbackLocationName: widget.fallbackLocationName, - ), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _isSharingCard ? null : _shareCard, - icon: _isSharingCard - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.ios_share_outlined), - label: Text( - _isSharingCard ? 'Preparing card...' : 'Share card', - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryColor, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(52), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -}