import 'package:dio/dio.dart'; import 'package:fixnum/fixnum.dart'; // ignore: unused_import — compute() is referenced by generated Retrofit code. import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:retrofit/retrofit.dart'; import 'package:uuid/uuid.dart'; import '../../../common/network/dio_provider.dart'; import '../../../models/pb/jinzhi/v1/ads.pb.dart' as pb; import '../../../models/pb/jinzhi/v1/ads.pb.dart' show GetAdReply, ListAdsReply, ReportAdEventReply, ReportAdEventRequest; part 'ads_api_client.g.dart'; const splashAdPlacement = 'splash'; const homeBannerAdPlacement = 'home_banner'; enum AdMediaType { image, gif, video, unsupported } enum AdOpenMode { external, inApp } class AdCreative { const AdCreative({ required this.adId, required this.placement, required this.targetApp, required this.title, required this.imageUrl, required this.mediaType, required this.h5Url, required this.trackingId, required this.countdownSeconds, required this.endAtMs, required this.trackedUrl, required this.openMode, }); final String adId, placement, targetApp, title, imageUrl, h5Url, trackingId, trackedUrl; final AdMediaType mediaType; final int countdownSeconds, endAtMs; final AdOpenMode openMode; Uri? get mediaUri => _http(imageUrl); Uri? get targetUri => _http(trackedUrl) ?? _http(h5Url); bool get isRenderable => adId.isNotEmpty && mediaUri != null && targetUri != null && mediaType != AdMediaType.unsupported && (endAtMs <= 0 || endAtMs > DateTime.now().millisecondsSinceEpoch); Uri? clickUri(String clickId) { final uri = targetUri; if (uri == null) return null; return uri.replace( queryParameters: { ...uri.queryParameters, 'ad_id': adId, 'placement': placement, 'tracking_id': trackingId, 'click_id': clickId, 'target_app': targetApp, 'hook_type': placement, 'ts': DateTime.now().millisecondsSinceEpoch.toString(), }, ); } Map toJson() => { 'adId': adId, 'placement': placement, 'targetApp': targetApp, 'title': title, 'imageUrl': imageUrl, 'mediaType': mediaType.index, 'h5Url': h5Url, 'trackingId': trackingId, 'countdownSeconds': countdownSeconds, 'endAtMs': endAtMs, 'trackedUrl': trackedUrl, 'openMode': openMode.index, }; factory AdCreative.fromJson(Map value) => AdCreative( adId: '${value['adId'] ?? ''}', placement: '${value['placement'] ?? ''}', targetApp: '${value['targetApp'] ?? ''}', title: '${value['title'] ?? ''}', imageUrl: '${value['imageUrl'] ?? ''}', mediaType: AdMediaType.values[(value['mediaType'] as num?)?.toInt().clamp( 0, AdMediaType.values.length - 1, ) ?? AdMediaType.unsupported.index], h5Url: '${value['h5Url'] ?? ''}', trackingId: '${value['trackingId'] ?? ''}', countdownSeconds: (value['countdownSeconds'] as num?)?.toInt() ?? 0, endAtMs: (value['endAtMs'] as num?)?.toInt() ?? 0, trackedUrl: '${value['trackedUrl'] ?? ''}', openMode: (value['openMode'] as num?)?.toInt() == AdOpenMode.inApp.index ? AdOpenMode.inApp : AdOpenMode.external, ); factory AdCreative.fromProto(pb.AdCreative creative) => AdCreative( adId: creative.adId, placement: creative.placement, targetApp: creative.targetApp, title: creative.title, imageUrl: creative.imageUrl, mediaType: switch (creative.mediaType) { pb.MediaType.MEDIA_TYPE_IMAGE => AdMediaType.image, pb.MediaType.MEDIA_TYPE_GIF => AdMediaType.gif, pb.MediaType.MEDIA_TYPE_VIDEO => AdMediaType.video, _ => AdMediaType.unsupported, }, h5Url: creative.h5Url, trackingId: creative.trackingId, countdownSeconds: creative.countdownSeconds, endAtMs: creative.endAtMs.toInt(), trackedUrl: creative.trackedUrl, openMode: creative.openMode == pb.AdOpenMode.AD_OPEN_MODE_IN_APP ? AdOpenMode.inApp : AdOpenMode.external, ); } class GetAdResult { const GetAdResult(this.enabled, this.ad, this.dailyCap); final bool enabled; final AdCreative? ad; final int dailyCap; } class FeaturedAdsState { const FeaturedAdsState(this.enabled, this.ads, {this.trackingId}); final bool enabled; final List ads; final String? trackingId; bool get hasAds => enabled && ads.isNotEmpty; } @RestApi() abstract class AdsBackendApiClient { factory AdsBackendApiClient(Dio dio, {String? baseUrl}) = _AdsBackendApiClient; @GET('/api/v1/ads') Future getAd({@Query('placement') required String placement}); @GET('/api/v1/ads/list') Future listAds({ @Query('placement') required String placement, }); @POST('/api/v1/ads/events') Future reportAdEvent( @Body() pb.ReportAdEventRequest body, ); } class AdsApiClient { AdsApiClient(this._api); final AdsBackendApiClient _api; Future getAdResult(String placement) async { final reply = await _api.getAd(placement: placement); final ad = reply.hasAd() ? AdCreative.fromProto(reply.ad) : null; return GetAdResult( reply.enabled, ad?.isRenderable == true ? ad : null, reply.dailyCap, ); } Future listAds(String placement) async { final reply = await _api.listAds(placement: placement); final ads = reply.ads .map(AdCreative.fromProto) .where((ad) => ad.isRenderable) .toList(growable: false); return FeaturedAdsState( reply.enabled, ads, trackingId: reply.trackingId.isEmpty ? null : reply.trackingId, ); } Future report( AdCreative ad, String event, { String? clickId, String? trackingId, }) async { await _api.reportAdEvent( pb.ReportAdEventRequest( eventName: event, placement: ad.placement, adId: ad.adId, trackingId: trackingId ?? ad.trackingId, clickId: clickId ?? '', clientTsMs: Int64(DateTime.now().millisecondsSinceEpoch), ), ); } String newClickId() => const Uuid().v4(); } final adsApiClientProvider = Provider( (ref) => AdsApiClient(AdsBackendApiClient(ref.watch(backendDioProvider))), ); final featuredAdsProvider = FutureProvider.autoDispose .family( (ref, placement) => ref.watch(adsApiClientProvider).listAds(placement), ); Uri? _http(String raw) { final uri = Uri.tryParse(raw.trim()); return uri != null && uri.host.isNotEmpty && (uri.scheme == 'https' || uri.scheme == 'http') ? uri : null; }