diff --git a/lib/src/extensions/num_extension.dart b/lib/src/extensions/num_extension.dart index a2329b21..86403e6f 100644 --- a/lib/src/extensions/num_extension.dart +++ b/lib/src/extensions/num_extension.dart @@ -1,3 +1,9 @@ +import 'package:intl/intl.dart'; + extension NumExt on num { String get formatted => (this == toInt() ? toInt() : this).toString(); + + String toCurrency(String currency) => NumberFormat.simpleCurrency(name: currency).format(this); + + String toPercentage() => '$this%'; } diff --git a/lib/src/mixins/ads_mixin.dart b/lib/src/mixins/ads_mixin.dart index 8f137dd4..7db6272e 100644 --- a/lib/src/mixins/ads_mixin.dart +++ b/lib/src/mixins/ads_mixin.dart @@ -1,19 +1,41 @@ +import 'package:azure_devops/src/models/amazon/amazon_item.dart'; import 'package:azure_devops/src/services/ads_service.dart'; import 'package:azure_devops/src/widgets/ad_widget.dart'; import 'package:flutter/widgets.dart'; mixin AdsMixin { List nativeAds = []; + List amazonAds = []; + var _hasAmazonAds = false; - /// Load new native ads and map them to [AdWithKey] objects with a new global key to force refresh the UI. Future getNewNativeAds(AdsService ads) async { + _hasAmazonAds = ads.hasAmazonAds; + + if (_hasAmazonAds) { + final items = await _getNewAmazonAds(ads); + if (items.isEmpty) { + _hasAmazonAds = false; + await _getNewAdmobAds(ads); + } + } else { + await _getNewAdmobAds(ads); + } + } + + /// Load new native ads and map them to [AdWithKey] objects with a new global key to force refresh the UI. + Future _getNewAdmobAds(AdsService ads) async { final newAds = await ads.getNewNativeAds(); nativeAds = newAds.map((ad) => (ad: ad, key: GlobalKey())).toList(); } + Future> _getNewAmazonAds(AdsService ads) async { + final newAmazonAds = await ads.getNewAmazonAds(); + return amazonAds = newAmazonAds.toList(); + } + /// Whether to show a native ad at the given [index] inside [items] list. bool shouldShowNativeAd(List items, T item, int index) => - items.indexOf(item) % 5 == 4 && item != items.first && index < nativeAds.length; + items.indexOf(item) % 5 == 4 && item != items.first && index < (_hasAmazonAds ? amazonAds : nativeAds).length; Future showInterstitialAd(AdsService ads, {VoidCallback? onDismiss}) async { await ads.showInterstitialAd(onDismiss: onDismiss); diff --git a/lib/src/models/amazon/amazon_item.dart b/lib/src/models/amazon/amazon_item.dart new file mode 100644 index 00000000..a2aa1d2d --- /dev/null +++ b/lib/src/models/amazon/amazon_item.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +class AmazonItem { + AmazonItem({ + required this.id, + required this.itemUrl, + required this.title, + required this.imageUrl, + required this.isPrime, + required this.originalPrice, + required this.discount, + required this.discountedPrice, + required this.currency, + }); + + factory AmazonItem.fromJson(Map json) => AmazonItem( + id: json['id'] as String? ?? '', + itemUrl: json['itemUrl'] as String? ?? '', + title: json['title'] as String? ?? '', + imageUrl: json['imageUrl'] as String? ?? '', + isPrime: json['isPrime'] as bool? ?? false, + originalPrice: Price.fromJson(json['originalPrice'] as Map? ?? {}), + discount: json['discount'] == null ? null : Discount.fromJson(json['discount'] as Map? ?? {}), + discountedPrice: Price.fromJson(json['discountedPrice'] as Map? ?? {}), + currency: json['currency'] as String? ?? '', + ); + + static List listFromJson(String str) => List.from( + (json.decode(str) as List? ?? []).map((x) => AmazonItem.fromJson(x as Map? ?? {})), + ); + + final String id; + final String itemUrl; + final String title; + final String imageUrl; + final bool isPrime; + final Price originalPrice; + final Discount? discount; + final Price discountedPrice; + final String currency; + + @override + bool operator ==(covariant AmazonItem other) { + if (identical(this, other)) return true; + + return other.id == id; + } + + @override + int get hashCode { + return id.hashCode; + } +} + +class Discount { + Discount({ + required this.amount, + required this.percentage, + }); + + factory Discount.fromJson(Map json) => Discount( + amount: (json['amount'] as num?)?.toDouble() ?? 0, + percentage: json['percentage'] as int? ?? 0, + ); + + final double amount; + final int percentage; +} + +class Price { + Price({required this.amount}); + + factory Price.fromJson(Map json) => Price( + amount: (json['amount'] as num?)?.toDouble() ?? 0, + ); + + final double amount; +} diff --git a/lib/src/screens/commits/screen_commits.dart b/lib/src/screens/commits/screen_commits.dart index f635b364..bf88e671 100644 --- a/lib/src/screens/commits/screen_commits.dart +++ b/lib/src/screens/commits/screen_commits.dart @@ -73,8 +73,8 @@ class _CommitsScreen extends StatelessWidget { ); if (ctrl.shouldShowNativeAd(commits, c, adsIndex)) { - yield NativeAdWidget( - ad: ctrl.nativeAds[adsIndex++], + yield CustomAdWidget( + item: ctrl.ads.hasAmazonAds ? ctrl.amazonAds[adsIndex++] : ctrl.nativeAds[adsIndex++], ); } }, diff --git a/lib/src/screens/pipelines/screen_pipelines.dart b/lib/src/screens/pipelines/screen_pipelines.dart index d5fb085c..0696e7c1 100644 --- a/lib/src/screens/pipelines/screen_pipelines.dart +++ b/lib/src/screens/pipelines/screen_pipelines.dart @@ -104,8 +104,8 @@ class _PipelinesScreen extends StatelessWidget { ); if (ctrl.shouldShowNativeAd(pipelines, p, adsIndex)) { - yield NativeAdWidget( - ad: ctrl.nativeAds[adsIndex++], + yield CustomAdWidget( + item: ctrl.ads.hasAmazonAds ? ctrl.amazonAds[adsIndex++] : ctrl.nativeAds[adsIndex++], ); } }, diff --git a/lib/src/screens/profile/screen_profile.dart b/lib/src/screens/profile/screen_profile.dart index b554ab8c..b02db51c 100644 --- a/lib/src/screens/profile/screen_profile.dart +++ b/lib/src/screens/profile/screen_profile.dart @@ -159,8 +159,8 @@ class _ProfileScreen extends StatelessWidget { ); if (ctrl.shouldShowNativeAd(commits, c, adsIndex)) { - yield NativeAdWidget( - ad: ctrl.nativeAds[adsIndex++], + yield CustomAdWidget( + item: ctrl.ads.hasAmazonAds ? ctrl.amazonAds[adsIndex++] : ctrl.nativeAds[adsIndex++], ); } }, diff --git a/lib/src/screens/pull_requests/screen_pull_requests.dart b/lib/src/screens/pull_requests/screen_pull_requests.dart index 39df6e2f..68434ceb 100644 --- a/lib/src/screens/pull_requests/screen_pull_requests.dart +++ b/lib/src/screens/pull_requests/screen_pull_requests.dart @@ -93,8 +93,8 @@ class _PullRequestsScreen extends StatelessWidget { ); if (ctrl.shouldShowNativeAd(prs, pr, adsIndex)) { - yield NativeAdWidget( - ad: ctrl.nativeAds[adsIndex++], + yield CustomAdWidget( + item: ctrl.ads.hasAmazonAds ? ctrl.amazonAds[adsIndex++] : ctrl.nativeAds[adsIndex++], ); } }, diff --git a/lib/src/screens/work_items/screen_work_items.dart b/lib/src/screens/work_items/screen_work_items.dart index 1ec381a4..41c0c30a 100644 --- a/lib/src/screens/work_items/screen_work_items.dart +++ b/lib/src/screens/work_items/screen_work_items.dart @@ -152,8 +152,8 @@ class _WorkItemsScreen extends StatelessWidget { ); if (ctrl.shouldShowNativeAd(items, i, adsIndex)) { - yield NativeAdWidget( - ad: ctrl.nativeAds[adsIndex++], + yield CustomAdWidget( + item: ctrl.ads.hasAmazonAds ? ctrl.amazonAds[adsIndex++] : ctrl.nativeAds[adsIndex++], ); } }).toList(), diff --git a/lib/src/services/ads_service.dart b/lib/src/services/ads_service.dart index c99be295..d4084ecb 100644 --- a/lib/src/services/ads_service.dart +++ b/lib/src/services/ads_service.dart @@ -5,7 +5,9 @@ import 'dart:io'; import 'package:azure_devops/src/extensions/context_extension.dart'; import 'package:azure_devops/src/mixins/logger_mixin.dart'; +import 'package:azure_devops/src/models/amazon/amazon_item.dart'; import 'package:azure_devops/src/router/router.dart'; +import 'package:azure_devops/src/services/amazon_service.dart'; import 'package:flutter/material.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; @@ -15,11 +17,13 @@ const _androidNativeAdId = String.fromEnvironment('ADMOB_NATIVE_ADID_ANDROID'); const _iosNativeAdId = String.fromEnvironment('ADMOB_NATIVE_ADID_IOS'); abstract interface class AdsService { + bool get hasAmazonAds; Future init(); Future showInterstitialAd({VoidCallback? onDismiss}); void removeAds(); void reactivateAds(); Future> getNewNativeAds(); + Future> getNewAmazonAds(); } class AdsServiceImpl with AppLogger implements AdsService { @@ -38,6 +42,10 @@ class AdsServiceImpl with AppLogger implements AdsService { bool _showAds = true; + @override + bool get hasAmazonAds => _hasAmazonAds; + bool _hasAmazonAds = true; + @override Future init() async { setTag(_tag); @@ -170,6 +178,22 @@ class AdsServiceImpl with AppLogger implements AdsService { return compl.future; } + @override + Future> getNewAmazonAds() async { + if (!_showAds) return []; + if (!_hasAmazonAds) return []; + + final items = await AmazonService().getItems(); + if (items.isEmpty) { + logDebug('No Amazon ads found'); + _hasAmazonAds = false; + } else { + _hasAmazonAds = true; + } + + return items; + } + void _handleAdFailedToLoad(LoadAdError error, String adType) { final errorCode = error.code; final errorMessage = error.message; diff --git a/lib/src/services/amazon_service.dart b/lib/src/services/amazon_service.dart new file mode 100644 index 00000000..2287789e --- /dev/null +++ b/lib/src/services/amazon_service.dart @@ -0,0 +1,56 @@ +import 'package:azure_devops/src/extensions/reponse_extension.dart'; +import 'package:azure_devops/src/mixins/logger_mixin.dart'; +import 'package:azure_devops/src/models/amazon/amazon_item.dart'; +import 'package:http/http.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +class AmazonService with AppLogger { + factory AmazonService() { + return instance ??= AmazonService._(); + } + + AmazonService._() { + setTag('AmazonService'); + } + + static AmazonService? instance; + + final _client = SentryHttpClient(); + + List? _items; + + static const _url = 'https://products.azdevops.app/api/products?category=all'; + + var _lastFetchTime = DateTime.now(); + + void dispose() { + instance = null; + } + + Future> getItems() async { + if (_items != null && _hasFetchedRecently()) return _items!; + + final Response jsonsRes; + + try { + jsonsRes = await _client.get(Uri.parse(_url)); + } catch (e, s) { + logError(e, s); + return []; + } + + if (jsonsRes.isError) { + logErrorMessage('Error fetching items'); + return []; + } + + _lastFetchTime = DateTime.now(); + + final items = AmazonItem.listFromJson(jsonsRes.body); + logDebug('Fetched ${items.length} items.'); + + return _items = items; + } + + bool _hasFetchedRecently() => _lastFetchTime.isAfter(DateTime.now().subtract(Duration(hours: 1))); +} diff --git a/lib/src/widgets/ad_widget.dart b/lib/src/widgets/ad_widget.dart index c37bd1a4..1aed8ad0 100644 --- a/lib/src/widgets/ad_widget.dart +++ b/lib/src/widgets/ad_widget.dart @@ -1,8 +1,29 @@ +import 'package:azure_devops/src/extensions/context_extension.dart'; +import 'package:azure_devops/src/extensions/num_extension.dart'; +import 'package:azure_devops/src/models/amazon/amazon_item.dart'; +import 'package:azure_devops/src/theme/theme.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; +import 'package:url_launcher/url_launcher_string.dart'; typedef AdWithKey = ({AdWithView ad, GlobalKey key}); +class CustomAdWidget extends StatelessWidget { + const CustomAdWidget({required this.item}); + + final Object item; + + @override + Widget build(BuildContext context) { + return switch (item) { + final AmazonItem amazonItem => AmazonAdWidget(item: amazonItem), + final AdWithKey adWithKey => NativeAdWidget(ad: adWithKey), + _ => const SizedBox(), + }; + } +} + class NativeAdWidget extends StatelessWidget { const NativeAdWidget({required this.ad}); @@ -22,3 +43,83 @@ class NativeAdWidget extends StatelessWidget { ); } } + +class AmazonAdWidget extends StatelessWidget { + const AmazonAdWidget({required this.item}); + + final AmazonItem item; + + @override + Widget build(BuildContext context) { + final hasDiscount = item.discount != null && item.discount!.amount > 0; + return SizedBox( + height: 160, + child: Center( + child: GestureDetector( + onTap: () => launchUrlString(item.itemUrl, mode: LaunchMode.externalApplication), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + item.imageUrl, + width: 80, + height: 80, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + item.title, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall, + ), + const SizedBox(height: 8), + Row( + children: [ + if (hasDiscount) ...[ + Text( + '-${item.discount!.percentage.toPercentage()}', + style: context.textTheme.bodyMedium!.copyWith( + color: context.colorScheme.error, + fontWeight: FontWeight.normal, + fontFamily: AppTheme.defaultFont, + ), + ), + const SizedBox(width: 4), + Text( + item.discountedPrice.amount.toCurrency(item.currency), + style: context.textTheme.bodyMedium, + ), + const SizedBox(width: 8), + ], + Text( + item.originalPrice.amount.toCurrency(item.currency), + style: context.textTheme.bodySmall?.copyWith( + decoration: hasDiscount ? TextDecoration.lineThrough : null, + ), + ), + if (item.isPrime) ...[ + const Spacer(), + SvgPicture.network( + 'https://m.media-amazon.com/images/G/29/perc/prime-logo.png', + width: 45, + ), + ], + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/pipeline_list_tile.dart b/lib/src/widgets/pipeline_list_tile.dart index dda93db2..9523d62b 100644 --- a/lib/src/widgets/pipeline_list_tile.dart +++ b/lib/src/widgets/pipeline_list_tile.dart @@ -27,75 +27,78 @@ class PipelineListTile extends StatelessWidget { key: ValueKey('pipeline_${pipe.id}'), child: Column( children: [ - Row( - children: [ - if (pipe.status == PipelineStatus.inProgress && pipe.approvals.isNotEmpty) - Icon( - Icons.warning, - color: Colors.orange, - ) - else - pipe.status == PipelineStatus.completed ? pipe.result.icon : pipe.status.icon, - const SizedBox( - width: 12, - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - pipe.triggerInfo?.ciMessage ?? pipe.reason ?? '', - style: context.textTheme.labelLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox( - height: 5, - ), - Row( - children: [ - Text( - pipe.requestedFor?.displayName ?? '', - style: subtitleStyle, - ), - Text( - ' in ', - style: subtitleStyle.copyWith(color: context.colorScheme.onSecondary), - ), - Expanded( - child: Text( - pipe.repository?.name ?? '-', - overflow: TextOverflow.ellipsis, + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + if (pipe.status == PipelineStatus.inProgress && pipe.approvals.isNotEmpty) + Icon( + Icons.warning, + color: Colors.orange, + ) + else + pipe.status == PipelineStatus.completed ? pipe.result.icon : pipe.status.icon, + const SizedBox( + width: 12, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pipe.triggerInfo?.ciMessage ?? pipe.reason ?? '', + style: context.textTheme.labelLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox( + height: 5, + ), + Row( + children: [ + Text( + pipe.requestedFor?.displayName ?? '', style: subtitleStyle, ), + Text( + ' in ', + style: subtitleStyle.copyWith(color: context.colorScheme.onSecondary), + ), + Expanded( + child: Text( + pipe.repository?.name ?? '-', + overflow: TextOverflow.ellipsis, + style: subtitleStyle, + ), + ), + ], + ), + if (isCustomPipelineName) ...[ + const SizedBox( + height: 3, + ), + Text( + pipe.definition!.name!, + style: subtitleStyle, ), ], - ), - if (isCustomPipelineName) ...[ - const SizedBox( - height: 3, - ), - Text( - pipe.definition!.name!, - style: subtitleStyle, - ), ], - ], + ), + ), + const SizedBox( + width: 8, ), - ), - const SizedBox( - width: 8, - ), - Text( - pipe.startTime?.minutesAgo ?? '', - style: subtitleStyle, - ), - ], + Text( + pipe.startTime?.minutesAgo ?? '', + style: subtitleStyle, + ), + ], + ), ), if (!isLast) AppLayoutBuilder( - smartphone: Divider(height: 24, thickness: 1), - tablet: Divider(height: 48, thickness: 1), + smartphone: const Divider(height: 1, thickness: 1), + tablet: const Divider(height: 10, thickness: 1), ), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index 9b4bd2ef..9214d0af 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: 'azure_devops' -version: 3.5.0+90 +version: 3.5.1+92 description: 'Azure DevOps unofficial mobile app' homepage: 'homepage' publish_to: 'none' diff --git a/test/api_service_mock.dart b/test/api_service_mock.dart index e92108e4..1887c112 100644 --- a/test/api_service_mock.dart +++ b/test/api_service_mock.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; +import 'package:azure_devops/src/models/amazon/amazon_item.dart'; import 'package:azure_devops/src/models/areas_and_iterations.dart'; import 'package:azure_devops/src/models/board.dart'; import 'package:azure_devops/src/models/commit.dart'; @@ -985,6 +986,14 @@ class AdsServiceMock implements AdsService { Future> getNewNativeAds() async { return []; } + + @override + Future> getNewAmazonAds() async { + return []; + } + + @override + bool get hasAmazonAds => false; } class PurchaseServiceMock implements PurchaseService {