diff --git a/lib/app.dart b/lib/app.dart index f57c96cb4..40ab6f997 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -6,6 +6,7 @@ import 'package:coffeecard/features/authentication/presentation/cubits/authentic import 'package:coffeecard/features/environment/presentation/cubit/environment_cubit.dart'; import 'package:coffeecard/features/product/presentation/cubit/product_cubit.dart'; import 'package:coffeecard/features/redirection/redirection_router.dart'; +import 'package:coffeecard/features/upgrader/presentation/cubit/upgrader_cubit.dart'; import 'package:coffeecard/features/user/presentation/cubit/user_cubit.dart'; import 'package:coffeecard/service_locator.dart'; import 'package:flutter/material.dart'; @@ -26,19 +27,22 @@ class App extends StatelessWidget { BlocProvider.value(value: sl()..getConfig()), BlocProvider(create: (_) => sl()), BlocProvider.value(value: sl()), + BlocProvider(create: (_) => sl()..load()), ], - child: MainRedirectionRouter( - navigatorKey: _navigatorKey, - child: MaterialApp( - title: Strings.appTitle, - theme: analogTheme, + child: ScaffoldMessenger( + child: MainRedirectionRouter( navigatorKey: _navigatorKey, - home: BlocBuilder( - builder: (_, state) { - return (state is EnvironmentError) - ? SplashErrorPage(errorMessage: state.message) - : const SplashLoadingPage(); - }, + child: MaterialApp( + title: Strings.appTitle, + theme: analogTheme, + navigatorKey: _navigatorKey, + home: BlocBuilder( + builder: (_, state) { + return (state is EnvironmentError) + ? SplashErrorPage(errorMessage: state.message) + : const SplashLoadingPage(); + }, + ), ), ), ), diff --git a/lib/core/api_uri_constants.dart b/lib/core/api_uri_constants.dart index 0cd0afa58..1ee664022 100644 --- a/lib/core/api_uri_constants.dart +++ b/lib/core/api_uri_constants.dart @@ -2,10 +2,19 @@ // ignore_for_file: avoid-global-state class ApiUriConstants { static Uri shiftyUrl = Uri.parse('https://analogio.dk/shiftplanning'); - static const String apiVersion = '1'; - static const String minAppVersion = '2.1.0'; static Uri analogIOGitHub = Uri.parse('https://github.com/AnalogIO/'); + static const String minAppVersion = '2.1.0'; + + // Upgrader + static const String androidId = 'dk.analog.digitalclipcard'; + static const String iOSBundle = 'DK.AnalogIO.DigitalCoffeeCard'; + + static const String playStoreUrl = + 'https://play.google.com/store/apps/details?id=$androidId'; + static const String appStoreUrl = + 'https://apps.apple.com/us/app/cafe-analog/id1148152818'; + // Mobilepay static Uri mobilepayAndroid = Uri.parse('market://details?id=dk.danskebank.mobilepay'); diff --git a/lib/core/external/external_url_launcher.dart b/lib/core/external/external_url_launcher.dart index 6dacf03d4..e23f839d0 100644 --- a/lib/core/external/external_url_launcher.dart +++ b/lib/core/external/external_url_launcher.dart @@ -11,8 +11,8 @@ class ExternalUrlLauncher { ); Future launchUrlExternalApplication( - Uri url, BuildContext context, + Uri url, ) async { if (await canLaunch(url)) { final _ = launch(url); diff --git a/lib/core/external/platform_service.dart b/lib/core/external/platform_service.dart new file mode 100644 index 000000000..2f9d7cceb --- /dev/null +++ b/lib/core/external/platform_service.dart @@ -0,0 +1,21 @@ +import 'dart:io'; + +import 'package:coffeecard/core/models/platform_type.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class PlatformService { + PlatformType platformType() { + if (Platform.isAndroid) { + return PlatformType.android; + } + + if (Platform.isIOS) { + return PlatformType.iOS; + } + + return PlatformType.unknown; + } + + Future currentVersion() async => + PackageInfo.fromPlatform().then((info) => info.version); +} diff --git a/lib/core/models/platform_type.dart b/lib/core/models/platform_type.dart new file mode 100644 index 000000000..8e4581422 --- /dev/null +++ b/lib/core/models/platform_type.dart @@ -0,0 +1,5 @@ +enum PlatformType { + android, + iOS, + unknown, +} diff --git a/lib/core/strings.dart b/lib/core/strings.dart index 5e165ddfe..33d439efb 100644 --- a/lib/core/strings.dart +++ b/lib/core/strings.dart @@ -381,6 +381,11 @@ abstract final class Strings { static String almost(String time) => 'almost $time'; static String moreThan(String time) => 'more than $time'; + // Upgrader + static const upgraderUpdateAvailable = 'An update is available, click '; + static const upgraderHere = 'here'; + static const upgraderToDownload = ' to download it'; + // Errors static const error = 'Error'; static const cantLaunchUrl = 'The app can not launch the Url'; diff --git a/lib/core/widgets/pages/home_page.dart b/lib/core/widgets/pages/home_page.dart index 27e194ec8..2f19b829a 100644 --- a/lib/core/widgets/pages/home_page.dart +++ b/lib/core/widgets/pages/home_page.dart @@ -14,6 +14,7 @@ import 'package:coffeecard/features/receipt/presentation/pages/receipts_page.dar import 'package:coffeecard/features/settings/presentation/pages/settings_page.dart'; import 'package:coffeecard/features/ticket/presentation/cubit/tickets_cubit.dart'; import 'package:coffeecard/features/ticket/presentation/pages/tickets_page.dart'; +import 'package:coffeecard/features/upgrader/presentation/widgets/upgrader.dart'; import 'package:coffeecard/service_locator.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -123,42 +124,44 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { - return Provider( - create: (BuildContext context) => widget.products, - child: MultiBlocProvider( - providers: [ - BlocProvider( - create: (_) => sl()..getTickets(), - ), - BlocProvider( - create: (_) => sl()..fetchReceipts(), - ), - BlocProvider( - create: (_) => sl()..loadLeaderboard(), - ), - BlocProvider( - create: (_) => sl()..getOpeninghours(), - ), - ], - child: PopScope( - onPopInvoked: (_) => onWillPop(), - child: Scaffold( - backgroundColor: AppColors.background, - body: LazyIndexedStack( - index: _currentPageIndex, - children: _bottomNavAppFlows, + return Upgrader( + child: Provider( + create: (BuildContext context) => widget.products, + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => sl()..getTickets(), + ), + BlocProvider( + create: (_) => sl()..fetchReceipts(), + ), + BlocProvider( + create: (_) => sl()..loadLeaderboard(), + ), + BlocProvider( + create: (_) => sl()..getOpeninghours(), ), - bottomNavigationBar: BottomNavigationBar( - items: _pages.map((p) => p.bottomNavigationBarItem).toList(), - currentIndex: _currentPageIndex, - onTap: onBottomNavTap, - type: BottomNavigationBarType.fixed, - backgroundColor: AppColors.primary, - selectedItemColor: AppColors.white, - unselectedItemColor: AppColors.white.withOpacity(0.5), - selectedFontSize: 12, - unselectedLabelStyle: AppTextStyle.bottomNavBarLabel, - selectedLabelStyle: AppTextStyle.bottomNavBarLabel, + ], + child: PopScope( + onPopInvoked: (_) => onWillPop(), + child: Scaffold( + backgroundColor: AppColors.background, + body: LazyIndexedStack( + index: _currentPageIndex, + children: _bottomNavAppFlows, + ), + bottomNavigationBar: BottomNavigationBar( + items: _pages.map((p) => p.bottomNavigationBarItem).toList(), + currentIndex: _currentPageIndex, + onTap: onBottomNavTap, + type: BottomNavigationBarType.fixed, + backgroundColor: AppColors.primary, + selectedItemColor: AppColors.white, + unselectedItemColor: AppColors.white.withOpacity(0.5), + selectedFontSize: 12, + unselectedLabelStyle: AppTextStyle.bottomNavBarLabel, + selectedLabelStyle: AppTextStyle.bottomNavBarLabel, + ), ), ), ), diff --git a/lib/core/widgets/upgrade_alert.dart b/lib/core/widgets/upgrade_alert.dart deleted file mode 100644 index 9c9b2e3f4..000000000 --- a/lib/core/widgets/upgrade_alert.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:upgrader/upgrader.dart' as upgrade; - -class UpgradeAlert extends StatelessWidget { - final Widget child; - const UpgradeAlert({required this.child}); - - @override - Widget build(BuildContext context) { - return upgrade.UpgradeAlert( - upgrader: upgrade.Upgrader( - showReleaseNotes: false, - dialogStyle: Platform.isIOS - ? upgrade.UpgradeDialogStyle.cupertino - : upgrade.UpgradeDialogStyle.material, - ), - child: child, - ); - } -} diff --git a/lib/features/contributor/presentation/pages/credits_page.dart b/lib/features/contributor/presentation/pages/credits_page.dart index a3ced5ea5..593fcfbe8 100644 --- a/lib/features/contributor/presentation/pages/credits_page.dart +++ b/lib/features/contributor/presentation/pages/credits_page.dart @@ -78,8 +78,8 @@ class CreditsPage extends StatelessWidget { name: Strings.github, onTap: () => sl().launchUrlExternalApplication( - ApiUriConstants.analogIOGitHub, context, + ApiUriConstants.analogIOGitHub, ), ), ], diff --git a/lib/features/contributor/presentation/widgets/contributor_card.dart b/lib/features/contributor/presentation/widgets/contributor_card.dart index f8a556aa6..4d2638d5d 100644 --- a/lib/features/contributor/presentation/widgets/contributor_card.dart +++ b/lib/features/contributor/presentation/widgets/contributor_card.dart @@ -17,8 +17,8 @@ class ContributorCard extends StatelessWidget { Widget build(BuildContext context) { return Tappable( onTap: () => sl().launchUrlExternalApplication( - Uri.parse(contributor.githubUrl), context, + Uri.parse(contributor.githubUrl), ), child: DecoratedBox( decoration: const BoxDecoration( diff --git a/lib/features/login/presentation/pages/login_page_base.dart b/lib/features/login/presentation/pages/login_page_base.dart index 6c1bb8d15..949f8a0b9 100644 --- a/lib/features/login/presentation/pages/login_page_base.dart +++ b/lib/features/login/presentation/pages/login_page_base.dart @@ -2,8 +2,8 @@ import 'package:coffeecard/core/styles/app_text_styles.dart'; import 'package:coffeecard/core/widgets/components/helpers/responsive.dart'; import 'package:coffeecard/core/widgets/components/scaffold.dart'; import 'package:coffeecard/core/widgets/images/analog_logo.dart'; -import 'package:coffeecard/core/widgets/upgrade_alert.dart'; import 'package:coffeecard/features/login/presentation/widgets/login_input_hint.dart'; +import 'package:coffeecard/features/upgrader/presentation/widgets/upgrader.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; @@ -28,10 +28,10 @@ class LoginPageBase extends StatelessWidget { @override Widget build(BuildContext context) { - return UpgradeAlert( - child: AppScaffold.withoutTitle( - resizeToAvoidBottomInset: resizeToAvoidBottomInset, - body: Column( + return AppScaffold.withoutTitle( + resizeToAvoidBottomInset: resizeToAvoidBottomInset, + body: Upgrader( + child: Column( children: [ Expanded( child: Column( diff --git a/lib/features/purchase/data/repositories/mobilepay_service.dart b/lib/features/purchase/data/repositories/mobilepay_service.dart index 5b3a94172..1bb465cbd 100644 --- a/lib/features/purchase/data/repositories/mobilepay_service.dart +++ b/lib/features/purchase/data/repositories/mobilepay_service.dart @@ -1,9 +1,9 @@ -import 'dart:io'; - import 'package:coffeecard/core/api_uri_constants.dart'; import 'package:coffeecard/core/errors/failures.dart'; import 'package:coffeecard/core/extensions/either_extensions.dart'; import 'package:coffeecard/core/external/external_url_launcher.dart'; +import 'package:coffeecard/core/external/platform_service.dart'; +import 'package:coffeecard/core/models/platform_type.dart'; import 'package:coffeecard/features/purchase/data/repositories/payment_handler.dart'; import 'package:coffeecard/features/purchase/domain/entities/payment.dart'; import 'package:coffeecard/features/purchase/domain/entities/payment_status.dart'; @@ -56,8 +56,8 @@ class MobilePayService extends PaymentHandler { // MobilePay not installed, send user to appstore if (buildContext.mounted) { await externalUrlLauncher.launchUrlExternalApplication( - url, buildContext, + url, ); } @@ -68,12 +68,12 @@ class MobilePayService extends PaymentHandler { } Uri _getAppStoreUri() { - if (Platform.isAndroid) { - return ApiUriConstants.mobilepayAndroid; - } else if (Platform.isIOS) { - return ApiUriConstants.mobilepayIOS; - } else { - throw UnsupportedError('Unsupported platform'); - } + final platformService = PlatformService(); + + return switch (platformService.platformType()) { + PlatformType.android => ApiUriConstants.mobilepayAndroid, + PlatformType.iOS => ApiUriConstants.mobilepayIOS, + PlatformType.unknown => throw UnsupportedError('Unsupported platform'), + }; } } diff --git a/lib/features/settings/presentation/widgets/sections/about_section.dart b/lib/features/settings/presentation/widgets/sections/about_section.dart index 22391b0ad..0f8f1db68 100644 --- a/lib/features/settings/presentation/widgets/sections/about_section.dart +++ b/lib/features/settings/presentation/widgets/sections/about_section.dart @@ -20,15 +20,15 @@ class AboutSection extends StatelessWidget { void privacyPolicyTapCallback(BuildContext context) { sl().launchUrlExternalApplication( - ApiUriConstants.privacyPolicyUri, context, + ApiUriConstants.privacyPolicyUri, ); } void provideFeedbackTapCallback(BuildContext context) { sl().launchUrlExternalApplication( - ApiUriConstants.feedbackFormUri, context, + ApiUriConstants.feedbackFormUri, ); } diff --git a/lib/features/ticket/presentation/pages/tickets_page.dart b/lib/features/ticket/presentation/pages/tickets_page.dart index ee1dd7125..25d08691c 100644 --- a/lib/features/ticket/presentation/pages/tickets_page.dart +++ b/lib/features/ticket/presentation/pages/tickets_page.dart @@ -1,7 +1,6 @@ import 'package:coffeecard/core/strings.dart'; import 'package:coffeecard/core/widgets/components/barista_perks_section.dart'; import 'package:coffeecard/core/widgets/components/scaffold.dart'; -import 'package:coffeecard/core/widgets/upgrade_alert.dart'; import 'package:coffeecard/features/product/domain/entities/purchasable_products.dart'; import 'package:coffeecard/features/ticket/presentation/widgets/shop_section.dart'; import 'package:coffeecard/features/ticket/presentation/widgets/tickets_section.dart'; @@ -25,26 +24,24 @@ class TicketsPage extends StatelessWidget { final user = (context.read().state as UserLoaded).user; final perksAvailable = context.read().perks.isNotEmpty; - return UpgradeAlert( - child: AppScaffold.withTitle( - title: Strings.ticketsPageTitle, - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: ListView( - controller: scrollController, - shrinkWrap: true, - padding: const EdgeInsets.all(16.0), - children: [ - const TicketSection(), - if (perksAvailable) BaristaPerksSection(userRole: user.role), - const ShopSection(), - ], - ), + return AppScaffold.withTitle( + title: Strings.ticketsPageTitle, + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ListView( + controller: scrollController, + shrinkWrap: true, + padding: const EdgeInsets.all(16.0), + children: [ + const TicketSection(), + if (perksAvailable) BaristaPerksSection(userRole: user.role), + const ShopSection(), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/features/upgrader/data/datasources/app_store_api.dart b/lib/features/upgrader/data/datasources/app_store_api.dart new file mode 100644 index 000000000..a031a30b0 --- /dev/null +++ b/lib/features/upgrader/data/datasources/app_store_api.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:fpdart/fpdart.dart'; +import 'package:http/http.dart'; +import 'package:logger/logger.dart'; + +class AppStoreAPI { + final Client client; + final Logger logger; + + static const prefixUrl = 'https://itunes.apple.com/lookup'; + + AppStoreAPI({ + required this.client, + required this.logger, + }); + + Future> lookupVersion(String id) async { + final response = await _lookupByBundleId(id); + + return response.match( + () => none(), + (response) => version(response), + ); + } + + Future> _lookupByBundleId( + String bundleId, { + String country = 'EU', //FIXME: validate this works + }) async { + if (bundleId.isEmpty) { + return none(); + } + + final url = _lookupURLByBundleId( + bundleId, + country: country, + ); + + return url.match( + () => none(), + (url) async { + try { + final response = await client.get(Uri.parse(url)); + + logger.d('upgrader response statusCode: ${response.statusCode}'); + + return _decodeResults(response.body); + } catch (e) { + logger.d('upgrader lookupByBundleId exception: $e'); + + return none(); + } + }, + ); + } + + Option _lookupURLByBundleId( + String bundleId, { + String country = 'US', + }) { + if (bundleId.isEmpty) { + return none(); + } + + return _lookupURLByQSP( + {'bundleId': bundleId, 'country': country.toUpperCase()}, + ); + } + + Option _lookupURLByQSP(Map qsp) { + if (qsp.isEmpty) { + return none(); + } + + final parameters = []; + qsp.forEach((key, value) => parameters.add('$key=$value')); + + parameters.add('_cb=${DateTime.now().microsecondsSinceEpoch}'); + + final allParameters = parameters.join('&'); + + return some('$prefixUrl?$allParameters'); + } + + Option _decodeResults(String jsonResponse) { + if (jsonResponse.isEmpty) { + return none(); + } + + final decodedResults = json.decode(jsonResponse); + + if (decodedResults is! Map) { + return none(); + } + + return some(decodedResults); + } + + Option version(Map response) { + try { + // ignore: avoid_dynamic_calls + final version = response['results'][0]['version'] as String; + return some(version); + } catch (e) { + logger.d('upgrader version exception: $e'); + } + + return none(); + } +} diff --git a/lib/features/upgrader/data/datasources/play_store_api.dart b/lib/features/upgrader/data/datasources/play_store_api.dart new file mode 100644 index 000000000..bd93f46fd --- /dev/null +++ b/lib/features/upgrader/data/datasources/play_store_api.dart @@ -0,0 +1,165 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:html/dom.dart'; +import 'package:html/parser.dart' show parse; +import 'package:http/http.dart'; +import 'package:logger/logger.dart'; +import 'package:version/version.dart'; + +class PlayStoreAPI { + final Client client; + final Logger logger; + + static const prefixUrl = 'play.google.com'; + + PlayStoreAPI({ + required this.client, + required this.logger, + }); + + Future> lookupVersion(String id) async { + final document = await _lookupById(id); + + return document.match( + () => none(), + (document) => _version(document), + ); + } + + Future> _lookupById( + String id, { + String? country = 'US', //FIXME: validate this works + String? language = 'en', + }) async { + if (id.isEmpty) return none(); + + final url = _lookupURLById( + id, + country: country, + language: language, + ); + + logger.d('upgrader lookupById url: $url'); + + return url.match( + () => none(), + (url) async { + try { + final response = await client.get(Uri.parse(url)); + if (response.statusCode < 200 || response.statusCode >= 300) { + logger.d( + "upgrader Can't find an app in the Play Store with the id: $id", + ); + + return none(); + } + + final decodedResults = _decodeResults(response.body); + + return decodedResults; + } on Exception catch (e) { + logger.d('upgrader lookupById exception: $e'); + return none(); + } + }, + ); + } + + Option _lookupURLById( + String id, { + String? country = 'US', + String? language = 'en', + }) { + if (id.isEmpty) { + return none(); + } + + final Map parameters = {'id': id}; + if (country != null && country.isNotEmpty) { + parameters['gl'] = country; + } + if (language != null && language.isNotEmpty) { + parameters['hl'] = language; + } + + parameters['_cb'] = DateTime.now().microsecondsSinceEpoch.toString(); + + final url = + Uri.https(prefixUrl, '/store/apps/details', parameters).toString(); + + return some(url); + } + + Option _decodeResults(String jsonResponse) { + if (jsonResponse.isEmpty) { + return none(); + } + + final decodedResults = parse(jsonResponse); + return some(decodedResults); + } + + Option _version(Document response) { + try { + final additionalInfoElements = response.getElementsByClassName('hAyfc'); + final versionElement = additionalInfoElements.firstWhere( + (elm) => elm.querySelector('.BgcNfc')!.text == 'Current Version', + ); + final storeVersion = versionElement.querySelector('.htlgb')!.text; + // storeVersion might be: 'Varies with device', which is not a valid version. + final version = Version.parse(storeVersion).toString(); + return some(version); + } catch (e) { + return _redesignedVersion(response); + } + } + + Option _redesignedVersion(Document response) { + try { + const patternName = ',"name":"'; + const patternVersion = ',[[["'; + const patternCallback = 'AF_initDataCallback'; + const patternEndOfString = '"'; + + final scripts = response.getElementsByTagName('script'); + final infoElements = + scripts.where((element) => element.text.contains(patternName)); + final additionalInfoElements = + scripts.where((element) => element.text.contains(patternCallback)); + final additionalInfoElementsFiltered = additionalInfoElements + .where((element) => element.text.contains(patternVersion)); + + final nameElement = infoElements.first.text; + final storeNameStartIndex = + nameElement.indexOf(patternName) + patternName.length; + final storeNameEndIndex = storeNameStartIndex + + nameElement + .substring(storeNameStartIndex) + .indexOf(patternEndOfString); + final storeName = + nameElement.substring(storeNameStartIndex, storeNameEndIndex); + + final versionElement = additionalInfoElementsFiltered + .where((element) => element.text.contains('"$storeName"')) + .first + .text; + final storeVersionStartIndex = + versionElement.lastIndexOf(patternVersion) + patternVersion.length; + final storeVersionEndIndex = storeVersionStartIndex + + versionElement + .substring(storeVersionStartIndex) + .indexOf(patternEndOfString); + final storeVersion = versionElement.substring( + storeVersionStartIndex, + storeVersionEndIndex, + ); + + // storeVersion might be: 'Varies with device', which is not a valid version. + final version = Version.parse(storeVersion).toString(); + return some(version); + } catch (e) { + logger.d('upgrader PlayStoreResults.redesignedVersion exception: $e'); + } + + return none(); + } +} diff --git a/lib/features/upgrader/domain/usecases/can_upgrade.dart b/lib/features/upgrader/domain/usecases/can_upgrade.dart new file mode 100644 index 000000000..60de34eea --- /dev/null +++ b/lib/features/upgrader/domain/usecases/can_upgrade.dart @@ -0,0 +1,35 @@ +import 'package:coffeecard/core/api_uri_constants.dart'; +import 'package:coffeecard/core/external/platform_service.dart'; +import 'package:coffeecard/core/models/platform_type.dart'; +import 'package:coffeecard/features/upgrader/data/datasources/app_store_api.dart'; +import 'package:coffeecard/features/upgrader/data/datasources/play_store_api.dart'; +import 'package:fpdart/fpdart.dart'; + +class CanUpgrade { + final AppStoreAPI appStoreAPI; + final PlayStoreAPI playStoreAPI; + final PlatformService platformService; + + CanUpgrade({ + required this.appStoreAPI, + required this.playStoreAPI, + required this.platformService, + }); + + Future call() async { + final currentVersion = await platformService.currentVersion(); + + final version = switch (platformService.platformType()) { + PlatformType.android => + await playStoreAPI.lookupVersion(ApiUriConstants.androidId), + PlatformType.iOS => + await appStoreAPI.lookupVersion(ApiUriConstants.iOSBundle), + PlatformType.unknown => none(), + }; + + return version.match( + () => false, + (version) => version != currentVersion, + ); + } +} diff --git a/lib/features/upgrader/presentation/cubit/upgrader_cubit.dart b/lib/features/upgrader/presentation/cubit/upgrader_cubit.dart new file mode 100644 index 000000000..fdb81dd16 --- /dev/null +++ b/lib/features/upgrader/presentation/cubit/upgrader_cubit.dart @@ -0,0 +1,17 @@ +import 'package:bloc/bloc.dart'; +import 'package:coffeecard/features/upgrader/domain/usecases/can_upgrade.dart'; +import 'package:equatable/equatable.dart'; + +part 'upgrader_state.dart'; + +class UpgraderCubit extends Cubit { + final CanUpgrade canUpgrade; + + UpgraderCubit({required this.canUpgrade}) : super(const UpgraderLoading()); + + Future load() async { + final upgradeAvailable = await canUpgrade(); + + emit(UpgraderLoaded(canUpgrade: upgradeAvailable)); + } +} diff --git a/lib/features/upgrader/presentation/cubit/upgrader_state.dart b/lib/features/upgrader/presentation/cubit/upgrader_state.dart new file mode 100644 index 000000000..3aedcb134 --- /dev/null +++ b/lib/features/upgrader/presentation/cubit/upgrader_state.dart @@ -0,0 +1,21 @@ +part of 'upgrader_cubit.dart'; + +sealed class UpgraderState extends Equatable { + const UpgraderState(); + + @override + List get props => []; +} + +final class UpgraderLoading extends UpgraderState { + const UpgraderLoading(); +} + +final class UpgraderLoaded extends UpgraderState { + final bool canUpgrade; + + const UpgraderLoaded({required this.canUpgrade}); + + @override + List get props => [canUpgrade]; +} diff --git a/lib/features/upgrader/presentation/widgets/upgrader.dart b/lib/features/upgrader/presentation/widgets/upgrader.dart new file mode 100644 index 000000000..6d301f30a --- /dev/null +++ b/lib/features/upgrader/presentation/widgets/upgrader.dart @@ -0,0 +1,31 @@ +import 'package:coffeecard/features/environment/domain/entities/environment.dart'; +import 'package:coffeecard/features/environment/presentation/cubit/environment_cubit.dart'; +import 'package:coffeecard/features/upgrader/presentation/cubit/upgrader_cubit.dart'; +import 'package:coffeecard/features/upgrader/presentation/widgets/upgrader_snackbar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class Upgrader extends StatelessWidget { + final Widget child; + + const Upgrader({required this.child}); + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + final currentEnvironment = context.read().state; + + if (currentEnvironment is EnvironmentLoaded && + !currentEnvironment.env.isProduction) { + return; + } + + if (state is UpgraderLoaded && state.canUpgrade) { + ScaffoldMessenger.of(context).showSnackBar(UpgraderSnackbar(context)); + } + }, + child: child, + ); + } +} diff --git a/lib/features/upgrader/presentation/widgets/upgrader_snackbar.dart b/lib/features/upgrader/presentation/widgets/upgrader_snackbar.dart new file mode 100644 index 000000000..53f00a603 --- /dev/null +++ b/lib/features/upgrader/presentation/widgets/upgrader_snackbar.dart @@ -0,0 +1,59 @@ +import 'package:coffeecard/core/api_uri_constants.dart'; +import 'package:coffeecard/core/external/external_url_launcher.dart'; +import 'package:coffeecard/core/external/platform_service.dart'; +import 'package:coffeecard/core/models/platform_type.dart'; +import 'package:coffeecard/core/strings.dart'; +import 'package:coffeecard/core/styles/app_colors.dart'; +import 'package:coffeecard/core/styles/app_text_styles.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:fpdart/fpdart.dart'; + +class UpgraderSnackbar extends SnackBar { + UpgraderSnackbar(BuildContext context) + : super( + content: RichText( + text: TextSpan( + style: AppTextStyle.loginExplainer, + children: [ + const TextSpan(text: Strings.upgraderUpdateAvailable), + TextSpan( + text: Strings.upgraderHere, + style: AppTextStyle.loginExplainer + .copyWith(decoration: TextDecoration.underline), + recognizer: TapGestureRecognizer() + ..onTap = () => handleClick(context), + ), + const TextSpan(text: Strings.upgraderToDownload), + ], + ), + ), + dismissDirection: DismissDirection.up, + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.only( + bottom: MediaQuery.of(context).size.height - 200, + left: 10, + right: 10, + ), + backgroundColor: AppColors.secondary, + duration: const Duration(seconds: 10), + showCloseIcon: true, + ); +} + +Future handleClick(BuildContext context) async { + final platformService = PlatformService(); + + final Option uri = switch (platformService.platformType()) { + PlatformType.android => some(ApiUriConstants.playStoreUrl), + PlatformType.iOS => some(ApiUriConstants.appStoreUrl), + PlatformType.unknown => none(), + }; + + uri.map((uri) { + ExternalUrlLauncher().launchUrlExternalApplication( + context, + Uri.parse(uri), + ); + }); +} diff --git a/lib/service_locator.dart b/lib/service_locator.dart index 38ab4e694..aaff25c4f 100644 --- a/lib/service_locator.dart +++ b/lib/service_locator.dart @@ -2,6 +2,7 @@ import 'package:chopper/chopper.dart'; import 'package:coffeecard/core/api_uri_constants.dart'; import 'package:coffeecard/core/external/date_service.dart'; import 'package:coffeecard/core/external/external_url_launcher.dart'; +import 'package:coffeecard/core/external/platform_service.dart'; import 'package:coffeecard/core/external/screen_brightness.dart'; import 'package:coffeecard/core/firebase_analytics_event_logging.dart'; import 'package:coffeecard/core/ignore_value.dart'; @@ -50,6 +51,10 @@ import 'package:coffeecard/features/ticket/data/datasources/ticket_remote_data_s import 'package:coffeecard/features/ticket/domain/usecases/consume_ticket.dart'; import 'package:coffeecard/features/ticket/domain/usecases/load_tickets.dart'; import 'package:coffeecard/features/ticket/presentation/cubit/tickets_cubit.dart'; +import 'package:coffeecard/features/upgrader/data/datasources/app_store_api.dart'; +import 'package:coffeecard/features/upgrader/data/datasources/play_store_api.dart'; +import 'package:coffeecard/features/upgrader/domain/usecases/can_upgrade.dart'; +import 'package:coffeecard/features/upgrader/presentation/cubit/upgrader_cubit.dart'; import 'package:coffeecard/features/user/data/datasources/user_remote_data_source.dart'; import 'package:coffeecard/features/user/domain/usecases/get_user.dart'; import 'package:coffeecard/features/user/domain/usecases/request_account_deletion.dart'; @@ -66,6 +71,7 @@ import 'package:coffeecard/generated/api/shiftplanning_api.swagger.dart' import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:get_it/get_it.dart'; +import 'package:http/http.dart'; import 'package:logger/logger.dart'; final GetIt sl = GetIt.instance; @@ -89,6 +95,8 @@ void initExternal() { ignoreValue(sl.registerFactory(() => DateService())); ignoreValue(sl.registerFactory(() => ScreenBrightness())); ignoreValue(sl.registerLazySingleton(() => ExternalUrlLauncher())); + ignoreValue(sl.registerLazySingleton(() => PlatformService())); + ignoreValue(sl.registerFactory(() => Client())); ignoreValue( sl.registerSingleton( @@ -105,6 +113,7 @@ void initExternal() { } void initFeatures() { + initUpgrader(); initAuthentication(); initOpeningHours(); initOccupation(); @@ -147,6 +156,26 @@ void initAuthentication() { ); } +void initUpgrader() { + // bloc + sl.registerFactory(() => UpgraderCubit(canUpgrade: sl())); + + // use case + sl.registerFactory( + () => CanUpgrade( + appStoreAPI: sl(), + playStoreAPI: sl(), + platformService: sl(), + ), + ); + + // data source + sl.registerLazySingleton(() => AppStoreAPI(client: sl(), logger: sl())); + sl.registerLazySingleton( + () => PlayStoreAPI(client: sl(), logger: sl()), + ); +} + void initOpeningHours() { // bloc sl.registerFactory( diff --git a/pubspec.lock b/pubspec.lock index 4e3355b02..260465421 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -273,22 +273,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" - device_info_plus: - dependency: transitive - description: - name: device_info_plus - sha256: "7035152271ff67b072a211152846e9f1259cf1be41e34cd3e0b5463d2d6b8419" - url: "https://pub.dev" - source: hosted - version: "9.1.0" - device_info_plus_platform_interface: - dependency: transitive - description: - name: device_info_plus_platform_interface - sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 - url: "https://pub.dev" - source: hosted - version: "7.0.0" diff_match_patch: dependency: transitive description: @@ -601,7 +585,7 @@ packages: source: hosted version: "2.3.1" html: - dependency: transitive + dependency: "direct main" description: name: html sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" @@ -784,14 +768,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - os_detect: - dependency: transitive - description: - name: os_detect - sha256: faf3bcf39515e64da8ff76b2f2805b20a6ff47ae515393e535f8579ff91d6b7f - url: "https://pub.dev" - source: hosted - version: "2.0.1" package_config: dependency: transitive description: @@ -801,13 +777,13 @@ packages: source: hosted version: "2.1.0" package_info_plus: - dependency: transitive + dependency: "direct main" description: name: package_info_plus - sha256: "7e76fad405b3e4016cd39d08f455a4eb5199723cf594cd1b8916d47140d93017" + sha256: "88bc797f44a94814f2213db1c9bd5badebafdfb8290ca9f78d4b9ee2a3db4d79" url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "5.0.1" package_info_plus_platform_interface: dependency: transitive description: @@ -1024,62 +1000,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" - shared_preferences: - dependency: transitive - description: - name: shared_preferences - sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" - url: "https://pub.dev" - source: hosted - version: "2.2.2" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" - url: "https://pub.dev" - source: hosted - version: "2.2.1" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" - url: "https://pub.dev" - source: hosted - version: "2.3.4" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a - url: "https://pub.dev" - source: hosted - version: "2.3.1" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf - url: "https://pub.dev" - source: hosted - version: "2.2.1" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" - url: "https://pub.dev" - source: hosted - version: "2.3.2" shelf: dependency: transitive description: @@ -1293,14 +1213,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" - upgrader: - dependency: "direct main" - description: - name: upgrader - sha256: "889c1ece7af143df32e8ee2126f2ef17b2ab6bb7ed8fc3b1b022d7faa4fdab20" - url: "https://pub.dev" - source: hosted - version: "8.2.0" url_launcher: dependency: "direct main" description: @@ -1382,7 +1294,7 @@ packages: source: hosted version: "2.1.4" version: - dependency: transitive + dependency: "direct main" description: name: version sha256: "3d4140128e6ea10d83da32fef2fa4003fccbf6852217bb854845802f04191f94" @@ -1437,14 +1349,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.0" - win32_registry: - dependency: transitive - description: - name: win32_registry - sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" - url: "https://pub.dev" - source: hosted - version: "1.1.2" xdg_directories: dependency: transitive description: @@ -1470,5 +1374,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.2.0-194.0.dev <4.0.0" + dart: ">=3.2.0 <4.0.0" flutter: ">=3.16.0" diff --git a/pubspec.yaml b/pubspec.yaml index e092be19e..23b5cfc46 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,12 +67,14 @@ dependencies: # functional programming thingies fpdart: 1.1.0 - # Upgrade notifier - upgrader: 8.2.0 - # env envied: 0.5.1 + # upgrader + html: 0.15.4 + version: 3.0.2 + package_info_plus: 5.0.1 + dev_dependencies: flutter_test: sdk: flutter diff --git a/test/features/upgrader/domain/usecases/can_upgrade_test.dart b/test/features/upgrader/domain/usecases/can_upgrade_test.dart new file mode 100644 index 000000000..f68128df3 --- /dev/null +++ b/test/features/upgrader/domain/usecases/can_upgrade_test.dart @@ -0,0 +1,113 @@ +import 'package:coffeecard/core/external/platform_service.dart'; +import 'package:coffeecard/core/models/platform_type.dart'; +import 'package:coffeecard/features/upgrader/data/datasources/app_store_api.dart'; +import 'package:coffeecard/features/upgrader/data/datasources/play_store_api.dart'; +import 'package:coffeecard/features/upgrader/domain/usecases/can_upgrade.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'can_upgrade_test.mocks.dart'; + +@GenerateMocks([AppStoreAPI, PlayStoreAPI, PlatformService]) +void main() { + late CanUpgrade usecase; + late MockAppStoreAPI appStoreAPI; + late MockPlayStoreAPI playStoreAPI; + late MockPlatformService platformService; + + setUp(() { + appStoreAPI = MockAppStoreAPI(); + playStoreAPI = MockPlayStoreAPI(); + platformService = MockPlatformService(); + usecase = CanUpgrade( + appStoreAPI: appStoreAPI, + playStoreAPI: playStoreAPI, + platformService: platformService, + ); + + provideDummy>(none()); + }); + + group('call', () { + test('should return false if device is not Android or iOS', () async { + // arrange + when(platformService.currentVersion()).thenAnswer((_) async => 'version'); + when(platformService.platformType()).thenReturn(PlatformType.unknown); + + // act + final actual = await usecase(); + + // assert + expect(actual, false); + }); + + test('should return true if device is Android and version mismatch', + () async { + // arrange + when(platformService.currentVersion()) + .thenAnswer((_) async => 'device_version'); + when(platformService.platformType()).thenReturn(PlatformType.android); + when(playStoreAPI.lookupVersion(any)) + .thenAnswer((_) async => some('external_version')); + + // act + final actual = await usecase(); + + // assert + expect(actual, true); + }); + + test('should return false if device is Android and version match', + () async { + // arrange + const version = 'device_version'; + + when(platformService.currentVersion()).thenAnswer((_) async => version); + when(platformService.platformType()).thenReturn(PlatformType.android); + + when(playStoreAPI.lookupVersion(any)) + .thenAnswer((_) async => some(version)); + + // act + final actual = await usecase(); + + // assert + expect(actual, false); + }); + + test('should return true if device is iOS and version mismatch', () async { + // arrange + when(platformService.currentVersion()) + .thenAnswer((_) async => 'device_version'); + when(platformService.platformType()).thenReturn(PlatformType.iOS); + + when(appStoreAPI.lookupVersion(any)) + .thenAnswer((_) async => some('external_version')); + + // act + final actual = await usecase(); + + // assert + expect(actual, true); + }); + + test('should return false if device is iOS and version match', () async { + // arrange + const version = 'device_version'; + + when(platformService.currentVersion()).thenAnswer((_) async => version); + when(platformService.platformType()).thenReturn(PlatformType.iOS); + + when(appStoreAPI.lookupVersion(any)) + .thenAnswer((_) async => some(version)); + + // act + final actual = await usecase(); + + // assert + expect(actual, false); + }); + }); +} diff --git a/test/features/upgrader/presentation/cubit/upgrader_cubit_test.dart b/test/features/upgrader/presentation/cubit/upgrader_cubit_test.dart new file mode 100644 index 000000000..7692d36c0 --- /dev/null +++ b/test/features/upgrader/presentation/cubit/upgrader_cubit_test.dart @@ -0,0 +1,41 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:coffeecard/features/upgrader/domain/usecases/can_upgrade.dart'; +import 'package:coffeecard/features/upgrader/presentation/cubit/upgrader_cubit.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'upgrader_cubit_test.mocks.dart'; + +@GenerateMocks([CanUpgrade]) +void main() { + late UpgraderCubit cubit; + late MockCanUpgrade canUpgrade; + + setUp(() { + canUpgrade = MockCanUpgrade(); + cubit = UpgraderCubit(canUpgrade: canUpgrade); + }); + + group('load', () { + test('initial state should be [Loading]', () { + expect(cubit.state, const UpgraderLoading()); + }); + + blocTest( + 'should emit [Loaded] when use case fails', + build: () => cubit, + setUp: () => when(canUpgrade()).thenAnswer((_) async => false), + act: (_) => cubit.load(), + expect: () => [const UpgraderLoaded(canUpgrade: false)], + ); + + blocTest( + 'should emit [Loaded] when use case succeeds', + build: () => cubit, + setUp: () => when(canUpgrade()).thenAnswer((_) async => true), + act: (_) => cubit.load(), + expect: () => [const UpgraderLoaded(canUpgrade: true)], + ); + }); +}