230 lines
6.7 KiB
Dart
230 lines
6.7 KiB
Dart
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<String, dynamic> 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<String, dynamic> 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<AdCreative> 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<pb.GetAdReply> getAd({@Query('placement') required String placement});
|
|
|
|
@GET('/api/v1/ads/list')
|
|
Future<pb.ListAdsReply> listAds({
|
|
@Query('placement') required String placement,
|
|
});
|
|
|
|
@POST('/api/v1/ads/events')
|
|
Future<pb.ReportAdEventReply> reportAdEvent(
|
|
@Body() pb.ReportAdEventRequest body,
|
|
);
|
|
}
|
|
|
|
class AdsApiClient {
|
|
AdsApiClient(this._api);
|
|
final AdsBackendApiClient _api;
|
|
|
|
Future<GetAdResult> 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<FeaturedAdsState> 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<void> 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<FeaturedAdsState, String>(
|
|
(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;
|
|
}
|