diff --git a/mobile/lib/constants/api.dart b/mobile/lib/constants/api.dart index 77805818d7..1fb67f7e53 100644 --- a/mobile/lib/constants/api.dart +++ b/mobile/lib/constants/api.dart @@ -1,6 +1,9 @@ import 'config.dart'; class AirQoUrls { + static String get termsUrl => + 'https://docs.airqo.net/#/mobile_app/privacy_policy'; + static String get firebaseLookup => '${Config.airqoApi}/v1/users/firebase/lookup'; diff --git a/mobile/lib/constants/config.dart b/mobile/lib/constants/config.dart index 7f9b772760..5468a36111 100644 --- a/mobile/lib/constants/config.dart +++ b/mobile/lib/constants/config.dart @@ -7,11 +7,16 @@ import 'package:geolocator/geolocator.dart'; final GlobalKey navigatorKey = GlobalKey(); class Config { - static String get airqoApiToken => dotenv.env['AIRQO_API_TOKEN'] ?? ''; + static String get airqoJWTToken => dotenv.env['AIRQO_API_TOKEN'] ?? ''; + static String get airqoApiV2Token => dotenv.env['AIRQO_API_V2_TOKEN'] ?? ''; + static String get searchApiKey => dotenv.env['SEARCH_API_KEY'] ?? ''; + static String get slackWebhookUrl => dotenv.env['SLACK_WEBHOOK_URL'] ?? ''; + static double get minimumTextScaleFactor => 1.0; + static double get maximumTextScaleFactor => 1.1; static String get airqoApi => 'https://platform.airqo.net/api'; @@ -59,12 +64,6 @@ class Config { static String get airqoSecondaryLogo => 'https://storage.cloud.google.com/airqo-app/public-images/airqo_logo.png'; - static String get placesSearchUrl => - 'https://maps.googleapis.com/maps/api/place/'; - - static String get appStoreUrl => - 'https://apps.apple.com/ug/app/airqo-monitoring-air-quality/id1337573091'; - static String get iosStoreId => '1337573091'; static String get iosBundleId => 'com.airqo.net'; @@ -79,18 +78,12 @@ class Config { static int get locationChangeRadiusInMetres => 100; - static String get playStoreUrl => - 'https://play.google.com/store/apps/details?id=com.airqo.app'; - static int get searchRadius => 4; static int get surroundingsSitesMaxRadiusInKilometres => 20; static int get shareLinkMaxLength => 56; - static String get termsUrl => - 'https://docs.airqo.net/#/mobile_app/privacy_policy'; - static double get refreshTriggerPullDistance => 40; static double get refreshIndicatorExtent => 30; diff --git a/mobile/lib/main_common.dart b/mobile/lib/main_common.dart index 4117e8d4e1..3ad8189177 100644 --- a/mobile/lib/main_common.dart +++ b/mobile/lib/main_common.dart @@ -124,14 +124,17 @@ Future initializeMainMethod() async { ); PlatformDispatcher.instance.onError = (error, stack) { - logException(error, stack); + logException(error, stack, fatal: true); return true; }; FlutterError.onError = (details) { - FlutterError.presentError(details); - logException(details, null); + if (kDebugMode) { + FlutterError.dumpErrorToConsole(details); + } else { + logException(details, null, fatal: true); + } }; ErrorWidget.builder = (FlutterErrorDetails details) { diff --git a/mobile/lib/models/app_store_version.dart b/mobile/lib/models/app_store_version.dart index b5b6cd97b2..0ee4cf884b 100644 --- a/mobile/lib/models/app_store_version.dart +++ b/mobile/lib/models/app_store_version.dart @@ -7,7 +7,14 @@ class AppStoreVersion { final String version; final Uri url; - AppStoreVersion({required this.version, required this.url}); + @JsonKey(name: 'is_updated', defaultValue: true) + final bool isUpdated; + + AppStoreVersion({ + required this.version, + required this.url, + required this.isUpdated, + }); factory AppStoreVersion.fromJson(Map json) => _$AppStoreVersionFromJson(json); diff --git a/mobile/lib/models/app_store_version.g.dart b/mobile/lib/models/app_store_version.g.dart index 45726fda93..4dc2cb51fb 100644 --- a/mobile/lib/models/app_store_version.g.dart +++ b/mobile/lib/models/app_store_version.g.dart @@ -10,4 +10,5 @@ AppStoreVersion _$AppStoreVersionFromJson(Map json) => AppStoreVersion( version: json['version'] as String, url: Uri.parse(json['url'] as String), + isUpdated: json['is_updated'] as bool, ); diff --git a/mobile/lib/screens/home_page.dart b/mobile/lib/screens/home_page.dart index 10164ccf5e..dc9b9b0dc4 100644 --- a/mobile/lib/screens/home_page.dart +++ b/mobile/lib/screens/home_page.dart @@ -203,8 +203,8 @@ class _HomePageState extends State { await SharedPreferencesHelper.updateOnBoardingPage(OnBoardingPage.home); WidgetsBinding.instance.addPostFrameCallback((_) async { if (context.read().state.checkForUpdates) { - await AppService().latestVersion().then((version) async { - if (version != null && mounted) { + await AirqoApiClient().getAppVersion().then((version) async { + if (version != null && mounted && !version.isUpdated) { await canLaunchUrl(version.url).then((bool result) async { await openUpdateScreen(context, version); }); diff --git a/mobile/lib/screens/insights/insights_widgets.dart b/mobile/lib/screens/insights/insights_widgets.dart index 881584d9be..4ec816e196 100644 --- a/mobile/lib/screens/insights/insights_widgets.dart +++ b/mobile/lib/screens/insights/insights_widgets.dart @@ -10,6 +10,7 @@ import 'package:flutter_svg/svg.dart'; class InsightAirQualityWidget extends StatelessWidget { const InsightAirQualityWidget(this.insight, {super.key, required this.name}); + final Insight insight; final String name; @@ -112,6 +113,7 @@ class InsightAirQualityWidget extends StatelessWidget { class InsightAirQualityMessageWidget extends StatelessWidget { InsightAirQualityMessageWidget(this.insight, {super.key}); + final Insight insight; final ScrollController _scrollController = ScrollController(); @@ -298,6 +300,7 @@ class InsightsDayReading extends StatelessWidget { super.key, required this.isActive, }); + final Insight insight; final bool isActive; @@ -351,6 +354,7 @@ class InsightsDayReading extends StatelessWidget { class InsightsCalendar extends StatelessWidget { const InsightsCalendar(this.airQualityReading, {super.key}); + final AirQualityReading airQualityReading; @override @@ -440,6 +444,7 @@ class InsightsCalendar extends StatelessWidget { class ForecastContainer extends StatelessWidget { const ForecastContainer(this.insight, {super.key}); + final Insight insight; @override @@ -503,6 +508,7 @@ class ForecastContainer extends StatelessWidget { class HealthTipsWidget extends StatefulWidget { const HealthTipsWidget(this.insight, {super.key}); + final Insight insight; @override diff --git a/mobile/lib/screens/on_boarding/introduction_screen.dart b/mobile/lib/screens/on_boarding/introduction_screen.dart index 85d2fdb22f..8af4b44af1 100644 --- a/mobile/lib/screens/on_boarding/introduction_screen.dart +++ b/mobile/lib/screens/on_boarding/introduction_screen.dart @@ -97,8 +97,8 @@ class IntroductionScreenState extends State { updateOnBoardingPage(); WidgetsBinding.instance.addPostFrameCallback((_) async { if (context.read().state.checkForUpdates) { - await AppService().latestVersion().then((version) async { - if (version != null && mounted) { + await AirqoApiClient().getAppVersion().then((version) async { + if (version != null && mounted && !version.isUpdated) { await canLaunchUrl(version.url).then((bool result) async { await openUpdateScreen(context, version); }); diff --git a/mobile/lib/screens/settings/about_page.dart b/mobile/lib/screens/settings/about_page.dart index 85c919f91c..f9ff493264 100644 --- a/mobile/lib/screens/settings/about_page.dart +++ b/mobile/lib/screens/settings/about_page.dart @@ -1,4 +1,4 @@ -import 'package:app/constants/config.dart'; +import 'package:app/constants/constants.dart'; import 'package:app/screens/web_view_page.dart'; import 'package:app/themes/theme.dart'; import 'package:app/widgets/widgets.dart'; @@ -61,7 +61,7 @@ class _AboutAirQoState extends State { MaterialPageRoute( builder: (context) { return WebViewScreen( - url: Config.termsUrl, + url: AirQoUrls.termsUrl, title: 'Terms & Privacy Policy', ); }, diff --git a/mobile/lib/screens/settings/settings_page.dart b/mobile/lib/screens/settings/settings_page.dart index 095d960b7a..fae6c77737 100644 --- a/mobile/lib/screens/settings/settings_page.dart +++ b/mobile/lib/screens/settings/settings_page.dart @@ -360,9 +360,10 @@ class DeleteAccountButton extends StatelessWidget { loadingScreen(context); await FirebaseAuth.instance.verifyPhoneNumber( phoneNumber: profile.phoneNumber, - // ignore: no-empty-block not used - verificationCompleted: (PhoneAuthCredential - _) {}, // ignore: no-empty-block not used + verificationCompleted: + (PhoneAuthCredential phoneAuthCredential) { + debugPrint(phoneAuthCredential.smsCode); + }, verificationFailed: (FirebaseAuthException exception) async { Navigator.pop(context); final firebaseAuthError = @@ -396,9 +397,9 @@ class DeleteAccountButton extends StatelessWidget { phoneAuthModel: phoneAuthModel, ); }, - // ignore: no-empty-block not used - codeAutoRetrievalTimeout: - (String _) {}, // ignore: no-empty-block not used + codeAutoRetrievalTimeout: (String code) { + debugPrint(code); + }, timeout: const Duration(seconds: 15), ); } diff --git a/mobile/lib/services/app_service.dart b/mobile/lib/services/app_service.dart index 5f5f540b21..310b04d659 100644 --- a/mobile/lib/services/app_service.dart +++ b/mobile/lib/services/app_service.dart @@ -1,12 +1,9 @@ -import 'dart:io'; - import 'package:app/blocs/blocs.dart'; import 'package:app/constants/constants.dart'; import 'package:app/models/models.dart'; import 'package:app/utils/utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'firebase_service.dart'; @@ -75,7 +72,7 @@ class AppService { return true; } catch (exception, stackTrace) { - logException(exception, stackTrace); + await logException(exception, stackTrace); return false; } @@ -109,35 +106,4 @@ class AppService { ), ); } - - Future latestVersion() async { - AppStoreVersion? appStoreVersion; - - try { - final PackageInfo packageInfo = await PackageInfo.fromPlatform(); - - if (Platform.isAndroid) { - appStoreVersion = await AirqoApiClient() - .getAppVersion(packageName: packageInfo.packageName); - } else if (Platform.isIOS) { - appStoreVersion = await AirqoApiClient() - .getAppVersion(bundleId: packageInfo.packageName); - } else { - return appStoreVersion; - } - - if (appStoreVersion == null) return null; - - return appStoreVersion.compareVersion(packageInfo.version) >= 1 - ? appStoreVersion - : null; - } catch (exception, stackTrace) { - await logException( - exception, - stackTrace, - ); - } - - return appStoreVersion; - } } diff --git a/mobile/lib/services/firebase_service.dart b/mobile/lib/services/firebase_service.dart index 6ad3678f57..2ca6f192e2 100644 --- a/mobile/lib/services/firebase_service.dart +++ b/mobile/lib/services/firebase_service.dart @@ -317,8 +317,6 @@ class CloudStore { return null; } - - static Future updateProfile(Profile profile) async { final User? currentUser = CustomAuth.getUser(); if (!profile.isSignedIn || currentUser == null) { diff --git a/mobile/lib/services/notification_service.dart b/mobile/lib/services/notification_service.dart index 4bbf4047f3..0474ad3909 100644 --- a/mobile/lib/services/notification_service.dart +++ b/mobile/lib/services/notification_service.dart @@ -112,11 +112,7 @@ class NotificationService { ); FirebaseMessaging.instance.onTokenRefresh.listen((fcmToken) { // TODO update cloud store - }).onError( - (exception) { - logException(exception, null); - }, - ); + }); } catch (exception, stackTrace) { await logException(exception, stackTrace); } diff --git a/mobile/lib/services/rest_api.dart b/mobile/lib/services/rest_api.dart index 0246262218..e482c7c31b 100644 --- a/mobile/lib/services/rest_api.dart +++ b/mobile/lib/services/rest_api.dart @@ -1,29 +1,25 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; +import 'dart:io'; import 'package:app/constants/constants.dart'; import 'package:app/models/models.dart'; import 'package:app/services/services.dart'; import 'package:app/utils/utils.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:http/retry.dart'; import 'package:intl/intl.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:uuid/uuid.dart'; String addQueryParameters(Map queryParams, String url) { - if (queryParams.isNotEmpty) { - url = '$url?'; - queryParams.forEach( - (key, value) { - url = queryParams.keys.first.compareTo(key) == 0 - ? '$url$key=$value' - : '$url&$key=$value'; - }, - ); - } + String formattedUrl = '$url?TOKEN=${Config.airqoApiV2Token}'; + queryParams + .forEach((key, value) => formattedUrl = "$formattedUrl&$key=$value"); - return url; + return formattedUrl; } class AirqoApiClient { @@ -57,26 +53,29 @@ class AirqoApiClient { final Map getHeaders = HashMap() ..putIfAbsent( 'Authorization', - () => 'JWT ${Config.airqoApiToken}', + () => 'JWT ${Config.airqoJWTToken}', ); final Map postHeaders = HashMap() ..putIfAbsent( 'Authorization', - () => 'JWT ${Config.airqoApiToken}', + () => 'JWT ${Config.airqoJWTToken}', ) ..putIfAbsent('Content-Type', () => 'application/json'); - Future getAppVersion({ - String bundleId = "", - String packageName = "", - }) async { + Future getAppVersion() async { try { + final PackageInfo packageInfo = await PackageInfo.fromPlatform(); + + Map queryParams = {"version": packageInfo.version}; + if (Platform.isAndroid) { + queryParams["packageName"] = packageInfo.packageName; + } else if (Platform.isIOS) { + queryParams["bundleId"] = packageInfo.packageName; + } + final body = await _performGetRequest( - { - "bundleId": bundleId, - "packageName": packageName, - }, + queryParams, AirQoUrls.appVersion, apiService: ApiService.view, ); @@ -96,9 +95,10 @@ class AirqoApiClient { try { Map headers = Map.from(postHeaders); headers["service"] = ApiService.metaData.serviceName; + String url = addQueryParameters({}, AirQoUrls.mobileCarrier); final response = await client.post( - Uri.parse("${AirQoUrls.mobileCarrier}?TOKEN=${Config.airqoApiV2Token}"), + Uri.parse(url), headers: headers, body: json.encode({'phone_number': phoneNumber}), ); @@ -108,12 +108,40 @@ class AirqoApiClient { await logException( exception, stackTrace, + fatal: false, ); } return ''; } + static Future sendErrorToSlack( + Object exception, + StackTrace? stackTrace, + ) async { + try { + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + final retryClient = RetryClient( + http.Client(), + retries: 10, + ); + + await retryClient.post( + Uri.parse(Config.slackWebhookUrl), + headers: { + HttpHeaders.contentTypeHeader: 'application/json', + }, + body: jsonEncode({ + 'text': "Exception: ${exception.toString()}\n\n " + "App details: $packageInfo\n\n " + "StackTrace: $stackTrace\n\n", + }), + ); + } catch (e) { + debugPrint(e.toString()); + } + } + Future checkIfUserExists({ String? phoneNumber, String? emailAddress, @@ -130,10 +158,10 @@ class AirqoApiClient { Map headers = Map.from(postHeaders); headers["service"] = ApiService.auth.serviceName; + String url = addQueryParameters({}, AirQoUrls.firebaseLookup); + final response = await client.post( - Uri.parse( - "${AirQoUrls.firebaseLookup}?TOKEN=${Config.airqoApiV2Token}", - ), + Uri.parse(url), headers: headers, body: jsonEncode(body), ); @@ -194,10 +222,10 @@ class AirqoApiClient { Map headers = Map.from(postHeaders); headers["service"] = ApiService.auth.serviceName; + String url = addQueryParameters({}, AirQoUrls.emailVerification); + final response = await client.post( - Uri.parse( - "${AirQoUrls.emailVerification}?TOKEN=${Config.airqoApiV2Token}", - ), + Uri.parse(url), headers: headers, body: jsonEncode({'email': emailAddress}), ); @@ -222,10 +250,10 @@ class AirqoApiClient { Map headers = Map.from(postHeaders); headers["service"] = ApiService.auth.serviceName; + String url = addQueryParameters({}, AirQoUrls.emailReAuthentication); + final response = await client.post( - Uri.parse( - "${AirQoUrls.emailReAuthentication}?TOKEN=${Config.airqoApiV2Token}", - ), + Uri.parse(url), headers: headers, body: jsonEncode({'email': emailAddress}), ); @@ -324,8 +352,8 @@ class AirqoApiClient { return favoritePlaces; } - Future syncFavouritePlaces(List favorites, - { + Future syncFavouritePlaces( + List favorites, { bool clear = false, }) async { final userId = CustomAuth.getUserId(); @@ -340,15 +368,18 @@ class AirqoApiClient { List> body = favorites.map((e) => e.toAPiJson(userId)).toList(); + String url = addQueryParameters( + {}, + "${AirQoUrls.favourites}/syncFavorites/$userId", + ); + final response = await client.post( - Uri.parse( - "${AirQoUrls.favourites}/syncFavorites/$userId?TOKEN=${Config.airqoApiV2Token}", - ), + Uri.parse(url), headers: headers, body: jsonEncode({'favorite_places': body}), ); - return response.statusCode == 200 ? true : false; + return response.statusCode == 200; } catch (exception, stackTrace) { await logException( exception, @@ -372,15 +403,15 @@ class AirqoApiClient { }, ); + String url = addQueryParameters({}, AirQoUrls.feedback); + final response = await client.post( - Uri.parse("${AirQoUrls.feedback}?TOKEN=${Config.airqoApiV2Token}"), + Uri.parse(url), headers: headers, body: body, ); - if (response.statusCode == 200) { - return true; - } + return response.statusCode == 200; } catch (exception, stackTrace) { await logException( exception, diff --git a/mobile/lib/utils/exception.dart b/mobile/lib/utils/exception.dart index cc870abdf8..3b8f88cd76 100644 --- a/mobile/lib/utils/exception.dart +++ b/mobile/lib/utils/exception.dart @@ -1,18 +1,21 @@ import 'dart:async'; import 'dart:io'; +import 'package:app/services/services.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; class NetworkConnectionException implements Exception { String cause; + NetworkConnectionException(this.cause); } Future logException( exception, - StackTrace? stackTrace, -) async { + StackTrace? stackTrace, { + bool fatal = true, +}) async { final unHandledExceptions = [ SocketException, TimeoutException, @@ -24,9 +27,20 @@ Future logException( } try { - FirebaseCrashlytics.instance - .recordError(exception, stackTrace, fatal: true); + if (!Platform.isAndroid) { + if (fatal) { + await AirqoApiClient.sendErrorToSlack(exception as Object, stackTrace); + } + + return; + } + + await FirebaseCrashlytics.instance.recordError( + exception, + stackTrace, + fatal: fatal, + ); } catch (e) { - debugPrint(e.toString()); + await AirqoApiClient.sendErrorToSlack(exception as Object, stackTrace); } } diff --git a/mobile/lib/utils/extensions.dart b/mobile/lib/utils/extensions.dart index 28bdb9020c..6b0ae8b386 100644 --- a/mobile/lib/utils/extensions.dart +++ b/mobile/lib/utils/extensions.dart @@ -653,9 +653,8 @@ extension DateTimeExt on DateTime { final now = DateTime.now(); return (day == now.day) - ? DateFormat('HH:mm') - .format(DateTime(now.year, now.month, now.day, hour, minute)) - : DateFormat('dd MMM').format(DateTime(now.year, now.month, day)); + ? DateFormat('HH:mm').format(this) + : DateFormat('dd MMM').format(this); } DateTime tomorrow() { @@ -673,41 +672,6 @@ extension FileExt on File { } } -extension AppStoreVersionExt on AppStoreVersion { - int compareVersion(String checkVersion) { - List versionSections = - version.split('.').take(3).map((e) => int.parse(e)).toList(); - - if (versionSections.length != 3) { - throw Exception('Invalid version $this'); - } - - List candidateSections = - checkVersion.split('.').take(3).map((e) => int.parse(e)).toList(); - - if (candidateSections.length != 3) { - throw Exception('Invalid version $checkVersion'); - } - - // checking first code - if (versionSections.first > candidateSections.first) return 1; - - if (versionSections.first < candidateSections.first) return -1; - - // checking second code - if (versionSections[1] > candidateSections[1]) return 1; - - if (versionSections[1] < candidateSections[1]) return -1; - - // checking last code - if (versionSections.last > candidateSections.last) return 1; - - if (versionSections.last < candidateSections.last) return -1; - - return 0; - } -} - extension StringExt on String { List getFirstAndLastNames() { List names = split(" "); diff --git a/mobile/lib/widgets/custom_widgets.dart b/mobile/lib/widgets/custom_widgets.dart index f7956d4420..93eccd8421 100644 --- a/mobile/lib/widgets/custom_widgets.dart +++ b/mobile/lib/widgets/custom_widgets.dart @@ -40,9 +40,9 @@ class HealthTipContainer extends StatelessWidget { Radius.circular(16.0), ), ), - child: Row( - children: [ - Container( + child: Row( + children: [ + Container( constraints: const BoxConstraints( maxWidth: 83, maxHeight: 112, @@ -67,18 +67,17 @@ class HealthTipContainer extends StatelessWidget { errorWidget: (context, url, error) => Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8.0), - color: Colors.grey, + color: Colors.grey, ), child: const Center( child: Icon( Icons.error, - color: Colors.white, + color: Colors.white, ), ), ), ), ), - const SizedBox( width: 12, ), diff --git a/mobile/lib/widgets/error_widgets.dart b/mobile/lib/widgets/error_widgets.dart index f94d13d351..01dde79114 100644 --- a/mobile/lib/widgets/error_widgets.dart +++ b/mobile/lib/widgets/error_widgets.dart @@ -1,16 +1,18 @@ +import 'package:app/services/services.dart'; import 'package:app/themes/theme.dart'; import 'package:app/widgets/buttons.dart'; +import 'package:app/widgets/custom_shimmer.dart'; import 'package:app/widgets/custom_widgets.dart'; +import 'package:app/widgets/dialogs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:share_plus/share_plus.dart'; import '../screens/home_page.dart'; import '../screens/search/search_page.dart'; class NoSearchResultsWidget extends StatelessWidget { const NoSearchResultsWidget({super.key, this.message}); + final String? message; @override @@ -109,6 +111,7 @@ class NoAirQualityDataWidget extends StatelessWidget { required this.callBack, this.actionButtonText, }); + final Function() callBack; final String? actionButtonText; @@ -231,6 +234,7 @@ class NoFavouritePlacesWidget extends StatelessWidget { class NoAnalyticsWidget extends StatelessWidget { const NoAnalyticsWidget({super.key, required this.callBack}); + final Function() callBack; @override @@ -280,6 +284,7 @@ class NoAnalyticsWidget extends StatelessWidget { class NoCompleteKyaWidget extends StatelessWidget { const NoCompleteKyaWidget({super.key, required this.callBack}); + final Function() callBack; @override @@ -328,6 +333,7 @@ class NoCompleteKyaWidget extends StatelessWidget { class NoKyaWidget extends StatelessWidget { const NoKyaWidget({super.key, required this.callBack}); + final Function() callBack; @override @@ -381,6 +387,7 @@ class NoInternetConnectionWidget extends StatelessWidget { required this.callBack, this.actionButtonText, }); + final Function() callBack; final String? actionButtonText; @@ -431,6 +438,7 @@ class NoInternetConnectionWidget extends StatelessWidget { class AppErrorWidget extends StatelessWidget { const AppErrorWidget({super.key, required this.callBack}); + final Function() callBack; @override @@ -502,7 +510,8 @@ class AppErrorWidget extends StatelessWidget { class AppCrushWidget extends StatelessWidget { const AppCrushWidget(this.exception, this.stackTrace, {super.key}); - final dynamic exception; + + final Object exception; final StackTrace? stackTrace; @override @@ -559,16 +568,16 @@ class AppCrushWidget extends StatelessWidget { const Spacer(), InkWell( onTap: () async { - // TODO log to a backend service - PackageInfo packageInfo = await PackageInfo.fromPlatform(); - String subject = "Mobile App Crush"; - String body = "" - "App Version : ${packageInfo.version}\n" - "Build Number : ${packageInfo.buildNumber}\n" - "Installed via : ${packageInfo.installerStore}\n\n" - "Error : $exception\n\n" - "StackTrace : $stackTrace\n\n"; - await Share.share(body, subject: subject); + loadingScreen(context); + await AirqoApiClient.sendErrorToSlack(exception, stackTrace) + .then((_) { + Navigator.pop(context); + showSnackBar( + context, + "Report has been successfully sent.", + durationInSeconds: 10, + ); + }); }, child: const ActionButton( icon: Icons.error_outline_rounded, diff --git a/mobile/test/api/check_if_user_exists_test.dart b/mobile/test/api/check_if_user_exists_test.dart index ca450fc9db..9d80ee1aa0 100644 --- a/mobile/test/api/check_if_user_exists_test.dart +++ b/mobile/test/api/check_if_user_exists_test.dart @@ -3,10 +3,9 @@ import 'dart:convert'; import 'package:app/constants/constants.dart'; import 'package:app/services/rest_api.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:mockito/annotations.dart'; - -import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'api.mocks.dart'; @@ -21,7 +20,7 @@ Future main() async { await dotenv.load(fileName: Config.environmentFile); headers = { 'Content-Type': 'application/json', - 'Authorization': 'JWT ${Config.airqoApiToken}', + 'Authorization': 'JWT ${Config.airqoJWTToken}', 'service': ApiService.auth.serviceName, }; client = MockClient(); diff --git a/mobile/test/api/check_if_user_exists_test.mocks.dart b/mobile/test/api/check_if_user_exists_test.mocks.dart index 8e701c4697..54cf4d92c3 100644 --- a/mobile/test/api/check_if_user_exists_test.mocks.dart +++ b/mobile/test/api/check_if_user_exists_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.0 from annotations +// Mocks generated by Mockito 5.4.2 from annotations // in app/test/api/check_if_user_exists_test.dart. // Do not manually edit this file. diff --git a/mobile/test/api/fetch_air_quality_readings_test.dart b/mobile/test/api/fetch_air_quality_readings_test.dart index 0cddef1c04..106393834f 100644 --- a/mobile/test/api/fetch_air_quality_readings_test.dart +++ b/mobile/test/api/fetch_air_quality_readings_test.dart @@ -2,14 +2,13 @@ import 'package:app/constants/constants.dart'; import 'package:app/models/models.dart'; import 'package:app/services/rest_api.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; - import 'package:flutter_test/flutter_test.dart'; Future main() async { await dotenv.load(fileName: Config.environmentFile); group('fetchesAirQualityReadings', () { - test('fetches latest air quality readings from API', () async { + test('fetches latest air quality readings from API', () async { List readings = await AirqoApiClient().fetchAirQualityReadings(); expect(readings.isEmpty, false); diff --git a/mobile/test/api/get_app_version_test.dart b/mobile/test/api/get_app_version_test.dart index c804822f81..385c1dbe06 100644 --- a/mobile/test/api/get_app_version_test.dart +++ b/mobile/test/api/get_app_version_test.dart @@ -2,11 +2,11 @@ import 'package:app/constants/constants.dart'; import 'package:app/models/app_store_version.dart'; import 'package:app/services/rest_api.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:mockito/annotations.dart'; - -import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'api.mocks.dart'; @@ -21,12 +21,19 @@ Future main() async { setUpAll(() async { await dotenv.load(fileName: Config.environmentFile); headers = { - 'Authorization': 'JWT ${Config.airqoApiToken}', + 'Authorization': 'JWT ${Config.airqoJWTToken}', 'service': ApiService.view.serviceName, }; client = MockClient(); bundleId = "com.airqo.net"; packageName = "com.airqo.app"; + PackageInfo.setMockInitialValues( + appName: "airqo", + packageName: "com.airqo.app", + version: "2.0.1", + buildNumber: "123", + buildSignature: "123", + ); }); test('returns mocked AppVersion', () async { @@ -39,17 +46,14 @@ Future main() async { ), ).thenAnswer( (_) async => http.Response( - '{"data": {"version": "v1.0.0", "url": "https://api.airqo.net/version"}}', + '{"data": {"version": "v1.0.0", "url": "https://api.airqo.net/version", "is_updated": true}}', 200, ), ); AirqoApiClient airqoApiClient = AirqoApiClient(client: client); - expect( - await airqoApiClient.getAppVersion( - bundleId: bundleId, packageName: packageName), - isA()); + expect(await airqoApiClient.getAppVersion(), isA()); }); test('returns null if data not in response body', () async { @@ -61,15 +65,14 @@ Future main() async { headers: headers), ).thenAnswer( (_) async => http.Response( - '{"version": "v1.0.0", "url": "https://api.airqo.net/version"}', + '{"version": "v1.0.0", "url": "https://api.airqo.net/version", "is_updated": true}', 200), ); AirqoApiClient airqoApiClient = AirqoApiClient(client: client); expect( - await airqoApiClient.getAppVersion( - bundleId: bundleId, packageName: packageName), + await airqoApiClient.getAppVersion(), null, ); }); @@ -89,32 +92,25 @@ Future main() async { AirqoApiClient airqoApiClient = AirqoApiClient(client: client); expect( - await airqoApiClient.getAppVersion( - bundleId: bundleId, packageName: packageName), + await airqoApiClient.getAppVersion(), null, ); }); test('return Android version from API', () async { AirqoApiClient airqoApiClient = AirqoApiClient(); - AppStoreVersion? appVersion = await airqoApiClient.getAppVersion( - bundleId: bundleId, - packageName: "", - ); + AppStoreVersion? appVersion = await airqoApiClient.getAppVersion(); expect(appVersion, isA()); expect( appVersion?.url, Uri.parse( - "https://apps.apple.com/us/app/airqo-air-quality/id1337573091?uo=4"), + "https://apps.apple.com/us/app/airqo-air-quality/id1337573091"), ); }); test('returns iOS version from API', () async { AirqoApiClient airqoApiClient = AirqoApiClient(); - AppStoreVersion? appVersion = await airqoApiClient.getAppVersion( - bundleId: "", - packageName: packageName, - ); + AppStoreVersion? appVersion = await airqoApiClient.getAppVersion(); expect(appVersion, isA()); expect( appVersion?.url, diff --git a/mobile/test/api/get_app_version_test.mocks.dart b/mobile/test/api/get_app_version_test.mocks.dart index b9e0ad9028..64f362d11f 100644 --- a/mobile/test/api/get_app_version_test.mocks.dart +++ b/mobile/test/api/get_app_version_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.0 from annotations +// Mocks generated by Mockito 5.4.2 from annotations // in app/test/api/get_app_version_test.dart. // Do not manually edit this file. diff --git a/mobile/test/api/get_mobile_carrier_test.dart b/mobile/test/api/get_mobile_carrier_test.dart index d3c58a67a2..66197d9045 100644 --- a/mobile/test/api/get_mobile_carrier_test.dart +++ b/mobile/test/api/get_mobile_carrier_test.dart @@ -3,10 +3,9 @@ import 'dart:convert'; import 'package:app/constants/constants.dart'; import 'package:app/services/rest_api.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:mockito/annotations.dart'; - -import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'api.mocks.dart'; @@ -23,7 +22,7 @@ Future main() async { await dotenv.load(fileName: Config.environmentFile); headers = { 'Content-Type': 'application/json', - 'Authorization': 'JWT ${Config.airqoApiToken}', + 'Authorization': 'JWT ${Config.airqoJWTToken}', 'service': ApiService.metaData.serviceName, }; client = MockClient(); diff --git a/mobile/test/api/get_mobile_carrier_test.mocks.dart b/mobile/test/api/get_mobile_carrier_test.mocks.dart index 71aa1ea531..b2be5bfb37 100644 --- a/mobile/test/api/get_mobile_carrier_test.mocks.dart +++ b/mobile/test/api/get_mobile_carrier_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.0 from annotations +// Mocks generated by Mockito 5.4.2 from annotations // in app/test/api/get_mobile_carrier_test.dart. // Do not manually edit this file. diff --git a/mobile/test/api/send_email_verification_test.dart b/mobile/test/api/send_email_verification_test.dart index cb439b3a41..21e1f62dd9 100644 --- a/mobile/test/api/send_email_verification_test.dart +++ b/mobile/test/api/send_email_verification_test.dart @@ -4,10 +4,9 @@ import 'package:app/constants/constants.dart'; import 'package:app/models/models.dart'; import 'package:app/services/rest_api.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:mockito/annotations.dart'; - -import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'api.mocks.dart'; @@ -23,7 +22,7 @@ Future main() async { await dotenv.load(fileName: Config.environmentFile); headers = { 'Content-Type': 'application/json', - 'Authorization': 'JWT ${Config.airqoApiToken}', + 'Authorization': 'JWT ${Config.airqoJWTToken}', 'service': ApiService.auth.serviceName, }; client = MockClient(); diff --git a/mobile/test/api/send_email_verification_test.mocks.dart b/mobile/test/api/send_email_verification_test.mocks.dart index b4dd70d67f..aa9dcbff09 100644 --- a/mobile/test/api/send_email_verification_test.mocks.dart +++ b/mobile/test/api/send_email_verification_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.0 from annotations +// Mocks generated by Mockito 5.4.2 from annotations // in app/test/api/send_email_verification_test.dart. // Do not manually edit this file. diff --git a/mobile/test/api/send_feedback_test.dart b/mobile/test/api/send_feedback_test.dart index 04abfb5b98..aab265bb48 100644 --- a/mobile/test/api/send_feedback_test.dart +++ b/mobile/test/api/send_feedback_test.dart @@ -4,10 +4,9 @@ import 'package:app/constants/constants.dart'; import 'package:app/models/models.dart'; import 'package:app/services/rest_api.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:mockito/annotations.dart'; - -import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'api.mocks.dart'; @@ -23,7 +22,7 @@ Future main() async { await dotenv.load(fileName: Config.environmentFile); headers = { 'Content-Type': 'application/json', - 'Authorization': 'JWT ${Config.airqoApiToken}', + 'Authorization': 'JWT ${Config.airqoJWTToken}', 'service': ApiService.auth.serviceName, }; client = MockClient(); diff --git a/mobile/test/api/send_feedback_test.mocks.dart b/mobile/test/api/send_feedback_test.mocks.dart index 9944f7e360..e5975aba36 100644 --- a/mobile/test/api/send_feedback_test.mocks.dart +++ b/mobile/test/api/send_feedback_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.0 from annotations +// Mocks generated by Mockito 5.4.2 from annotations // in app/test/api/send_feedback_test.dart. // Do not manually edit this file. diff --git a/mobile/test/screens/insights/insights_widgets_test.dart b/mobile/test/screens/insights/insights_widgets_test.dart index cc50dd7794..a501c71eed 100644 --- a/mobile/test/screens/insights/insights_widgets_test.dart +++ b/mobile/test/screens/insights/insights_widgets_test.dart @@ -105,24 +105,6 @@ void main() { }); }); - testWidgets('Insights health tips list tests', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: HealthTipsWidget(insight), - ), - ); - - final titleFinder = find.text(insight.healthTipsTitle()); - expect(titleFinder, findsOneWidget); - expect( - find.byWidgetPredicate( - (widget) => - widget is ListView && widget.scrollDirection == Axis.horizontal, - ), - findsOneWidget, - ); - }); - testWidgets('Health tip widget', (tester) async { await tester.pumpWidget( MaterialApp( diff --git a/mobile/test/utils/extensions/primitive_data_types_test.dart b/mobile/test/utils/extensions/primitive_data_types_test.dart index 2fad66334d..72805bb048 100644 --- a/mobile/test/utils/extensions/primitive_data_types_test.dart +++ b/mobile/test/utils/extensions/primitive_data_types_test.dart @@ -280,8 +280,7 @@ void main() { 'isWithInNextWeek should return true if the date is within the next week', () { expect(today.add(const Duration(days: 7)).isWithInNextWeek(), isTrue); - expect(today.add(Duration(days: 7 + today.weekday)).isWithInNextWeek(), - isFalse); + expect(today.add(const Duration(days: 14)).isWithInNextWeek(), isFalse); if (today.weekday > 7) { expect(today.isWithInNextWeek(), isTrue); @@ -324,14 +323,6 @@ void main() { expect(today.add(day).isYesterday(), isFalse); }); - test( - 'notificationDisplayDate should return the formatted date for notification display', - () { - expect(fixedDate2.notificationDisplayDate(), '04 May'); - expect(fixedDate1.subtract(day).notificationDisplayDate(), '03 May'); - expect(fixedDate1.add(day).notificationDisplayDate(), '05 May'); - }); - test('tomorrow should return the date of tomorrow', () { expect(today.tomorrow().weekday, tomorrow.weekday); expect(today.tomorrow().day, tomorrow.day); diff --git a/mobile/test/utils/extensions_test.dart b/mobile/test/utils/extensions_test.dart deleted file mode 100644 index 6eb4157130..0000000000 --- a/mobile/test/utils/extensions_test.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:app/models/models.dart'; -import 'package:app/utils/utils.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter_test/flutter_test.dart'; - -class TestModel extends Equatable { - const TestModel({ - required this.testDateTime, - required this.testInt, - }); - - final int testInt; - final DateTime testDateTime; - - @override - List get props => [testDateTime.day]; -} - -void main() { - group('AppStoreVersion', () { - test('store version should be greater than user version', () { - const String userAppVersion = '1.0.0'; - - AppStoreVersion appStoreVersion = AppStoreVersion.fromJson({ - 'version': '1.0.1', - 'url': 'https://airqo.net', - }); - expect(appStoreVersion.compareVersion(userAppVersion), 1); - - appStoreVersion = AppStoreVersion.fromJson({ - 'version': '1.1.0', - 'url': 'https://airqo.net', - }); - expect(appStoreVersion.compareVersion(userAppVersion), 1); - - appStoreVersion = AppStoreVersion.fromJson({ - 'version': '2.0.0', - 'url': 'https://airqo.net', - }); - expect(appStoreVersion.compareVersion(userAppVersion), 1); - }); - - test('store version should be lesser than user version', () { - final AppStoreVersion appStoreVersion = AppStoreVersion.fromJson({ - 'version': '1.0.0', - 'url': 'https://airqo.net', - }); - - String userAppVersion = '1.0.1'; - expect(appStoreVersion.compareVersion(userAppVersion), -1); - - userAppVersion = '1.1.0'; - expect(appStoreVersion.compareVersion(userAppVersion), -1); - - userAppVersion = '2.0.0'; - expect(appStoreVersion.compareVersion(userAppVersion), -1); - }); - - test('store version should be equal to user version', () { - final AppStoreVersion appStoreVersion = AppStoreVersion.fromJson({ - 'version': '1.0.0', - 'url': 'https://airqo.net', - }); - - const String userAppVersion = '1.0.0'; - expect(appStoreVersion.compareVersion(userAppVersion), 0); - }); - }); -}