diff --git a/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_card.dart b/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_card.dart new file mode 100644 index 0000000000..6868ea03d6 --- /dev/null +++ b/src/mobile/lib/src/app/dashboard/widgets/air_quality_share_card.dart @@ -0,0 +1,300 @@ +import 'dart:convert'; + +import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; +import 'package:airqo/src/meta/utils/colors.dart'; +import 'package:flutter/material.dart'; + +class AirQualityShareCard extends StatelessWidget { + final Measurement measurement; + final String? fallbackLocationName; + + const AirQualityShareCard({ + super.key, + required this.measurement, + this.fallbackLocationName, + }); + + @override + Widget build(BuildContext context) { + final locationName = _sanitizeText( + measurement.siteDetails?.searchName ?? + measurement.siteDetails?.name ?? + fallbackLocationName ?? + 'AirQo location', + ); + final locationDescription = _sanitizeText( + _getLocationDescription(measurement), + ); + final category = _categoryLabel( + _sanitizeText(measurement.aqiCategory ?? 'Unavailable'), + ); + final pm25Value = measurement.pm25?.value; + final healthTip = _sanitizeText( + measurement.healthTips?.firstOrNull?.tagLine ?? + measurement.healthTips?.firstOrNull?.description ?? + 'Stay informed and plan your outdoor time wisely.', + ); + final categoryColor = _getAqiColor(measurement); + final isDark = Theme.of(context).brightness == Brightness.dark; + final cardTextColor = isDark ? Colors.white : const Color(0xFF122033); + final secondaryTextColor = cardTextColor.withValues(alpha: 0.72); + final titleFontSize = _titleFontSize(locationName); + final categoryFontSize = _categoryFontSize(category); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + categoryColor.withValues(alpha: isDark ? 0.75 : 0.92), + AppColors.primaryColor, + isDark ? const Color(0xFF111827) : const Color(0xFFF4F8FF), + ], + stops: const [0.0, 0.58, 1.0], + ), + boxShadow: [ + BoxShadow( + color: categoryColor.withValues(alpha: 0.22), + blurRadius: 28, + offset: const Offset(0, 14), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AirQo', + style: TextStyle( + color: cardTextColor.withValues(alpha: 0.88), + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 10), + Text( + locationName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cardTextColor, + fontSize: titleFontSize, + height: 1.02, + fontWeight: FontWeight.w800, + ), + ), + if (locationDescription.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + locationDescription, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: secondaryTextColor, + fontSize: 13, + height: 1.25, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 124), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: isDark ? 0.12 : 0.84), + borderRadius: BorderRadius.circular(999), + ), + child: Center( + child: Text( + category, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: isDark ? Colors.white : categoryColor, + fontSize: categoryFontSize, + height: 1.0, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 20), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.bottomLeft, + child: Text( + pm25Value?.toStringAsFixed(1) ?? '--', + style: TextStyle( + color: cardTextColor, + fontSize: 72, + height: 0.92, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'PM2.5 µg/m³', + style: TextStyle( + color: secondaryTextColor, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: isDark ? 0.08 : 0.82), + borderRadius: BorderRadius.circular(18), + ), + child: Text( + healthTip, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cardTextColor, + fontSize: 14, + height: 1.3, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Shared from the AirQo app', + style: TextStyle( + color: secondaryTextColor, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + String _getLocationDescription(Measurement measurement) { + final siteDetails = measurement.siteDetails; + if (siteDetails == null) return ''; + + final parts = []; + + if (siteDetails.city != null && siteDetails.city!.isNotEmpty) { + parts.add(siteDetails.city!); + } else if (siteDetails.town != null && siteDetails.town!.isNotEmpty) { + parts.add(siteDetails.town!); + } + + if (siteDetails.region != null && siteDetails.region!.isNotEmpty) { + parts.add(siteDetails.region!); + } else if (siteDetails.county != null && siteDetails.county!.isNotEmpty) { + parts.add(siteDetails.county!); + } + + if (siteDetails.country != null && siteDetails.country!.isNotEmpty) { + parts.add(siteDetails.country!); + } + + return parts.join(', '); + } + + Color _getAqiColor(Measurement measurement) { + if (measurement.aqiColor != null) { + try { + final colorString = measurement.aqiColor!.replaceAll('#', ''); + return Color(int.parse('0xFF$colorString')); + } catch (_) {} + } + + switch (measurement.aqiCategory?.toLowerCase() ?? '') { + case 'good': + return const Color(0xFF179D5B); + case 'moderate': + return const Color(0xFFC79000); + case 'unhealthy for sensitive groups': + case 'u4sg': + return const Color(0xFFE17827); + case 'unhealthy': + return const Color(0xFFD63A45); + case 'very unhealthy': + return const Color(0xFF7540B5); + case 'hazardous': + return const Color(0xFF6D4C41); + default: + return AppColors.primaryColor; + } + } + + double _titleFontSize(String title) { + if (title.length <= 12) return 30; + if (title.length <= 20) return 27; + if (title.length <= 28) return 24; + return 22; + } + + double _categoryFontSize(String category) { + if (category.length <= 10) return 12; + if (category.length <= 16) return 11; + return 10; + } + + String _categoryLabel(String category) { + switch (category.toLowerCase()) { + case 'unhealthy for sensitive groups': + return 'Sensitive Groups'; + case 'very unhealthy': + return 'Very Unhealthy'; + default: + return category; + } + } + + String _sanitizeText(String value) { + if (!value.contains('Ã') && !value.contains('Â') && !value.contains('â')) { + return value; + } + + try { + return utf8.decode(latin1.encode(value)); + } catch (_) { + return value; + } + } +} 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 88a98a1260..0e4371fa62 100644 --- a/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart +++ b/src/mobile/lib/src/app/dashboard/widgets/analytics_specifics.dart @@ -1,10 +1,18 @@ +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/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'; + +enum _ShareAction { quickText, card } class AnalyticsSpecifics extends StatefulWidget { final Measurement measurement; @@ -19,6 +27,8 @@ class AnalyticsSpecifics extends StatefulWidget { class _AnalyticsSpecificsState extends State { double containerHeight = 90; bool expanded = false; + bool _isSharing = false; + final GlobalKey _shareButtonKey = GlobalKey(); @override void initState() { @@ -66,6 +76,118 @@ class _AnalyticsSpecificsState extends State { "Unknown location"; } + Future _shareAirQuality() async { + final renderObject = + _shareButtonKey.currentContext?.findRenderObject() as RenderBox?; + final shareOrigin = renderObject == null + ? 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, + ), + ); + } + + 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; @@ -101,12 +223,34 @@ class _AnalyticsSpecificsState extends State { overflow: TextOverflow.ellipsis, ), ), - InkWell( - onTap: () => Navigator.pop(context), - child: Icon( - Icons.close, - color: nameColor, - ), + Row( + mainAxisSize: MainAxisSize.min, + 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), + label: const TranslatedText('Share'), + style: TextButton.styleFrom( + foregroundColor: AppColors.primaryColor, + ), + ), + InkWell( + onTap: () => Navigator.pop(context), + child: Icon( + Icons.close, + color: nameColor, + ), + ), + ], ) ], ), @@ -153,8 +297,8 @@ class _AnalyticsSpecificsState extends State { MaterialPageRoute( builder: (_) => ForecastOverviewPage( siteId: widget.measurement.siteDetails!.id!, - siteName: widget.measurement.siteDetails - ?.searchName ?? + siteName: widget + .measurement.siteDetails?.searchName ?? widget.measurement.siteDetails?.name ?? widget.fallbackLocationName ?? '', @@ -195,3 +339,224 @@ 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), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/src/mobile/lib/src/app/shared/services/air_quality_share_service.dart b/src/mobile/lib/src/app/shared/services/air_quality_share_service.dart new file mode 100644 index 0000000000..25084a6c10 --- /dev/null +++ b/src/mobile/lib/src/app/shared/services/air_quality_share_service.dart @@ -0,0 +1,100 @@ +import 'dart:typed_data'; + +import 'package:airqo/src/app/dashboard/models/airquality_response.dart'; +import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; + +class AirQualityShareService { + const AirQualityShareService._(); + + static Future shareMeasurement( + Measurement measurement, { + String? fallbackLocationName, + Rect? sharePositionOrigin, + }) { + final message = buildShareMessage( + measurement, + fallbackLocationName: fallbackLocationName, + ); + + return Share.share( + message, + subject: 'Air quality update from AirQo', + sharePositionOrigin: sharePositionOrigin, + ); + } + + static Future shareMeasurementCard( + Uint8List imageBytes, + Measurement measurement, { + String? fallbackLocationName, + Rect? sharePositionOrigin, + }) { + return Share.shareXFiles( + [ + XFile.fromData( + imageBytes, + mimeType: 'image/png', + name: 'airqo-air-quality-card.png', + ), + ], + text: buildShareMessage( + measurement, + fallbackLocationName: fallbackLocationName, + ), + subject: 'Air quality update from AirQo', + sharePositionOrigin: sharePositionOrigin, + ); + } + + static String buildShareMessage( + Measurement measurement, { + String? fallbackLocationName, + }) { + final locationName = measurement.siteDetails?.searchName ?? + measurement.siteDetails?.name ?? + fallbackLocationName ?? + 'this location'; + final category = measurement.aqiCategory ?? 'Unavailable'; + final pm25Value = measurement.pm25?.value; + final locationDescription = _locationDescription(measurement); + final healthTip = measurement.healthTips?.firstOrNull?.tagLine ?? + measurement.healthTips?.firstOrNull?.description; + + final lines = [ + 'Air quality in $locationName is $category.', + if (pm25Value != null) 'PM2.5: ${pm25Value.toStringAsFixed(1)} µg/m³', + if (locationDescription.isNotEmpty) 'Location: $locationDescription', + if (healthTip != null && healthTip.isNotEmpty) healthTip, + '', + 'Shared from the AirQo app.', + ]; + + return lines.join('\n'); + } + + static String _locationDescription(Measurement measurement) { + final siteDetails = measurement.siteDetails; + if (siteDetails == null) return ''; + + final parts = []; + + if (siteDetails.city != null && siteDetails.city!.isNotEmpty) { + parts.add(siteDetails.city!); + } else if (siteDetails.town != null && siteDetails.town!.isNotEmpty) { + parts.add(siteDetails.town!); + } + + if (siteDetails.region != null && siteDetails.region!.isNotEmpty) { + parts.add(siteDetails.region!); + } else if (siteDetails.county != null && siteDetails.county!.isNotEmpty) { + parts.add(siteDetails.county!); + } + + if (siteDetails.country != null && siteDetails.country!.isNotEmpty) { + parts.add(siteDetails.country!); + } + + return parts.join(', '); + } +} diff --git a/src/mobile/pubspec.lock b/src/mobile/pubspec.lock index 062f6d14c5..1262273700 100644 --- a/src/mobile/pubspec.lock +++ b/src/mobile/pubspec.lock @@ -1293,6 +1293,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" shared_preferences: dependency: "direct main" description: diff --git a/src/mobile/pubspec.yaml b/src/mobile/pubspec.yaml index f5a3a7e297..4faac89280 100644 --- a/src/mobile/pubspec.yaml +++ b/src/mobile/pubspec.yaml @@ -78,6 +78,7 @@ dependencies: plugin_platform_interface: ^2.1.8 url_launcher: ^6.3.1 flutter_secure_storage: ^9.2.2 + share_plus: ^10.0.2 posthog_flutter: ^5.9.0 firebase_core: ^4.4.0 firebase_messaging: ^16.1.1 @@ -155,4 +156,4 @@ flutter: - asset: assets/fonts/Inter-Black.ttf weight: 800 - \ No newline at end of file + diff --git a/src/mobile/test/app/auth/bloc/auth_bloc_test.dart b/src/mobile/test/app/auth/bloc/auth_bloc_test.dart deleted file mode 100644 index e25e140ff1..0000000000 --- a/src/mobile/test/app/auth/bloc/auth_bloc_test.dart +++ /dev/null @@ -1,267 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:airqo/src/app/auth/bloc/auth_bloc.dart'; -import 'package:airqo/src/app/auth/models/input_model.dart'; -import 'package:airqo/src/app/auth/repository/auth_repository.dart'; -import 'package:airqo/src/app/auth/repository/social_auth_repository.dart'; -import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; -import 'package:airqo/src/meta/utils/api_utils.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; - -class _FakeAuthRepository extends AuthRepository { - @override - Future deleteUserAccount() async {} - - @override - Future loginWithEmailAndPassword( - String username, String password) async { - throw UnimplementedError(); - } - - @override - Future registerWithEmailAndPassword(RegisterInputModel model) async { - throw UnimplementedError(); - } - - @override - Future requestPasswordReset(String email) async { - throw UnimplementedError(); - } - - @override - Future updatePassword({ - required String token, - required String password, - required String confirmPassword, - }) async { - throw UnimplementedError(); - } - - @override - Future validateResetCodeFormat(String pin) async { - throw UnimplementedError(); - } - - @override - Future verifyEmailCode(String token, String email) async { - throw UnimplementedError(); - } -} - -class _FakeSocialAuthRepository extends SocialAuthRepository { - @override - Future loginWithProvider(String provider) async { - throw UnimplementedError(); - } -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - String expiredToken() { - String encode(Map value) => - base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); - - return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ - 'sub': 'user-1', - 'exp': DateTime.now() - .subtract(const Duration(hours: 1)) - .millisecondsSinceEpoch ~/ - 1000, - })}.'; - } - - String validToken() { - String encode(Map value) => - base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); - - return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ - 'sub': 'user-1', - 'exp': DateTime.now() - .add(const Duration(hours: 1)) - .millisecondsSinceEpoch ~/ - 1000, - })}.'; - } - - setUp(() { - FlutterSecureStorage.setMockInitialValues({}); - dotenv.testLoad(fileInput: ''' -FORCE_AUTH_TOKEN_EXPIRED=false -AUTH_DEBUG_SEED_MOBILE_TOKEN=false -'''); - }); - - tearDown(() { - ApiUtils.baseUrl = 'http://localhost:3001'; - }); - - group('AuthBloc on AppStarted', () { - // My current hypothesis is that the user-visible logout flow starts here - // once the helper has already reduced the session problem to "null". - // If app startup sees a stored token but cannot restore a usable session, - // the bloc should move through loading, mark the session as expired, and - // finally fall back to guest mode. - test('emits loading, session expired, then guest when startup cannot restore the session', - () async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - ApiUtils.baseUrl = 'http://127.0.0.1:${server.port}'; - - server.listen((request) async { - expect(request.uri.path, '/api/v2/users/token/refresh'); - request.response.statusCode = HttpStatus.internalServerError; - request.response.write('{"success":false}'); - await request.response.close(); - }); - - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.authToken, - expiredToken(), - ); - - final bloc = AuthBloc( - authRepository: _FakeAuthRepository(), - socialAuthRepository: _FakeSocialAuthRepository(), - ); - - bloc.add(AppStarted()); - - await expectLater( - bloc.stream, - emitsInOrder([ - isA(), - isA(), - isA(), - ]), - ); - - final storedToken = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.authToken); - expect(storedToken, isNull); - - await server.close(force: true); - await bloc.close(); - }); - - // The next thing we need is the healthy startup baseline. If the app finds - // a still-valid stored token, startup should restore the session instead - // of pushing the user into the expiry flow. This gives us the clean - // comparison against the failing startup case above. - test('emits loading then loaded when startup finds a valid stored session', - () async { - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.authToken, - validToken(), - ); - - final bloc = AuthBloc( - authRepository: _FakeAuthRepository(), - socialAuthRepository: _FakeSocialAuthRepository(), - ); - - bloc.add(AppStarted()); - - await expectLater( - bloc.stream, - emitsInOrder([ - isA(), - isA().having( - (state) => state.authPurpose, - 'authPurpose', - AuthPurpose.login, - ), - ]), - ); - - await bloc.close(); - }); - - }); - - group('AuthBloc on SessionExpired', () { - // Once another layer has already decided the session is no longer usable, - // the auth bloc should perform the same cleanup and fallback sequence the - // user would feel in the app: mark the session as expired, then return to - // guest mode. - test('emits session expired then guest and clears stored auth data', - () async { - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.authToken, - validToken(), - ); - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.userId, - 'user-1', - ); - - final bloc = AuthBloc( - authRepository: _FakeAuthRepository(), - socialAuthRepository: _FakeSocialAuthRepository(), - ); - - bloc.add(const SessionExpired()); - - await expectLater( - bloc.stream, - emitsInOrder([ - isA(), - isA(), - ]), - ); - - final storedToken = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.authToken); - final storedUserId = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.userId); - - expect(storedToken, isNull); - expect(storedUserId, isNull); - - await bloc.close(); - }); - }); - - group('AuthBloc on LogoutUser', () { - // This is the intentional version of leaving a session. We need it so we - // can compare "the user chose to log out" with "the app forced the user - // out because it believed the session had expired". - test('emits loading then guest and clears stored auth data', () async { - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.authToken, - validToken(), - ); - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.userId, - 'user-1', - ); - - final bloc = AuthBloc( - authRepository: _FakeAuthRepository(), - socialAuthRepository: _FakeSocialAuthRepository(), - ); - - bloc.add(const LogoutUser()); - - await expectLater( - bloc.stream, - emitsInOrder([ - isA(), - isA(), - ]), - ); - - final storedToken = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.authToken); - final storedUserId = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.userId); - - expect(storedToken, isNull); - expect(storedUserId, isNull); - - await bloc.close(); - }); - }); -} diff --git a/src/mobile/test/app/auth/integration/session_expiry_flow_test.dart b/src/mobile/test/app/auth/integration/session_expiry_flow_test.dart deleted file mode 100644 index a3fa518efd..0000000000 --- a/src/mobile/test/app/auth/integration/session_expiry_flow_test.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'dart:convert'; - -import 'package:airqo/src/app/auth/bloc/auth_bloc.dart'; -import 'package:airqo/src/app/auth/models/input_model.dart'; -import 'package:airqo/src/app/auth/repository/auth_repository.dart'; -import 'package:airqo/src/app/auth/repository/social_auth_repository.dart'; -import 'package:airqo/src/app/shared/exceptions/session_expired_exception.dart'; -import 'package:airqo/src/app/shared/repository/base_repository.dart'; -import 'package:airqo/src/app/shared/repository/global_auth_manager.dart'; -import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; -import 'package:airqo/src/app/shared/repository/token_refresher.dart'; -import 'package:airqo/src/meta/utils/api_utils.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; - -class _FakeAuthRepository extends AuthRepository { - @override - Future deleteUserAccount() async {} - - @override - Future loginWithEmailAndPassword( - String username, String password) async { - throw UnimplementedError(); - } - - @override - Future registerWithEmailAndPassword(RegisterInputModel model) async { - throw UnimplementedError(); - } - - @override - Future requestPasswordReset(String email) async { - throw UnimplementedError(); - } - - @override - Future updatePassword({ - required String token, - required String password, - required String confirmPassword, - }) async { - throw UnimplementedError(); - } - - @override - Future validateResetCodeFormat(String pin) async { - throw UnimplementedError(); - } - - @override - Future verifyEmailCode(String token, String email) async { - throw UnimplementedError(); - } -} - -class _FakeSocialAuthRepository extends SocialAuthRepository { - @override - Future loginWithProvider(String provider) async { - throw UnimplementedError(); - } -} - -class _FakeTokenRefresher implements TokenRefresher { - const _FakeTokenRefresher(this.token); - - final String token; - - @override - Future refreshTokenIfNeeded() async => token; -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() { - FlutterSecureStorage.setMockInitialValues({}); - GlobalAuthManager.instance.resetSessionExpiredGuard(); - }); - - tearDown(() { - ApiUtils.baseUrl = 'http://localhost:3001'; - }); - - group('session expiry integration flow', () { - // My current hypothesis is that one of the real forced-logout paths is: - // repository GET receives a session-like 401, the global auth manager - // raises SessionExpired, and the auth bloc clears stored auth data before - // returning the app to guest mode. This test wires those layers together - // so we can verify that the same chain happens as one end-to-end flow. - test('session-like 401 from repository triggers bloc expiry cleanup and guest fallback', - () async { - final token = _jwtToken( - userId: 'user-1', - expiresAt: DateTime.now().add(const Duration(hours: 1)), - ); - - await SecureStorageRepository.instance - .saveSecureData(SecureStorageKeys.authToken, token); - await SecureStorageRepository.instance - .saveSecureData(SecureStorageKeys.userId, 'user-1'); - - final bloc = AuthBloc( - authRepository: _FakeAuthRepository(), - socialAuthRepository: _FakeSocialAuthRepository(), - ); - addTearDown(bloc.close); - GlobalAuthManager.instance.setAuthBloc(bloc); - - final repository = BaseRepository( - tokenRefresher: _FakeTokenRefresher(token), - ); - final client = MockClient((request) async { - expect(request.method, 'GET'); - expect(request.url.path, '/test'); - expect(request.headers['Authorization'], 'JWT $token'); - - return http.Response( - jsonEncode({'message': 'Token expired'}), - 401, - headers: {'content-type': 'application/json'}, - ); - }); - - ApiUtils.baseUrl = 'https://api.test'; - - final futureStates = expectLater( - bloc.stream, - emitsInOrder([ - isA(), - isA(), - ]), - ); - - await expectLater( - () => http.runWithClient( - () => repository.createGetRequest('/test', const {}), - () => client, - ), - throwsA(isA()), - ); - - await futureStates; - - final storedToken = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.authToken); - final storedUserId = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.userId); - - expect(storedToken, isNull); - expect(storedUserId, isNull); - }); - }); -} - -String _jwtToken({ - required String userId, - required DateTime expiresAt, -}) { - String encode(Map value) => - base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); - - return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ - 'sub': userId, - 'exp': expiresAt.millisecondsSinceEpoch ~/ 1000, - })}.'; -} diff --git a/src/mobile/test/app/auth/repository/base_repository_auth_test.dart b/src/mobile/test/app/auth/repository/base_repository_auth_test.dart deleted file mode 100644 index bd4110e417..0000000000 --- a/src/mobile/test/app/auth/repository/base_repository_auth_test.dart +++ /dev/null @@ -1,189 +0,0 @@ -import 'dart:convert'; - -import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; -import 'package:airqo/src/app/shared/exceptions/session_expired_exception.dart'; -import 'package:airqo/src/app/shared/repository/base_repository.dart'; -import 'package:airqo/src/app/shared/repository/session_expiry_notifier.dart'; -import 'package:airqo/src/app/shared/repository/token_refresher.dart'; -import 'package:airqo/src/meta/utils/api_utils.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; - -class _FakeTokenRefresher implements TokenRefresher { - const _FakeTokenRefresher(this.token); - - final String? token; - - @override - Future refreshTokenIfNeeded() async => token; -} - -class _RecordingSessionExpiryNotifier implements SessionExpiryNotifier { - int notifications = 0; - - @override - void notifySessionExpired() { - notifications += 1; - } -} - -void main() { - setUp(() { - FlutterSecureStorage.setMockInitialValues({}); - }); - - tearDown(() { - ApiUtils.baseUrl = 'http://localhost:3001'; - }); - - group('BaseRepository authenticated response handling', () { - // My current hypothesis is that repository-level 401 handling is the other - // major path that can force the user out. For GET requests, the repository - // is trying to distinguish "the user's JWT is bad" from unrelated 401s. - // If the body clearly looks like a token/session problem, it should notify - // session expiry and throw the dedicated exception. - // - // If this test passes, we have confirmed that a session-like 401 body is - // intentionally treated as a forced logout signal at the repository layer. - test('notifies session expiry and throws when a GET request returns a session-like 401 body', - () async { - const token = 'valid-token'; - final notifier = _RecordingSessionExpiryNotifier(); - final repository = BaseRepository( - tokenRefresher: const _FakeTokenRefresher(token), - sessionExpiryNotifier: notifier, - ); - final client = MockClient((request) async { - expect(request.method, 'GET'); - expect(request.url.path, '/test'); - expect(request.headers['Authorization'], 'JWT $token'); - - return http.Response( - jsonEncode({'message': 'Token expired'}), - 401, - headers: {'content-type': 'application/json'}, - ); - }); - - ApiUtils.baseUrl = 'https://api.test'; - - await expectLater( - () => http.runWithClient( - () => repository.createGetRequest('/test', const {}), - () => client, - ), - throwsA(isA()), - ); - - expect(notifier.notifications, 1); - }); - - // The repository is also trying to avoid false logouts. Some GET requests - // can return 401 for reasons that are not really about the user's session, - // so a body that does not look JWT- or session-related should stay a - // normal request failure instead of escalating into a session-expired flow. - test('does not notify session expiry when a GET request returns an unrelated 401 body', - () async { - const token = 'valid-token'; - final notifier = _RecordingSessionExpiryNotifier(); - final repository = BaseRepository( - tokenRefresher: const _FakeTokenRefresher(token), - sessionExpiryNotifier: notifier, - ); - final client = MockClient((request) async { - expect(request.method, 'GET'); - expect(request.url.path, '/test'); - expect(request.headers['Authorization'], 'JWT $token'); - - return http.Response( - jsonEncode({'message': 'API key missing'}), - 401, - headers: {'content-type': 'application/json'}, - ); - }); - - ApiUtils.baseUrl = 'https://api.test'; - - await expectLater( - () => http.runWithClient( - () => repository.createGetRequest('/test', const {}), - () => client, - ), - throwsA( - predicate( - (error) => - error is Exception && - error.toString().contains('API key missing') && - error is! SessionExpiredException, - ), - ), - ); - - expect(notifier.notifications, 0); - }); - - // Successful authenticated responses can quietly deliver a replacement - // token in the response headers. The repository should persist that token - // so later requests keep using the refreshed session instead of falling - // back to stale credentials. - test('stores the refreshed token from a successful response header', - () async { - const oldToken = 'old-token'; - final refreshedToken = _jwtToken( - userId: 'user-1', - expiresAt: DateTime.now().add(const Duration(hours: 1)), - ); - final notifier = _RecordingSessionExpiryNotifier(); - final repository = BaseRepository( - tokenRefresher: const _FakeTokenRefresher(oldToken), - sessionExpiryNotifier: notifier, - ); - final client = MockClient((request) async { - expect(request.method, 'GET'); - expect(request.url.path, '/test'); - expect(request.headers['Authorization'], 'JWT $oldToken'); - - return http.Response( - jsonEncode({'success': true}), - 200, - headers: { - 'content-type': 'application/json', - 'x-access-token': refreshedToken, - }, - ); - }); - - ApiUtils.baseUrl = 'https://api.test'; - - final response = await http.runWithClient( - () => repository.createAuthenticatedGetRequest('/test', const {}), - () => client, - ); - - final storedToken = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.authToken); - final storedUserId = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.userId); - - expect(response.statusCode, 200); - expect(storedToken, refreshedToken); - expect(storedUserId, 'user-1'); - expect(notifier.notifications, 0); - }); - }); -} - -String _jwtToken({ - required String userId, - required DateTime expiresAt, -}) { - String encode(Map value) => - base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); - - return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ - 'sub': userId, - 'exp': expiresAt.millisecondsSinceEpoch ~/ 1000, - })}.'; -} diff --git a/src/mobile/test/app/auth/repository/secure_storage_repository_test.dart b/src/mobile/test/app/auth/repository/secure_storage_repository_test.dart deleted file mode 100644 index 71f588f959..0000000000 --- a/src/mobile/test/app/auth/repository/secure_storage_repository_test.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - setUp(() { - FlutterSecureStorage.setMockInitialValues({}); - }); - - group('SecureStorageRepository', () { - // My current hypothesis is that auth can appear unstable if the storage - // layer does not give back exactly what was written. Before we blame token - // refresh or lifecycle logic, we need to prove the storage wrapper can do - // a calm round-trip for auth values. - // - // If this test passes, we have confirmed that the repository can save and - // later retrieve a stored auth token in the expected form. - test('saves and retrieves a secure value', () async { - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.authToken, - 'stored-token-value', - ); - - final storedValue = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.authToken); - - expect(storedValue, 'stored-token-value'); - }); - - // Session-expiry and logout paths both rely on secure values actually - // disappearing when the app asks for cleanup. If delete fails silently, - // later startup checks can look like random re-authentication bugs. - test('deletes a stored secure value', () async { - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.authToken, - 'stored-token-value', - ); - - await SecureStorageRepository.instance - .deleteSecureData(SecureStorageKeys.authToken); - - final storedValue = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.authToken); - - expect(storedValue, isNull); - }); - }); -} diff --git a/src/mobile/test/app/auth/repository/user_preferences_auth_test.dart b/src/mobile/test/app/auth/repository/user_preferences_auth_test.dart deleted file mode 100644 index 7c7defb0db..0000000000 --- a/src/mobile/test/app/auth/repository/user_preferences_auth_test.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'dart:convert'; - -import 'package:airqo/src/app/auth/services/auth_token_storage.dart'; -import 'package:airqo/src/app/dashboard/repository/user_preferences_repository.dart'; -import 'package:airqo/src/app/shared/repository/hive_repository.dart'; -import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; -import 'package:airqo/src/meta/utils/api_utils.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hive/hive.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - String jwtToken({ - required String userId, - required DateTime expiresAt, - }) { - String encode(Map value) => - base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); - - return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ - 'sub': userId, - 'exp': expiresAt.millisecondsSinceEpoch ~/ 1000, - })}.'; - } - - setUpAll(() async { - Hive.init('.'); - }); - - setUp(() async { - FlutterSecureStorage.setMockInitialValues({}); - await HiveRepository.clearAllCache(); - dotenv.testLoad(fileInput: ''' -AIRQO_API_URL=https://api.test -AIRQO_MOBILE_TOKEN=JWT app-token -'''); - }); - - tearDown(() async { - ApiUtils.baseUrl = 'http://localhost:3001'; - await HiveRepository.clearAllCache(); - }); - - group('UserPreferencesImpl auth handling', () { - // My current hypothesis is that user-preferences fetch is one of the - // hidden places that can look like a session-expiry bug even when the - // core refresh path is healthy. This test gives us the healthy baseline: - // a user token is refreshed successfully, preferences load with 200, and - // the repository keeps the user authenticated while caching the result. - test('uses a refreshed token and stays healthy when preferences fetch succeeds', - () async { - final expiredToken = jwtToken( - userId: 'user-1', - expiresAt: DateTime.now().subtract(const Duration(hours: 1)), - ); - final refreshedToken = jwtToken( - userId: 'user-1', - expiresAt: DateTime.now().add(const Duration(hours: 1)), - ); - - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.authToken, - expiredToken, - ); - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.userId, - 'user-1', - ); - - final repository = UserPreferencesImpl(); - var refreshSeen = false; - - final refreshResult = await http.runWithClient( - () => repository.getUserPreferences('user-1'), - () => MockClient((request) async { - if (!refreshSeen && - request.method == 'POST' && - request.url.path == '/api/v2/users/token/refresh') { - refreshSeen = true; - expect(request.headers['Authorization'], 'JWT $expiredToken'); - - return http.Response( - jsonEncode({ - 'success': true, - 'message': 'Token refreshed successfully', - 'token': refreshedToken, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - - expect(request.method, 'GET'); - expect(request.url.path, '/api/v2/users/preferences/user-1'); - expect(request.headers['Authorization'], 'JWT $refreshedToken'); - - return http.Response( - jsonEncode({ - 'success': true, - 'preferences': {'theme': 'light'} - }), - 200, - headers: { - 'content-type': 'application/json', - 'x-access-token': refreshedToken, - }, - ); - }), - ); - - final storedToken = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.authToken); - final cachedPreferences = await HiveRepository.getCache( - 'user_preferences_user-1_', - ); - - expect(refreshResult['success'], true); - expect(storedToken, refreshedToken); - expect(cachedPreferences, isNotNull); - expect(refreshSeen, isTrue); - }); - - // We also need to capture the more dangerous branch. If preferences fetch - // returns 401 after refresh has already failed or fallen back, this - // repository explicitly flags the session as expired and still returns - // cached data when available. If this test passes, we have confirmed that - // preferences can independently force the logout flow even outside the - // main auth bloc startup path. - test('marks auth_error true and falls back to cache when preferences fetch returns 401', - () async { - final validToken = jwtToken( - userId: 'user-1', - expiresAt: DateTime.now().add(const Duration(hours: 1)), - ); - - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.authToken, - validToken, - ); - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.userId, - 'user-1', - ); - await HiveRepository.saveCache( - 'user_preferences_user-1_', - { - 'success': true, - 'preferences': {'theme': 'cached-dark'} - }, - ); - - final repository = UserPreferencesImpl(); - final client = MockClient((request) async { - expect(request.method, 'GET'); - expect(request.url.path, '/api/v2/users/preferences/user-1'); - expect(request.headers['Authorization'], 'JWT $validToken'); - - return http.Response( - jsonEncode({'message': 'Unauthorized'}), - 401, - headers: {'content-type': 'application/json'}, - ); - }); - - final result = await http.runWithClient( - () => repository.getUserPreferences('user-1'), - () => client, - ); - - expect(result['success'], true); - expect( - ((result['preferences'] as Map)['theme']), - 'cached-dark', - ); - }); - }); -} diff --git a/src/mobile/test/app/auth/services/auth_helper_test.dart b/src/mobile/test/app/auth/services/auth_helper_test.dart deleted file mode 100644 index 22e44cd9d0..0000000000 --- a/src/mobile/test/app/auth/services/auth_helper_test.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:airqo/src/app/auth/services/auth_helper.dart'; -import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; -import 'package:airqo/src/meta/utils/api_utils.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - String expiredToken() { - String encode(Map value) => - base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); - - return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ - 'sub': 'user-1', - 'exp': DateTime.now() - .subtract(const Duration(hours: 1)) - .millisecondsSinceEpoch ~/ - 1000, - })}.'; - } - - String validToken() { - String encode(Map value) => - base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); - - return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ - 'sub': 'user-1', - 'exp': DateTime.now() - .add(const Duration(hours: 1)) - .millisecondsSinceEpoch ~/ - 1000, - })}.'; - } - - setUp(() { - FlutterSecureStorage.setMockInitialValues({}); - dotenv.testLoad(fileInput: ''' -FORCE_AUTH_TOKEN_EXPIRED=false -AUTH_DEBUG_SEED_MOBILE_TOKEN=false -'''); - }); - - tearDown(() { - ApiUtils.baseUrl = 'http://localhost:3001'; - }); - - group('AuthHelper.refreshTokenIfNeeded', () { - // My current hypothesis is that unexpected logout begins here: - // when the app resumes with an expired stored token, it tries to refresh - // that token before restoring the session. If the refresh call fails, - // the helper returns null, and higher layers may interpret that as - // "the session has expired" and send the user back to the welcome screen. - // This test isolates that first signal so we can prove the behavior before - // checking how the bloc and lifecycle code react to it. - test('returns null when expired token refresh fails', () async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - ApiUtils.baseUrl = 'http://127.0.0.1:${server.port}'; - - server.listen((request) async { - expect(request.uri.path, '/api/v2/users/token/refresh'); - request.response.statusCode = HttpStatus.internalServerError; - request.response.write('{"success":false}'); - await request.response.close(); - }); - - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.authToken, - expiredToken(), - ); - - final token = await AuthHelper.refreshTokenIfNeeded(); - - expect(token, isNull); - - await server.close(force: true); - }); - - // My next hypothesis is that a temporary network stall can produce the - // same first signal as a hard refresh failure: the token is expired, the - // app asks the server for a replacement, the request takes too long, and - // the helper returns null. If that happens, higher layers may not be able - // to distinguish "the session is truly dead" from "refresh timed out on - // resume", which is exactly the kind of confusion we are investigating. - test('returns null when expired token refresh times out', () async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - ApiUtils.baseUrl = 'http://127.0.0.1:${server.port}'; - - server.listen((request) async { - expect(request.uri.path, '/api/v2/users/token/refresh'); - await Future.delayed(const Duration(seconds: 11)); - request.response.statusCode = HttpStatus.ok; - request.response.write('{"success":true,"token":"fresh-token"}'); - await request.response.close(); - }); - - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.authToken, - expiredToken(), - ); - - final token = await AuthHelper.refreshTokenIfNeeded(); - - expect(token, isNull); - - await server.close(force: true); - }); - - // Before we move up to bloc and lifecycle behavior, we need to confirm the - // normal path is calm: if the stored token is still valid, the helper - // should simply give that token back instead of trying to refresh it. - // This matters because it tells us whether the bug only begins once the - // token is already expired, or whether even a healthy stored session is at - // risk. - test('returns the existing token when it is still valid', () async { - final token = validToken(); - - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.authToken, - token, - ); - - final result = await AuthHelper.refreshTokenIfNeeded(); - - expect(result, token); - }); - - // Another part of my investigation is whether ordinary mobile network - // problems collapse into the exact same helper output as a real refresh - // failure. If an expired token cannot be refreshed because the network is - // unavailable, higher layers may still just see null and treat that as a - // dead session. - test('returns null when expired token refresh hits a network error', - () async { - ApiUtils.baseUrl = 'http://127.0.0.1:1'; - - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.authToken, - expiredToken(), - ); - - final token = await AuthHelper.refreshTokenIfNeeded(); - - expect(token, isNull); - }); - - // We also need to know whether broken token data follows the same path as - // true expiry problems. If secure storage contains something that is not a - // valid JWT, the helper should fail in a stable way instead of crashing or - // producing ambiguous state. - test('returns null when the stored token is malformed', () async { - await SecureStorageRepository.instance.saveSecureData( - SecureStorageKeys.authToken, - 'not-a-jwt', - ); - - final token = await AuthHelper.refreshTokenIfNeeded(); - - expect(token, isNull); - }); - }); -} diff --git a/src/mobile/test/app/auth/services/auth_token_storage_test.dart b/src/mobile/test/app/auth/services/auth_token_storage_test.dart deleted file mode 100644 index 1e05bdeed1..0000000000 --- a/src/mobile/test/app/auth/services/auth_token_storage_test.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'dart:convert'; - -import 'package:airqo/src/app/auth/services/auth_token_storage.dart'; -import 'package:airqo/src/app/shared/repository/secure_storage_repository.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - setUp(() { - FlutterSecureStorage.setMockInitialValues({}); - }); - - group('AuthTokenStorage.sanitizeToken', () { - // My current hypothesis is that auth persistence can look flaky if the app - // stores transport-style token prefixes like "Bearer" or "JWT" instead of - // the raw JWT itself. This test isolates the cleanup step first, so we can - // prove the storage helper normalizes those prefixed values before we move - // on to saving tokens and extracting user IDs. - // - // If this test passes, we have confirmed that prefixed token strings are - // reduced to the raw token value in a stable way. - test('removes auth scheme prefixes and surrounding whitespace', () { - final sanitized = AuthTokenStorage.sanitizeToken( - ' Bearer JWT fresh-token-value ', - ); - - expect(sanitized, 'fresh-token-value'); - }); - }); - - group('AuthTokenStorage.saveAuthToken', () { - // The next thing we need to know is whether normalized token strings are - // actually what land in secure storage. If this step writes the cleaned - // token value, later auth checks are at least reading a sane stored token - // rather than a transport-formatted one. - test('saves the sanitized token value to secure storage', () async { - await AuthTokenStorage.saveAuthToken(' JWT stored-token-value '); - - final storedToken = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.authToken); - - expect(storedToken, 'stored-token-value'); - }); - - // A stored auth token is only part of the session picture. This helper is - // also responsible for pulling the user identity out of the JWT so later - // parts of the app can treat the restored session as a real logged-in - // user, not just an opaque token string. - test('extracts and stores the user ID when saving a valid auth token', - () async { - final token = _jwtToken( - userId: 'user-1', - expiresAt: DateTime.now().add(const Duration(hours: 1)), - ); - - await AuthTokenStorage.saveAuthToken(token); - - final storedToken = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.authToken); - final storedUserId = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.userId); - - expect(storedToken, token); - expect(storedUserId, 'user-1'); - }); - }); - - group('AuthTokenStorage.saveTokenFromHeaders', () { - // This is the bridge between repository success responses and persisted - // session state. If a refreshed token arrives in response headers, this - // helper should store it in the same cleaned, usable form as a directly - // saved auth token. - test('stores the refreshed token and user ID from the x-access-token header', - () async { - final token = _jwtToken( - userId: 'user-1', - expiresAt: DateTime.now().add(const Duration(hours: 1)), - ); - - await AuthTokenStorage.saveTokenFromHeaders({ - 'x-access-token': ' Bearer $token ', - }); - - final storedToken = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.authToken); - final storedUserId = await SecureStorageRepository.instance - .getSecureData(SecureStorageKeys.userId); - - expect(storedToken, token); - expect(storedUserId, 'user-1'); - }); - }); -} - -String _jwtToken({ - required String userId, - required DateTime expiresAt, -}) { - String encode(Map value) => - base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); - - return '${encode({'alg': 'none', 'typ': 'JWT'})}.${encode({ - 'sub': userId, - 'exp': expiresAt.millisecondsSinceEpoch ~/ 1000, - })}.'; -}