From 0a876557cbd752948b2b9189ac1c57f115cff025 Mon Sep 17 00:00:00 2001 From: Simone Stasi Date: Tue, 29 Apr 2025 15:36:28 +0200 Subject: [PATCH 01/10] show amazon ads instead of admob ones if there are any --- lib/src/extensions/num_extension.dart | 4 + lib/src/mixins/ads_mixin.dart | 22 ++++- lib/src/models/amazon/amazon_item.dart | 63 +++++++++++++ lib/src/screens/commits/screen_commits.dart | 4 +- .../screens/pipelines/screen_pipelines.dart | 4 +- lib/src/screens/profile/screen_profile.dart | 4 +- .../pull_requests/screen_pull_requests.dart | 4 +- .../screens/work_items/screen_work_items.dart | 4 +- lib/src/services/ads_service.dart | 24 +++++ lib/src/services/amazon_service.dart | 38 ++++++++ lib/src/widgets/ad_widget.dart | 89 +++++++++++++++++++ test/api_service_mock.dart | 9 ++ 12 files changed, 257 insertions(+), 12 deletions(-) create mode 100644 lib/src/models/amazon/amazon_item.dart create mode 100644 lib/src/services/amazon_service.dart diff --git a/lib/src/extensions/num_extension.dart b/lib/src/extensions/num_extension.dart index a2329b21..8ad158e5 100644 --- a/lib/src/extensions/num_extension.dart +++ b/lib/src/extensions/num_extension.dart @@ -1,3 +1,7 @@ +import 'package:intl/intl.dart'; + extension NumExt on num { String get formatted => (this == toInt() ? toInt() : this).toString(); + + String toCurrency(String currency) => NumberFormat.currency(name: currency).format(this); } diff --git a/lib/src/mixins/ads_mixin.dart b/lib/src/mixins/ads_mixin.dart index 8f137dd4..888051f2 100644 --- a/lib/src/mixins/ads_mixin.dart +++ b/lib/src/mixins/ads_mixin.dart @@ -1,19 +1,37 @@ +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 (ads.hasAmazonAds) { + await _getNewAmazonAds(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(); + 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..e05ce2eb --- /dev/null +++ b/lib/src/models/amazon/amazon_item.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; + +class AmazonItem { + AmazonItem({ + 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( + 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 itemUrl; + final String title; + final String imageUrl; + final bool isPrime; + final Price originalPrice; + final Discount? discount; + final Price discountedPrice; + final String currency; +} + +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..6fbf98b3 --- /dev/null +++ b/lib/src/services/amazon_service.dart @@ -0,0 +1,38 @@ +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: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; + + void dispose() { + instance = null; + } + + Future> getItems() async { + if (_items != null) return _items!; + + // TODO handle multiple categories + final url = 'https://products.azdevops.app/api/products?category=computers'; + final jsonsRes = await _client.get(Uri.parse(url)); + if (jsonsRes.isError) return []; + + final items = AmazonItem.listFromJson(jsonsRes.body); + logDebug('Fetched ${items.length} items.'); + + return _items = items; + } +} diff --git a/lib/src/widgets/ad_widget.dart b/lib/src/widgets/ad_widget.dart index c37bd1a4..c5879a1c 100644 --- a/lib/src/widgets/ad_widget.dart +++ b/lib/src/widgets/ad_widget.dart @@ -1,8 +1,28 @@ +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: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 +42,72 @@ 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: 100, + height: 100, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + 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) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Text(item.discountedPrice.amount.toCurrency(item.currency)), + ), + 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: 50, + ), + ], + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} 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 { From 5adb4bd06f426141d9efb4b06f27966c56afe042 Mon Sep 17 00:00:00 2001 From: Simone Stasi Date: Tue, 29 Apr 2025 16:27:15 +0200 Subject: [PATCH 02/10] handle multiple categories --- lib/src/services/amazon_service.dart | 33 ++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/lib/src/services/amazon_service.dart b/lib/src/services/amazon_service.dart index 6fbf98b3..a8e48742 100644 --- a/lib/src/services/amazon_service.dart +++ b/lib/src/services/amazon_service.dart @@ -1,6 +1,7 @@ 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:collection/collection.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; class AmazonService with AppLogger { @@ -18,6 +19,13 @@ class AmazonService with AppLogger { List? _items; + static const _categories = [ + 'computers', + 'electronics', + ]; + + static const _basePath = 'https://products.azdevops.app'; + void dispose() { instance = null; } @@ -25,14 +33,25 @@ class AmazonService with AppLogger { Future> getItems() async { if (_items != null) return _items!; - // TODO handle multiple categories - final url = 'https://products.azdevops.app/api/products?category=computers'; - final jsonsRes = await _client.get(Uri.parse(url)); - if (jsonsRes.isError) return []; + final allItems = []; + + for (final category in _categories) { + final url = '$_basePath/api/products?category=$category'; + final jsonsRes = await _client.get(Uri.parse(url)); + + if (jsonsRes.isError) { + logErrorMessage('Error fetching items for category: $category'); + continue; + } + + final items = AmazonItem.listFromJson(jsonsRes.body); + allItems.addAll(items); + + logDebug('Fetched ${items.length} items for category: $category.'); + } - final items = AmazonItem.listFromJson(jsonsRes.body); - logDebug('Fetched ${items.length} items.'); + logDebug('Total items fetched: ${allItems.length}'); - return _items = items; + return _items = allItems.sorted((a, b) => (b.discount?.percentage ?? 0).compareTo(a.discount?.percentage ?? 0)); } } From cb43e26ffc44d7c074e35eaf84e413ca1da2ebaa Mon Sep 17 00:00:00 2001 From: Simone Stasi Date: Tue, 29 Apr 2025 16:27:42 +0200 Subject: [PATCH 03/10] slightly improve UI --- lib/src/widgets/ad_widget.dart | 99 +++++++++++++++++----------------- 1 file changed, 51 insertions(+), 48 deletions(-) diff --git a/lib/src/widgets/ad_widget.dart b/lib/src/widgets/ad_widget.dart index c5879a1c..c2e8b86c 100644 --- a/lib/src/widgets/ad_widget.dart +++ b/lib/src/widgets/ad_widget.dart @@ -53,58 +53,61 @@ class AmazonAdWidget extends StatelessWidget { 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: 100, - height: 100, - fit: BoxFit.cover, + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + 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: 100, + height: 100, + fit: BoxFit.cover, + ), ), - ), - const SizedBox(width: 16), - 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) - Padding( - padding: const EdgeInsets.only(right: 8), - child: Text(item.discountedPrice.amount.toCurrency(item.currency)), - ), - 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: 50, + const SizedBox(width: 16), + 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) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Text(item.discountedPrice.amount.toCurrency(item.currency)), + ), + 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: 50, + ), + ], ], - ], - ), - ], + ), + ], + ), ), - ), - ], + ], + ), ), ), ), From 0d2b56efa8f5a52f0f27ec52b746568d556b7108 Mon Sep 17 00:00:00 2001 From: Simone Stasi Date: Tue, 29 Apr 2025 16:37:04 +0200 Subject: [PATCH 04/10] remove duplicate items --- lib/src/models/amazon/amazon_item.dart | 15 +++++++++++++++ lib/src/services/amazon_service.dart | 9 ++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/src/models/amazon/amazon_item.dart b/lib/src/models/amazon/amazon_item.dart index e05ce2eb..a2aa1d2d 100644 --- a/lib/src/models/amazon/amazon_item.dart +++ b/lib/src/models/amazon/amazon_item.dart @@ -2,6 +2,7 @@ import 'dart:convert'; class AmazonItem { AmazonItem({ + required this.id, required this.itemUrl, required this.title, required this.imageUrl, @@ -13,6 +14,7 @@ class AmazonItem { }); 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? ?? '', @@ -27,6 +29,7 @@ class AmazonItem { (json.decode(str) as List? ?? []).map((x) => AmazonItem.fromJson(x as Map? ?? {})), ); + final String id; final String itemUrl; final String title; final String imageUrl; @@ -35,6 +38,18 @@ class AmazonItem { 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 { diff --git a/lib/src/services/amazon_service.dart b/lib/src/services/amazon_service.dart index a8e48742..42d6aea0 100644 --- a/lib/src/services/amazon_service.dart +++ b/lib/src/services/amazon_service.dart @@ -52,6 +52,13 @@ class AmazonService with AppLogger { logDebug('Total items fetched: ${allItems.length}'); - return _items = allItems.sorted((a, b) => (b.discount?.percentage ?? 0).compareTo(a.discount?.percentage ?? 0)); + final distinctItems = allItems + .sorted((a, b) => (b.discount?.percentage ?? 0).compareTo(a.discount?.percentage ?? 0)) + .toSet() + .toList(); + + logDebug('Distinct items: ${distinctItems.length}'); + + return _items = distinctItems; } } From 145d2ff7cf148a24e40cbda1e16338d0e1814253 Mon Sep 17 00:00:00 2001 From: Simone Stasi Date: Tue, 29 Apr 2025 17:04:02 +0200 Subject: [PATCH 05/10] improve UI --- lib/src/extensions/num_extension.dart | 4 +++- lib/src/widgets/ad_widget.dart | 28 +++++++++++++++++++-------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/lib/src/extensions/num_extension.dart b/lib/src/extensions/num_extension.dart index 8ad158e5..86403e6f 100644 --- a/lib/src/extensions/num_extension.dart +++ b/lib/src/extensions/num_extension.dart @@ -3,5 +3,7 @@ import 'package:intl/intl.dart'; extension NumExt on num { String get formatted => (this == toInt() ? toInt() : this).toString(); - String toCurrency(String currency) => NumberFormat.currency(name: currency).format(this); + String toCurrency(String currency) => NumberFormat.simpleCurrency(name: currency).format(this); + + String toPercentage() => '$this%'; } diff --git a/lib/src/widgets/ad_widget.dart b/lib/src/widgets/ad_widget.dart index c2e8b86c..6abf7f67 100644 --- a/lib/src/widgets/ad_widget.dart +++ b/lib/src/widgets/ad_widget.dart @@ -1,6 +1,7 @@ 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'; @@ -64,12 +65,12 @@ class AmazonAdWidget extends StatelessWidget { borderRadius: BorderRadius.circular(4), child: Image.network( item.imageUrl, - width: 100, - height: 100, + width: 80, + height: 80, fit: BoxFit.cover, ), ), - const SizedBox(width: 16), + const SizedBox(width: 12), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -83,11 +84,22 @@ class AmazonAdWidget extends StatelessWidget { const SizedBox(height: 8), Row( children: [ - if (hasDiscount) - Padding( - padding: const EdgeInsets.only(right: 8), - child: Text(item.discountedPrice.amount.toCurrency(item.currency)), + 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( @@ -98,7 +110,7 @@ class AmazonAdWidget extends StatelessWidget { const Spacer(), SvgPicture.network( 'https://m.media-amazon.com/images/G/29/perc/prime-logo.png', - width: 50, + width: 45, ), ], ], From 0223d83f7acb21384f42af73e2f87838fcfbb1a7 Mon Sep 17 00:00:00 2001 From: Simone Stasi Date: Tue, 29 Apr 2025 17:10:22 +0200 Subject: [PATCH 06/10] cache items for 1 hour --- lib/src/services/amazon_service.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/src/services/amazon_service.dart b/lib/src/services/amazon_service.dart index 42d6aea0..f33f2a92 100644 --- a/lib/src/services/amazon_service.dart +++ b/lib/src/services/amazon_service.dart @@ -26,12 +26,14 @@ class AmazonService with AppLogger { static const _basePath = 'https://products.azdevops.app'; + var _lastFetchTime = DateTime.now(); + void dispose() { instance = null; } Future> getItems() async { - if (_items != null) return _items!; + if (_items != null && _lastFetchTime.isAfter(DateTime.now().subtract(Duration(hours: 1)))) return _items!; final allItems = []; @@ -48,6 +50,7 @@ class AmazonService with AppLogger { allItems.addAll(items); logDebug('Fetched ${items.length} items for category: $category.'); + _lastFetchTime = DateTime.now(); } logDebug('Total items fetched: ${allItems.length}'); From 7071b284c0f33b0bb57174d9e79798c0dd132a2d Mon Sep 17 00:00:00 2001 From: Simone Stasi Date: Tue, 29 Apr 2025 17:24:22 +0200 Subject: [PATCH 07/10] improve UI --- lib/src/widgets/ad_widget.dart | 117 +++++++++++----------- lib/src/widgets/pipeline_list_tile.dart | 123 ++++++++++++------------ 2 files changed, 120 insertions(+), 120 deletions(-) diff --git a/lib/src/widgets/ad_widget.dart b/lib/src/widgets/ad_widget.dart index 6abf7f67..1aed8ad0 100644 --- a/lib/src/widgets/ad_widget.dart +++ b/lib/src/widgets/ad_widget.dart @@ -54,72 +54,69 @@ class AmazonAdWidget extends StatelessWidget { final hasDiscount = item.discount != null && item.discount!.amount > 0; return SizedBox( height: 160, - child: Padding( - padding: const EdgeInsets.only(bottom: 12), - 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, - ), + 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), - ], + ), + 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.originalPrice.amount.toCurrency(item.currency), - style: context.textTheme.bodySmall?.copyWith( - decoration: hasDiscount ? TextDecoration.lineThrough : null, + '-${item.discount!.percentage.toPercentage()}', + style: context.textTheme.bodyMedium!.copyWith( + color: context.colorScheme.error, + fontWeight: FontWeight.normal, + fontFamily: AppTheme.defaultFont, ), ), - if (item.isPrime) ...[ - const Spacer(), - SvgPicture.network( - 'https://m.media-amazon.com/images/G/29/perc/prime-logo.png', - width: 45, - ), - ], + 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), ), ], ), From a3fc08be2c62bf8466b17827ef209af66822f95d Mon Sep 17 00:00:00 2001 From: Simone Stasi Date: Tue, 29 Apr 2025 18:15:49 +0200 Subject: [PATCH 08/10] show admob ads on amazon api error --- lib/src/mixins/ads_mixin.dart | 12 ++++++++---- lib/src/services/amazon_service.dart | 10 +++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/src/mixins/ads_mixin.dart b/lib/src/mixins/ads_mixin.dart index 888051f2..7db6272e 100644 --- a/lib/src/mixins/ads_mixin.dart +++ b/lib/src/mixins/ads_mixin.dart @@ -11,8 +11,12 @@ mixin AdsMixin { Future getNewNativeAds(AdsService ads) async { _hasAmazonAds = ads.hasAmazonAds; - if (ads.hasAmazonAds) { - await _getNewAmazonAds(ads); + if (_hasAmazonAds) { + final items = await _getNewAmazonAds(ads); + if (items.isEmpty) { + _hasAmazonAds = false; + await _getNewAdmobAds(ads); + } } else { await _getNewAdmobAds(ads); } @@ -24,9 +28,9 @@ mixin AdsMixin { nativeAds = newAds.map((ad) => (ad: ad, key: GlobalKey())).toList(); } - Future _getNewAmazonAds(AdsService ads) async { + Future> _getNewAmazonAds(AdsService ads) async { final newAmazonAds = await ads.getNewAmazonAds(); - amazonAds = newAmazonAds.toList(); + return amazonAds = newAmazonAds.toList(); } /// Whether to show a native ad at the given [index] inside [items] list. diff --git a/lib/src/services/amazon_service.dart b/lib/src/services/amazon_service.dart index f33f2a92..85c12b9b 100644 --- a/lib/src/services/amazon_service.dart +++ b/lib/src/services/amazon_service.dart @@ -2,6 +2,7 @@ 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:collection/collection.dart'; +import 'package:http/http.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; class AmazonService with AppLogger { @@ -39,7 +40,14 @@ class AmazonService with AppLogger { for (final category in _categories) { final url = '$_basePath/api/products?category=$category'; - final jsonsRes = await _client.get(Uri.parse(url)); + final Response jsonsRes; + + try { + jsonsRes = await _client.get(Uri.parse(url)); + } catch (e, s) { + logError(e, s); + continue; + } if (jsonsRes.isError) { logErrorMessage('Error fetching items for category: $category'); From a9bd9e1864082b5ac68ebd35ae4b69723448848e Mon Sep 17 00:00:00 2001 From: Simone Stasi Date: Wed, 30 Apr 2025 18:16:39 +0200 Subject: [PATCH 09/10] get items from category 'all' json And don't sort/distinct them --- lib/src/services/amazon_service.dart | 55 +++++++++------------------- 1 file changed, 18 insertions(+), 37 deletions(-) diff --git a/lib/src/services/amazon_service.dart b/lib/src/services/amazon_service.dart index 85c12b9b..2287789e 100644 --- a/lib/src/services/amazon_service.dart +++ b/lib/src/services/amazon_service.dart @@ -1,7 +1,6 @@ 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:collection/collection.dart'; import 'package:http/http.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -20,12 +19,7 @@ class AmazonService with AppLogger { List? _items; - static const _categories = [ - 'computers', - 'electronics', - ]; - - static const _basePath = 'https://products.azdevops.app'; + static const _url = 'https://products.azdevops.app/api/products?category=all'; var _lastFetchTime = DateTime.now(); @@ -34,42 +28,29 @@ class AmazonService with AppLogger { } Future> getItems() async { - if (_items != null && _lastFetchTime.isAfter(DateTime.now().subtract(Duration(hours: 1)))) return _items!; - - final allItems = []; - - for (final category in _categories) { - final url = '$_basePath/api/products?category=$category'; - final Response jsonsRes; - - try { - jsonsRes = await _client.get(Uri.parse(url)); - } catch (e, s) { - logError(e, s); - continue; - } + if (_items != null && _hasFetchedRecently()) return _items!; - if (jsonsRes.isError) { - logErrorMessage('Error fetching items for category: $category'); - continue; - } + final Response jsonsRes; - final items = AmazonItem.listFromJson(jsonsRes.body); - allItems.addAll(items); - - logDebug('Fetched ${items.length} items for category: $category.'); - _lastFetchTime = DateTime.now(); + try { + jsonsRes = await _client.get(Uri.parse(_url)); + } catch (e, s) { + logError(e, s); + return []; } - logDebug('Total items fetched: ${allItems.length}'); + if (jsonsRes.isError) { + logErrorMessage('Error fetching items'); + return []; + } - final distinctItems = allItems - .sorted((a, b) => (b.discount?.percentage ?? 0).compareTo(a.discount?.percentage ?? 0)) - .toSet() - .toList(); + _lastFetchTime = DateTime.now(); - logDebug('Distinct items: ${distinctItems.length}'); + final items = AmazonItem.listFromJson(jsonsRes.body); + logDebug('Fetched ${items.length} items.'); - return _items = distinctItems; + return _items = items; } + + bool _hasFetchedRecently() => _lastFetchTime.isAfter(DateTime.now().subtract(Duration(hours: 1))); } From 2cc47d7211ccf8e6da7a2678552e28f4f33059dd Mon Sep 17 00:00:00 2001 From: Simone Stasi Date: Wed, 30 Apr 2025 18:53:08 +0200 Subject: [PATCH 10/10] bump version --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'