parent
							
								
									59b48f342c
								
							
						
					
					
						commit
						ea55fd4551
					
				|  | @ -1,5 +1,7 @@ | |||
| app_name: GuruApp | ||||
| 
 | ||||
| app_category: app | ||||
| 
 | ||||
| flavor: "guru_test" | ||||
| 
 | ||||
| # App接入GuruApp的基础信息(下面内容必填) | ||||
|  | @ -81,7 +83,6 @@ deployment: | |||
|   # ios 验证服务器的密码 | ||||
|   ios_validate_receipt_password: aa998877665544332211bb00cc | ||||
| 
 | ||||
| 
 | ||||
|   # 被标注的conversion点,在自打点库中将被以Emergency的优先级进行发送 | ||||
|   conversion_events: | ||||
|     - first_rads_rewarded | ||||
|  | @ -166,6 +167,30 @@ deployment: | |||
|   # 因此它的优先级永远不会大于正常的 Banner 广告, 默认值是 false | ||||
|   show_internal_ads_when_banner_unavailable: true | ||||
| 
 | ||||
|   # 由于订阅订单比较重要,而从用户反馈的日志上来看,会存在接口返回异常的问题 | ||||
|   # 因此针对这种情况,添加订阅的恢复宽限次数,默认为 3 次 | ||||
|   # 当订阅订单恢复失败次数超过该次数,才会真正删除 | ||||
|   subscription_restore_grace_count: 3 | ||||
| 
 | ||||
|   # 插屏在展示广告前,为了保证用户的体验,会有一个广告的保护时间, | ||||
|   # 即:距上一次全屏广告(插屏广告和激励广告)的结束间隔时间, | ||||
|   # 默认的间隔保护时间为 1 分钟(60 秒)单位为秒 | ||||
|   fullscreen_ads_min_interval: 60 | ||||
| 
 | ||||
|   # 是否打开中台的 AccountProfile 同步机制 | ||||
|   # 打开后,在登陆后(包括匿名登陆) 会启动向 Firestore 进行同步AccountProfile的机制 | ||||
|   # Firestore 针对 AccountProfile的存储位置默认放在 users 表中 | ||||
|   enabled_sync_account_profile: false | ||||
| 
 | ||||
|   # 根据 BI 的需求,对应的 Purchase事件只能报太极的 001 或 020的其中一个 | ||||
|   # 因此添加 Purchase Event 的 trigger, 默认值为 1 | ||||
|   # 1: 表示在发生购买时打 tch_ad_rev_roas_001 | ||||
|   # 2: 表示在发生购买时打 tch_ad_rev_roas_020 | ||||
|   # 在广告展示时也会依据该 trigger 的值,在不同的时机打对应的 purchase事件 | ||||
|   purchase_event_trigger: 1 | ||||
| 
 | ||||
| #  tracking_notification_permission_pass_analytics_type : guru|firebase | ||||
| 
 | ||||
| # 广告配置 | ||||
| ads_profile: | ||||
|   # Banner广告ID(变现提供) | ||||
|  | @ -202,7 +227,6 @@ ads_profile: | |||
|     android: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx | ||||
|     ios: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx | ||||
| 
 | ||||
| 
 | ||||
| remote_config: | ||||
|   # 保留配置,插屏广告相关配置 | ||||
|   iads_config: '{"free_s":600,"win_count":4,"scene":"game_start","sp_scene":"new_block:120;reset_scs:120","retry_min_s":10,"retry_max_s":600,"amazon_enable":false,"imp_gap_s":120}' | ||||
|  | @ -215,6 +239,10 @@ remote_config: | |||
| 
 | ||||
|   # 保留配置,打点相关配置 | ||||
|   analytics_config: '{"cap":"firebase|facebook|guru", "init_delay_s": 10}' | ||||
| # | ||||
| #  _mapping: | ||||
| #    cdn_config: "cdn2_config" | ||||
| 
 | ||||
| 
 | ||||
| products: | ||||
|   # sku | ||||
|  | @ -311,6 +339,7 @@ products: | |||
|     manifest: | ||||
|       category: "prop" | ||||
|       details: | ||||
|         sku: "{1}_{2}" | ||||
|         type: "prop" | ||||
|         amount: 1 | ||||
|         theme_id: "{1}" | ||||
|  | @ -389,15 +418,15 @@ products: | |||
|       details: | ||||
|         type: "igc" | ||||
|         amount: 16000 | ||||
| 
 | ||||
|   theme_mul: | ||||
|     sku: "theme_{category}_{theme_id}" | ||||
|     attr: possessive | ||||
|     method: igc | ||||
|     manifest: | ||||
|       category: "{1}" | ||||
|       theme_id: "{2}" | ||||
|       cate: "{1}" | ||||
| # | ||||
| #  theme_mul: | ||||
| #    sku: "theme_{category}_{theme_id}" | ||||
| #    attr: possessive | ||||
| #    method: igc | ||||
| #    manifest: | ||||
| #      category: "{1}" | ||||
| #      theme_id: "{2}" | ||||
| #      cate: "{1}" | ||||
| 
 | ||||
| # adjust 相关配置 | ||||
| adjust_profile: | ||||
|  | @ -429,3 +458,46 @@ adjust_profile: | |||
|       android: 95fu7q | ||||
|       ios: 1p8z5t | ||||
| 
 | ||||
| experiments: | ||||
| 
 | ||||
|   test: | ||||
|     start: 20240129T000000 | ||||
|     end: 20240129T000000 | ||||
|     audience: | ||||
|       filters: | ||||
|         - version: | ||||
|             opt: lt | ||||
|             mmp: 2.3.0 | ||||
|         - country: | ||||
|             included: "" | ||||
|             excluded: "us,cn,en" | ||||
|         - platform: | ||||
|             android: | ||||
|               opt: gte | ||||
|               ver: 33 | ||||
|             ios: | ||||
|               opt: gte | ||||
|               ver: 14 | ||||
|       variant: 2 | ||||
|   test2: | ||||
|     start: 20240129T000000 | ||||
|     end: 20240129T000000 | ||||
|     audience: | ||||
|       filters: | ||||
|         - version: | ||||
|             opt: lt | ||||
|             mmp: 2.3.0 | ||||
|         - country: | ||||
|             included: "cn" | ||||
|             excluded: "us" | ||||
|         - platform: | ||||
|             android: | ||||
|               opt: lt | ||||
|               ver: 24 | ||||
|             ios: | ||||
|               opt: gte | ||||
|               ver: 14 | ||||
|         - new_user: true | ||||
|       variant: 5 | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,41 +3,41 @@ | |||
| part of "account_manager.dart"; | ||||
| 
 | ||||
| extension AccountAuthExtension on AccountManager { | ||||
|   Future<AccountAuth> _authenticate(SaasUser saasUser, | ||||
|   Future<FirebaseAccountAuth> _loginFirebase(GuruUser guruUser, | ||||
|       {bool canRefreshFirebaseToken = true}) async { | ||||
|     User? firebaseUser; | ||||
|     SaasUser newSaasUser = saasUser; | ||||
|     firebaseUser = await _authenticateFirebase(saasUser).catchError((error) { | ||||
|     GuruUser newGuruUser = guruUser; | ||||
|     firebaseUser = await _authenticateFirebase(guruUser).catchError((error) { | ||||
|       Log.e("_authenticateFirebase error! $error", tag: "Account"); | ||||
|       return null; | ||||
|     }); | ||||
|     if (firebaseUser == null && canRefreshFirebaseToken) { | ||||
|       try { | ||||
|         newSaasUser = await _refreshFirebaseToken(saasUser); | ||||
|         return _authenticate(newSaasUser, canRefreshFirebaseToken: false); | ||||
|         newGuruUser = await _refreshFirebaseToken(guruUser); | ||||
|         return _loginFirebase(newGuruUser, canRefreshFirebaseToken: false); | ||||
|       } catch (error, stacktrace) { | ||||
|         return AccountAuth(saasUser, null); | ||||
|         return FirebaseAccountAuth(guruUser, firebaseUser: null); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return AccountAuth(newSaasUser, firebaseUser); | ||||
|     return FirebaseAccountAuth(newGuruUser, firebaseUser: firebaseUser); | ||||
|   } | ||||
| 
 | ||||
|   Future<SaasUser> _refreshFirebaseToken(SaasUser oldSaasUser) async { | ||||
|   Future<GuruUser> _refreshFirebaseToken(GuruUser oldSaasUser) async { | ||||
|     return await GuruApi.instance | ||||
|         .renewFirebaseToken() | ||||
|         .then((tokenData) => oldSaasUser.copyWith(firebaseToken: tokenData.firebaseToken)); | ||||
|   } | ||||
| 
 | ||||
|   Future<User?> _authenticateFirebase(SaasUser saasUser) async { | ||||
|   Future<User?> _authenticateFirebase(GuruUser guruUser) async { | ||||
|     int retry = 0; | ||||
|     dynamic lastError; | ||||
|     while (retry < 1) { | ||||
|       try { | ||||
|         Log.i("[$retry] _authenticateFirebase:${saasUser.firebaseToken}", tag: "Account"); | ||||
|         Log.i("[$retry] _authenticateFirebase:${guruUser.firebaseToken}", tag: "Account"); | ||||
| 
 | ||||
|         return await FirebaseAuth.instance | ||||
|             .signInWithCustomToken(saasUser.firebaseToken) | ||||
|             .signInWithCustomToken(guruUser.firebaseToken) | ||||
|             .then((result) => result.user); | ||||
|       } catch (error, stacktrace) { | ||||
|         await Future.delayed(const Duration(milliseconds: 600)); | ||||
|  | @ -48,4 +48,26 @@ extension AccountAuthExtension on AccountManager { | |||
|     } | ||||
|     throw lastError ?? ("_authenticateFirebase error!"); | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> authenticateFirebase() async { | ||||
|     final guruUser = accountDataStore.user; | ||||
|     if (guruUser == null) { | ||||
|       return false; | ||||
|     } | ||||
|     try { | ||||
|       final auth = await _loginFirebase(guruUser); | ||||
|       final newGuruUser = auth.user; | ||||
|       if (!guruUser.isSame(newGuruUser)) { | ||||
|         _updateGuruUser(newGuruUser); | ||||
|       } | ||||
|       if (auth.firebaseUser != null) { | ||||
|         _updateFirebaseUser(auth.firebaseUser!); | ||||
|         Log.i("_updateFirebaseUser success!", tag: "Account"); | ||||
|       } | ||||
|       return true; | ||||
|     } catch (error, stacktrace) { | ||||
|       GuruAnalytics.instance.logException(error, stacktrace: stacktrace); | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,26 @@ | |||
| part of "account_manager.dart"; | ||||
| 
 | ||||
| extension AccountAuthInvoker on AccountManager { | ||||
|   Future<bool> _invokeLogin(GuruUser loginUser, Credential credential) async { | ||||
|     return await GuruApp.instance.protocol.accountAuthDelegate?.onLogin(loginUser, credential) ?? | ||||
|         true; | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> _invokeLogout(GuruUser logoutUser) async { | ||||
|     return await GuruApp.instance.protocol.accountAuthDelegate?.onLogout(logoutUser) ?? true; | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> _invokeAnonymousLogout(GuruUser logoutUser) async { | ||||
|     return await GuruApp.instance.protocol.accountAuthDelegate?.onAnonymousLogout(logoutUser) ?? | ||||
|         true; | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> _invokeAnonymousLogin(GuruUser loginUser, Credential credential) async { | ||||
|     return await GuruApp.instance.protocol.accountAuthDelegate?.onAnonymousLogin(loginUser, credential) ?? | ||||
|         true; | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> _invokeConflict() async { | ||||
|     return await GuruApp.instance.protocol.accountAuthDelegate?.onConflict() ?? false; | ||||
|   } | ||||
| } | ||||
|  | @ -1,11 +1,13 @@ | |||
| import 'dart:convert'; | ||||
| import 'package:firebase_auth/firebase_auth.dart'; | ||||
| import 'package:guru_app/account/model/account.dart'; | ||||
| import 'package:guru_app/account/model/account_profile.dart'; | ||||
| import 'package:guru_app/account/model/user.dart'; | ||||
| import 'package:guru_app/analytics/guru_analytics.dart'; | ||||
| import 'package:guru_app/api/guru_api.dart'; | ||||
| import 'package:guru_app/guru_app.dart'; | ||||
| import 'package:guru_app/property/app_property.dart'; | ||||
| import 'package:guru_utils/auth/auth_credential_manager.dart'; | ||||
| import 'package:guru_utils/device/device_info.dart'; | ||||
| import 'package:guru_utils/extensions/extensions.dart'; | ||||
| 
 | ||||
|  | @ -19,11 +21,15 @@ class AccountDataStore { | |||
|   static final AccountDataStore instance = AccountDataStore._(); | ||||
| 
 | ||||
|   final BehaviorSubject<DeviceInfo?> _deviceInfoSubject = BehaviorSubject.seeded(null); | ||||
|   final BehaviorSubject<SaasUser?> _saasUserSubject = BehaviorSubject.seeded(null); | ||||
|   final BehaviorSubject<GuruUser?> _guruUserSubject = BehaviorSubject.seeded(null); | ||||
|   final BehaviorSubject<User?> _firebaseUser = BehaviorSubject.seeded(null); | ||||
|   final BehaviorSubject<AccountProfile?> _accountProfile = BehaviorSubject.seeded(null); | ||||
|   final BehaviorSubject<AccountDataStatus> _accountDataStatus = | ||||
|       BehaviorSubject<AccountDataStatus>.seeded(AccountDataStatus.idle); | ||||
| 
 | ||||
|   final BehaviorSubject<Map<AuthType, Credential>> _credentials = | ||||
|       BehaviorSubject.seeded(<AuthType, Credential>{}); | ||||
| 
 | ||||
|   int initRetryCount = 0; | ||||
| 
 | ||||
|   Stream<AccountProfile?> get observableAccountProfile => _accountProfile.stream; | ||||
|  | @ -32,9 +38,12 @@ class AccountDataStore { | |||
| 
 | ||||
|   AccountDataStore._(); | ||||
| 
 | ||||
|   String? get saasToken => _saasUserSubject.value?.token; | ||||
|   @Deprecated("use guruToken instead") | ||||
|   String? get saasToken => _guruUserSubject.value?.token; | ||||
| 
 | ||||
|   String? get uid => _saasUserSubject.value?.uid; | ||||
|   String? get guruToken => _guruUserSubject.value?.token; | ||||
| 
 | ||||
|   String? get uid => _guruUserSubject.value?.uid; | ||||
| 
 | ||||
|   AccountProfile? get accountProfile => _accountProfile.value; | ||||
| 
 | ||||
|  | @ -42,7 +51,7 @@ class AccountDataStore { | |||
| 
 | ||||
|   String? get countryCode => _accountProfile.value?.countryCode; | ||||
| 
 | ||||
|   SaasUser? get user => _saasUserSubject.value; | ||||
|   GuruUser? get user => _guruUserSubject.value; | ||||
| 
 | ||||
|   String? get avatar => _accountProfile.value?.avatar; | ||||
| 
 | ||||
|  | @ -55,16 +64,42 @@ class AccountDataStore { | |||
|   Stream<bool> get observableInitialized => | ||||
|       _accountDataStatus.stream.map((status) => status == AccountDataStatus.initialized); | ||||
| 
 | ||||
|   Stream<SaasUser?> get observableSaasUser => _saasUserSubject.stream; | ||||
|   bool get hasUid => uid?.isNotEmpty == true; | ||||
| 
 | ||||
|   bool get isAnonymous => | ||||
|       (uid?.isNotEmpty != true) || | ||||
|       (_credentials.value.containsKey(AuthType.anonymous) && _credentials.value.length == 1); | ||||
| 
 | ||||
|   Stream<GuruUser?> get observableSaasUser => _guruUserSubject.stream; | ||||
| 
 | ||||
|   Map<AuthType, Credential> get credentials => _credentials.value; | ||||
| 
 | ||||
|   Account get account => Account.restore( | ||||
|       guruUser: user, | ||||
|       device: currentDevice, | ||||
|       accountProfile: accountProfile, | ||||
|       firebaseUser: _firebaseUser.value, | ||||
|       credentials: credentials); | ||||
| 
 | ||||
|   Stream<Account> get observableAccount => Rx.combineLatestList([ | ||||
|         _guruUserSubject.stream, | ||||
|         _deviceInfoSubject.stream, | ||||
|         _accountProfile.stream, | ||||
|         _firebaseUser.stream, | ||||
|         _credentials.stream | ||||
|       ]).debounceTime(const Duration(milliseconds: 100)).map((_) => account); | ||||
| 
 | ||||
|   bool get isSocialLogged => (uid?.isNotEmpty == true) && credentials.isNotEmpty; | ||||
| 
 | ||||
|   void dispose() { | ||||
|     _deviceInfoSubject.close(); | ||||
|     _saasUserSubject.close(); | ||||
|     _guruUserSubject.close(); | ||||
|     _firebaseUser.close(); | ||||
|     _accountProfile.close(); | ||||
|     _credentials.close(); | ||||
|   } | ||||
| 
 | ||||
|   Future<SaasUser?> signInAnonymousInLocked() async { | ||||
|   Future<GuruUser?> signInAnonymousInLocked() async { | ||||
|     // 这里需要使用原始的http post请求。否则这里将会死锁DIO所有请求 | ||||
|     final secret = await AppProperty.getInstance().getAnonymousSecretKey(); | ||||
|     final headers = { | ||||
|  | @ -82,7 +117,7 @@ class AccountDataStore { | |||
|       final data = const Utf8Decoder().convert(response.bodyBytes); | ||||
|       if (data.isNotEmpty) { | ||||
|         final result = json.decode(data); | ||||
|         return SaasUser.fromJson(result["data"]); | ||||
|         return GuruUser.fromJson(result["data"]); | ||||
|       } | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.v("signInAnonymousInLocked error:$error", tag: "Account"); | ||||
|  | @ -91,9 +126,9 @@ class AccountDataStore { | |||
|   } | ||||
| 
 | ||||
|   Future refreshAuth() async { | ||||
|     final saasUser = await signInAnonymousInLocked(); | ||||
|     if (saasUser != null) { | ||||
|       updateSaasUser(saasUser); | ||||
|     final guruUser = await signInAnonymousInLocked(); | ||||
|     if (guruUser != null) { | ||||
|       updateGuruUser(guruUser); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -101,12 +136,16 @@ class AccountDataStore { | |||
|     _deviceInfoSubject.addEx(deviceInfo); | ||||
|   } | ||||
| 
 | ||||
|   void updateSaasUser(SaasUser saasUser) { | ||||
|     _saasUserSubject.addEx(saasUser); | ||||
|   @Deprecated("use updateGuruUser instead") | ||||
|   void updateSaasUser(GuruUser saasUser) { | ||||
|     updateGuruUser(saasUser); | ||||
|   } | ||||
| 
 | ||||
|     if (saasUser.createAtTimestamp > 0) { | ||||
|   void updateGuruUser(GuruUser guruUser) { | ||||
|     _guruUserSubject.addEx(guruUser); | ||||
|     if (guruUser.createAtTimestamp > 0) { | ||||
|       GuruAnalytics.instance | ||||
|           .setUserProperty("user_created_timestamp", saasUser.createAtTimestamp.toString()); | ||||
|           .setUserProperty("user_created_timestamp", guruUser.createAtTimestamp.toString()); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -118,7 +157,31 @@ class AccountDataStore { | |||
|     _accountProfile.addEx(profile); | ||||
|   } | ||||
| 
 | ||||
|   void bindCredential(Credential credential) { | ||||
|     final newCredentials = Map.of(_credentials.value); | ||||
|     newCredentials[credential.authType] = credential; | ||||
|     _credentials.addEx(newCredentials); | ||||
|   } | ||||
| 
 | ||||
|   void unbindCredential(AuthType authType) { | ||||
|     final newCredentials = Map.of(_credentials.value); | ||||
|     newCredentials.remove(authType); | ||||
|     _credentials.addEx(newCredentials); | ||||
|   } | ||||
| 
 | ||||
|   void updateCredentials(Map<AuthType, Credential> credentials) { | ||||
|     _credentials.addEx(Map.of(credentials)); | ||||
|   } | ||||
| 
 | ||||
|   bool transitionTo(AccountDataStatus status, {AccountDataStatus? expectStatus}) { | ||||
|     return _accountDataStatus.addIfChanged(status); | ||||
|   } | ||||
| 
 | ||||
|   logout() { | ||||
|     _guruUserSubject.addEx(null); | ||||
|     _firebaseUser.addEx(null); | ||||
|     _deviceInfoSubject.addEx(null); | ||||
|     _accountProfile.addEx(null); | ||||
|     _accountDataStatus.addIfChanged(AccountDataStatus.idle); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| import 'dart:async'; | ||||
| 
 | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:firebase_auth/firebase_auth.dart'; | ||||
| import 'package:guru_app/account/account_data_store.dart'; | ||||
| import 'package:guru_app/account/model/account.dart'; | ||||
|  | @ -9,8 +10,10 @@ import 'package:guru_app/analytics/guru_analytics.dart'; | |||
| import 'package:guru_app/api/guru_api.dart'; | ||||
| import 'package:guru_app/firebase/firebase.dart'; | ||||
| import 'package:guru_app/firebase/firestore/firestore_manager.dart'; | ||||
| import 'package:guru_app/guru_app.dart'; | ||||
| import 'package:guru_app/property/app_property.dart'; | ||||
| import 'package:guru_app/property/settings/guru_settings.dart'; | ||||
| import 'package:guru_utils/auth/auth_credential_manager.dart'; | ||||
| import 'package:guru_utils/collection/collectionutils.dart'; | ||||
| import 'package:guru_utils/core/ext.dart'; | ||||
| import 'package:guru_utils/datetime/datetime_utils.dart'; | ||||
|  | @ -19,6 +22,8 @@ import 'package:guru_utils/device/device_utils.dart'; | |||
| import 'package:guru_utils/log/log.dart'; | ||||
| import 'package:guru_utils/network/network_utils.dart'; | ||||
| 
 | ||||
| import 'model/credential.dart'; | ||||
| 
 | ||||
| /// Created by Haoyi on 6/3/21 | ||||
| /// | ||||
| /// | ||||
|  | @ -26,6 +31,8 @@ part "account_service_extension.dart"; | |||
| 
 | ||||
| part "account_auth_extension.dart"; | ||||
| 
 | ||||
| part "account_auth_invoker.dart"; | ||||
| 
 | ||||
| class ModifyNicknameException implements Exception { | ||||
|   final String? message; | ||||
|   final dynamic cause; | ||||
|  | @ -53,11 +60,13 @@ class ModifyLevelException implements Exception { | |||
| class AccountManager { | ||||
|   final AccountDataStore accountDataStore; | ||||
| 
 | ||||
|   // final FirestoreService firestoreService; | ||||
| 
 | ||||
|   Timer? retryTimer; | ||||
| 
 | ||||
|   static AccountManager instance = AccountManager(); | ||||
|   static final AccountManager instance = AccountManager(); | ||||
| 
 | ||||
|   static const List<AuthCredentialDelegate> defaultSupportedAuthCredentialDelegates = [ | ||||
|     AnonymousCredentialDelegate() | ||||
|   ]; | ||||
| 
 | ||||
|   AccountManager() : accountDataStore = AccountDataStore.instance; | ||||
| 
 | ||||
|  | @ -109,6 +118,117 @@ class AccountManager { | |||
|     accountDataStore.updateAccountProfile(dirtyAccountProfile); | ||||
|   } | ||||
| 
 | ||||
|   /// 登录 | ||||
|   /// | ||||
|   /// [authType] 登录类型 | ||||
|   /// [onConflict] 登录冲突处理 | ||||
|   /// [onLogin] 登录成功处理 | ||||
|   /// | ||||
|   Future<bool> loginWith(AuthType authType) async { | ||||
|     late final Credential? credential; | ||||
|     try { | ||||
|       final result = await AuthCredentialManager.instance.loginWith(authType); | ||||
|       credential = result.credential; | ||||
|       if (!result.isSuccess || credential == null) { | ||||
|         Log.w("loginWith $authType error! credential: [$credential]", tag: "Account"); | ||||
|         return false; | ||||
|       } | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.e("loginWith $authType error:$error, $stacktrace"); | ||||
|       return false; | ||||
|     } | ||||
| 
 | ||||
|     try { | ||||
|       /// 如果冲突将会报 409 的错 | ||||
|       final guruUser = await _requestGuruUser(credential); | ||||
|       await processLogin(guruUser, credential); | ||||
|       return true; | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.w("loginWith $authType error:$error, $stacktrace"); | ||||
|       if (error is DioError && error.response?.statusCode == 409) { | ||||
|         return await _processConflict(credential); | ||||
|       } else { | ||||
|         return false; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> processLogin(GuruUser user, Credential credential) async { | ||||
|     await _updateGuruUser(user); | ||||
|     await _bindCredential(credential); | ||||
|     try { | ||||
|       await _verifyOrReportAuthDevice(user); | ||||
|       authenticateFirebase(); | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.e("_verifyOrReportAuthDevice error!$error $stacktrace"); | ||||
|     } | ||||
|     if (credential.isAnonymous) { | ||||
|       return await _invokeAnonymousLogin(user, credential); | ||||
|     } else { | ||||
|       return await _invokeLogin(user, credential); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /// | ||||
|   /// 登出操作,会把所有的第三方登陆都登出,如果当前有对应的匿名登陆的 credential 那么保留到匿名登陆的状态 | ||||
|   /// 如果当前没有匿名登录的 credential 那么这里将会重新创建一个匿名登陆,并且这里不会进行数据迁移 | ||||
|   /// | ||||
|   /// 如果在调用 logout 时,明确指定了要登出哪些 AuthType, | ||||
|   /// 那么 logout 方法将尝试用 unbind 的方法去处理用户信息。 | ||||
|   /// 当尝试解绑掉指定的 authTypes 时,只要满足下面两种情况的其中一种, | ||||
|   /// 都不能以 unbind 形式进行处理,都会认定为是真正的 logout | ||||
|   /// 1. 如果解绑掉所有指定的凭证后,当前凭证信息只保留了一个匿名凭证 | ||||
|   /// 2. 如果解绑掉所有指定的凭证后,当前没有任何凭证信息 | ||||
|   /// 如果上面两个条件都不满足,那么将以 unbind方法进行 logout | ||||
|   /// 以 unbind 形式进行 logout时,将不会通知应用 onLogout方法 | ||||
|   /// | ||||
|   /// 因此这里需要注意,就算明确指定了登出的 AuthType,依然存在调用 onLogout 的情况 | ||||
|   /// | ||||
|   Future<GuruUser?> logout({bool switching = false, Set<AuthType>? authTypes}) async { | ||||
|     bool isUnbind = false; | ||||
| 
 | ||||
|     if (authTypes != null && authTypes.isNotEmpty) { | ||||
|       final currentCredentials = accountDataStore.credentials.keys.toSet(); | ||||
|       currentCredentials.removeAll(authTypes); | ||||
|       currentCredentials.remove(AuthType.anonymous); | ||||
|       isUnbind = currentCredentials.isNotEmpty; | ||||
|     } | ||||
| 
 | ||||
|     final logoutUser = accountDataStore.user?.copyWith(); | ||||
|     try { | ||||
|       if (!isUnbind && logoutUser != null) { | ||||
|         final result = await _invokeLogout(logoutUser); | ||||
|         if (!result) { | ||||
|           Log.w("logout error! ignore!"); | ||||
|           return null; | ||||
|         } | ||||
|       } | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.w("invokeLogout error! $error!"); | ||||
|       return null; | ||||
|     } | ||||
|     for (var authType in accountDataStore.credentials.keys) { | ||||
|       /// 默认的登出只是 unbind 掉三方的 credentials ,不会真正的登出 | ||||
|       /// 如果当前没有匿名登陆,那么就会真正的登出,并会重新登陆匿名,但是数据不会清除 | ||||
|       /// 如果 authTypes传的是 null,这里会返回空,依然满足不等于 False, | ||||
|       /// 这里只要是空或是真正的包含才会进行真正的解绑 | ||||
|       if (authTypes?.contains(authType) != false && authType != AuthType.anonymous) { | ||||
|         await AuthCredentialManager.instance.logout(authType); | ||||
|         _unbindCredential(authType); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     /// 如果当前连匿名登陆也没有了,那么就会重新登陆匿名帐号 | ||||
|     /// 如果是正在切换帐号的话,这里不需要登录一个新的匿名帐号 | ||||
|     if (!switching && accountDataStore.credentials.isEmpty) { | ||||
|       final auth = await _retrieveAnonymous(); | ||||
|       if (auth != null) { | ||||
|         await processLogin(auth.user, auth.credential!); | ||||
|       } | ||||
|     } | ||||
|     return logoutUser; | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> modifyProfile( | ||||
|       {String? nickname, | ||||
|       String? avatar, | ||||
|  | @ -134,6 +254,10 @@ class AccountManager { | |||
|     }); | ||||
|     await updateLocalProfile(modifiedJson); | ||||
| 
 | ||||
|     /// 如果本地部署没有打开同步 AccountProfile 机制,这里直接返回 true | ||||
|     if (!GuruApp.instance.appSpec.deployment.enabledSyncAccountProfile) { | ||||
|       return true; | ||||
|     } | ||||
|     while ((await NetworkUtils.isNetworkConnected()) && retryCount-- > 0) { | ||||
|       final accountProfile = | ||||
|           await FirestoreManager.instance.modifyProfile(modifiedJson).onError((error, stackTrace) { | ||||
|  | @ -149,7 +273,9 @@ class AccountManager { | |||
|         return true; | ||||
|       } else { | ||||
|         Log.i("[$retryCount] modify profile error!", tag: "Account"); | ||||
|         await authenticate().timeout(const Duration(seconds: 15)).catchError((error, stackTrace) { | ||||
|         await authenticateFirebase() | ||||
|             .timeout(const Duration(seconds: 15)) | ||||
|             .catchError((error, stackTrace) { | ||||
|           Log.i("re-authenticate error:$error", stackTrace: stackTrace, tag: "Account"); | ||||
|         }); | ||||
|         await Future.delayed(const Duration(seconds: 1)); | ||||
|  |  | |||
|  | @ -1,17 +1,21 @@ | |||
| /// Created by Haoyi on 6/3/21 | ||||
| 
 | ||||
| part of "account_manager.dart"; | ||||
| 
 | ||||
| extension AccountServiceExtension on AccountManager { | ||||
|   Future<bool> _restoreAccount(Account account) async { | ||||
|     SaasUser? saasUser = account.saasUser; | ||||
|     Log.d("restoreAccount $saasUser", tag: "Account"); | ||||
|     saasUser ??= await signInWithAnonymous().catchError((error, stacktrace) { | ||||
|       Log.v("signInWithAnonymous error:$error, $stacktrace"); | ||||
|       return null; | ||||
|     }); | ||||
|     AccountAuth? anonymousAuth; | ||||
|     GuruUser? guruUser = account.guruUser; | ||||
|     Log.d("restoreAccount $guruUser", tag: "Account"); | ||||
|     try { | ||||
|       if (guruUser == null) { | ||||
|         anonymousAuth = await _retrieveAnonymous(); | ||||
|         guruUser = anonymousAuth?.user; | ||||
|       } | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.w("loginWith Anonymous error:$error, $stacktrace"); | ||||
|     } | ||||
| 
 | ||||
|     Log.v("_restoreAccount saasUser:$saasUser", tag: "Account"); | ||||
|     Log.v("_restoreAccount saasUser:$guruUser", tag: "Account"); | ||||
|     final device = account.device; | ||||
|     if (device != null) { | ||||
|       _updateDevice(device); | ||||
|  | @ -22,45 +26,116 @@ extension AccountServiceExtension on AccountManager { | |||
|       _updateAccountProfile(accountProfile); | ||||
|     } | ||||
| 
 | ||||
|     if (saasUser != null) { | ||||
|       _updateSaasUser(saasUser); | ||||
|       await _verifyOrReportAuthDevice(saasUser); | ||||
|       final auth = await authenticate(); | ||||
|       if (auth == null) { | ||||
|         return false; | ||||
|       } | ||||
|     final credentials = account.credentials; | ||||
|     if (credentials.isNotEmpty) { | ||||
|       _restoreCredentials(credentials); | ||||
|     } | ||||
| 
 | ||||
|     if (guruUser != null) { | ||||
|       await _updateGuruUser(guruUser); | ||||
|       await _verifyOrReportAuthDevice(guruUser); | ||||
|       await authenticateFirebase(); | ||||
|       if (accountProfile != null) { | ||||
|         await _checkOrUploadAccountProfile(accountProfile); | ||||
|       } | ||||
|       if (anonymousAuth != null) { | ||||
|         final anonymousCredential = anonymousAuth.credential; | ||||
|         if (anonymousCredential != null) { | ||||
|           _bindCredential(anonymousCredential); | ||||
|           return await _invokeAnonymousLogin(anonymousAuth.user, anonymousCredential); | ||||
|         } | ||||
|       } | ||||
|       return true; | ||||
|     } else { | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future<AccountAuth?> authenticate() async { | ||||
|     final saasUser = accountDataStore.user; | ||||
|     if (saasUser == null) { | ||||
|       return null; | ||||
|     } | ||||
|   Future switchUser(GuruUser newUser) async { | ||||
|     /// 更新 login 的用户信息 | ||||
|     _updateGuruUser(newUser); | ||||
|     try { | ||||
|       final auth = await _authenticate(saasUser); | ||||
|       final newSaasUser = auth.user; | ||||
|       if (newSaasUser != null && !saasUser.isSame(newSaasUser)) { | ||||
|         _updateSaasUser(newSaasUser); | ||||
|       } | ||||
|       if (auth.firebaseUser != null) { | ||||
|         _updateFirebaseUser(auth.firebaseUser!); | ||||
|         Log.i("_updateFirebaseUser success!", tag: "Account"); | ||||
|       } | ||||
|       return auth; | ||||
|       await _verifyOrReportAuthDevice(newUser); | ||||
|       // 登陆 firebase 不需要同步等待 | ||||
|       authenticateFirebase(); | ||||
|     } catch (error, stacktrace) { | ||||
|       GuruAnalytics.instance.logException(error, stacktrace: stacktrace); | ||||
|       Log.w("loginWithCredential error:$error, $stacktrace"); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   Future<DeviceTrack> _buildDevice(SaasUser saasUser) async { | ||||
|   Future<bool> _switchAccount(Credential credential) async { | ||||
|     GuruUser? loginUser; | ||||
|     GuruUser? logoutUser; | ||||
| 
 | ||||
|     /// 这里只调用接口获取对应的新用户信息,还没有做对应的绑定操作 | ||||
|     try { | ||||
|       loginUser = await _loginGuruWithCredential(credential); | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.w("loginWithCredential[${credential.authType}] error:$error, $stacktrace"); | ||||
|       return false; | ||||
|     } | ||||
|     if (loginUser.isSame(accountDataStore.user)) { | ||||
|       Log.w("loginWithCredential same user!", tag: "Account"); | ||||
|       _bindCredential(credential); | ||||
|       return false; | ||||
|     } | ||||
|     bool result = false; | ||||
| 
 | ||||
|     /// logout 内部进行了拦截,因此这里总是会返回一个 logoutUser | ||||
|     /// logout传入 switch参数,表示是一个切换帐号,不需要真正的登出, | ||||
|     /// 因为在下面的 SwitchAccount方法中会完成后续的过程 | ||||
|     logoutUser = await logout(switching: true); | ||||
| 
 | ||||
|     /// 如果这里没有返回出对应的退出用户,将认为退出失败 | ||||
|     /// 因为进到 switchAccount 里肯定是非匿名登陆的帐号做登出操作 | ||||
|     if (logoutUser != null) { | ||||
|       result = await GuruApp.instance.switchAccount(loginUser, credential, oldUser: logoutUser); | ||||
|     } | ||||
|     return result; | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> _processConflict(Credential credential) async { | ||||
|     final historicalSocialAuths = await AppProperty.getInstance().getHistoricalSocialAuths(); | ||||
| 
 | ||||
|     /// 如果是匿名登录,并且在这个设备上同样的用户没有绑定过其它的三方登陆凭证 | ||||
|     /// 这种情况下,认定为新用户,中台会静默解决冲突,并且对应的数据库不会发生迁移 | ||||
|     if (accountDataStore.isAnonymous && historicalSocialAuths.isEmpty) { | ||||
|       Log.d("associate conflict: _loginGuruWithCredential!"); | ||||
|       final user = accountDataStore.user; | ||||
|       final oldUid = user?.uid ?? ""; | ||||
|       if (user != null) { | ||||
|         await _invokeAnonymousLogout(user); | ||||
|       } | ||||
| 
 | ||||
|       /// 因为这里是匿名登陆,因此在冲突的时候通过静默的方法切换账户 | ||||
|       final guruUser = await _loginGuruWithCredential(credential); | ||||
| 
 | ||||
|       /// 由于是冲突处理,此时的匿名帐号已经和当前新登陆的用户不能配对 | ||||
|       /// 因此在新用户登陆成功后,这里需要将匿名帐户的凭证解绑,并清除匿名的密钥 | ||||
|       /// 这样做的目的是为了在该帐号退出时,判断匿名帐号是否存在, | ||||
|       /// 如果不存在会创建一个新的匿名帐号,确保数据不被污染 | ||||
|       await _unbindCredential(AuthType.anonymous); | ||||
| 
 | ||||
|       /// 将新的用户进行关联,此时当前设备上只有一个登陆凭证 | ||||
|       await processLogin(guruUser, credential); | ||||
| 
 | ||||
|       GuruAnalytics.instance.logGuruEvent("switch_account", { | ||||
|         "auth": getAuthName(credential.authType), | ||||
|         "old_uid": oldUid, | ||||
|         "new_uid": guruUser.uid, | ||||
|         "silent": true | ||||
|       }); | ||||
|       return true; | ||||
|     } else { | ||||
|       final canSwitch = await _invokeConflict(); | ||||
|       if (canSwitch) { | ||||
|         return await _switchAccount(credential); | ||||
|       } | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   Future<DeviceTrack> _buildDevice(GuruUser saasUser) async { | ||||
|     final DeviceInfo? deviceInfo = await AppProperty.getInstance().getAccountDevice(); | ||||
|     final firebasePushToken = await RemoteMessagingManager.instance.getToken(); | ||||
| 
 | ||||
|  | @ -73,13 +148,39 @@ extension AccountServiceExtension on AccountManager { | |||
|     return DeviceTrack(null, deviceInfo); | ||||
|   } | ||||
| 
 | ||||
|   Future<SaasUser?> signInWithAnonymous() async { | ||||
|     final anonymousSecretKey = await AppProperty.getInstance().getAnonymousSecretKey(); | ||||
|     return GuruApi.instance.signInWithAnonymous(secret: anonymousSecretKey); | ||||
|   Future<AccountAuth?> _retrieveAnonymous() async { | ||||
|     final result = await AuthCredentialManager.instance.loginWith(AuthType.anonymous); | ||||
|     final credential = result.credential; | ||||
|     if (!result.isSuccess || credential == null) { | ||||
|       Log.w("_retrieveAnonymous error!", tag: "Account"); | ||||
|       return null; | ||||
|     } | ||||
|     final user = await _requestGuruUser(credential); | ||||
|     return AccountAuth(user, credential: credential); | ||||
|   } | ||||
| 
 | ||||
|   Future _verifyOrReportAuthDevice(SaasUser saasUser) async { | ||||
|     final deviceTrack = await _buildDevice(saasUser); | ||||
|   Future<GuruUser> _loginGuruWithCredential(Credential credential) async { | ||||
|     return await GuruApi.instance.loginGuruWithCredential(credential: credential); | ||||
|   } | ||||
| 
 | ||||
|   Future<GuruUser> _associateCredential(Credential credential) async { | ||||
|     return await GuruApi.instance.associateCredential(credential: credential); | ||||
|   } | ||||
| 
 | ||||
|   Future<GuruUser> _requestGuruUser(Credential credential) async { | ||||
|     //MetaData是匿名请求,或者当前没有任何 GuruUser Id,走signIn接口 | ||||
|     if (!accountDataStore.hasUid || credential.isAnonymous) { | ||||
|       Log.d("_loginGuruWithCredential!", tag: "Account"); | ||||
|       return await _loginGuruWithCredential(credential); | ||||
|     } else { | ||||
|       Log.d("_associateCredential!"); | ||||
|       //当前有 GuruUser id,并且MetaData是三方登录Token,走associate接口(不管已有的SaasUser是不是三方登录) | ||||
|       return await _associateCredential(credential); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future _verifyOrReportAuthDevice(GuruUser guruUser) async { | ||||
|     final deviceTrack = await _buildDevice(guruUser); | ||||
|     final latestReportDeviceTimestamp = | ||||
|         await AppProperty.getInstance().getLatestReportDeviceTimestamp(); | ||||
|     final elapsedInterval = DateTimeUtils.currentTimeInMillis() - latestReportDeviceTimestamp; | ||||
|  | @ -89,7 +190,7 @@ extension AccountServiceExtension on AccountManager { | |||
|     if (deviceId.isNotEmpty) { | ||||
|       GuruAnalytics.instance.setDeviceId(deviceId); | ||||
|     } | ||||
|     if (isChanged && reportDevice?.isValid == true && saasUser.isValid == true) { | ||||
|     if (isChanged && reportDevice?.isValid == true && guruUser.isValid == true) { | ||||
|       final result = await GuruApi.instance.reportDevice(reportDevice!).then((_) { | ||||
|         return true; | ||||
|       }).catchError((error) { | ||||
|  | @ -135,10 +236,32 @@ extension AccountServiceExtension on AccountManager { | |||
|     accountDataStore.updateDeviceInfo(device); | ||||
|   } | ||||
| 
 | ||||
|   void _updateSaasUser(SaasUser saasUser) { | ||||
|     accountDataStore.updateSaasUser(saasUser); | ||||
|     AppProperty.getInstance().setAccountSaasUser(saasUser); | ||||
|     GuruAnalytics.instance.setUserId(saasUser.uid); | ||||
|   Future _bindCredential(Credential credential) async { | ||||
|     accountDataStore.bindCredential(credential); | ||||
| 
 | ||||
|     /// 这里匿名帐号是不会保存凭证的,因为匿名帐号的登陆凭证是自生成的 | ||||
|     if (credential.authType != AuthType.anonymous) { | ||||
|       await AppProperty.getInstance().saveCredential(credential); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future _unbindCredential(AuthType authType) async { | ||||
|     accountDataStore.unbindCredential(authType); | ||||
|     if (authType != AuthType.anonymous) { | ||||
|       await AppProperty.getInstance().deleteCredential(authType); | ||||
|     } else { | ||||
|       await AppProperty.getInstance().clearAnonymousSecretKey(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   void _restoreCredentials(Map<AuthType, Credential> credentials) { | ||||
|     accountDataStore.updateCredentials(credentials); | ||||
|   } | ||||
| 
 | ||||
|   Future _updateGuruUser(GuruUser guruUser) async { | ||||
|     accountDataStore.updateGuruUser(guruUser); | ||||
|     await AppProperty.getInstance().setAccountGuruUser(guruUser); | ||||
|     await GuruAnalytics.instance.setUserId(guruUser.uid); | ||||
|   } | ||||
| 
 | ||||
|   void _updateFirebaseUser(User user) { | ||||
|  |  | |||
|  | @ -1,40 +1,108 @@ | |||
| import 'package:firebase_auth/firebase_auth.dart'; | ||||
| import 'package:guru_app/account/account_manager.dart'; | ||||
| import 'package:guru_app/account/model/account_profile.dart'; | ||||
| import 'package:guru_app/account/model/user.dart'; | ||||
| import 'package:guru_utils/auth/auth_credential_manager.dart'; | ||||
| import 'package:guru_utils/device/device_info.dart'; | ||||
| 
 | ||||
| import 'package:guru_utils/property/app_property.dart'; | ||||
| 
 | ||||
| /// Created by Haoyi on 6/3/21 | ||||
| 
 | ||||
| class Account { | ||||
|   final SaasUser? saasUser; | ||||
|   final GuruUser? guruUser; | ||||
|   final DeviceInfo? device; | ||||
|   final AccountProfile? accountProfile; | ||||
|   final User? firebaseUser; | ||||
|   final Map<AuthType, Credential> credentials; // facebook, google, apple, anonymous | ||||
| 
 | ||||
|   String? get uid => saasUser?.uid; | ||||
|   @Deprecated("use guruUser instead") | ||||
|   SaasUser? get saasUser => guruUser; | ||||
| 
 | ||||
|   String? get uid => guruUser?.uid; | ||||
| 
 | ||||
|   String? get nickname => accountProfile?.nickname; | ||||
| 
 | ||||
|   Account.restore({this.saasUser, this.device, this.accountProfile, this.firebaseUser}); | ||||
|   Account.restore( | ||||
|       {this.guruUser, | ||||
|       this.device, | ||||
|       this.accountProfile, | ||||
|       this.firebaseUser, | ||||
|       this.credentials = const {}}); | ||||
| } | ||||
| 
 | ||||
| class AccountAuth { | ||||
|   final SaasUser? user; | ||||
|   final User? firebaseUser; | ||||
|   final GuruUser user; | ||||
|   final Credential? credential; | ||||
| 
 | ||||
|   AccountAuth(this.user, this.firebaseUser); | ||||
|   AccountAuth(this.user, {this.credential}); | ||||
| 
 | ||||
|   bool get isValid => uid != null && uid != ""; | ||||
| 
 | ||||
|   String? get saasToken => user?.token; | ||||
|   String? get saasToken => user.token; | ||||
| 
 | ||||
|   String? get uid => user?.uid; | ||||
|   String? get uid => user.uid; | ||||
| 
 | ||||
|   // bool get existsFirebaseUser => firebaseUser != null; | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'AccountAuth{user: $user}'; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class FirebaseAccountAuth { | ||||
|   final GuruUser user; | ||||
|   final User? firebaseUser; | ||||
| 
 | ||||
|   FirebaseAccountAuth(this.user, {this.firebaseUser}); | ||||
| 
 | ||||
|   bool get isValid => uid != null && uid != ""; | ||||
| 
 | ||||
|   String? get guruToken => user.token; | ||||
| 
 | ||||
|   String? get uid => user.uid; | ||||
| 
 | ||||
|   bool get existsFirebaseUser => firebaseUser != null; | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'AccountAuth{user: $user, firebaseUser: $firebaseUser}'; | ||||
|     return 'AccountAuth{user: $user}'; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| abstract class IAccountAuthDelegate { | ||||
|   /// 支持登陆的代理,这块不管返回不返回都会支持匿名登陆 | ||||
|   List<AuthCredentialDelegate> get supportedAuthCredentialDelegates => | ||||
|       AccountManager.defaultSupportedAuthCredentialDelegates; | ||||
| 
 | ||||
|   /// 返回设备共享的用户属性,在帐号切换的时候会保证这些 KEY,会保留下来 | ||||
|   /// 注意,这里尽量不要把用户相关的属性设到这里面,否则会出现不必要的问题 | ||||
|   Set<PropertyKey> get deviceSharedProperties => {}; | ||||
| 
 | ||||
|   /// 这个方法调用时,中台会确保当前的数据系统是切换后的数据系统 | ||||
|   /// 因此可以放心使用模板的数据,当 processor 返回后,对应的数据系统将会被切换 | ||||
|   /// 因此确保 processor 返回后,数据系统已经切换到新用户的数据系统 | ||||
|   Future<bool> onLogin(GuruUser loginUser, Credential credential); | ||||
| 
 | ||||
|   /// 这个方法调用时,中台会确保当前的数据系统是切换前的数据系统 | ||||
|   /// 因此可以放心使用模板的数据,当 processor返回后,对应的数据系统将会被切换 | ||||
|   /// 因此确保 processor 返回前,数据系统已经完成老用户数据的迁移 | ||||
|   Future<bool> onLogout(GuruUser logoutUser); | ||||
| 
 | ||||
|   /// 当出现登陆冲突时,有可能当前是匿名帐号,而这时,中台模板会静默登入到新的帐号中 | ||||
|   /// 与此同时,中台会调用 onAnonymousLogout | ||||
|   Future<bool> onAnonymousLogout(GuruUser logoutUser) async { | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
|   /// 当 APP首次匿名登陆时,或当登陆一个没有绑定匿名Credential的帐号时,会在登出时重新登陆一个新的匿名帐号 | ||||
|   /// 中台在这种情况下会调用 onAnonymousLogin | ||||
|   Future<bool> onAnonymousLogin(GuruUser loginUser, Credential credential) async { | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
|   /// 当登陆时,有可能会出现帐号冲突,因此针对冲突可以选择切换帐号或者忽略 | ||||
|   /// 如果你选择切换帐号,那么你需要提供 onLogout 方法,用于处理老用户的数据迁移 | ||||
|   /// 返回值为是否继续 | ||||
|   Future<bool> onConflict(); | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,37 @@ | |||
| import 'package:guru_app/property/app_property.dart'; | ||||
| import 'package:guru_utils/auth/auth_credential_manager.dart'; | ||||
| 
 | ||||
| class AnonymousCredential extends Credential { | ||||
|   final String secretKey; | ||||
| 
 | ||||
|   AnonymousCredential(this.secretKey); | ||||
| 
 | ||||
|   @override | ||||
|   String get token => secretKey; | ||||
| 
 | ||||
|   @override | ||||
|   AuthType get authType => AuthType.anonymous; | ||||
| 
 | ||||
|   String toJson() => secretKey; | ||||
| } | ||||
| 
 | ||||
| class AnonymousCredentialDelegate extends AuthCredentialDelegate { | ||||
|   const AnonymousCredentialDelegate(); | ||||
| 
 | ||||
|   @override | ||||
|   AuthType get authType => AuthType.anonymous; | ||||
| 
 | ||||
|   @override | ||||
|   Future<AuthResult> login() async { | ||||
|     final secretKey = await AppProperty.getInstance().getAnonymousSecretKey(); | ||||
|     return AuthResult.success(AnonymousCredential(secretKey)); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Future logout() async {} | ||||
| 
 | ||||
|   @override | ||||
|   Credential deserializeCredential(String data) { | ||||
|     return AnonymousCredential(data); | ||||
|   } | ||||
| } | ||||
|  | @ -4,8 +4,11 @@ import 'package:json_annotation/json_annotation.dart'; | |||
| 
 | ||||
| part 'user.g.dart'; | ||||
| 
 | ||||
| @Deprecated("Use Guru User instead") | ||||
| typedef SaasUser = GuruUser; | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class SaasUser { | ||||
| class GuruUser { | ||||
|   @JsonKey(name: 'uid', defaultValue: "") | ||||
|   final String uid; | ||||
| 
 | ||||
|  | @ -23,22 +26,22 @@ class SaasUser { | |||
|   bool get isValid => | ||||
|       (uid != "") && (token.isNotEmpty == true) && (firebaseToken.isNotEmpty == true); | ||||
| 
 | ||||
|   SaasUser( | ||||
|   GuruUser( | ||||
|       {required this.uid, | ||||
|       required this.token, | ||||
|       required this.firebaseToken, | ||||
|       this.createAtTimestamp = 0}); | ||||
| 
 | ||||
|   factory SaasUser.fromJson(Map<String, dynamic> json) => _$SaasUserFromJson(json); | ||||
|   factory GuruUser.fromJson(Map<String, dynamic> json) => _$GuruUserFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$SaasUserToJson(this); | ||||
|   Map<String, dynamic> toJson() => _$GuruUserToJson(this); | ||||
| 
 | ||||
|   SaasUser copyWith({String? firebaseToken, String? token}) { | ||||
|     return SaasUser( | ||||
|   GuruUser copyWith({String? firebaseToken, String? token}) { | ||||
|     return GuruUser( | ||||
|         uid: uid, token: token ?? this.token, firebaseToken: firebaseToken ?? this.firebaseToken); | ||||
|   } | ||||
| 
 | ||||
|   bool isSame(SaasUser? user) { | ||||
|   bool isSame(GuruUser? user) { | ||||
|     return uid == user?.uid && | ||||
|         token == user?.token && | ||||
|         firebaseToken == user?.firebaseToken && | ||||
|  | @ -70,6 +73,63 @@ class AnonymousLoginReqBody { | |||
|   Map<String, dynamic> toJson() => _$AnonymousLoginReqBodyToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class FacebookLoginReqBody { | ||||
|   @JsonKey(name: 'accessToken', defaultValue: "") | ||||
|   final String? accessToken; | ||||
| 
 | ||||
|   FacebookLoginReqBody({this.accessToken}); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'FacebookLoginReqBody{accessToken: $accessToken}'; | ||||
|   } | ||||
| 
 | ||||
|   factory FacebookLoginReqBody.fromJson(Map<String, dynamic> json) => | ||||
|       _$FacebookLoginReqBodyFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$FacebookLoginReqBodyToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class GoogleLoginReqBody { | ||||
|   @JsonKey(name: 'idToken', defaultValue: "") | ||||
|   final String? idToken; | ||||
| 
 | ||||
|   GoogleLoginReqBody({this.idToken}); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'GoogleLoginReqBody{idToken: $idToken}'; | ||||
|   } | ||||
| 
 | ||||
|   factory GoogleLoginReqBody.fromJson(Map<String, dynamic> json) => | ||||
|       _$GoogleLoginReqBodyFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$GoogleLoginReqBodyToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class AppleLoginReqBody { | ||||
|   @JsonKey(name: 'token', defaultValue: "") | ||||
|   final String? token; | ||||
| 
 | ||||
|   @JsonKey(name: 'clientType', defaultValue: "ios") | ||||
|   final String clientType; | ||||
| 
 | ||||
|   AppleLoginReqBody({this.token, this.clientType = "ios"}); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'AppleLoginReqBody{token: $token, clientType: $clientType}'; | ||||
|   } | ||||
| 
 | ||||
|   factory AppleLoginReqBody.fromJson(Map<String, dynamic> json) => | ||||
|       _$AppleLoginReqBodyFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$AppleLoginReqBodyToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class FirebaseTokenData { | ||||
|   @JsonKey(name: 'uid', defaultValue: "") | ||||
|  | @ -78,7 +138,7 @@ class FirebaseTokenData { | |||
|   @JsonKey(name: 'firebaseToken', defaultValue: "") | ||||
|   final String firebaseToken; | ||||
| 
 | ||||
|   FirebaseTokenData({required this.uid, required this.firebaseToken}); | ||||
|   FirebaseTokenData({this.uid = "", this.firebaseToken = ""}); | ||||
| 
 | ||||
|   factory FirebaseTokenData.fromJson(Map<String, dynamic> json) => | ||||
|       _$FirebaseTokenDataFromJson(json); | ||||
|  | @ -91,9 +151,45 @@ class FirebaseTokenData { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class UserAuthInfo { | ||||
|   @JsonKey(name: "secret", defaultValue: "") | ||||
|   final String secret; | ||||
| 
 | ||||
|   @JsonKey(name: 'providerList', defaultValue: const <String>[]) | ||||
|   final List<String> providerList; | ||||
| 
 | ||||
|   factory UserAuthInfo.fromJson(Map<String, dynamic> json) => _$UserAuthInfoFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$UserAuthInfoToJson(this); | ||||
| 
 | ||||
|   UserAuthInfo({this.secret = "", this.providerList = const []}); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'UserAuthList{providerList: $providerList}'; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class UnbindReqBody { | ||||
|   @JsonKey(name: 'provider', defaultValue: "") | ||||
|   final String provider; | ||||
| 
 | ||||
|   UnbindReqBody({this.provider = ""}); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'UnbindReqBody{provider: $provider}'; | ||||
|   } | ||||
| 
 | ||||
|   factory UnbindReqBody.fromJson(Map<String, dynamic> json) => _$UnbindReqBodyFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$UnbindReqBodyToJson(this); | ||||
| } | ||||
| 
 | ||||
| class UserAttr { | ||||
|   static const real = 0; | ||||
|   static const tester = 10; | ||||
|   static const machine = 100; | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -6,14 +6,14 @@ part of 'user.dart'; | |||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
| 
 | ||||
| SaasUser _$SaasUserFromJson(Map<String, dynamic> json) => SaasUser( | ||||
| GuruUser _$GuruUserFromJson(Map<String, dynamic> json) => GuruUser( | ||||
|       uid: json['uid'] as String? ?? '', | ||||
|       token: json['token'] as String? ?? '', | ||||
|       firebaseToken: json['firebaseToken'] as String? ?? '', | ||||
|       createAtTimestamp: json['createdAtTimestamp'] as int? ?? 0, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$SaasUserToJson(SaasUser instance) => <String, dynamic>{ | ||||
| Map<String, dynamic> _$GuruUserToJson(GuruUser instance) => <String, dynamic>{ | ||||
|       'uid': instance.uid, | ||||
|       'token': instance.token, | ||||
|       'firebaseToken': instance.firebaseToken, | ||||
|  | @ -32,6 +32,40 @@ Map<String, dynamic> _$AnonymousLoginReqBodyToJson( | |||
|       'secret': instance.secret, | ||||
|     }; | ||||
| 
 | ||||
| FacebookLoginReqBody _$FacebookLoginReqBodyFromJson( | ||||
|         Map<String, dynamic> json) => | ||||
|     FacebookLoginReqBody( | ||||
|       accessToken: json['accessToken'] as String? ?? '', | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$FacebookLoginReqBodyToJson( | ||||
|         FacebookLoginReqBody instance) => | ||||
|     <String, dynamic>{ | ||||
|       'accessToken': instance.accessToken, | ||||
|     }; | ||||
| 
 | ||||
| GoogleLoginReqBody _$GoogleLoginReqBodyFromJson(Map<String, dynamic> json) => | ||||
|     GoogleLoginReqBody( | ||||
|       idToken: json['idToken'] as String? ?? '', | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$GoogleLoginReqBodyToJson(GoogleLoginReqBody instance) => | ||||
|     <String, dynamic>{ | ||||
|       'idToken': instance.idToken, | ||||
|     }; | ||||
| 
 | ||||
| AppleLoginReqBody _$AppleLoginReqBodyFromJson(Map<String, dynamic> json) => | ||||
|     AppleLoginReqBody( | ||||
|       token: json['token'] as String? ?? '', | ||||
|       clientType: json['clientType'] as String? ?? 'ios', | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$AppleLoginReqBodyToJson(AppleLoginReqBody instance) => | ||||
|     <String, dynamic>{ | ||||
|       'token': instance.token, | ||||
|       'clientType': instance.clientType, | ||||
|     }; | ||||
| 
 | ||||
| FirebaseTokenData _$FirebaseTokenDataFromJson(Map<String, dynamic> json) => | ||||
|     FirebaseTokenData( | ||||
|       uid: json['uid'] as String? ?? '', | ||||
|  | @ -43,3 +77,27 @@ Map<String, dynamic> _$FirebaseTokenDataToJson(FirebaseTokenData instance) => | |||
|       'uid': instance.uid, | ||||
|       'firebaseToken': instance.firebaseToken, | ||||
|     }; | ||||
| 
 | ||||
| UserAuthInfo _$UserAuthInfoFromJson(Map<String, dynamic> json) => UserAuthInfo( | ||||
|       secret: json['secret'] as String? ?? '', | ||||
|       providerList: (json['providerList'] as List<dynamic>?) | ||||
|               ?.map((e) => e as String) | ||||
|               .toList() ?? | ||||
|           [], | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$UserAuthInfoToJson(UserAuthInfo instance) => | ||||
|     <String, dynamic>{ | ||||
|       'secret': instance.secret, | ||||
|       'providerList': instance.providerList, | ||||
|     }; | ||||
| 
 | ||||
| UnbindReqBody _$UnbindReqBodyFromJson(Map<String, dynamic> json) => | ||||
|     UnbindReqBody( | ||||
|       provider: json['provider'] as String? ?? '', | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$UnbindReqBodyToJson(UnbindReqBody instance) => | ||||
|     <String, dynamic>{ | ||||
|       'provider': instance.provider, | ||||
|     }; | ||||
|  |  | |||
|  | @ -560,18 +560,26 @@ class AdsManager extends AdsManagerDelegate { | |||
|     return ad; | ||||
|   } | ||||
| 
 | ||||
|   Future<int> requestGdpr({int? debugGeography, String? testDeviceId}) { | ||||
|   Future<int> requestGdpr({int? debugGeography, String? testDeviceId}) async { | ||||
|     Log.d("requestGdpr! debugGeography:$debugGeography testDeviceId:$testDeviceId", tag: "Ads"); | ||||
|     // adb logcat -s UserMessagingPlatform | ||||
|     // Use new ConsentDebugSettings.Builder().addTestDeviceHashedId("xxxx") to set this as a debug device. | ||||
|     return GuruApplovinFlutter.instance | ||||
|     final result = await GuruApplovinFlutter.instance | ||||
|         .requestGdpr(debugGeography: debugGeography, testDeviceId: testDeviceId); | ||||
|     final consentResult = await GuruAnalytics.instance.refreshConsents(); | ||||
|     Log.d("requestGdpr result:$result consentResult:$consentResult"); | ||||
|     return result; | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> resetGdpr() { | ||||
|     return GuruApplovinFlutter.instance.resetGdpr(); | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> updateOrientation(int orientation) async { | ||||
|     final result = await GuruApplovinFlutter.instance.updateOrientation(orientation); | ||||
|     return result == true; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Future<ApplovinBannerAds> createBannerAds({String? scene, AdsLifecycleObserver? observer}) async { | ||||
|     final _adsProfile = adsProfile; | ||||
|  | @ -583,21 +591,14 @@ class AdsManager extends AdsManagerDelegate { | |||
|     if (isPurchasedNoAd) { | ||||
|       return AdCause.noAds; | ||||
|     } | ||||
|     final _adsProfile = adsProfile; | ||||
|     Ads? ad = interstitialAds[_adsProfile.interstitialId]; | ||||
|     int hiddenAt = 0; | ||||
|     if (ad is AdsAudit) { | ||||
|       hiddenAt = ad.latestHiddenAt; | ||||
|     } | ||||
| 
 | ||||
|     final hiddenAt = AdsManager.instance.latestFullscreenAdsHiddenTimestamps; | ||||
|     final now = DateTimeUtils.currentTimeInMillis(); | ||||
|     final impGapInMillis = | ||||
|         AdsManager.instance.adsConfig.interstitialConfig.getSceneImpGapInSeconds(scene) * 1000; | ||||
|     Log.d( | ||||
|         "canShowInterstitial($scene): now:$now latestFullscreenAdsHiddenTimestamps:$latestFullscreenAdsHiddenTimestamps hiddenAt:$hiddenAt impGapInMillis:$impGapInMillis", | ||||
|         tag: "Ads"); | ||||
|     if ((now - AdsManager.instance.latestFullscreenAdsHiddenTimestamps) < 60000 || | ||||
|         ((now - hiddenAt) < impGapInMillis)) { | ||||
|     if ((now - hiddenAt) < impGapInMillis) { | ||||
|       Log.d("show ads too frequency", syncFirebase: true); | ||||
|       return AdCause.tooFrequent; | ||||
|     } | ||||
|  |  | |||
|  | @ -316,7 +316,7 @@ class AdInterstitialConfig { | |||
|   @joinedStringConvert | ||||
|   final List<String> scenes; | ||||
| 
 | ||||
|   @JsonKey(name: "sp_scene", defaultValue: {"new_block": 120, "reset_scs": 120}) | ||||
|   @JsonKey(name: "sp_scene", defaultValue: {}) | ||||
|   @configStringIntMapStringConvert | ||||
|   final Map<String, int> specialScenes; | ||||
| 
 | ||||
|  | @ -329,8 +329,8 @@ class AdInterstitialConfig { | |||
|   @JsonKey(name: "amazon_enable", defaultValue: false) | ||||
|   final bool amazonEnable; | ||||
| 
 | ||||
|   @JsonKey(name: "imp_gap_s", defaultValue: 120) | ||||
|   final int impGapInSeconds; | ||||
|   @JsonKey(name: "imp_gap_s") | ||||
|   final int? impGapInSeconds; | ||||
| 
 | ||||
|   AdInterstitialConfig(this.freeInSecond, this.validation, this.scenes, this.retryMinTimeInSecond, | ||||
|       this.retryMaxTimeInSecond, | ||||
|  | @ -346,7 +346,10 @@ class AdInterstitialConfig { | |||
|   } | ||||
| 
 | ||||
|   int getSceneImpGapInSeconds(String scene) { | ||||
|     return specialScenes[scene] ?? impGapInSeconds; | ||||
|     return (specialScenes[scene] ?? | ||||
|             impGapInSeconds ?? | ||||
|             GuruApp.instance.appSpec.deployment.fullscreenAdsMinInterval) | ||||
|         .clamp(5, 600); | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> checkFreeTime() async { | ||||
|  |  | |||
|  | @ -132,10 +132,10 @@ AdInterstitialConfig _$AdInterstitialConfigFromJson( | |||
|       json['retry_max_s'] as int? ?? 600, | ||||
|       amazonEnable: json['amazon_enable'] as bool? ?? false, | ||||
|       specialScenes: json['sp_scene'] == null | ||||
|           ? {'new_block': 120, 'reset_scs': 120} | ||||
|           ? {} | ||||
|           : configStringIntMapStringConvert | ||||
|               .fromJson(json['sp_scene'] as String), | ||||
|       impGapInSeconds: json['imp_gap_s'] as int? ?? 120, | ||||
|       impGapInSeconds: json['imp_gap_s'] as int?, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$AdInterstitialConfigToJson( | ||||
|  |  | |||
|  | @ -3,9 +3,11 @@ import 'dart:io'; | |||
| 
 | ||||
| import 'package:guru_app/analytics/guru_analytics.dart'; | ||||
| import 'package:guru_app/firebase/remoteconfig/remote_config_manager.dart'; | ||||
| import 'package:guru_app/guru_app.dart'; | ||||
| import 'package:guru_app/property/app_property.dart'; | ||||
| import 'package:guru_app/property/property_keys.dart'; | ||||
| import 'package:guru_utils/collection/collectionutils.dart'; | ||||
| import 'package:guru_utils/datetime/datetime_utils.dart'; | ||||
| import 'package:guru_utils/log/log.dart'; | ||||
| import 'package:guru_applovin_flutter/ad_impression.dart'; | ||||
| 
 | ||||
|  | @ -65,26 +67,7 @@ class AdImpressionController { | |||
|             } | ||||
|             final payloadMap = json.decode(payload); | ||||
|             ImpressionData impressionData = ImpressionData.fromJson(payloadMap); | ||||
|             // 判断是不是facebook的network | ||||
|             // if (impressionData.networkName == "undisclosed") { | ||||
|             //   final calibrationCpm = await facebookCalibrator.getCpm(impressionData.unitFormat, impressionData.country); | ||||
|             //   if (calibrationCpm > 0) { | ||||
|             //     final newImpressionData = impressionData.derive(newPublisherRevenue: calibrationCpm); | ||||
|             //     if (facebookCalibrator.config?.fbIrldReport == true) { | ||||
|             //       AnalyticsUtils.logEventEx("tch_fb_ad_rev", value: calibrationCpm, parameters: { | ||||
|             //         FirebaseEventsParams.AD_FORMAT: impressionData.unitFormat, | ||||
|             //         FirebaseEventsParams.AD_UNIT_NAME: impressionData.unitName, | ||||
|             //         FirebaseEventsParams.CURRENCY: impressionData.currency, | ||||
|             //         "country": impressionData.country, | ||||
|             //         "mopub_rev": impressionData.publisherRevenue | ||||
|             //       }); | ||||
|             //     } | ||||
|             // | ||||
|             //     impressionData = newImpressionData; | ||||
|             //   } | ||||
|             // } | ||||
|             await refreshLtv(impressionData); | ||||
|             // _reportAdImpression(arguments); | ||||
| 
 | ||||
|             final jsonPayload = jsonEncode(impressionData.payload); | ||||
|             latestImpressionPayload = jsonPayload; | ||||
|  | @ -119,12 +102,13 @@ class AdImpressionController { | |||
|     final currency = impressionData.currency; | ||||
|     if (revenue != -1) { | ||||
|       _logAdRevenue(impressionData); | ||||
|       // if () | ||||
|       // _logAdLtv(revenue: revenue, adPlatform: adPlatform, currency: currency); | ||||
|     } | ||||
|     Log.d("refreshLtv payload:${impressionData.payload}"); | ||||
|   } | ||||
| 
 | ||||
|   // _logAdLtv({double revenue = 0.0, String adPlatform = "MAX", String currency = ""}) async { | ||||
|   // Future _logAdLtv({double revenue = 0.0, String adPlatform = "MAX", String currency = ""}) async { | ||||
|   //   final nowDate = DateTimeUtils.yyyyMMddUtcNum; | ||||
|   //   final appProperty = AppProperty.getInstance(); | ||||
|   //   final totalLtv = await appProperty.getDouble(PropertyKeys.totalLtv, defValue: 0.0); | ||||
|  | @ -179,11 +163,25 @@ class AdImpressionController { | |||
|     totalRevenue += data.publisherRevenue; | ||||
|     if (totalRevenue >= 0.01) { | ||||
|       GuruAnalytics.instance.logAdRevenue(totalRevenue, data.platform, data.currency); | ||||
|       GuruAnalytics.instance.logPurchase(totalRevenue, | ||||
|           currency: data.currency, contentId: "MAX", adPlatform: "MAX"); | ||||
| 
 | ||||
|       if (GuruApp.instance.appSpec.deployment.purchaseEventTrigger == 1) { | ||||
|         GuruAnalytics.instance.logPurchase(totalRevenue, | ||||
|             currency: data.currency, contentId: "MAX", adPlatform: "MAX"); | ||||
|       } | ||||
|       totalRevenue = .0; | ||||
|     } | ||||
|     appProperty.setDouble(PropertyKeys.totalRevenue, totalRevenue); | ||||
| 
 | ||||
|     double totalRevenue020 = | ||||
|         await appProperty.getDouble(PropertyKeys.totalRevenue020, defValue: 0.0); | ||||
|     totalRevenue020 += data.publisherRevenue; | ||||
|     if (totalRevenue020 >= 0.2) { | ||||
|       GuruAnalytics.instance.logAdRevenue020(totalRevenue020, data.platform, data.currency); | ||||
|       if (GuruApp.instance.appSpec.deployment.purchaseEventTrigger == 2) { | ||||
|         GuruAnalytics.instance.logPurchase(totalRevenue020, | ||||
|             currency: data.currency, contentId: "MAX", adPlatform: "MAX"); | ||||
|       } | ||||
|       totalRevenue020 = .0; | ||||
|     } | ||||
|     appProperty.setDouble(PropertyKeys.totalRevenue020, totalRevenue020); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,388 @@ | |||
| import 'dart:io'; | ||||
| 
 | ||||
| import 'package:guru_app/account/account_data_store.dart'; | ||||
| import 'package:guru_app/firebase/remoteconfig/remote_config_manager.dart'; | ||||
| import 'package:guru_app/property/settings/guru_settings.dart'; | ||||
| import 'package:guru_utils/device/device_utils.dart'; | ||||
| import 'package:guru_utils/extensions/extensions.dart'; | ||||
| import 'package:guru_utils/log/log.dart'; | ||||
| import 'package:guru_utils/random/random_utils.dart'; | ||||
| import 'package:json_annotation/json_annotation.dart'; | ||||
| 
 | ||||
| part 'abtest_model.g.dart'; | ||||
| 
 | ||||
| class ConditionOpt { | ||||
|   static const equals = "eq"; | ||||
|   static const greaterThan = "gt"; | ||||
|   static const greaterThanOrEquals = "gte"; | ||||
|   static const lessThan = "lt"; | ||||
|   static const lessThanOrEquals = "lte"; | ||||
|   static const notEquals = "ne"; | ||||
| 
 | ||||
|   static bool evaluate<T extends Comparable>(T value, T target, String opt) { | ||||
|     final result = value.compareTo(target); | ||||
|     switch (opt) { | ||||
|       case ConditionOpt.equals: | ||||
|         return result == 0; | ||||
|       case ConditionOpt.greaterThan: | ||||
|         return result > 0; | ||||
|       case ConditionOpt.greaterThanOrEquals: | ||||
|         return result >= 0; | ||||
|       case ConditionOpt.lessThan: | ||||
|         return result < 0; | ||||
|       case ConditionOpt.lessThanOrEquals: | ||||
|         return result <= 0; | ||||
|       case ConditionOpt.notEquals: | ||||
|         return result != 0; | ||||
|       default: | ||||
|         return false; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| abstract class ABTestFilter { | ||||
|   static const platform = 1; | ||||
|   static const version = 2; | ||||
|   static const country = 3; | ||||
|   static const newUser = 4; | ||||
| 
 | ||||
|   final int type; | ||||
| 
 | ||||
|   const ABTestFilter(this.type); | ||||
| 
 | ||||
|   bool filter(); | ||||
| 
 | ||||
|   factory ABTestFilter.fromJson(Map<String, dynamic> json) { | ||||
|     final type = json["type"] = json["type"] ?? 0; | ||||
|     switch (type) { | ||||
|       case ABTestFilter.platform: | ||||
|         return PlatformFilter.fromJson(json); | ||||
|       case ABTestFilter.country: | ||||
|         return CountryFilter.fromJson(json); | ||||
|       case ABTestFilter.version: | ||||
|         return VersionFilter.fromJson(json); | ||||
|       case ABTestFilter.newUser: | ||||
|         return NewUserFilter.fromJson(json); | ||||
|       default: | ||||
|         throw UnimplementedError("Unknown ABTestFilter type: $type"); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => toJson()..addAll({"type": type}); | ||||
| } | ||||
| 
 | ||||
| abstract class ABTestCondition { | ||||
|   bool validate(); | ||||
| } | ||||
| 
 | ||||
| /// 为了后面可以做一些定制,因此这里按平台进行区分 | ||||
| @JsonSerializable() | ||||
| class AndroidCondition extends ABTestCondition { | ||||
|   @JsonKey(name: "opt") | ||||
|   final String? opt; | ||||
| 
 | ||||
|   @JsonKey(name: "sdk") | ||||
|   final int? sdkInt; | ||||
| 
 | ||||
|   AndroidCondition({this.opt, this.sdkInt}); | ||||
| 
 | ||||
|   factory AndroidCondition.fromJson(Map<String, dynamic> json) => _$AndroidConditionFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$AndroidConditionToJson(this); | ||||
| 
 | ||||
|   @override | ||||
|   bool validate() { | ||||
|     final versionOpt = opt; | ||||
|     final targetVersion = sdkInt; | ||||
|     if (versionOpt != null && targetVersion != null) { | ||||
|       final versionCode = DeviceUtils.peekOSVersion(); | ||||
|       // 操作系统版本号获取失败,直接返回false | ||||
|       if (versionCode == -1) { | ||||
|         return false; | ||||
|       } | ||||
|       if (!ConditionOpt.evaluate<int>(versionCode, targetVersion, versionOpt)) { | ||||
|         return false; | ||||
|       } | ||||
|     } | ||||
|     // 方便后面扩展其它字段 | ||||
|     return true; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class IosCondition extends ABTestCondition { | ||||
|   @JsonKey(name: "opt") | ||||
|   final String? opt; | ||||
| 
 | ||||
|   @JsonKey(name: "ver") | ||||
|   final int? version; // 这里只记录大版本号 | ||||
| 
 | ||||
|   IosCondition({this.opt, this.version}); | ||||
| 
 | ||||
|   factory IosCondition.fromJson(Map<String, dynamic> json) => _$IosConditionFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$IosConditionToJson(this); | ||||
| 
 | ||||
|   @override | ||||
|   bool validate() { | ||||
|     final versionOpt = opt; | ||||
|     final targetVersion = version; | ||||
|     if (versionOpt != null && targetVersion != null) { | ||||
|       final versionCode = DeviceUtils.peekOSVersion(); | ||||
|       // 操作系统版本号获取失败,直接返回false | ||||
|       if (versionCode == -1) { | ||||
|         return false; | ||||
|       } | ||||
|       if (!ConditionOpt.evaluate<int>(versionCode, targetVersion, versionOpt)) { | ||||
|         return false; | ||||
|       } | ||||
|     } | ||||
|     // 方便后面扩展其它字段 | ||||
|     return true; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class PlatformFilter extends ABTestFilter { | ||||
|   @JsonKey(name: "ac") | ||||
|   final AndroidCondition? androidCondition; | ||||
| 
 | ||||
|   @JsonKey(name: "ic") | ||||
|   final IosCondition? iosCondition; | ||||
| 
 | ||||
|   PlatformFilter({this.androidCondition, this.iosCondition}) : super(ABTestFilter.platform); | ||||
| 
 | ||||
|   @override | ||||
|   bool filter() { | ||||
|     // 如果配了 Platform Filter, 如果指定平台没有 condition, 则默认为true | ||||
|     if (Platform.isAndroid) { | ||||
|       return androidCondition?.validate() != false; | ||||
|     } else if (Platform.isIOS) { | ||||
|       return iosCondition?.validate() != false; | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   factory PlatformFilter.fromJson(Map<String, dynamic> json) => _$PlatformFilterFromJson(json); | ||||
| 
 | ||||
|   @override | ||||
|   Map<String, dynamic> toJson() => _$PlatformFilterToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable(constructor: "_") | ||||
| class VersionFilter extends ABTestFilter { | ||||
|   @JsonKey(name: "opt") | ||||
|   final String opt; | ||||
| 
 | ||||
|   @JsonKey(name: "mmp") | ||||
|   final String mmp; // major.minor.patch | ||||
| 
 | ||||
|   VersionFilter._(this.opt, this.mmp) : super(ABTestFilter.version); | ||||
| 
 | ||||
|   VersionFilter.equals(this.mmp) | ||||
|       : opt = ConditionOpt.equals, | ||||
|         super(ABTestFilter.version); | ||||
| 
 | ||||
|   VersionFilter.greaterThan(this.mmp) | ||||
|       : opt = ConditionOpt.greaterThan, | ||||
|         super(ABTestFilter.version); | ||||
| 
 | ||||
|   VersionFilter.greaterThanOrEquals(this.mmp) | ||||
|       : opt = ConditionOpt.greaterThanOrEquals, | ||||
|         super(ABTestFilter.version); | ||||
| 
 | ||||
|   VersionFilter.lessThan(this.mmp) | ||||
|       : opt = ConditionOpt.lessThan, | ||||
|         super(ABTestFilter.version); | ||||
| 
 | ||||
|   VersionFilter.lessThanOrEquals(this.mmp) | ||||
|       : opt = ConditionOpt.lessThanOrEquals, | ||||
|         super(ABTestFilter.version); | ||||
| 
 | ||||
|   VersionFilter.notEquals(this.mmp) | ||||
|       : opt = ConditionOpt.notEquals, | ||||
|         super(ABTestFilter.version); | ||||
| 
 | ||||
|   @override | ||||
|   bool filter() { | ||||
|     final version = GuruSettings.instance.version.get(); | ||||
|     Log.d("[$runtimeType] $version $opt $mmp"); | ||||
|     return ConditionOpt.evaluate<String>(version, mmp, opt); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'VersionValidator{opt: $opt, mmp: $mmp}'; | ||||
|   } | ||||
| 
 | ||||
|   factory VersionFilter.fromJson(Map<String, dynamic> json) => _$VersionFilterFromJson(json); | ||||
| 
 | ||||
|   @override | ||||
|   Map<String, dynamic> toJson() => _$VersionFilterToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable(constructor: "_") | ||||
| class CountryFilter extends ABTestFilter { | ||||
|   @JsonKey(name: "included", defaultValue: {}) | ||||
|   final Set<String> included; | ||||
| 
 | ||||
|   @JsonKey(name: "excluded", defaultValue: {}) | ||||
|   final Set<String> excluded; | ||||
| 
 | ||||
|   CountryFilter._(this.included, this.excluded) : super(ABTestFilter.country); | ||||
| 
 | ||||
|   CountryFilter.included(this.included) | ||||
|       : excluded = {}, | ||||
|         super(ABTestFilter.country); | ||||
| 
 | ||||
|   CountryFilter.excluded(this.excluded) | ||||
|       : included = {}, | ||||
|         super(ABTestFilter.country); | ||||
| 
 | ||||
|   @override | ||||
|   bool filter() { | ||||
|     final String countryCode = Platform.localeName.split('_').safeLast?.toLowerCase() ?? ""; | ||||
|     Log.d("[$runtimeType] $countryCode included: $included excluded: $excluded"); | ||||
|     if (countryCode.isEmpty) { | ||||
|       return false; | ||||
|     } | ||||
| 
 | ||||
| // 如果excluded不为空,证明存在排除选项,该validate将只判断所有excluded中的逻辑, | ||||
| // 将不会在判断included中的逻辑 | ||||
|     if (excluded.isNotEmpty) { | ||||
|       return !excluded.contains(countryCode); | ||||
|     } | ||||
| 
 | ||||
|     if (included.contains(countryCode)) { | ||||
|       return true; | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   factory CountryFilter.fromJson(Map<String, dynamic> json) => _$CountryFilterFromJson(json); | ||||
| 
 | ||||
|   @override | ||||
|   Map<String, dynamic> toJson() => _$CountryFilterToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class NewUserFilter extends ABTestFilter { | ||||
|   NewUserFilter() : super(ABTestFilter.newUser); | ||||
| 
 | ||||
|   @override | ||||
|   bool filter() { | ||||
|     final version = GuruSettings.instance.version.get(); | ||||
|     final fiv = GuruSettings.instance.firstInstallVersion.get(); | ||||
|     return fiv.startsWith(version); | ||||
|   } | ||||
| 
 | ||||
|   factory NewUserFilter.fromJson(Map<String, dynamic> json) => _$NewUserFilterFromJson(json); | ||||
| 
 | ||||
|   @override | ||||
|   Map<String, dynamic> toJson() => _$NewUserFilterToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class ABTestAudience { | ||||
|   @JsonKey(name: "filters") | ||||
|   final List<ABTestFilter> filters; | ||||
| 
 | ||||
|   @JsonKey(name: "variant", defaultValue: 2) | ||||
|   final int variant; | ||||
| 
 | ||||
|   ABTestAudience({required this.filters, this.variant = 2}); | ||||
| 
 | ||||
|   factory ABTestAudience.fromJson(Map<String, dynamic> json) => _$ABTestAudienceFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$ABTestAudienceToJson(this); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'ABTestAudience{filters: $filters, variant: $variant}'; | ||||
|   } | ||||
| 
 | ||||
|   bool validate() { | ||||
|     for (var filter in filters) { | ||||
|       if (!filter.filter()) { | ||||
|         return false; | ||||
|       } | ||||
|     } | ||||
|     return true; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class ABTestExperiment { | ||||
|   @JsonKey(name: "name") | ||||
|   final String name; | ||||
| 
 | ||||
|   @JsonKey(name: "start_ts", defaultValue: 0) | ||||
|   final int startTs; | ||||
| 
 | ||||
|   @JsonKey(name: "end_ts", defaultValue: 0) | ||||
|   final int endTs; | ||||
| 
 | ||||
|   @JsonKey(name: "audience") | ||||
|   final ABTestAudience audience; | ||||
| 
 | ||||
|   ABTestExperiment( | ||||
|       {required String name, required this.startTs, required this.endTs, required this.audience}) | ||||
|       : name = _validExperimentName(name); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'ABTestExperiment{name: $name, startTs: $startTs, endTs: $endTs, audience: $audience}'; | ||||
|   } | ||||
| 
 | ||||
|   static String _validExperimentName(String experimentName) { | ||||
|     if (experimentName.contains(RemoteConfigManager.invalidABKeyRegExp)) { | ||||
|       Log.w("abName($experimentName) use invalid key! $experimentName! replace invalid char to _"); | ||||
|       experimentName = experimentName.replaceAll(RemoteConfigManager.invalidABKeyRegExp, "_"); | ||||
|     } else { | ||||
|       if (experimentName.length > 20) { | ||||
|         experimentName = experimentName.substring(0, 20); | ||||
|       } | ||||
|     } | ||||
|     return experimentName; | ||||
|   } | ||||
| 
 | ||||
|   factory ABTestExperiment.fromJson(Map<String, dynamic> json) => _$ABTestExperimentFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$ABTestExperimentToJson(this); | ||||
| 
 | ||||
|   bool isExpired() { | ||||
|     final now = DateTime.now().millisecondsSinceEpoch; | ||||
|     return now < startTs || now > endTs; | ||||
|   } | ||||
| 
 | ||||
|   bool isMatchAudience() { | ||||
|     return audience.validate(); | ||||
|   } | ||||
| 
 | ||||
|   @JsonKey(includeToJson: false) | ||||
|   String? _variantName; | ||||
| 
 | ||||
|   String get variantName => | ||||
|       (_variantName ??= _toVariantName(RandomUtils.nextInt(audience.variant))); | ||||
| 
 | ||||
|   static const _originalVariant = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; | ||||
| 
 | ||||
|   String _toVariantName(int value) { | ||||
|     String codes = ""; | ||||
|     int nv = value; | ||||
|     while (true) { | ||||
|       final nextNum = nv ~/ _originalVariant.length; | ||||
|       if (nextNum <= 0) { | ||||
|         break; | ||||
|       } | ||||
|       codes = "${_originalVariant[nv % _originalVariant.length]}$codes"; | ||||
|       nv = nextNum; | ||||
|     } | ||||
| 
 | ||||
|     final tailIndex = nv % _originalVariant.length; | ||||
|     if (tailIndex >= 0) { | ||||
|       codes = "${_originalVariant[tailIndex]}$codes"; | ||||
|     } | ||||
|     return codes.toString(); | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,109 @@ | |||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| 
 | ||||
| part of 'abtest_model.dart'; | ||||
| 
 | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
| 
 | ||||
| AndroidCondition _$AndroidConditionFromJson(Map<String, dynamic> json) => | ||||
|     AndroidCondition( | ||||
|       opt: json['opt'] as String?, | ||||
|       sdkInt: json['sdk'] as int?, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$AndroidConditionToJson(AndroidCondition instance) => | ||||
|     <String, dynamic>{ | ||||
|       'opt': instance.opt, | ||||
|       'sdk': instance.sdkInt, | ||||
|     }; | ||||
| 
 | ||||
| IosCondition _$IosConditionFromJson(Map<String, dynamic> json) => IosCondition( | ||||
|       opt: json['opt'] as String?, | ||||
|       version: json['ver'] as int?, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$IosConditionToJson(IosCondition instance) => | ||||
|     <String, dynamic>{ | ||||
|       'opt': instance.opt, | ||||
|       'ver': instance.version, | ||||
|     }; | ||||
| 
 | ||||
| PlatformFilter _$PlatformFilterFromJson(Map<String, dynamic> json) => | ||||
|     PlatformFilter( | ||||
|       androidCondition: json['ac'] == null | ||||
|           ? null | ||||
|           : AndroidCondition.fromJson(json['ac'] as Map<String, dynamic>), | ||||
|       iosCondition: json['ic'] == null | ||||
|           ? null | ||||
|           : IosCondition.fromJson(json['ic'] as Map<String, dynamic>), | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$PlatformFilterToJson(PlatformFilter instance) => | ||||
|     <String, dynamic>{ | ||||
|       'ac': instance.androidCondition, | ||||
|       'ic': instance.iosCondition, | ||||
|     }; | ||||
| 
 | ||||
| VersionFilter _$VersionFilterFromJson(Map<String, dynamic> json) => | ||||
|     VersionFilter._( | ||||
|       json['opt'] as String, | ||||
|       json['mmp'] as String, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$VersionFilterToJson(VersionFilter instance) => | ||||
|     <String, dynamic>{ | ||||
|       'opt': instance.opt, | ||||
|       'mmp': instance.mmp, | ||||
|     }; | ||||
| 
 | ||||
| CountryFilter _$CountryFilterFromJson(Map<String, dynamic> json) => | ||||
|     CountryFilter._( | ||||
|       (json['included'] as List<dynamic>?)?.map((e) => e as String).toSet() ?? | ||||
|           {}, | ||||
|       (json['excluded'] as List<dynamic>?)?.map((e) => e as String).toSet() ?? | ||||
|           {}, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$CountryFilterToJson(CountryFilter instance) => | ||||
|     <String, dynamic>{ | ||||
|       'included': instance.included.toList(), | ||||
|       'excluded': instance.excluded.toList(), | ||||
|     }; | ||||
| 
 | ||||
| NewUserFilter _$NewUserFilterFromJson(Map<String, dynamic> json) => | ||||
|     NewUserFilter(); | ||||
| 
 | ||||
| Map<String, dynamic> _$NewUserFilterToJson(NewUserFilter instance) => | ||||
|     <String, dynamic>{}; | ||||
| 
 | ||||
| ABTestAudience _$ABTestAudienceFromJson(Map<String, dynamic> json) => | ||||
|     ABTestAudience( | ||||
|       filters: (json['filters'] as List<dynamic>) | ||||
|           .map((e) => ABTestFilter.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList(), | ||||
|       variant: json['variant'] as int? ?? 2, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$ABTestAudienceToJson(ABTestAudience instance) => | ||||
|     <String, dynamic>{ | ||||
|       'filters': instance.filters, | ||||
|       'variant': instance.variant, | ||||
|     }; | ||||
| 
 | ||||
| ABTestExperiment _$ABTestExperimentFromJson(Map<String, dynamic> json) => | ||||
|     ABTestExperiment( | ||||
|       name: json['name'] as String, | ||||
|       startTs: json['start_ts'] as int? ?? 0, | ||||
|       endTs: json['end_ts'] as int? ?? 0, | ||||
|       audience: | ||||
|           ABTestAudience.fromJson(json['audience'] as Map<String, dynamic>), | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$ABTestExperimentToJson(ABTestExperiment instance) => | ||||
|     <String, dynamic>{ | ||||
|       'name': instance.name, | ||||
|       'start_ts': instance.startTs, | ||||
|       'end_ts': instance.endTs, | ||||
|       'audience': instance.audience, | ||||
|     }; | ||||
|  | @ -8,6 +8,8 @@ part 'analytics_model.g.dart'; | |||
| 
 | ||||
| @JsonSerializable() | ||||
| class AnalyticsConfig { | ||||
|   static const _defaultGoogleDma = [1, 0, 12, 65]; | ||||
|   static const _defaultDmaCountry = []; | ||||
|   @JsonKey(name: "cap", defaultValue: ["firebase", "facebook", "guru"]) | ||||
|   @joinedStringConvert | ||||
|   final List<String> capabilities; | ||||
|  | @ -24,6 +26,13 @@ class AnalyticsConfig { | |||
|   @JsonKey(name: "enabled_strategy", defaultValue: false) | ||||
|   final bool enabledStrategy; | ||||
| 
 | ||||
|   /// ad_storage,analytics_storage,personalization,user_data | ||||
|   @JsonKey(name: "google_dma", defaultValue: _defaultGoogleDma) | ||||
|   final List<int> googleDmaMask; | ||||
| 
 | ||||
|   @JsonKey(name: "dma_country", defaultValue: _defaultDmaCountry) | ||||
|   final List<String> dmaCountry; | ||||
| 
 | ||||
|   AppEventCapabilities toAppEventCapabilities() { | ||||
|     int capValue = 0; | ||||
|     if (capabilities.contains("firebase")) { | ||||
|  | @ -38,8 +47,15 @@ class AnalyticsConfig { | |||
|     return AppEventCapabilities(capValue); | ||||
|   } | ||||
| 
 | ||||
|   bool googleDmaGranted(ConsentType type, int flags) { | ||||
|     if (type.index < googleDmaMask.length) { | ||||
|       return (googleDmaMask[type.index] & flags) == googleDmaMask[type.index]; | ||||
|     } | ||||
|     return _defaultGoogleDma[type.index] & flags == _defaultGoogleDma[type.index]; | ||||
|   } | ||||
| 
 | ||||
|   AnalyticsConfig(this.capabilities, this.delayedInSeconds, this.expiredInDays, this.strategy, | ||||
|       this.enabledStrategy); | ||||
|       this.enabledStrategy, this.googleDmaMask, this.dmaCountry); | ||||
| 
 | ||||
|   factory AnalyticsConfig.fromJson(Map<String, dynamic> json) => _$AnalyticsConfigFromJson(json); | ||||
| 
 | ||||
|  | @ -72,3 +88,12 @@ class UserIdentification { | |||
|     return 'UserIdentification{firebaseAppInstanceId: $firebaseAppInstanceId, idfa: $idfa, adId: $adId, gpsAdId: $gpsAdId}'; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class ConsentFieldName { | ||||
|   static const adStorage = "ad_storage"; | ||||
|   static const analyticsStorage = "analytics_storage"; | ||||
|   static const adPersonalization = "ad_personalization"; | ||||
|   static const adUserData = "ad_user_data"; | ||||
| } | ||||
| 
 | ||||
| enum ConsentType { adStorage, analyticsStorage, adPersonalization, adUserData } | ||||
|  |  | |||
|  | @ -15,6 +15,12 @@ AnalyticsConfig _$AnalyticsConfigFromJson(Map<String, dynamic> json) => | |||
|       json['expired_d'] as int? ?? 7, | ||||
|       json['strategy'] as String? ?? '', | ||||
|       json['enabled_strategy'] as bool? ?? false, | ||||
|       (json['google_dma'] as List<dynamic>?)?.map((e) => e as int).toList() ?? | ||||
|           [1, 0, 12, 65], | ||||
|       (json['dma_country'] as List<dynamic>?) | ||||
|               ?.map((e) => e as String) | ||||
|               .toList() ?? | ||||
|           [], | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$AnalyticsConfigToJson(AnalyticsConfig instance) => | ||||
|  | @ -24,6 +30,8 @@ Map<String, dynamic> _$AnalyticsConfigToJson(AnalyticsConfig instance) => | |||
|       'expired_d': instance.expiredInDays, | ||||
|       'strategy': instance.strategy, | ||||
|       'enabled_strategy': instance.enabledStrategy, | ||||
|       'google_dma': instance.googleDmaMask, | ||||
|       'dma_country': instance.dmaCountry, | ||||
|     }; | ||||
| 
 | ||||
| UserIdentification _$UserIdentificationFromJson(Map<String, dynamic> json) => | ||||
|  |  | |||
|  | @ -4,7 +4,9 @@ import 'dart:collection'; | |||
| import 'dart:core'; | ||||
| import 'dart:io'; | ||||
| 
 | ||||
| import 'package:adjust_sdk/adjust_third_party_sharing.dart'; | ||||
| import 'package:firebase_crashlytics/firebase_crashlytics.dart'; | ||||
| import 'package:firebase_remote_config/firebase_remote_config.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:guru_analytics_flutter/event_logger.dart'; | ||||
| import 'package:guru_analytics_flutter/event_logger_common.dart'; | ||||
|  | @ -15,13 +17,18 @@ import 'package:guru_app/account/account_data_store.dart'; | |||
| import 'package:guru_app/ads/ads_manager.dart'; | ||||
| import 'package:guru_app/ads/core/ads_config.dart'; | ||||
| import 'package:guru_app/aigc/bi/ai_bi.dart'; | ||||
| import 'package:guru_app/analytics/abtest/abtest_model.dart'; | ||||
| import 'package:guru_app/analytics/data/analytics_model.dart'; | ||||
| import 'package:guru_app/analytics/strategy/guru_analytics_strategy.dart'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:guru_app/firebase/remoteconfig/remote_config_manager.dart'; | ||||
| import 'package:guru_app/guru_app.dart'; | ||||
| import 'package:guru_app/property/app_property.dart'; | ||||
| import 'package:guru_app/property/property_keys.dart'; | ||||
| import 'package:guru_app/property/runtime_property.dart'; | ||||
| import 'package:guru_app/property/settings/guru_settings.dart'; | ||||
| import 'package:guru_platform_data/guru_platform_data.dart'; | ||||
| import 'package:guru_utils/collection/collectionutils.dart'; | ||||
| import 'package:guru_utils/datetime/datetime_utils.dart'; | ||||
| import 'package:guru_utils/device/device_info.dart'; | ||||
| import 'package:guru_utils/device/device_utils.dart'; | ||||
|  | @ -38,7 +45,7 @@ part 'modules/ads_analytics.dart'; | |||
| part 'modules/adjust_aware.dart'; | ||||
| 
 | ||||
| class GuruAnalytics extends Analytics with AdjustAware { | ||||
|   bool get release => !_mock && _enabledAnalytics && kReleaseMode; | ||||
|   bool get release => !_mock && (_enabledAnalytics || kReleaseMode); | ||||
| 
 | ||||
|   String appInstanceId = ""; | ||||
| 
 | ||||
|  | @ -55,6 +62,10 @@ class GuruAnalytics extends Analytics with AdjustAware { | |||
| 
 | ||||
|   static String currentScreen = ""; | ||||
| 
 | ||||
|   static final RegExp _consentPurposeRegExp = RegExp(r"^[01]+$"); | ||||
| 
 | ||||
|   static String? mockCountryCode; | ||||
| 
 | ||||
|   static const errorEventCodes = { | ||||
|     14, // 上报事件失败 | ||||
|     22, // 网络状态不可用 | ||||
|  | @ -71,8 +82,13 @@ class GuruAnalytics extends Analytics with AdjustAware { | |||
|   final BehaviorSubject<GuruStatistic> guruEventStatistic = | ||||
|       BehaviorSubject.seeded(GuruStatistic.invalid); | ||||
| 
 | ||||
|   final BehaviorSubject<Map<String, String>> abTestExperimentVariant = BehaviorSubject.seeded({}); | ||||
| 
 | ||||
|   Stream<GuruStatistic> get observableGuruEventStatistic => guruEventStatistic.stream; | ||||
| 
 | ||||
|   Stream<Map<String, String>> get observableABTestExperimentVariant => | ||||
|       abTestExperimentVariant.stream; | ||||
| 
 | ||||
|   final BehaviorSubject<UserIdentification> userIdentificationSubject = | ||||
|       BehaviorSubject.seeded(UserIdentification()); | ||||
| 
 | ||||
|  | @ -98,6 +114,19 @@ class GuruAnalytics extends Analytics with AdjustAware { | |||
|     return Analytics.userProperties[key]; | ||||
|   } | ||||
| 
 | ||||
|   Future prepare() async { | ||||
|     if (GuruApp.instance.appSpec.localABTestExperiments.isNotEmpty) { | ||||
|       await initLocalExperiments(); | ||||
|     } | ||||
|     RemoteConfigManager.instance.observeConfig().listen((config) { | ||||
|       Log.i( | ||||
|           "GuruAnalytics observeConfig changed: ${config.lastFetchStatus} ${config.lastFetchTime}"); | ||||
|       if (config.lastFetchStatus == RemoteConfigFetchStatus.success) { | ||||
|         refreshABProperties(); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   void init() async { | ||||
|     Log.d( | ||||
|         "AnalyticsUtil init### Platform.localeName :${Platform.localeName} ${Intl.getCurrentLocale()}"); | ||||
|  | @ -142,6 +171,7 @@ class GuruAnalytics extends Analytics with AdjustAware { | |||
|       Future.delayed(const Duration(seconds: 1), () { | ||||
|         initAdjust(); | ||||
|         initFbEventMapping(); | ||||
|         refreshConsents(); | ||||
|         Log.d("register transmitter"); | ||||
|       }); | ||||
|       initialized = true; | ||||
|  | @ -151,6 +181,100 @@ class GuruAnalytics extends Analytics with AdjustAware { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future switchSession(String oldToken, String newToken) async { | ||||
|     _initEnvProperties(); | ||||
|     _logLocale(); | ||||
|     _logDeviceType(); | ||||
|   } | ||||
| 
 | ||||
|   Future initLocalExperiments() async { | ||||
|     final runningExperiments = await AppProperty.getInstance().loadRunningExperiments(); | ||||
|     final experiments = GuruApp.instance.appSpec.localABTestExperiments; | ||||
|     final validRunningExperimentKeys = | ||||
|         runningExperiments.keys.toSet().intersection(experiments.keys.toSet()); | ||||
|     for (var experiment in experiments.values) { | ||||
|       // 如果在已经开始的实验中,但是不在当前的实验列表中,需要删除 | ||||
|       final needRemove = runningExperiments.containsKey(experiment.name) && | ||||
|           !validRunningExperimentKeys.contains(experiment.name); | ||||
|       if (needRemove) { | ||||
|         await removeExperiment(experiment.name); | ||||
|       } else { | ||||
|         await _applyExperiment(experiment); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future<String> refreshConsents({AnalyticsConfig? analyticsConfig}) async { | ||||
|     final config = analyticsConfig ?? RemoteConfigManager.instance.getAnalyticsConfig(); | ||||
|     final purposeConsents = await GuruPlatformData.getPurposeConsents(); | ||||
|     Log.i("refreshConsents: '$purposeConsents'"); | ||||
|     if (purposeConsents.isEmpty) { | ||||
|       return ""; | ||||
|     } | ||||
| 
 | ||||
|     /// 如果他不是完全使用 1,0 组成的字符串 | ||||
|     if (!_consentPurposeRegExp.hasMatch(purposeConsents)) { | ||||
|       Log.i("invalid consents $purposeConsents"); | ||||
|       return ""; | ||||
|     } | ||||
| 
 | ||||
|     /// 获取当前的 countryCode, 判断是否在 dma country的范围内 | ||||
|     if (config.dmaCountry.isNotEmpty) { | ||||
|       final countryCode = getCountryCode(); | ||||
|       if (!config.dmaCountry.contains(countryCode)) { | ||||
|         Log.i("invalid country $countryCode"); | ||||
|         return ""; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     final length = min(purposeConsents.length, 32); | ||||
|     int flags = 0; | ||||
|     for (var i = 0; i < length; i++) { | ||||
|       flags |= (((purposeConsents[i] == "1") ? 1 : 0) << i); | ||||
|     } | ||||
| 
 | ||||
|     final consentsData = { | ||||
|       ConsentFieldName.adStorage: config.googleDmaGranted(ConsentType.adStorage, flags), | ||||
|       ConsentFieldName.analyticsStorage: | ||||
|           config.googleDmaGranted(ConsentType.analyticsStorage, flags), | ||||
|       ConsentFieldName.adPersonalization: | ||||
|           config.googleDmaGranted(ConsentType.adPersonalization, flags), | ||||
|       ConsentFieldName.adUserData: config.googleDmaGranted(ConsentType.adUserData, flags), | ||||
|     }; | ||||
| 
 | ||||
|     String _flag(String key) { | ||||
|       return consentsData[key] == true ? "1" : "0"; | ||||
|     } | ||||
| 
 | ||||
|     Log.d("setConsents consentsData: $consentsData"); | ||||
| 
 | ||||
|     try { | ||||
|       final result = await EventLogger.guru.setConsents(consentsData); | ||||
|       Log.d("setConsents result: $result"); | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.e("setConsents error! $error, $stacktrace"); | ||||
|     } | ||||
| 
 | ||||
|     if (enabledAdjust) { | ||||
|       AdjustThirdPartySharing adjustThirdPartySharing = AdjustThirdPartySharing(null); | ||||
|       adjustThirdPartySharing.addGranularOption("google_dma", "eea", "1"); | ||||
|       adjustThirdPartySharing.addGranularOption( | ||||
|           "google_dma", "ad_personalization", _flag(ConsentFieldName.adPersonalization)); | ||||
|       adjustThirdPartySharing.addGranularOption( | ||||
|           "google_dma", "ad_user_data", _flag(ConsentFieldName.adUserData)); | ||||
|       Adjust.trackThirdPartySharing(adjustThirdPartySharing); | ||||
|       Log.d("setAdjust complete!"); | ||||
|     } | ||||
| 
 | ||||
|     final result = | ||||
|         "${_flag(ConsentFieldName.adStorage)}${_flag(ConsentFieldName.analyticsStorage)}${_flag(ConsentFieldName.adPersonalization)}${_flag(ConsentFieldName.adUserData)}"; | ||||
|     final changed = await AppProperty.getInstance().refreshGoogleDma(result); | ||||
|     if (changed || GuruSettings.instance.debugMode.get()) { | ||||
|       logEventEx("dma_gg", parameters: {"purpose": purposeConsents, "result": result}); | ||||
|     } | ||||
|     return result; | ||||
|   } | ||||
| 
 | ||||
|   void processAnalyticsCallback(int code, String? errorInfo) { | ||||
|     if (!errorEventCodes.contains(code)) { | ||||
|       return; | ||||
|  | @ -269,7 +393,10 @@ class GuruAnalytics extends Analytics with AdjustAware { | |||
|     if (firebaseId?.isNotEmpty == true) { | ||||
|       setFirebaseId(firebaseId!); | ||||
|     } | ||||
|     refreshABProperties(); | ||||
|   } | ||||
| 
 | ||||
|   void refreshABProperties() { | ||||
|     final abProperties = RemoteConfigManager.instance.getABProperties(); | ||||
| 
 | ||||
|     final PropertyBundle propertyBundle = PropertyBundle(); | ||||
|  | @ -293,6 +420,17 @@ class GuruAnalytics extends Analytics with AdjustAware { | |||
|     setUserProperty("first_open_time", firstInstallTime.toString()); | ||||
|   } | ||||
| 
 | ||||
|   String getCountryCode() { | ||||
|     if (mockCountryCode != null) { | ||||
|       return mockCountryCode!; | ||||
|     } | ||||
|     final currentLocale = Platform.localeName.split('_'); | ||||
|     if (currentLocale.length > 1) { | ||||
|       return currentLocale.last.toLowerCase(); | ||||
|     } | ||||
|     return ""; | ||||
|   } | ||||
| 
 | ||||
|   void _logLocale() { | ||||
|     if (Platform.localeName.isNotEmpty == true) { | ||||
|       String lanCode = ""; | ||||
|  | @ -335,6 +473,60 @@ class GuruAnalytics extends Analytics with AdjustAware { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   static String buildVariantKey(String experimentName) { | ||||
|     return "ab_$experimentName"; | ||||
|   } | ||||
| 
 | ||||
|   String getExperimentVariant(String experimentName) { | ||||
|     return abTestExperimentVariant.value[experimentName] ?? "BASELINE"; | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> setLocalABTest(ABTestExperiment experiment, {PropertyBundle? bundle}) async { | ||||
|     Log.d("setLocalABTest: $experiment"); | ||||
|     String experimentName = experiment.name; | ||||
|     final exp = await AppProperty.getInstance().getExperiment(experimentName, bundle: bundle); | ||||
|     if (exp != null) { | ||||
|       Log.w("Experiment already exists!"); | ||||
|       experiment = exp; | ||||
|     } | ||||
| 
 | ||||
|     return await _applyExperiment(experiment); | ||||
|   } | ||||
| 
 | ||||
|   Future removeExperiment(String experimentName) async { | ||||
|     await AppProperty.getInstance().removeExperiment(experimentName); | ||||
|     final data = Map<String, String>.of(abTestExperimentVariant.value); | ||||
|     data.remove(experimentName); | ||||
|     abTestExperimentVariant.addIfChanged(data); | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> _applyExperiment(ABTestExperiment experiment) async { | ||||
|     final experimentName = experiment.name; | ||||
|     if (experiment.isExpired()) { | ||||
|       Log.w("Experiment($experimentName) is expired"); | ||||
|       await removeExperiment(experimentName); | ||||
|       return false; | ||||
|     } | ||||
| 
 | ||||
|     if (!experiment.isMatchAudience()) { | ||||
|       Log.i("NOT match audience! $experiment! INTO BASELINE"); | ||||
|       return false; | ||||
|     } | ||||
| 
 | ||||
|     String variantName = await AppProperty.getInstance().getExperimentVariant(experimentName); | ||||
|     if (variantName.isEmpty) { | ||||
|       variantName = await AppProperty.getInstance().setExperiment(experiment); | ||||
|     } | ||||
| 
 | ||||
|     await setGuruUserProperty(buildVariantKey(experimentName), variantName); | ||||
|     Log.i("==> Setup Local Experiment($experimentName) variantName: $variantName"); | ||||
| 
 | ||||
|     final data = Map<String, String>.of(abTestExperimentVariant.value); | ||||
|     data[experimentName] = variantName; | ||||
|     abTestExperimentVariant.addIfChanged(data); | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
|   void setDeviceId(String deviceId) { | ||||
|     Log.d("setDeviceId: $deviceId"); | ||||
|     recordEvents("setDeviceId", {"userId": deviceId}); | ||||
|  | @ -348,12 +540,12 @@ class GuruAnalytics extends Analytics with AdjustAware { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   void setUserId(String userId) { | ||||
|   Future setUserId(String userId) async { | ||||
|     Log.d("setUserId: $userId"); | ||||
|     recordEvents("setUserId", {"userId": userId}); | ||||
|     recordProperty("userId", userId); | ||||
|     if (userId.isNotEmpty) { | ||||
|       AppProperty.getInstance().setUserId(userId); | ||||
|       await AppProperty.getInstance().setUserId(userId); | ||||
|       if (release) { | ||||
|         EventLogger.setUserId(userId); | ||||
|         FirebaseCrashlytics.instance.setUserIdentifier(userId); | ||||
|  | @ -528,12 +720,17 @@ class GuruAnalytics extends Analytics with AdjustAware { | |||
|       {String currency = "", | ||||
|       String contentId = "", | ||||
|       String adPlatform = "", | ||||
|       Map<String, dynamic> parameters = const <String, dynamic>{}}) { | ||||
|     EventLogger.logFbPurchase(amount, | ||||
|         currency: currency, | ||||
|         contentId: contentId, | ||||
|         adPlatform: adPlatform, | ||||
|         additionParameters: parameters); | ||||
|       Map<String, dynamic> parameters = const <String, dynamic>{}}) async { | ||||
|     Log.i("logPurchase:$amount, $currency, $contentId, $adPlatform, $parameters"); | ||||
|     try { | ||||
|       await EventLogger.logFbPurchase(amount, | ||||
|           currency: currency, | ||||
|           contentId: contentId, | ||||
|           adPlatform: adPlatform, | ||||
|           additionParameters: parameters); | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.w("logFbPurchase error$error, $stacktrace"); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   void logEventShare({String? itemCategory, String? itemName}) { | ||||
|  | @ -547,6 +744,7 @@ class GuruAnalytics extends Analytics with AdjustAware { | |||
| 
 | ||||
|   void logSpendCredits(String contentId, String contentType, int price, | ||||
|       {required String virtualCurrencyName, required int balance, String scene = ''}) { | ||||
|     final levelName = GuruApp.instance.protocol.getLevelName(); | ||||
|     if (release) { | ||||
|       EventLogger.logSpendCredits(contentId, contentType, price, | ||||
|           virtualCurrencyName: virtualCurrencyName, balance: balance, scene: scene); | ||||
|  | @ -557,7 +755,8 @@ class GuruAnalytics extends Analytics with AdjustAware { | |||
|         "virtual_currency_name": virtualCurrencyName, | ||||
|         "value": price, | ||||
|         "balance": balance, | ||||
|         "scene": scene | ||||
|         "scene": scene, | ||||
|         "level_name": levelName | ||||
|       }; | ||||
|       Log.d("logEvent: spend_virtual_currency $parameters"); | ||||
|       EventLogger.transmit("spend_virtual_currency", parameters); | ||||
|  | @ -565,22 +764,34 @@ class GuruAnalytics extends Analytics with AdjustAware { | |||
|     AiBi.instance.spendVirtualCurrency(balance, price.toDouble(), contentType); | ||||
|   } | ||||
| 
 | ||||
|   Future<void> logEarnVirtualCurrency({ | ||||
|     required String virtualCurrencyName, | ||||
|     required String method, | ||||
|     required int balance, | ||||
|     required int value, | ||||
|   }) async { | ||||
|     logEvent("earn_virtual_currency", <String, dynamic>{ | ||||
|       "virtual_currency_name": virtualCurrencyName, | ||||
|       "item_category": method, | ||||
|       "value": value, | ||||
|       "balance": balance | ||||
|     }); | ||||
|   Future<void> logEarnVirtualCurrency( | ||||
|       {required String virtualCurrencyName, | ||||
|       required String method, | ||||
|       required int balance, | ||||
|       required int value, | ||||
|       String? specific, | ||||
|       String? scene}) async { | ||||
|     final levelName = GuruApp.instance.protocol.getLevelName(); | ||||
|     logEvent( | ||||
|         "earn_virtual_currency", | ||||
|         filterOutNulls(<String, dynamic>{ | ||||
|           "virtual_currency_name": virtualCurrencyName, | ||||
|           "item_category": method, | ||||
|           "item_name": specific, | ||||
|           "value": value, | ||||
|           "balance": balance, | ||||
|           "level_name": levelName, | ||||
|           "scene": scene | ||||
|         })); | ||||
|     AiBi.instance.earnVirtualCurrency(balance, value.toDouble(), method); | ||||
|   } | ||||
| 
 | ||||
|   String? peekUserProperty(String key) { | ||||
|     return Analytics.userProperties[key]; | ||||
|   } | ||||
| 
 | ||||
|   Future<void> setGuruUserProperty(String key, String value) async { | ||||
|     recordProperty(key, value); | ||||
|     return await EventLogger.setGuruUserProperty(key, value); | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -7,15 +7,44 @@ | |||
| part of "../guru_analytics.dart"; | ||||
| 
 | ||||
| extension AdsAnalytics on GuruAnalytics { | ||||
|   void logAdRevenue(double adRevenue, String adPlatform, String currency) { | ||||
|   void logAdRevenue(double adRevenue, String adPlatform, String currency, | ||||
|       {String? orderType, String? orderId, String? productId, int? transactionDate}) { | ||||
|     // logEventEx(name, itemCategory: scene, itemName: adName); | ||||
|     final orderExtras = CollectionUtils.filterOutNulls(<String, dynamic>{ | ||||
|       "order_type": orderType, | ||||
|       "order_id": orderId, | ||||
|       "product_id": productId, | ||||
|       "trans_ts": transactionDate | ||||
|     }); | ||||
|     if (release) { | ||||
|       EventLogger.logAdRevenue(adRevenue, adPlatform, currency); | ||||
|       EventLogger.logAdRevenue(adRevenue, adPlatform, currency, extras: orderExtras); | ||||
|     } else { | ||||
|       Log.d("[firebase] logAdRevenue ${<String, dynamic>{ | ||||
|         "adRevenue": adRevenue, | ||||
|         "adPlatform": adPlatform, | ||||
|         "currency": currency | ||||
|         "currency": currency, | ||||
|         ...orderExtras | ||||
|       }}"); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   void logAdRevenue020(double adRevenue, String adPlatform, String currency, | ||||
|       {String? orderType, String? orderId, String? productId, int? transactionDate}) { | ||||
|     // logEventEx(name, itemCategory: scene, itemName: adName); | ||||
|     final orderExtras = CollectionUtils.filterOutNulls(<String, dynamic>{ | ||||
|       "order_type": orderType, | ||||
|       "order_id": orderId, | ||||
|       "product_id": productId, | ||||
|       "trans_ts": transactionDate | ||||
|     }); | ||||
|     if (release) { | ||||
|       EventLogger.logAdRevenue020(adRevenue, adPlatform, currency, extras: orderExtras); | ||||
|     } else { | ||||
|       Log.d("[firebase] logAdRevenue020 ${<String, dynamic>{ | ||||
|         "adRevenue": adRevenue, | ||||
|         "adPlatform": adPlatform, | ||||
|         "currency": currency, | ||||
|         ...orderExtras | ||||
|       }}"); | ||||
|     } | ||||
|   } | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| import 'dart:io'; | ||||
| 
 | ||||
| import 'package:guru_app/analytics/data/analytics_model.dart'; | ||||
| import 'package:guru_utils/datetime/datetime_utils.dart'; | ||||
| import 'package:json_annotation/json_annotation.dart'; | ||||
| 
 | ||||
| part 'orders_model.g.dart'; | ||||
|  | @ -14,8 +15,7 @@ class OrderUserInfo { | |||
| 
 | ||||
|   OrderUserInfo(this.level); | ||||
| 
 | ||||
|   factory OrderUserInfo.fromJson(Map<String, dynamic> json) => | ||||
|       _$OrderUserInfoFromJson(json); | ||||
|   factory OrderUserInfo.fromJson(Map<String, dynamic> json) => _$OrderUserInfoFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$OrderUserInfoToJson(this); | ||||
| } | ||||
|  | @ -66,6 +66,12 @@ class OrdersReport { | |||
|   @JsonKey(name: "eventConfig") | ||||
|   UserIdentification? userIdentification; | ||||
| 
 | ||||
|   @JsonKey(name: "orderId") | ||||
|   String? orderId; | ||||
| 
 | ||||
|   @JsonKey(name: "transactionDate") | ||||
|   int? transactionDate; | ||||
| 
 | ||||
|   OrdersReport( | ||||
|       {this.orderType, | ||||
|       this.token, | ||||
|  | @ -81,7 +87,9 @@ class OrdersReport { | |||
|       this.orderUserInfo, | ||||
|       this.userIdentification, | ||||
|       this.offerId, | ||||
|       this.basePlanId}); | ||||
|       this.basePlanId, | ||||
|       this.orderId, | ||||
|       this.transactionDate}); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|  | @ -91,6 +99,7 @@ class OrdersReport { | |||
|     sb.writeln("  price: $price"); | ||||
|     sb.writeln("  currency: $currency"); | ||||
|     sb.writeln("  userIdentification: $userIdentification"); | ||||
|     sb.writeln("  orderId: $orderId"); | ||||
|     if (Platform.isAndroid) { | ||||
|       sb.writeln("  orderType: $orderType"); | ||||
|       sb.writeln("  packageName: $packageName"); | ||||
|  | @ -108,8 +117,7 @@ class OrdersReport { | |||
|         .toString(); //'OrdersReport{orderType: $orderType, packageName: $packageName, productId: $productId, subscriptionId: $subscriptionId, token: $token, bundleId: $bundleId, receipt: $receipt, price: $price, currency: $currency, introductoryPrice: $introductoryPrice}'; | ||||
|   } | ||||
| 
 | ||||
|   factory OrdersReport.fromJson(Map<String, dynamic> json) => | ||||
|       _$OrdersReportFromJson(json); | ||||
|   factory OrdersReport.fromJson(Map<String, dynamic> json) => _$OrdersReportFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$OrdersReportToJson(this); | ||||
| } | ||||
|  | @ -126,8 +134,7 @@ class OrdersResponse { | |||
| 
 | ||||
|   OrdersResponse(this.usdPrice, this.test); | ||||
| 
 | ||||
|   factory OrdersResponse.fromJson(Map<String, dynamic> json) => | ||||
|       _$OrdersResponseFromJson(json); | ||||
|   factory OrdersResponse.fromJson(Map<String, dynamic> json) => _$OrdersResponseFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$OrdersResponseToJson(this); | ||||
| 
 | ||||
|  |  | |||
|  | @ -37,6 +37,8 @@ OrdersReport _$OrdersReportFromJson(Map<String, dynamic> json) => OrdersReport( | |||
|               json['eventConfig'] as Map<String, dynamic>), | ||||
|       offerId: json['offerId'] as String?, | ||||
|       basePlanId: json['basePlanId'] as String?, | ||||
|       orderId: json['orderId'] as String?, | ||||
|       transactionDate: json['transactionDate'] as int?, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$OrdersReportToJson(OrdersReport instance) => | ||||
|  | @ -56,6 +58,8 @@ Map<String, dynamic> _$OrdersReportToJson(OrdersReport instance) => | |||
|       'currency': instance.currency, | ||||
|       'userInfo': instance.orderUserInfo, | ||||
|       'eventConfig': instance.userIdentification, | ||||
|       'orderId': instance.orderId, | ||||
|       'transactionDate': instance.transactionDate, | ||||
|     }; | ||||
| 
 | ||||
| OrdersResponse _$OrdersResponseFromJson(Map<String, dynamic> json) => | ||||
|  |  | |||
|  | @ -7,6 +7,7 @@ import 'package:guru_app/account/account_data_store.dart'; | |||
| import 'package:guru_app/account/model/user.dart'; | ||||
| import 'package:guru_app/guru_app.dart'; | ||||
| import 'package:guru_app/property/app_property.dart'; | ||||
| import 'package:guru_utils/auth/auth_credential_manager.dart'; | ||||
| import 'package:guru_utils/device/device_info.dart'; | ||||
| import 'package:guru_utils/device/device_utils.dart'; | ||||
| import 'package:retrofit/retrofit.dart'; | ||||
|  | @ -120,10 +121,28 @@ abstract class GuruApiMethods { | |||
| 
 | ||||
|   // Auth | ||||
|   @POST("/auth/api/v1/tokens/provider/secret") | ||||
|   Future<SaasUser> signInWithAnonymous(@Body() AnonymousLoginReqBody body); | ||||
|   Future<GuruUser> signInWithAnonymous(@Body() AnonymousLoginReqBody body); | ||||
| 
 | ||||
|   @POST("/auth/api/v1/tokens/provider/facebook-gaming") | ||||
|   Future<GuruUser> signInWithFacebook(@Body() FacebookLoginReqBody body); | ||||
| 
 | ||||
|   @POST("/auth/api/v1/tokens/provider/google") | ||||
|   Future<GuruUser> signInWithGoogle(@Body() GoogleLoginReqBody body); | ||||
| 
 | ||||
|   @POST("/auth/api/v1/tokens/provider/apple") | ||||
|   Future<GuruUser> signInWithApple(@Body() AppleLoginReqBody body); | ||||
| 
 | ||||
|   @POST("/auth/api/v1/bindings/provider/facebook-gaming") | ||||
|   Future<GuruUser> associateWithFacebook(@Body() FacebookLoginReqBody body); | ||||
| 
 | ||||
|   @POST("/auth/api/v1/bindings/provider/google") | ||||
|   Future<GuruUser> associateWithGoogle(@Body() GoogleLoginReqBody body); | ||||
| 
 | ||||
|   @POST("/auth/api/v1/bindings/provider/apple") | ||||
|   Future<GuruUser> associateWithApple(@Body() AppleLoginReqBody body); | ||||
| 
 | ||||
|   @POST("/auth/api/v1/renewals/token") | ||||
|   Future<SaasUser> refreshSaasToken(); | ||||
|   Future<GuruUser> refreshSaasToken(); | ||||
| 
 | ||||
|   @POST("/auth/api/v1/renewals/firebase") | ||||
|   Future<FirebaseTokenData> renewFirebaseToken(); | ||||
|  |  | |||
|  | @ -46,14 +46,14 @@ class _GuruApiMethods implements GuruApiMethods { | |||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Future<SaasUser> signInWithAnonymous(AnonymousLoginReqBody body) async { | ||||
|   Future<GuruUser> signInWithAnonymous(AnonymousLoginReqBody body) async { | ||||
|     const _extra = <String, dynamic>{}; | ||||
|     final queryParameters = <String, dynamic>{}; | ||||
|     final _headers = <String, dynamic>{}; | ||||
|     final _data = <String, dynamic>{}; | ||||
|     _data.addAll(body.toJson()); | ||||
|     final _result = | ||||
|         await _dio.fetch<Map<String, dynamic>>(_setStreamType<SaasUser>(Options( | ||||
|         await _dio.fetch<Map<String, dynamic>>(_setStreamType<GuruUser>(Options( | ||||
|       method: 'POST', | ||||
|       headers: _headers, | ||||
|       extra: _extra, | ||||
|  | @ -69,18 +69,186 @@ class _GuruApiMethods implements GuruApiMethods { | |||
|               _dio.options.baseUrl, | ||||
|               baseUrl, | ||||
|             )))); | ||||
|     final value = SaasUser.fromJson(_result.data!); | ||||
|     final value = GuruUser.fromJson(_result.data!); | ||||
|     return value; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Future<SaasUser> refreshSaasToken() async { | ||||
|   Future<GuruUser> signInWithFacebook(FacebookLoginReqBody body) async { | ||||
|     const _extra = <String, dynamic>{}; | ||||
|     final queryParameters = <String, dynamic>{}; | ||||
|     final _headers = <String, dynamic>{}; | ||||
|     final _data = <String, dynamic>{}; | ||||
|     _data.addAll(body.toJson()); | ||||
|     final _result = | ||||
|         await _dio.fetch<Map<String, dynamic>>(_setStreamType<GuruUser>(Options( | ||||
|       method: 'POST', | ||||
|       headers: _headers, | ||||
|       extra: _extra, | ||||
|     ) | ||||
|             .compose( | ||||
|               _dio.options, | ||||
|               '/auth/api/v1/tokens/provider/facebook-gaming', | ||||
|               queryParameters: queryParameters, | ||||
|               data: _data, | ||||
|             ) | ||||
|             .copyWith( | ||||
|                 baseUrl: _combineBaseUrls( | ||||
|               _dio.options.baseUrl, | ||||
|               baseUrl, | ||||
|             )))); | ||||
|     final value = GuruUser.fromJson(_result.data!); | ||||
|     return value; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Future<GuruUser> signInWithGoogle(GoogleLoginReqBody body) async { | ||||
|     const _extra = <String, dynamic>{}; | ||||
|     final queryParameters = <String, dynamic>{}; | ||||
|     final _headers = <String, dynamic>{}; | ||||
|     final _data = <String, dynamic>{}; | ||||
|     _data.addAll(body.toJson()); | ||||
|     final _result = | ||||
|         await _dio.fetch<Map<String, dynamic>>(_setStreamType<GuruUser>(Options( | ||||
|       method: 'POST', | ||||
|       headers: _headers, | ||||
|       extra: _extra, | ||||
|     ) | ||||
|             .compose( | ||||
|               _dio.options, | ||||
|               '/auth/api/v1/tokens/provider/google', | ||||
|               queryParameters: queryParameters, | ||||
|               data: _data, | ||||
|             ) | ||||
|             .copyWith( | ||||
|                 baseUrl: _combineBaseUrls( | ||||
|               _dio.options.baseUrl, | ||||
|               baseUrl, | ||||
|             )))); | ||||
|     final value = GuruUser.fromJson(_result.data!); | ||||
|     return value; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Future<GuruUser> signInWithApple(AppleLoginReqBody body) async { | ||||
|     const _extra = <String, dynamic>{}; | ||||
|     final queryParameters = <String, dynamic>{}; | ||||
|     final _headers = <String, dynamic>{}; | ||||
|     final _data = <String, dynamic>{}; | ||||
|     _data.addAll(body.toJson()); | ||||
|     final _result = | ||||
|         await _dio.fetch<Map<String, dynamic>>(_setStreamType<GuruUser>(Options( | ||||
|       method: 'POST', | ||||
|       headers: _headers, | ||||
|       extra: _extra, | ||||
|     ) | ||||
|             .compose( | ||||
|               _dio.options, | ||||
|               '/auth/api/v1/tokens/provider/apple', | ||||
|               queryParameters: queryParameters, | ||||
|               data: _data, | ||||
|             ) | ||||
|             .copyWith( | ||||
|                 baseUrl: _combineBaseUrls( | ||||
|               _dio.options.baseUrl, | ||||
|               baseUrl, | ||||
|             )))); | ||||
|     final value = GuruUser.fromJson(_result.data!); | ||||
|     return value; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Future<GuruUser> associateWithFacebook(FacebookLoginReqBody body) async { | ||||
|     const _extra = <String, dynamic>{}; | ||||
|     final queryParameters = <String, dynamic>{}; | ||||
|     final _headers = <String, dynamic>{}; | ||||
|     final _data = <String, dynamic>{}; | ||||
|     _data.addAll(body.toJson()); | ||||
|     final _result = | ||||
|         await _dio.fetch<Map<String, dynamic>>(_setStreamType<GuruUser>(Options( | ||||
|       method: 'POST', | ||||
|       headers: _headers, | ||||
|       extra: _extra, | ||||
|     ) | ||||
|             .compose( | ||||
|               _dio.options, | ||||
|               '/auth/api/v1/bindings/provider/facebook-gaming', | ||||
|               queryParameters: queryParameters, | ||||
|               data: _data, | ||||
|             ) | ||||
|             .copyWith( | ||||
|                 baseUrl: _combineBaseUrls( | ||||
|               _dio.options.baseUrl, | ||||
|               baseUrl, | ||||
|             )))); | ||||
|     final value = GuruUser.fromJson(_result.data!); | ||||
|     return value; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Future<GuruUser> associateWithGoogle(GoogleLoginReqBody body) async { | ||||
|     const _extra = <String, dynamic>{}; | ||||
|     final queryParameters = <String, dynamic>{}; | ||||
|     final _headers = <String, dynamic>{}; | ||||
|     final _data = <String, dynamic>{}; | ||||
|     _data.addAll(body.toJson()); | ||||
|     final _result = | ||||
|         await _dio.fetch<Map<String, dynamic>>(_setStreamType<GuruUser>(Options( | ||||
|       method: 'POST', | ||||
|       headers: _headers, | ||||
|       extra: _extra, | ||||
|     ) | ||||
|             .compose( | ||||
|               _dio.options, | ||||
|               '/auth/api/v1/bindings/provider/google', | ||||
|               queryParameters: queryParameters, | ||||
|               data: _data, | ||||
|             ) | ||||
|             .copyWith( | ||||
|                 baseUrl: _combineBaseUrls( | ||||
|               _dio.options.baseUrl, | ||||
|               baseUrl, | ||||
|             )))); | ||||
|     final value = GuruUser.fromJson(_result.data!); | ||||
|     return value; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Future<GuruUser> associateWithApple(AppleLoginReqBody body) async { | ||||
|     const _extra = <String, dynamic>{}; | ||||
|     final queryParameters = <String, dynamic>{}; | ||||
|     final _headers = <String, dynamic>{}; | ||||
|     final _data = <String, dynamic>{}; | ||||
|     _data.addAll(body.toJson()); | ||||
|     final _result = | ||||
|         await _dio.fetch<Map<String, dynamic>>(_setStreamType<GuruUser>(Options( | ||||
|       method: 'POST', | ||||
|       headers: _headers, | ||||
|       extra: _extra, | ||||
|     ) | ||||
|             .compose( | ||||
|               _dio.options, | ||||
|               '/auth/api/v1/bindings/provider/apple', | ||||
|               queryParameters: queryParameters, | ||||
|               data: _data, | ||||
|             ) | ||||
|             .copyWith( | ||||
|                 baseUrl: _combineBaseUrls( | ||||
|               _dio.options.baseUrl, | ||||
|               baseUrl, | ||||
|             )))); | ||||
|     final value = GuruUser.fromJson(_result.data!); | ||||
|     return value; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Future<GuruUser> refreshSaasToken() async { | ||||
|     const _extra = <String, dynamic>{}; | ||||
|     final queryParameters = <String, dynamic>{}; | ||||
|     final _headers = <String, dynamic>{}; | ||||
|     final Map<String, dynamic>? _data = null; | ||||
|     final _result = | ||||
|         await _dio.fetch<Map<String, dynamic>>(_setStreamType<SaasUser>(Options( | ||||
|         await _dio.fetch<Map<String, dynamic>>(_setStreamType<GuruUser>(Options( | ||||
|       method: 'POST', | ||||
|       headers: _headers, | ||||
|       extra: _extra, | ||||
|  | @ -96,7 +264,7 @@ class _GuruApiMethods implements GuruApiMethods { | |||
|               _dio.options.baseUrl, | ||||
|               baseUrl, | ||||
|             )))); | ||||
|     final value = SaasUser.fromJson(_result.data!); | ||||
|     final value = GuruUser.fromJson(_result.data!); | ||||
|     return value; | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,8 +3,36 @@ | |||
| part of "../guru_api.dart"; | ||||
| 
 | ||||
| extension GuruApiExtension on GuruApi { | ||||
|   Future<SaasUser> signInWithAnonymous({required String secret}) async { | ||||
|     return await methods.signInWithAnonymous(AnonymousLoginReqBody(secret: secret)); | ||||
|   // Future<GuruUser> signInWithAnonymous({required String secret}) async { | ||||
|   //   return await methods.signInWithAnonymous(AnonymousLoginReqBody(secret: secret)); | ||||
|   // } | ||||
| 
 | ||||
|   Future<GuruUser> loginGuruWithCredential({required Credential credential}) async { | ||||
|     switch (credential.authType) { | ||||
|       case AuthType.facebook: | ||||
|         return await methods | ||||
|             .signInWithFacebook(FacebookLoginReqBody(accessToken: credential.token)); | ||||
|       case AuthType.google: | ||||
|         return await methods.signInWithGoogle(GoogleLoginReqBody(idToken: credential.token)); | ||||
|       case AuthType.apple: | ||||
|         return await methods.signInWithApple(AppleLoginReqBody(token: credential.token)); | ||||
|       case AuthType.anonymous: | ||||
|         return await methods.signInWithAnonymous(AnonymousLoginReqBody(secret: credential.token)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future<GuruUser> associateCredential({required Credential credential}) async { | ||||
|     switch (credential.authType) { | ||||
|       case AuthType.facebook: | ||||
|         return await methods | ||||
|             .associateWithFacebook(FacebookLoginReqBody(accessToken: credential.token)); | ||||
|       case AuthType.google: | ||||
|         return await methods.associateWithGoogle(GoogleLoginReqBody(idToken: credential.token)); | ||||
|       case AuthType.apple: | ||||
|         return await methods.associateWithApple(AppleLoginReqBody(token: credential.token)); | ||||
|       case AuthType.anonymous: | ||||
|         return await methods.signInWithAnonymous(AnonymousLoginReqBody(secret: credential.token)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future reportDevice(DeviceInfo deviceInfo) async { | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| import 'dart:io'; | ||||
| 
 | ||||
| import 'package:guru_app/firebase/messaging/remote_messaging_manager.dart'; | ||||
| import 'package:guru_utils/datetime/datetime_utils.dart'; | ||||
| import 'package:json_annotation/json_annotation.dart'; | ||||
| 
 | ||||
| /// Created by Haoyi on 2022/8/29 | ||||
|  | @ -64,6 +65,9 @@ class Deployment { | |||
|   static const int defaultApiTimeout = 15000; // 15s | ||||
|   static const int defaultIosSandboxSubsRenewalSpeed = 2; | ||||
|   static const int defaultTrackingNotificationPermissionPassLimitTimes = 10; | ||||
|   static const int defaultSubscriptionRestoreGraceCount = 3; | ||||
|   static const int defaultFullscreenMinInterval = 60; | ||||
|   static const int defaultSubscriptionGraceDays = DateTimeUtils.dayInMillis; | ||||
| 
 | ||||
|   @JsonKey(name: "property_cache_size", defaultValue: 256) | ||||
|   final int propertyCacheSize; | ||||
|  | @ -139,6 +143,21 @@ class Deployment { | |||
|   @JsonKey(name: "show_internal_ads_when_banner_unavailable", defaultValue: false) | ||||
|   final bool showInternalAdsWhenBannerUnavailable; | ||||
| 
 | ||||
|   @JsonKey(name: "subscription_restore_grace_count", defaultValue: defaultSubscriptionRestoreGraceCount) | ||||
|   final int subscriptionRestoreGraceCount; | ||||
| 
 | ||||
|   @JsonKey(name: "fullscreen_ads_min_interval", defaultValue: defaultFullscreenMinInterval) | ||||
|   final int fullscreenAdsMinInterval; | ||||
| 
 | ||||
|   @JsonKey(name: "subscription_grace_period", defaultValue: defaultSubscriptionGraceDays) | ||||
|   final int subscriptionGraceDays; | ||||
| 
 | ||||
|   @JsonKey(name: "enabled_sync_account_profile", defaultValue: false) | ||||
|   final bool enabledSyncAccountProfile; | ||||
| 
 | ||||
|   @JsonKey(name: "purchase_event_trigger", defaultValue: 1) | ||||
|   final int purchaseEventTrigger; | ||||
| 
 | ||||
|   Deployment( | ||||
|       {this.propertyCacheSize = 256, | ||||
|       this.enableDithering = true, | ||||
|  | @ -164,7 +183,12 @@ class Deployment { | |||
|           defaultTrackingNotificationPermissionPassLimitTimes, | ||||
|       this.enabledGuruAnalyticsStrategy = false, | ||||
|       this.allowInterstitialAsAlternativeReward = false, | ||||
|       this.showInternalAdsWhenBannerUnavailable = false}); | ||||
|       this.showInternalAdsWhenBannerUnavailable = false, | ||||
|       this.subscriptionRestoreGraceCount = defaultSubscriptionRestoreGraceCount, | ||||
|       this.fullscreenAdsMinInterval = defaultFullscreenMinInterval, | ||||
|       this.subscriptionGraceDays = defaultSubscriptionGraceDays, | ||||
|       this.enabledSyncAccountProfile = false, | ||||
|       this.purchaseEventTrigger = 1}); | ||||
| 
 | ||||
|   factory Deployment.fromJson(Map<String, dynamic> json) => _$DeploymentFromJson(json); | ||||
| 
 | ||||
|  | @ -176,7 +200,11 @@ class RemoteDeployment { | |||
|   @JsonKey(name: "keep_screen_on_duration_m", defaultValue: 0) | ||||
|   final int keepScreenOnDuration; | ||||
| 
 | ||||
|   RemoteDeployment({this.keepScreenOnDuration = 0}); | ||||
|   @JsonKey(name: "subscriptionGraceDays") | ||||
|   final int? subscriptionGraceDays; | ||||
| 
 | ||||
|   RemoteDeployment( | ||||
|       {this.keepScreenOnDuration = 0, this.subscriptionGraceDays}); | ||||
| 
 | ||||
|   factory RemoteDeployment.fromJson(Map<String, dynamic> json) => _$RemoteDeploymentFromJson(json); | ||||
| 
 | ||||
|  |  | |||
|  | @ -79,6 +79,14 @@ Deployment _$DeploymentFromJson(Map<String, dynamic> json) => Deployment( | |||
|           json['allow_interstitial_as_alternative_reward'] as bool? ?? false, | ||||
|       showInternalAdsWhenBannerUnavailable: | ||||
|           json['show_internal_ads_when_banner_unavailable'] as bool? ?? false, | ||||
|       subscriptionRestoreGraceCount: | ||||
|           json['subscription_restore_grace_count'] as int? ?? 3, | ||||
|       fullscreenAdsMinInterval: | ||||
|           json['fullscreen_ads_min_interval'] as int? ?? 60, | ||||
|       subscriptionGraceDays: | ||||
|           json['subscription_grace_period'] as int? ?? 86400000, | ||||
|       enabledSyncAccountProfile: | ||||
|           json['enabled_sync_account_profile'] as bool? ?? false, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$DeploymentToJson(Deployment instance) => | ||||
|  | @ -113,6 +121,11 @@ Map<String, dynamic> _$DeploymentToJson(Deployment instance) => | |||
|           instance.allowInterstitialAsAlternativeReward, | ||||
|       'show_internal_ads_when_banner_unavailable': | ||||
|           instance.showInternalAdsWhenBannerUnavailable, | ||||
|       'subscription_restore_grace_count': | ||||
|           instance.subscriptionRestoreGraceCount, | ||||
|       'fullscreen_ads_min_interval': instance.fullscreenAdsMinInterval, | ||||
|       'subscription_grace_period': instance.subscriptionGraceDays, | ||||
|       'enabled_sync_account_profile': instance.enabledSyncAccountProfile, | ||||
|     }; | ||||
| 
 | ||||
| const _$PromptTriggerEnumMap = { | ||||
|  | @ -123,9 +136,11 @@ const _$PromptTriggerEnumMap = { | |||
| RemoteDeployment _$RemoteDeploymentFromJson(Map<String, dynamic> json) => | ||||
|     RemoteDeployment( | ||||
|       keepScreenOnDuration: json['keep_screen_on_duration_m'] as int? ?? 0, | ||||
|       subscriptionGraceDays: json['subscriptionGraceDays'] as int?, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$RemoteDeploymentToJson(RemoteDeployment instance) => | ||||
|     <String, dynamic>{ | ||||
|       'keep_screen_on_duration_m': instance.keepScreenOnDuration, | ||||
|       'subscriptionGraceDays': instance.subscriptionGraceDays, | ||||
|     }; | ||||
|  |  | |||
|  | @ -3,13 +3,18 @@ import 'package:guru_app/financial/asset/assets_store.dart'; | |||
| import 'package:guru_app/financial/financial_manager.dart'; | ||||
| import 'package:guru_app/financial/iap/iap_manager.dart'; | ||||
| import 'package:guru_app/financial/iap/iap_model.dart'; | ||||
| import 'package:guru_app/financial/igb/igb_manager.dart'; | ||||
| import 'package:guru_app/financial/igb/igb_product.dart'; | ||||
| import 'package:guru_app/financial/igc/igc_manager.dart'; | ||||
| import 'package:guru_app/financial/igc/igc_model.dart'; | ||||
| import 'package:guru_app/financial/product/product_store.dart'; | ||||
| import 'package:guru_app/financial/reward/reward_manager.dart'; | ||||
| import 'package:guru_app/financial/reward/reward_model.dart'; | ||||
| import 'package:guru_app/guru_app.dart'; | ||||
| import 'package:guru_app/inventory/db/inventory_database.dart'; | ||||
| import 'package:guru_app/inventory/inventory_manager.dart'; | ||||
| import 'package:guru_app/test/test_guru_app_creator.dart'; | ||||
| import 'package:guru_utils/collection/collectionutils.dart'; | ||||
| import 'package:guru_utils/datetime/datetime_utils.dart'; | ||||
| import 'package:guru_utils/extensions/extensions.dart'; | ||||
| import 'package:guru_utils/controller/controller.dart'; | ||||
|  | @ -18,7 +23,7 @@ import 'package:guru_utils/controller/controller.dart'; | |||
| 
 | ||||
| mixin AssetsAware on LifecycleController { | ||||
|   final BehaviorSubject<ProductStore<IapProduct>> _productStoreSubject = | ||||
|   BehaviorSubject.seeded(ProductStore()); | ||||
|       BehaviorSubject.seeded(ProductStore()); | ||||
| 
 | ||||
|   ProductStore<IapProduct> get currentProductStore => _productStoreSubject.value; | ||||
| 
 | ||||
|  | @ -44,6 +49,9 @@ mixin AssetsAware on LifecycleController { | |||
| 
 | ||||
|   Stream<int> get observableIgcBalance => IgcManager.instance.observableCurrentBalance; | ||||
| 
 | ||||
|   Stream<Map<String, InventoryItem>> get observableInventoryItems => | ||||
|       InventoryManager.instance.observableData; | ||||
| 
 | ||||
|   Future restorePurchases() async { | ||||
|     return await IapManager.instance.restorePurchases(); | ||||
|   } | ||||
|  | @ -73,6 +81,52 @@ mixin AssetsAware on LifecycleController { | |||
|     return RewardManager.instance.buildRewardProduct(intent); | ||||
|   } | ||||
| 
 | ||||
|   Future<IgbProduct> buildIgbProduct(TransactionIntent intent) { | ||||
|     return IgbManager.instance.buildIgbProduct(intent); | ||||
|   } | ||||
| 
 | ||||
|   int getInventoryBalance(String sku) { | ||||
|     return InventoryManager.instance.getData(sku)?.balance ?? 0; | ||||
|   } | ||||
| 
 | ||||
|   TimeSensitiveData getInventoryTimeSensitiveData(String sku) { | ||||
|     return InventoryManager.instance.getData(sku)?.timeSensitive ?? const TimeSensitiveData(); | ||||
|   } | ||||
| 
 | ||||
|   /// 使用指定[sku]的道具,[amount]为使用数量,[action]的行为,[scene]为使用场景 | ||||
|   /// useProp最终会得到一个行为上的收益,因此这里为了方便针对道具使用进行统一的行为分析 | ||||
|   /// 这里的 [action]和[scene]最终会通过 spend_virtual_currency 事件进行统一的统计 | ||||
|   /// propCategory可以参照 [PropCategory] 中的定义 | ||||
|   /// 具体参数对照如下: | ||||
|   ///   - **`item_name`**: [intent] 如果这里指定了使用道具的意图,那么这里就会使用这个意图,否则使用场景, | ||||
|   ///   - **`item_category`**: [category], | ||||
|   ///   - **`virtual_currency_name`**: [propSku], | ||||
|   ///   - **`value`**: [amount], | ||||
|   ///   - **`balance`**: balance, | ||||
|   ///   - **`scene`**: [scene], | ||||
|   ///   - **`level_name`**: levelName | ||||
|   /// | ||||
|   ///  返回值为是否成功使用道具,false表示道具不足,true表示使用成功 | ||||
|   /// | ||||
|   Future<bool> useProp( | ||||
|     String propSku, | ||||
|     String scene, { | ||||
|     int amount = 1, | ||||
|     String? intent, | ||||
|     String category = PropCategory.boosts, | ||||
|     bool timeSensitiveOnly = false, | ||||
|     int? transactionTs, | ||||
|   }) async { | ||||
|     final manifest = Manifest.action(category, scene, | ||||
|         extras: CollectionUtils.filterOutNulls({ | ||||
|           ExtraReservedField.contentId: intent ?? scene, // 如果这里指定了使用道具的意图,那么这里就会使用这个意图,否则使用场景 | ||||
|           ExtraReservedField.transactionTs: transactionTs // 如果这里指定了交易时间,那么就会使用这个时间,否则使用当前时间 | ||||
|         })); | ||||
|     return await InventoryManager.instance.consume( | ||||
|         [StockItem.consumable(propSku, amount)], manifest, | ||||
|         timeSensitiveOnly: timeSensitiveOnly); | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> requestProduct(Product product, {String from = ""}) async { | ||||
|     if (product is IapProduct) { | ||||
|       return await IapManager.instance.buy(product); | ||||
|  | @ -80,6 +134,8 @@ mixin AssetsAware on LifecycleController { | |||
|       return await IgcManager.instance.purchase(product); | ||||
|     } else if (product is RewardProduct) { | ||||
|       return await RewardManager.instance.claim(product); | ||||
|     } else if (product is IgbProduct) { | ||||
|       return await IgbManager.instance.redeem(product); | ||||
|     } else { | ||||
|       return false; | ||||
|     } | ||||
|  |  | |||
|  | @ -1,4 +1,5 @@ | |||
| import 'package:guru_app/financial/data/db/order_database.dart'; | ||||
| import 'package:guru_app/inventory/db/inventory_database.dart'; | ||||
| import 'package:guru_utils/database/database.dart'; | ||||
| import 'package:guru_utils/property/storage/db/property_database.dart'; | ||||
| 
 | ||||
|  | @ -8,6 +9,8 @@ final List<TableCreator> _creatorV1 = [PropertyEntity.createTable]; | |||
| 
 | ||||
| final List<TableCreator> _creatorV2 = [OrderEntity.createTable]; | ||||
| 
 | ||||
| final List<TableCreator> _creatorV4 = [InventoryTable.createTable]; | ||||
| 
 | ||||
| class Creators { | ||||
|   static final List<TableCreator> creators = [..._creatorV1, ..._creatorV2]; | ||||
|   static final List<TableCreator> creators = [..._creatorV1, ..._creatorV2, ..._creatorV4]; | ||||
| } | ||||
|  |  | |||
|  | @ -25,5 +25,5 @@ class GuruDB extends _GuruDB with PropertyDatabase { | |||
|   List<TableCreator> get tableCreators => Creators.creators; | ||||
| 
 | ||||
|   @override | ||||
|   int get version => 3; | ||||
|   int get version => 4; | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,16 @@ | |||
| part of "migrations.dart"; | ||||
| 
 | ||||
| class _MigrationV3toV4 implements Migration { | ||||
|   @override | ||||
|   Future<MigrateResult> migrate(Transaction transaction) async { | ||||
|     // 由于这里无法保证所在平台是否支持IF NOT EXISTS,所以这里用try catch来处理 | ||||
|     try { | ||||
|       await InventoryTable.createTable(transaction); | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.w("ignore alter cmd!"); | ||||
|     } | ||||
|     return MigrateResult.success; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| final migration3to4 = _MigrationV3toV4(); | ||||
|  | @ -1,13 +1,15 @@ | |||
| import 'package:guru_app/financial/data/db/order_database.dart'; | ||||
| import 'package:guru_app/inventory/db/inventory_database.dart'; | ||||
| import 'package:guru_utils/database/database.dart'; | ||||
| import 'package:guru_utils/log/log.dart'; | ||||
| 
 | ||||
| part "migration_v1_to_v2.dart"; | ||||
| part 'migration_v2_to_v3.dart'; | ||||
| part 'migration_v3_to_v4.dart'; | ||||
| 
 | ||||
| /// Created by @Haoyi on 2020/5/22 | ||||
| /// | ||||
| 
 | ||||
| class Migrations { | ||||
|   static final migrations = [migration1to2, migration2to3]; | ||||
|   static final migrations = [migration1to2, migration2to3, migration3to4]; | ||||
| } | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ import 'package:guru_app/financial/asset/assets_model.dart'; | |||
| import 'package:guru_app/financial/asset/assets_store.dart'; | ||||
| import 'package:guru_app/financial/iap/iap_manager.dart'; | ||||
| import 'package:guru_app/financial/iap/iap_model.dart'; | ||||
| import 'package:guru_app/financial/igb/igb_manager.dart'; | ||||
| import 'package:guru_app/financial/igc/igc_manager.dart'; | ||||
| import 'package:guru_app/financial/reward/reward_manager.dart'; | ||||
| import 'package:guru_utils/extensions/extensions.dart'; | ||||
|  | @ -54,6 +55,13 @@ class FinancialManager { | |||
|   void init() { | ||||
|     IapManager.instance.init(); | ||||
|     IgcManager.instance.init(); | ||||
|     IgbManager.instance.init(); | ||||
|     RewardManager.instance.init(); | ||||
|   } | ||||
| 
 | ||||
|   void switchSession(String fromUid, String toUid) { | ||||
|     IapManager.instance.switchSession(); | ||||
|     IgcManager.instance.switchSession(); | ||||
|     RewardManager.instance.switchSession(); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -18,6 +18,7 @@ import 'package:guru_app/financial/manifest/manifest.dart'; | |||
| import 'package:guru_app/financial/manifest/manifest_manager.dart'; | ||||
| import 'package:guru_app/financial/product/product_model.dart'; | ||||
| import 'package:guru_app/financial/product/product_store.dart'; | ||||
| import 'package:guru_app/firebase/firebase.dart'; | ||||
| import 'package:guru_app/guru_app.dart'; | ||||
| import 'package:guru_app/property/app_property.dart'; | ||||
| import 'package:guru_app/property/property_keys.dart'; | ||||
|  | @ -50,17 +51,14 @@ class IapManager { | |||
|   final BehaviorSubject<AssetsStore<Asset>> _iapStoreSubject = | ||||
|       BehaviorSubject.seeded(AssetsStore<Asset>.inactive()); | ||||
| 
 | ||||
|   final Map<ProductId, IapRequest> iapRequestMap = | ||||
|       HashMap<ProductId, IapRequest>(); | ||||
|   final Map<ProductId, IapRequest> iapRequestMap = HashMap<ProductId, IapRequest>(); | ||||
| 
 | ||||
|   Stream<Map<ProductId, ProductDetails>> get observableProductDetails => | ||||
|       _productDetailsSubject.stream; | ||||
| 
 | ||||
|   Stream<AssetsStore<Asset>> get observableAssetStore => | ||||
|       _iapStoreSubject.stream; | ||||
|   Stream<AssetsStore<Asset>> get observableAssetStore => _iapStoreSubject.stream; | ||||
| 
 | ||||
|   Map<ProductId, ProductDetails> get loadedProductDetails => | ||||
|       _productDetailsSubject.value; | ||||
|   Map<ProductId, ProductDetails> get loadedProductDetails => _productDetailsSubject.value; | ||||
| 
 | ||||
|   AssetsStore<Asset> get purchasedStore => _iapStoreSubject.value; | ||||
| 
 | ||||
|  | @ -83,8 +81,8 @@ class IapManager { | |||
|   bool _restorePurchase = false; | ||||
| 
 | ||||
|   final iapRevenueAppEventOptions = AppEventOptions( | ||||
|       capabilities: const AppEventCapabilities( | ||||
|           AppEventCapabilities.firebase | AppEventCapabilities.guru), | ||||
|       capabilities: | ||||
|           const AppEventCapabilities(AppEventCapabilities.firebase | AppEventCapabilities.guru), | ||||
|       firebaseParamsConvertor: _iapRevenueToValue, | ||||
|       guruParamsConvertor: _iapRevenueToValue); | ||||
| 
 | ||||
|  | @ -100,8 +98,7 @@ class IapManager { | |||
|   void init() async { | ||||
|     final iapCount = await AppProperty.getInstance().getIapCount(); | ||||
|     if (iapCount > 0) { | ||||
|       GuruAnalytics.instance | ||||
|           .setUserProperty("purchase_count", iapCount.toString()); | ||||
|       GuruAnalytics.instance.setUserProperty("purchase_count", iapCount.toString()); | ||||
|       GuruAnalytics.instance.setUserProperty("is_iap_user", "true"); | ||||
|     } else { | ||||
|       GuruAnalytics.instance.setUserProperty("is_iap_user", "false"); | ||||
|  | @ -113,8 +110,7 @@ class IapManager { | |||
|           stackTrace: stacktrace, syncCrashlytics: true, syncFirebase: true); | ||||
|     } | ||||
|     if (subscription == null) { | ||||
|       final Stream<List<PurchaseDetails>> purchaseUpdated = | ||||
|           _inAppPurchase.purchaseStream; | ||||
|       final Stream<List<PurchaseDetails>> purchaseUpdated = _inAppPurchase.purchaseStream; | ||||
|       subscription = purchaseUpdated.listen( | ||||
|           (List<PurchaseDetails> purchaseDetailsList) { | ||||
|             _listenToPurchaseUpdated(purchaseDetailsList); | ||||
|  | @ -141,13 +137,27 @@ class IapManager { | |||
|     } finally {} | ||||
|   } | ||||
| 
 | ||||
|   Future switchSession() async { | ||||
|     final iapCount = await AppProperty.getInstance().getIapCount(); | ||||
|     if (iapCount > 0) { | ||||
|       GuruAnalytics.instance.setUserProperty("purchase_count", iapCount.toString()); | ||||
|       GuruAnalytics.instance.setUserProperty("is_iap_user", "true"); | ||||
|     } else { | ||||
|       GuruAnalytics.instance.setUserProperty("is_iap_user", "false"); | ||||
|     } | ||||
|     try { | ||||
|       await reloadOrders(); | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.w("reloadOrders error! $error", | ||||
|           stackTrace: stacktrace, syncCrashlytics: true, syncFirebase: true); | ||||
|     } | ||||
|     _checkAndLoad(); | ||||
|   } | ||||
| 
 | ||||
|   Future reloadOrders() async { | ||||
|     final transactions = await GuruDB.instance.selectOrders( | ||||
|         method: TransactionMethod.iap, | ||||
|         attrs: [ | ||||
|           TransactionAttributes.asset, | ||||
|           TransactionAttributes.subscriptions | ||||
|         ]); | ||||
|         attrs: [TransactionAttributes.asset, TransactionAttributes.subscriptions]); | ||||
|     final newAssetStore = AssetsStore<Asset>(); | ||||
|     Log.d("reloadOrders ${transactions.length}"); | ||||
|     for (var transaction in transactions) { | ||||
|  | @ -165,15 +175,14 @@ class IapManager { | |||
|     do { | ||||
|       final seconds = min(MathUtils.fibonacci(retry, offset: 2), 900); | ||||
|       await Future.delayed(Duration(seconds: seconds)); | ||||
|       available = | ||||
|           await _inAppPurchase.isAvailable().catchError((error, stacktrace) { | ||||
|       available = await _inAppPurchase.isAvailable().catchError((error, stacktrace) { | ||||
|         Log.w("isAvailable error:$error", stackTrace: stacktrace); | ||||
|         return false; | ||||
|       }); | ||||
|       Log.d("_checkAndLoad:$retry available:$available"); | ||||
|       retry++; | ||||
|     } while (!available); | ||||
|     availableSubject.addEx(true); | ||||
|     availableSubject.addIfChanged(true); | ||||
|     try { | ||||
|       await refreshProducts(); | ||||
|       if (GuruApp.instance.appSpec.deployment.autoRestoreIap || | ||||
|  | @ -196,12 +205,9 @@ class IapManager { | |||
|       iapRequest.response(false); | ||||
|       final iapErrorMsg = "_processIapError:${iapRequest.productId}"; | ||||
|       Log.w(iapErrorMsg, | ||||
|           error: PurchaseError(iapErrorMsg), | ||||
|           syncFirebase: true, | ||||
|           syncCrashlytics: true); | ||||
|           error: PurchaseError(iapErrorMsg), syncFirebase: true, syncCrashlytics: true); | ||||
|       try { | ||||
|         await GuruDB.instance | ||||
|             .upsertOrder(order: iapRequest.order.error(iapErrorMsg)); | ||||
|         await GuruDB.instance.upsertOrder(order: iapRequest.order.error(iapErrorMsg)); | ||||
|       } catch (error, stacktrace) { | ||||
|         Log.w("_processIapError upsert error! $error", syncFirebase: true); | ||||
|       } | ||||
|  | @ -218,8 +224,7 @@ class IapManager { | |||
|       try { | ||||
|         await GuruDB.instance.deleteOrder(order: order); | ||||
|       } catch (error, stacktrace) { | ||||
|         Log.w("_processIapCancel deleteOrder error! $error", | ||||
|             syncFirebase: true); | ||||
|         Log.w("_processIapCancel deleteOrder error! $error", syncFirebase: true); | ||||
|       } | ||||
|     } | ||||
|     iapRequestMap.clear(); | ||||
|  | @ -248,22 +253,18 @@ class IapManager { | |||
|   //   }); | ||||
|   // } | ||||
| 
 | ||||
|   String dumpProductAndPurchased( | ||||
|       ProductDetails details, PurchaseDetails purchaseDetails) { | ||||
|   String dumpProductAndPurchased(ProductDetails details, PurchaseDetails purchaseDetails) { | ||||
|     final StringBuffer sb = StringBuffer(); | ||||
| 
 | ||||
|     if (Platform.isAndroid) { | ||||
|       try { | ||||
|         GooglePlayPurchaseDetails googlePlayDetails = | ||||
|             purchaseDetails as GooglePlayPurchaseDetails; | ||||
|         GooglePlayProductDetails googlePlayProduct = | ||||
|             details as GooglePlayProductDetails; | ||||
|         GooglePlayPurchaseDetails googlePlayDetails = purchaseDetails as GooglePlayPurchaseDetails; | ||||
|         GooglePlayProductDetails googlePlayProduct = details as GooglePlayProductDetails; | ||||
|         Log.d( | ||||
|             "Android Product/Purchase ${buildGooglePlayDetailsString(googlePlayProduct, googlePlayDetails)}"); | ||||
|       } catch (error, stacktrace) {} | ||||
|     } else if (Platform.isIOS) { | ||||
|       AppStorePurchaseDetails appleDetails = | ||||
|           purchaseDetails as AppStorePurchaseDetails; | ||||
|       AppStorePurchaseDetails appleDetails = purchaseDetails as AppStorePurchaseDetails; | ||||
|       AppStoreProductDetails appleProduct = details as AppStoreProductDetails; | ||||
|       sb.writeln("#### purchase ####"); | ||||
|       sb.writeln("productID: ${appleDetails.productID}"); | ||||
|  | @ -274,23 +275,18 @@ class IapManager { | |||
|       sb.writeln("skPaymentTransaction:"); | ||||
|       sb.writeln( | ||||
|           "   =>originalTransaction:${appleDetails.skPaymentTransaction.originalTransaction}"); | ||||
|       sb.writeln( | ||||
|           "       =>${appleDetails.skPaymentTransaction.originalTransaction?.payment}"); | ||||
|       sb.writeln("       =>${appleDetails.skPaymentTransaction.originalTransaction?.payment}"); | ||||
|       sb.writeln("   =>${appleDetails.skPaymentTransaction.transactionState}:"); | ||||
|       sb.writeln( | ||||
|           "   =>${appleDetails.skPaymentTransaction.transactionIdentifier}:"); | ||||
|       sb.writeln("   =>${appleDetails.skPaymentTransaction.transactionIdentifier}:"); | ||||
|       sb.writeln("\n#### product ####"); | ||||
|       sb.writeln("currencyCode: ${appleProduct.currencyCode}"); | ||||
|       sb.writeln("rawPrice: ${appleProduct.rawPrice}"); | ||||
|       sb.writeln("currencyCode: ${appleProduct.currencyCode}"); | ||||
|       sb.writeln("currencyCode skProduct"); | ||||
|       sb.writeln( | ||||
|           "    =>localizedTitle: ${appleProduct.skProduct.localizedTitle}"); | ||||
|       sb.writeln( | ||||
|           "    =>localizedDescription: ${appleProduct.skProduct.localizedDescription}"); | ||||
|       sb.writeln("    =>localizedTitle: ${appleProduct.skProduct.localizedTitle}"); | ||||
|       sb.writeln("    =>localizedDescription: ${appleProduct.skProduct.localizedDescription}"); | ||||
|       sb.writeln("    =>priceLocale: ${appleProduct.skProduct.priceLocale}"); | ||||
|       sb.writeln( | ||||
|           "    =>productIdentifier: ${appleProduct.skProduct.productIdentifier}"); | ||||
|       sb.writeln("    =>productIdentifier: ${appleProduct.skProduct.productIdentifier}"); | ||||
|       sb.writeln( | ||||
|           "    =>subscriptionGroupIdentifier: ${appleProduct.skProduct.subscriptionGroupIdentifier}"); | ||||
|       sb.writeln("    =>appleProduct.skProduct.priceLocale"); | ||||
|  | @ -319,9 +315,8 @@ class IapManager { | |||
| 
 | ||||
|   int getIOSPeriodInterval(int numberOfUnits, SKSubscriptionPeriodUnit unit) { | ||||
|     if (GuruSettings.instance.debugMode.get()) { | ||||
|       final renewalSpeed = GuruApp | ||||
|           .instance.appSpec.deployment.iosSandboxSubsRenewalSpeed | ||||
|           .clamp(1, 5); | ||||
|       final renewalSpeed = | ||||
|           GuruApp.instance.appSpec.deployment.iosSandboxSubsRenewalSpeed.clamp(1, 5); | ||||
|       switch (unit) { | ||||
|         case SKSubscriptionPeriodUnit.day: | ||||
|           return numberOfUnits * weekRenewalDurations[renewalSpeed - 1] ~/ 7; | ||||
|  | @ -346,8 +341,52 @@ class IapManager { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future processRestoredSubscription( | ||||
|       List<PurchaseDetails> subscriptionPurchased) async { | ||||
|   bool checkSubscriptionPeriod(PurchaseDetails purchaseDetails, ProductDetails productDetails) { | ||||
|     bool validOrder = false; | ||||
|     if (Platform.isAndroid) { | ||||
|       validOrder = true; | ||||
|     } else if (Platform.isIOS) { | ||||
|       final appleProduct = productDetails as AppStoreProductDetails; | ||||
|       SKProductSubscriptionPeriodWrapper? period = appleProduct.skProduct.subscriptionPeriod; | ||||
| 
 | ||||
|       final appleDetails = purchaseDetails as AppStorePurchaseDetails; | ||||
|       final paymentDiscount = appleDetails.skPaymentTransaction.payment.paymentDiscount; | ||||
|       if (paymentDiscount != null) { | ||||
|         Log.i("paymentDiscount: ${paymentDiscount.identifier} ${paymentDiscount.timestamp}"); | ||||
|         for (var discount in appleProduct.skProduct.discounts) { | ||||
|           final discountSubPeriod = discount.subscriptionPeriod; | ||||
|           Log.i( | ||||
|               "check discount(${paymentDiscount.identifier}) product [ ${discount.identifier}  ${discountSubPeriod.unit} ${discountSubPeriod.numberOfUnits} ]"); | ||||
|           if (discount.identifier == paymentDiscount.identifier) { | ||||
|             period = discount.subscriptionPeriod; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       Log.i("checkSubscriptionPeriod: ${appleProduct.skProduct.productIdentifier} ${period?.unit} ${period?.numberOfUnits} ${appleDetails.transactionDate}"); | ||||
| 
 | ||||
|       if (period != null) { | ||||
|         final numberOfUnits = period.numberOfUnits; | ||||
|         final unit = period.unit; | ||||
|         final int validInterval = getIOSPeriodInterval(numberOfUnits, unit); | ||||
|         final transactionTs = int.tryParse(purchaseDetails.transactionDate ?? "") ?? 0; | ||||
|         final now = DateTimeUtils.currentTimeInMillis(); | ||||
|         final gracePeriod = GuruApp.instance.remoteDeployment.subscriptionGraceDays ?? | ||||
|             GuruApp.instance.appSpec.deployment.subscriptionGraceDays; | ||||
|         /// 过期时间 = 订单的最后一次刷新时间 + 订阅周期(优惠周期) + 宽限期 | ||||
|         final expiredTs = transactionTs + validInterval + gracePeriod; | ||||
|         /// 如果当前的时间小于过期时间,那么这个订单是有效的 | ||||
|         validOrder = now < expiredTs; | ||||
|         Log.d( | ||||
|             "productID: ${purchaseDetails.productID}) purchaseID: ${purchaseDetails.purchaseID}[$numberOfUnits][$unit] [$now < $transactionTs + $validInterval]($validOrder) ", | ||||
|             tag: PropertyTags.iap); | ||||
|       } | ||||
|     } | ||||
|     return validOrder; | ||||
|   } | ||||
| 
 | ||||
|   Future processRestoredSubscription(List<PurchaseDetails> subscriptionPurchased) async { | ||||
|     List<PurchaseDetails> purchasedDetails = subscriptionPurchased; | ||||
|     if (Platform.isIOS) { | ||||
|       purchasedDetails = buildLatestPurchasedPlanForIos(subscriptionPurchased); | ||||
|  | @ -359,8 +398,7 @@ class IapManager { | |||
|     if (Platform.isAndroid) { | ||||
|       final purchasedSkus = purchasedDetails.map((e) => e.productID).toSet(); | ||||
|       newPurchasedStore.removeWhere((productId, asset) { | ||||
|         final expired = | ||||
|             productId.isSubscription && !purchasedSkus.contains(productId.sku); | ||||
|         final expired = productId.isSubscription && !purchasedSkus.contains(productId.sku); | ||||
|         Log.i("remove expired subscription[$productId] expired:$expired"); | ||||
|         if (expired) { | ||||
|           expiredSkus.add(asset.productId.sku); | ||||
|  | @ -370,8 +408,7 @@ class IapManager { | |||
|     } | ||||
| 
 | ||||
|     for (var purchased in purchasedDetails) { | ||||
|       final productId = | ||||
|           GuruApp.instance.findProductId(sku: purchased.productID); | ||||
|       final productId = GuruApp.instance.findProductId(sku: purchased.productID); | ||||
|       if (productId == null) { | ||||
|         Log.w("productId is null! ${purchased.productID}"); | ||||
|         continue; | ||||
|  | @ -381,26 +418,7 @@ class IapManager { | |||
|         Log.w("product is null! ${purchased.productID}"); | ||||
|         continue; | ||||
|       } | ||||
|       purchased.transactionDate; | ||||
|       bool validPurchase = false; | ||||
|       if (Platform.isAndroid) { | ||||
|         validPurchase = true; | ||||
|       } else if (Platform.isIOS) { | ||||
|         final appleProduct = productDetails as AppStoreProductDetails; | ||||
|         final period = appleProduct.skProduct.subscriptionPeriod; | ||||
|         if (period != null) { | ||||
|           final numberOfUnits = period.numberOfUnits; | ||||
|           final unit = period.unit; | ||||
|           final int validInterval = getIOSPeriodInterval(numberOfUnits, unit); | ||||
|           final transactionTs = | ||||
|               int.tryParse(purchased.transactionDate ?? "") ?? 0; | ||||
|           final now = DateTimeUtils.currentTimeInMillis(); | ||||
|           validPurchase = transactionTs + validInterval < now; | ||||
|           Log.d( | ||||
|               "productID: ${purchased.productID}) purchaseID: ${purchased.purchaseID}[$numberOfUnits][$unit] $transactionTs + $validInterval < $now ($validPurchase)", | ||||
|               tag: PropertyTags.iap); | ||||
|         } | ||||
|       } | ||||
|       final bool validPurchase = checkSubscriptionPeriod(purchased, productDetails); | ||||
|       if (validPurchase) { | ||||
|         Log.d( | ||||
|             "[Restored Subscription] productID: ${purchased.productID}) purchaseID: ${purchased.purchaseID}", | ||||
|  | @ -408,8 +426,8 @@ class IapManager { | |||
|         final asset = newPurchasedStore.getAsset(productId); | ||||
|         late OrderEntity newOrder; | ||||
|         if (asset == null) { | ||||
|           final product = await _createProduct( | ||||
|               productId.createIntent(scene: "restore"), productDetails); | ||||
|           final product = | ||||
|               await _createProduct(productId.createIntent(scene: "restore"), productDetails); | ||||
|           newOrder = product.createOrder().success(); | ||||
|         } else { | ||||
|           newOrder = asset.order.success(); | ||||
|  | @ -417,8 +435,7 @@ class IapManager { | |||
|         try { | ||||
|           await GuruDB.instance.replaceOrderBySku(order: newOrder); | ||||
|         } catch (error, stacktrace) { | ||||
|           Log.w("Failed to upsert order: $error $stacktrace", | ||||
|               tag: PropertyTags.iap); | ||||
|           Log.w("Failed to upsert order: $error $stacktrace", tag: PropertyTags.iap); | ||||
|         } | ||||
|         final newAsset = Asset(productId, newOrder); | ||||
|         newPurchasedStore.addAsset(newAsset); | ||||
|  | @ -431,21 +448,30 @@ class IapManager { | |||
|     } | ||||
| 
 | ||||
|     if (expiredSkus.isNotEmpty) { | ||||
|       Log.i("expired orders:${expiredSkus.length}}"); | ||||
|       try { | ||||
|         await GuruDB.instance.deleteOrdersBySkus(expiredSkus); | ||||
|       } catch (error, stacktrace) { | ||||
|         Log.w("Failed to upsert order: $error $stacktrace", | ||||
|             tag: PropertyTags.iap); | ||||
|       final graceCount = await AppProperty.getInstance().increaseGraceCount(); | ||||
|       Log.i("expired orders:${expiredSkus.length}} grace count: $graceCount"); | ||||
|       if (graceCount > GuruApp.instance.appSpec.deployment.subscriptionRestoreGraceCount) { | ||||
|         try { | ||||
|           await GuruDB.instance.deleteOrdersBySkus(expiredSkus); | ||||
|         } catch (error, stacktrace) { | ||||
|           Log.w("Failed to upsert order: $error $stacktrace", tag: PropertyTags.iap); | ||||
|         } | ||||
|         await AppProperty.getInstance().resetGraceCount(); | ||||
|         GuruAnalytics.instance.logGuruEvent('dev_iap_audit', { | ||||
|           "item_category": "expired", | ||||
|           "item_name": "sub", | ||||
|           "platform": Platform.isAndroid ? "Android" : "iOS", | ||||
|           "value": graceCount | ||||
|         }); | ||||
|       } | ||||
|     } else { | ||||
|       await AppProperty.getInstance().resetGraceCount(); | ||||
|     } | ||||
|     _iapStoreSubject.addEx(newPurchasedStore); | ||||
|     Log.d( | ||||
|         "[RestoredSubscription] update purchasedStore ${newPurchasedStore.data.length}"); | ||||
|     Log.d("[RestoredSubscription] update purchasedStore ${newPurchasedStore.data.length}"); | ||||
|   } | ||||
| 
 | ||||
|   List<PurchaseDetails> buildLatestPurchasedPlanForIos( | ||||
|       List<PurchaseDetails> purchaseDetails) { | ||||
|   List<PurchaseDetails> buildLatestPurchasedPlanForIos(List<PurchaseDetails> purchaseDetails) { | ||||
|     if (purchaseDetails.isEmpty) { | ||||
|       return []; | ||||
|     } | ||||
|  | @ -459,15 +485,14 @@ class IapManager { | |||
|         .toSet(); | ||||
|     Log.d("rawTransactionIds:$rawTransactionIds"); | ||||
|     final sortedPurchaseDetails = purchaseDetails.toList(); | ||||
|     sortedPurchaseDetails.sort((a, b) => | ||||
|         (int.tryParse(b.transactionDate ?? '') ?? 0) | ||||
|             .compareTo(int.tryParse(a.transactionDate ?? '') ?? 0)); | ||||
|     sortedPurchaseDetails.sort((a, b) => (int.tryParse(b.transactionDate ?? '') ?? 0) | ||||
|         .compareTo(int.tryParse(a.transactionDate ?? '') ?? 0)); | ||||
|     sortedPurchaseDetails.retainWhere((details) { | ||||
|       var detail = details as AppStorePurchaseDetails; | ||||
|       Log.d( | ||||
|           "checkSubscriptionForIos ${detail.skPaymentTransaction.originalTransaction?.transactionIdentifier} ${detail.transactionDate} ${detail.skPaymentTransaction.transactionTimeStamp}"); | ||||
|       return rawTransactionIds.remove(detail | ||||
|           .skPaymentTransaction.originalTransaction?.transactionIdentifier); | ||||
|       return rawTransactionIds | ||||
|           .remove(detail.skPaymentTransaction.originalTransaction?.transactionIdentifier); | ||||
|     }); | ||||
| 
 | ||||
|     return sortedPurchaseDetails; | ||||
|  | @ -487,15 +512,14 @@ class IapManager { | |||
|         .toSet(); | ||||
|     Log.d("rawTransactionIds:$rawTransactionIds"); | ||||
|     final sortedPurchaseDetails = purchaseDetails.toList(); | ||||
|     sortedPurchaseDetails.sort((a, b) => | ||||
|         (int.tryParse(b.transactionDate ?? '') ?? 0) | ||||
|             .compareTo(int.tryParse(a.transactionDate ?? '') ?? 0)); | ||||
|     sortedPurchaseDetails.sort((a, b) => (int.tryParse(b.transactionDate ?? '') ?? 0) | ||||
|         .compareTo(int.tryParse(a.transactionDate ?? '') ?? 0)); | ||||
|     sortedPurchaseDetails.retainWhere((details) { | ||||
|       var detail = details as AppStorePurchaseDetails; | ||||
|       Log.d( | ||||
|           "checkSubscriptionForIos ${detail.skPaymentTransaction.originalTransaction?.transactionIdentifier} ${detail.transactionDate} ${detail.skPaymentTransaction.transactionTimeStamp}"); | ||||
|       return rawTransactionIds.remove(detail | ||||
|           .skPaymentTransaction.originalTransaction?.transactionIdentifier); | ||||
|       return rawTransactionIds | ||||
|           .remove(detail.skPaymentTransaction.originalTransaction?.transactionIdentifier); | ||||
|     }); | ||||
| 
 | ||||
|     for (var details in sortedPurchaseDetails) { | ||||
|  | @ -509,8 +533,7 @@ class IapManager { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   void _listenToPurchaseUpdated( | ||||
|       List<PurchaseDetails> purchaseDetailsList) async { | ||||
|   void _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) async { | ||||
|     final List<Tuple2<ProductId, PurchaseDetails>> restoredIapPurchases = []; | ||||
|     final List<Tuple2<ProductId, PurchaseDetails>> pendingCompletePurchase = []; | ||||
|     final List<PurchaseDetails> subscriptionPurchases = []; | ||||
|  | @ -532,9 +555,7 @@ class IapManager { | |||
|       return; | ||||
|     } | ||||
|     for (var details in purchaseDetailsList) { | ||||
|       final productId = | ||||
|           GuruApp.instance.findProductId(sku: details.productID) ?? | ||||
|               ProductId.invalid; | ||||
|       final productId = GuruApp.instance.findProductId(sku: details.productID) ?? ProductId.invalid; | ||||
|       Log.d( | ||||
|           "[details]: $productId [${details.productID}] ${details.purchaseID} ${details.status} ${details.pendingCompletePurchase}"); | ||||
|       GuruAnalytics.instance.logGuruEvent('dev_iap_update', { | ||||
|  | @ -557,7 +578,13 @@ class IapManager { | |||
| 
 | ||||
|           final productDetails = loadedProductDetails[productId]; | ||||
|           if (productDetails != null) { | ||||
|             await _completePurchase(productId, productDetails, details); | ||||
|             /// 如果是 IOS的 purchased订单,并且是订阅的订单,他又没在当前请求的列表中,证明他是一个恢复的订单 | ||||
|             if (Platform.isIOS && productId.isSubscription && !iapRequestMap.containsKey(productId)) { | ||||
|               subscriptionPurchases.add(details); | ||||
|               existsRestored = true; | ||||
|             } else { | ||||
|               await _completePurchase(productId, productDetails, details); | ||||
|             } | ||||
|           } | ||||
| 
 | ||||
|           Log.d("completePurchase ${details.productID} ${details.purchaseID}"); | ||||
|  | @ -574,9 +601,8 @@ class IapManager { | |||
|           } | ||||
|           // 如果是未完成的商品或是恢复出了消耗品,都需要手动完成 | ||||
|           if (Platform.isAndroid) { | ||||
|             final originPurchaseState = (details as GooglePlayPurchaseDetails) | ||||
|                 .billingClientPurchase | ||||
|                 .purchaseState; | ||||
|             final originPurchaseState = | ||||
|                 (details as GooglePlayPurchaseDetails).billingClientPurchase.purchaseState; | ||||
|             Log.d( | ||||
|                 "restore android ${details.pendingCompletePurchase} $productId $originPurchaseState"); | ||||
|             if (originPurchaseState == PurchaseStateWrapper.purchased) { | ||||
|  | @ -614,8 +640,7 @@ class IapManager { | |||
|     if (existsRestored) { | ||||
|       if (pendingCompletePurchase.isNotEmpty) { | ||||
|         await completeAllPurchases(pendingCompletePurchase); | ||||
|         Log.d("manual complete/consume all purchases!", | ||||
|             syncFirebase: true, syncCrashlytics: true); | ||||
|         Log.d("manual complete/consume all purchases!", syncFirebase: true, syncCrashlytics: true); | ||||
|       } | ||||
| 
 | ||||
|       if (restoredIapPurchases.isNotEmpty) { | ||||
|  | @ -659,8 +684,8 @@ class IapManager { | |||
|           upsertOrders.add(newOrder); | ||||
|         } | ||||
|       } else if (productDetails != null) { | ||||
|         final product = await _createProduct( | ||||
|             productId.createIntent(scene: "restore"), productDetails); | ||||
|         final product = | ||||
|             await _createProduct(productId.createIntent(scene: "restore"), productDetails); | ||||
|         final newOrder = product.createOrder().success(); | ||||
|         upsertOrders.add(newOrder); | ||||
|       } | ||||
|  | @ -671,20 +696,17 @@ class IapManager { | |||
|         await GuruDB.instance.upsertOrders(upsertOrders); | ||||
|         updatedOrder.addAll(upsertOrders); | ||||
|       } catch (error, stacktrace) { | ||||
|         Log.w("upsertOrders error:$error $stacktrace", | ||||
|             syncCrashlytics: true, syncFirebase: true); | ||||
|         Log.w("upsertOrders error:$error $stacktrace", syncCrashlytics: true, syncFirebase: true); | ||||
|         for (var order in upsertOrders) { | ||||
|           try { | ||||
|             await GuruDB.instance.upsertOrder(order: order); | ||||
|             updatedOrder.add(order); | ||||
|           } catch (error1, stacktrace1) { | ||||
|             Log.w("upsertOrder(${order.sku}) error:$error1 $stacktrace1", | ||||
|                 syncFirebase: true); | ||||
|             Log.w("upsertOrder(${order.sku}) error:$error1 $stacktrace1", syncFirebase: true); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       final assets = | ||||
|           updatedOrder.map((order) => Asset(order.productId, order)).toList(); | ||||
|       final assets = updatedOrder.map((order) => Asset(order.productId, order)).toList(); | ||||
|       newPurchased.addAllAssets(assets); | ||||
|     } | ||||
|     _iapStoreSubject.addEx(newPurchased); | ||||
|  | @ -692,16 +714,13 @@ class IapManager { | |||
|   } | ||||
| 
 | ||||
|   Future reportFailedOrders() async { | ||||
|     final failedIapOrders = | ||||
|         await AppProperty.getInstance().loadAllFailedIapOrders(); | ||||
|     final failedIapOrders = await AppProperty.getInstance().loadAllFailedIapOrders(); | ||||
|     failedIapOrders.forEach((key, value) async { | ||||
|       try { | ||||
|         final order = OrdersReport.fromJson(json.decode(value)); | ||||
|         final result = await GuruApi.instance.reportOrders(order); | ||||
|         if (result.usdPrice > 0) { | ||||
|           logRevenue( | ||||
|               result.usdPrice, order.productId ?? order.subscriptionId); | ||||
|         } | ||||
|         // 这里不管返回什么值,都认为是成功的, 只有崩溃才会返回失败 | ||||
|         await logRevenue(order, result); | ||||
|         AppProperty.getInstance().removeReportSuccessOrder(key); | ||||
|       } catch (error, stacktrace) {} | ||||
|     }); | ||||
|  | @ -709,8 +728,7 @@ class IapManager { | |||
|   } | ||||
| 
 | ||||
|   String buildGooglePlayDetailsString( | ||||
|       GooglePlayProductDetails googlePlayProduct, | ||||
|       GooglePlayPurchaseDetails googlePlayDetails) { | ||||
|       GooglePlayProductDetails googlePlayProduct, GooglePlayPurchaseDetails googlePlayDetails) { | ||||
|     final StringBuffer sb = StringBuffer(); | ||||
|     sb.writeln("#### purchase ####"); | ||||
| 
 | ||||
|  | @ -742,15 +760,12 @@ class IapManager { | |||
|     if (oneTimeDetails != null) { | ||||
|       sb.writeln("  => oneTimeDetails:"); | ||||
|       sb.writeln("    - formattedPrice: ${oneTimeDetails.formattedPrice}"); | ||||
|       sb.writeln( | ||||
|           "    - priceAmountMicros: ${oneTimeDetails.priceAmountMicros}"); | ||||
|       sb.writeln( | ||||
|           "    - priceCurrencyCode: ${oneTimeDetails.priceCurrencyCode}"); | ||||
|       sb.writeln("    - priceAmountMicros: ${oneTimeDetails.priceAmountMicros}"); | ||||
|       sb.writeln("    - priceCurrencyCode: ${oneTimeDetails.priceCurrencyCode}"); | ||||
|     } | ||||
| 
 | ||||
|     final subscriptionOfferDetails = productDetails.subscriptionOfferDetails; | ||||
|     if (subscriptionOfferDetails != null && | ||||
|         subscriptionOfferDetails.isNotEmpty) { | ||||
|     if (subscriptionOfferDetails != null && subscriptionOfferDetails.isNotEmpty) { | ||||
|       for (var offer in subscriptionOfferDetails) { | ||||
|         sb.writeln("  => sub offer: ${offer.offerId}"); | ||||
|         sb.writeln("    - basePlanId: ${offer.basePlanId}"); | ||||
|  | @ -773,13 +788,12 @@ class IapManager { | |||
|     return sb.toString(); | ||||
|   } | ||||
| 
 | ||||
|   Future reportOrders(ProductId productId, ProductDetails details, | ||||
|       PurchaseDetails purchaseDetails, OrderEntity? order) async { | ||||
|   Future reportOrders(ProductId productId, ProductDetails details, PurchaseDetails purchaseDetails, | ||||
|       OrderEntity? order) async { | ||||
|     final OrdersReport ordersReport = OrdersReport(); | ||||
| 
 | ||||
|     if (Platform.isAndroid) { | ||||
|       ordersReport.token = | ||||
|           purchaseDetails.verificationData.serverVerificationData; | ||||
|       ordersReport.token = purchaseDetails.verificationData.serverVerificationData; | ||||
|       ordersReport.packageName = GuruApp.instance.details.packageName; | ||||
|       final manifest = order?.manifest; | ||||
|       final basePlanId = manifest?.basePlanId; | ||||
|  | @ -789,16 +803,13 @@ class IapManager { | |||
|         ordersReport.offerId = offerId; | ||||
|       } | ||||
|       try { | ||||
|         GooglePlayPurchaseDetails googlePlayDetails = | ||||
|             purchaseDetails as GooglePlayPurchaseDetails; | ||||
|         GooglePlayProductDetails googlePlayProduct = | ||||
|             details as GooglePlayProductDetails; | ||||
|         GooglePlayPurchaseDetails googlePlayDetails = purchaseDetails as GooglePlayPurchaseDetails; | ||||
|         GooglePlayProductDetails googlePlayProduct = details as GooglePlayProductDetails; | ||||
|         Log.d( | ||||
|             "Android Product/Purchase ${buildGooglePlayDetailsString(googlePlayProduct, googlePlayDetails)}"); | ||||
|       } catch (error, stacktrace) {} | ||||
|     } else if (Platform.isIOS) { | ||||
|       AppStorePurchaseDetails appleDetails = | ||||
|           purchaseDetails as AppStorePurchaseDetails; | ||||
|       AppStorePurchaseDetails appleDetails = purchaseDetails as AppStorePurchaseDetails; | ||||
|       AppStoreProductDetails appleProduct = details as AppStoreProductDetails; | ||||
|       final StringBuffer sb = StringBuffer(); | ||||
|       sb.writeln("#### purchase ####"); | ||||
|  | @ -810,31 +821,25 @@ class IapManager { | |||
|       sb.writeln("skPaymentTransaction:"); | ||||
|       sb.writeln( | ||||
|           "   =>originalTransaction:${appleDetails.skPaymentTransaction.originalTransaction}"); | ||||
|       sb.writeln( | ||||
|           "       =>${appleDetails.skPaymentTransaction.originalTransaction?.payment}"); | ||||
|       sb.writeln("       =>${appleDetails.skPaymentTransaction.originalTransaction?.payment}"); | ||||
|       sb.writeln("   =>${appleDetails.skPaymentTransaction.transactionState}:"); | ||||
|       sb.writeln( | ||||
|           "   =>${appleDetails.skPaymentTransaction.transactionIdentifier}:"); | ||||
|       sb.writeln("   =>${appleDetails.skPaymentTransaction.transactionIdentifier}:"); | ||||
|       sb.writeln("\n#### product ####"); | ||||
|       sb.writeln("currencyCode: ${appleProduct.currencyCode}"); | ||||
|       sb.writeln("rawPrice: ${appleProduct.rawPrice}"); | ||||
|       sb.writeln("currencyCode: ${appleProduct.currencyCode}"); | ||||
|       sb.writeln("currencyCode skProduct"); | ||||
|       sb.writeln( | ||||
|           "    =>localizedTitle: ${appleProduct.skProduct.localizedTitle}"); | ||||
|       sb.writeln( | ||||
|           "    =>localizedDescription: ${appleProduct.skProduct.localizedDescription}"); | ||||
|       sb.writeln("    =>localizedTitle: ${appleProduct.skProduct.localizedTitle}"); | ||||
|       sb.writeln("    =>localizedDescription: ${appleProduct.skProduct.localizedDescription}"); | ||||
|       sb.writeln("    =>priceLocale: ${appleProduct.skProduct.priceLocale}"); | ||||
|       sb.writeln( | ||||
|           "    =>productIdentifier: ${appleProduct.skProduct.productIdentifier}"); | ||||
|       sb.writeln("    =>productIdentifier: ${appleProduct.skProduct.productIdentifier}"); | ||||
|       sb.writeln( | ||||
|           "    =>subscriptionGroupIdentifier: ${appleProduct.skProduct.subscriptionGroupIdentifier}"); | ||||
|       sb.writeln("    =>appleProduct.skProduct.priceLocale"); | ||||
|       sb.writeln("        ->{appleProduct.skProduct.priceLocale}"); | ||||
| 
 | ||||
|       ordersReport.bundleId = GuruApp.instance.appSpec.details.bundleId; | ||||
|       ordersReport.receipt = | ||||
|           purchaseDetails.verificationData.serverVerificationData; | ||||
|       ordersReport.receipt = purchaseDetails.verificationData.serverVerificationData; | ||||
|       ordersReport.sku = appleDetails.productID; | ||||
|       ordersReport.countryCode = appleProduct.skProduct.priceLocale.countryCode; | ||||
|       Log.d("IOS Product/Purchase ${sb.toString()}"); | ||||
|  | @ -847,40 +852,66 @@ class IapManager { | |||
|       ordersReport.orderType = OrderType.inapp; | ||||
|       ordersReport.productId = details.id; | ||||
|     } | ||||
|     ordersReport.orderId = purchaseDetails.purchaseID; | ||||
|     ordersReport.price = details.rawPrice.toString(); | ||||
|     ordersReport.currency = details.currencyCode; | ||||
| 
 | ||||
|     ordersReport.orderUserInfo = | ||||
|         OrderUserInfo(GuruSettings.instance.bestLevel.get().toString()); | ||||
|     ordersReport.orderUserInfo = OrderUserInfo(GuruSettings.instance.bestLevel.get().toString()); | ||||
|     ordersReport.userIdentification = GuruAnalytics.instance.userIdentification; | ||||
| 
 | ||||
|     final transactionDate = purchaseDetails.transactionDate; | ||||
|     if (transactionDate != null && transactionDate.isNotEmpty) { | ||||
|       try { | ||||
|         final ts = int.tryParse(transactionDate) ?? DateTimeUtils.currentTimeInMillis(); | ||||
|         ordersReport.transactionDate = ts; | ||||
|       } catch (error, stacktrace) { | ||||
|         Log.w("parse transactionDate error! $error", stackTrace: stacktrace); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     Log.d("orderReport:$ordersReport", tag: "Iap"); | ||||
|     try { | ||||
|       final result = await GuruApi.instance.reportOrders(ordersReport); | ||||
|       if ((result.usdPrice > 0) || | ||||
|           (result.usdPrice == 0 && result.isTestOrder)) { | ||||
|         logRevenue(result.usdPrice, purchaseDetails.productID); | ||||
|         Log.i("reportOrders success! $result"); | ||||
|         return; | ||||
|       } | ||||
|       Log.i("ignoreInvalidResult $result", tag: "Iap"); | ||||
|       // 这里不管返回什么值,都认为是成功的 | ||||
|       await logRevenue(ordersReport, result); | ||||
|       return; | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.i("reportOrders error!", error: error, stackTrace: stacktrace); | ||||
|     } | ||||
|     AppProperty.getInstance().saveFailedIapOrders(ordersReport); | ||||
|   } | ||||
| 
 | ||||
|   Future logRevenue(double usdPrice, String? sku) async { | ||||
|   Future<bool> logRevenue(OrdersReport order, OrdersResponse result) async { | ||||
|     final isSubscription = order.orderType == OrderType.subs; | ||||
|     final sku = (isSubscription ? order.subscriptionId : order.productId) ?? | ||||
|         (order.productId ?? order.subscriptionId ?? order.sku); | ||||
|     final usdPrice = result.usdPrice; | ||||
|     if (sku == null || sku.isEmpty) { | ||||
|       return; | ||||
|       return false; | ||||
|     } | ||||
| 
 | ||||
|     if (!result.isTestOrder && usdPrice <= 0) { | ||||
|       Log.i("ignoreInvalidResult $result", tag: "Iap"); | ||||
|       return false; | ||||
|     } | ||||
|     Log.i("prepare logRevenue! $result $sku"); | ||||
| 
 | ||||
|     final platform = Platform.isIOS ? "appstore" : "google_play"; | ||||
|     GuruAnalytics.instance.logAdRevenue(usdPrice, platform, "USD"); | ||||
|     final productId = | ||||
|         GuruApp.instance.findProductId(sku: sku) ?? ProductId.invalid; | ||||
|     GuruAnalytics.instance.logPurchase(usdPrice, | ||||
|         currency: 'USD', contentId: sku, adPlatform: platform); | ||||
|     if (productId.isSubscription) { | ||||
|     if (GuruApp.instance.appSpec.deployment.purchaseEventTrigger == 2) { | ||||
|       GuruAnalytics.instance.logAdRevenue020(usdPrice, platform, "USD", | ||||
|           orderId: order.orderId, | ||||
|           orderType: isSubscription ? "SUB" : "IAP", | ||||
|           productId: sku, | ||||
|           transactionDate: order.transactionDate); | ||||
|     } else { | ||||
|       GuruAnalytics.instance.logAdRevenue(usdPrice, platform, "USD", | ||||
|           orderId: order.orderId, | ||||
|           orderType: isSubscription ? "SUB" : "IAP", | ||||
|           productId: sku, | ||||
|           transactionDate: order.transactionDate); | ||||
|     } | ||||
|     GuruAnalytics.instance | ||||
|         .logPurchase(usdPrice, currency: 'USD', contentId: sku, adPlatform: platform); | ||||
|     if (isSubscription) { | ||||
|       GuruAnalytics.instance.logEvent( | ||||
|           "sub_purchase", | ||||
|           { | ||||
|  | @ -888,6 +919,9 @@ class IapManager { | |||
|             "currency": "USD", | ||||
|             "revenue": usdPrice, | ||||
|             "product_id": sku, | ||||
|             "order_type": "SUB", | ||||
|             "order_id": order.orderId, | ||||
|             "trans_ts": order.transactionDate | ||||
|           }, | ||||
|           options: iapRevenueAppEventOptions); | ||||
|     } else { | ||||
|  | @ -898,11 +932,16 @@ class IapManager { | |||
|             "currency": "USD", | ||||
|             "revenue": usdPrice, | ||||
|             "product_id": sku, | ||||
|             "order_type": "IAP", | ||||
|             "order_id": order.orderId, | ||||
|             "trans_ts": order.transactionDate | ||||
|           }, | ||||
|           options: iapRevenueAppEventOptions); | ||||
|     } | ||||
|     GuruAnalytics.instance.logGuruEvent("dev_iap_action", | ||||
|         {"item_category": "reported", "item_name": sku, "result": "true"}); | ||||
|     GuruAnalytics.instance.logGuruEvent( | ||||
|         "dev_iap_action", {"item_category": "reported", "item_name": sku, "result": "true"}); | ||||
|     Log.i("reportOrders completed! logRevenue success! $result $sku"); | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
|   Future _deliverManifest(ProductId productId, Manifest manifest) async { | ||||
|  | @ -912,8 +951,7 @@ class IapManager { | |||
|       result = await ManifestManager.instance | ||||
|           .deliver(manifest, TransactionMethod.iap) | ||||
|           .catchError((error) { | ||||
|         Log.w("applyManifest error:$error", | ||||
|             syncCrashlytics: true, syncFirebase: true); | ||||
|         Log.w("applyManifest error:$error", syncCrashlytics: true, syncFirebase: true); | ||||
|       }); | ||||
|     } catch (error, stacktrace) { | ||||
|       cause = error.toString(); | ||||
|  | @ -991,8 +1029,7 @@ class IapManager { | |||
|     await _inAppPurchase.completePurchase(details); | ||||
|     final count = await AppProperty.getInstance().increaseAndGetIapCount(); | ||||
|     GuruAnalytics.instance.setUserProperty("purchase_count", count.toString()); | ||||
|     Log.d("_completePurchase $productId ${details.pendingCompletePurchase}", | ||||
|         tag: "Iap"); | ||||
|     Log.d("_completePurchase $productId ${details.pendingCompletePurchase}", tag: "Iap"); | ||||
|     OrderEntity? resultOrder; | ||||
| 
 | ||||
|     IapRequest? iapRequest = iapRequestMap.remove(productId); | ||||
|  | @ -1075,27 +1112,24 @@ class IapManager { | |||
| 
 | ||||
|     await appProperty.getAndIncrease(PropertyKeys.subscriptionCount); | ||||
|     if (group != null) { | ||||
|       await appProperty | ||||
|           .getAndIncrease(PropertyKeys.buildGroupSubscriptionCount(group)); | ||||
|       await appProperty.getAndIncrease(PropertyKeys.buildGroupSubscriptionCount(group)); | ||||
|     } | ||||
|     await appProperty | ||||
|         .getAndIncrease(PropertyKeys.buildSubscriptionCount(productId)); | ||||
|     await appProperty.getAndIncrease(PropertyKeys.buildSubscriptionCount(productId)); | ||||
|   } | ||||
| 
 | ||||
|   Future<Manifest> createPurchaseManifest(TransactionIntent intent) { | ||||
|     return ManifestManager.instance.createManifest(intent); | ||||
|   } | ||||
| 
 | ||||
|   Future<ProductDetails?> checkAndDistributeOfferDetails(ProductId productId, | ||||
|       ProductDetails? details, EligibilityCriteria eligibilityCriteria) async { | ||||
|   Future<ProductDetails?> checkAndDistributeOfferDetails( | ||||
|       ProductId productId, ProductDetails? details, EligibilityCriteria eligibilityCriteria) async { | ||||
|     Log.d("checkAndDistributeOfferDetails $productId $eligibilityCriteria"); | ||||
|     switch (eligibilityCriteria) { | ||||
|       case EligibilityCriteria.newCustomerNeverHadSubscribedThisGroup: | ||||
|         final group = GuruApp.instance.appSpec.productProfile.group(productId); | ||||
|         if (group != null) { | ||||
|           final key = PropertyKeys.buildGroupSubscriptionCount(group); | ||||
|           final count = | ||||
|               await AppProperty.getInstance().getInt(key, defValue: 0); | ||||
|           final count = await AppProperty.getInstance().getInt(key, defValue: 0); | ||||
|           Log.d("  ==> $key $count"); | ||||
|           return count > 0 ? null : details; | ||||
|         } | ||||
|  | @ -1107,8 +1141,8 @@ class IapManager { | |||
|         Log.d("  ==> $key $count"); | ||||
|         return count > 0 ? null : details; | ||||
|       case EligibilityCriteria.newCustomerNeverHadAnySubscription: | ||||
|         final count = await AppProperty.getInstance() | ||||
|             .getInt(PropertyKeys.subscriptionCount, defValue: 0); | ||||
|         final count = | ||||
|             await AppProperty.getInstance().getInt(PropertyKeys.subscriptionCount, defValue: 0); | ||||
|         Log.d("  ==> subscriptionCount $count"); | ||||
|         return count > 0 ? null : details; | ||||
|       default: | ||||
|  | @ -1117,21 +1151,17 @@ class IapManager { | |||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   Future<IapProduct> _createProduct( | ||||
|       TransactionIntent intent, ProductDetails details) async { | ||||
|   Future<IapProduct> _createProduct(TransactionIntent intent, ProductDetails details) async { | ||||
|     final productId = intent.productId; | ||||
|     Manifest manifest = await ManifestManager.instance.createManifest(intent); | ||||
|     Log.d("createProduct ${productId.sku} ${productId.hasBasePlan}"); | ||||
|     ProductDetails baseDetails = details; | ||||
|     ProductDetails? offerDetails; | ||||
|     if (Platform.isAndroid && | ||||
|         productId.isSubscription && | ||||
|         productId.hasBasePlan) { | ||||
|     if (Platform.isAndroid && productId.isSubscription && productId.hasBasePlan) { | ||||
|       final googlePlayProductDetails = details as GooglePlayProductDetails; | ||||
|       final productDetails = googlePlayProductDetails.productDetails; | ||||
|       final subscriptionOfferDetails = productDetails.subscriptionOfferDetails; | ||||
|       final offerProductDetails = | ||||
|           GooglePlayProductDetails.fromProductDetails(productDetails); | ||||
|       final offerProductDetails = GooglePlayProductDetails.fromProductDetails(productDetails); | ||||
|       final expectBasePlan = productId.basePlan; | ||||
|       final expectOfferId = productId.offerId; | ||||
|       Log.d( | ||||
|  | @ -1161,12 +1191,10 @@ class IapManager { | |||
|         } | ||||
|       } | ||||
|     } | ||||
|     return Product.iap(productId, baseDetails, manifest, | ||||
|         offerDetails: offerDetails) as IapProduct; | ||||
|     return Product.iap(productId, baseDetails, manifest, offerDetails: offerDetails) as IapProduct; | ||||
|   } | ||||
| 
 | ||||
|   Future<ProductStore<IapProduct>> buildProducts( | ||||
|       Set<TransactionIntent> intents) async { | ||||
|   Future<ProductStore<IapProduct>> buildProducts(Set<TransactionIntent> intents) async { | ||||
|     ProductStore<IapProduct> iapStore = ProductStore(); | ||||
|     final _productDetails = loadedProductDetails; | ||||
|     for (var intent in intents) { | ||||
|  | @ -1180,8 +1208,8 @@ class IapManager { | |||
|       final product = await _createProduct(intent, details); | ||||
|       iapStore.putProduct(product); | ||||
|       if (intent.productId.hasOffer && !iapStore.existsProduct(productId)) { | ||||
|         final originProduct = await _createProduct( | ||||
|             productId.createIntent(scene: intent.scene), details); | ||||
|         final originProduct = | ||||
|             await _createProduct(productId.createIntent(scene: intent.scene), details); | ||||
|         iapStore.putProduct(originProduct); | ||||
|       } | ||||
|     } | ||||
|  | @ -1222,8 +1250,7 @@ class IapManager { | |||
|       result = await _inAppPurchase.buyNonConsumable(purchaseParam: param); | ||||
|     } | ||||
|     if (!result) { | ||||
|       Log.d( | ||||
|           "_requestPurchases error! ${product.productId} ${product.details.price}", | ||||
|       Log.d("_requestPurchases error! ${product.productId} ${product.details.price}", | ||||
|           syncFirebase: true); | ||||
|       GuruAnalytics.instance.logGuruEvent("dev_iap_action", { | ||||
|         "item_category": "request", | ||||
|  | @ -1252,13 +1279,12 @@ class IapManager { | |||
|     if (!Platform.isAndroid) { | ||||
|       return; | ||||
|     } | ||||
|     final InAppPurchaseAndroidPlatformAddition androidAddition = _inAppPurchase | ||||
|         .getPlatformAddition<InAppPurchaseAndroidPlatformAddition>(); | ||||
|     final InAppPurchaseAndroidPlatformAddition androidAddition = | ||||
|         _inAppPurchase.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>(); | ||||
|     final response = await androidAddition.queryPastPurchases(); | ||||
|     for (var purchase in response.pastPurchases) { | ||||
|       androidAddition.consumePurchase(purchase); | ||||
|       Log.w( | ||||
|           "[clearAssetRecord] purchase clear:${purchase.productID} ${purchase.verificationData}"); | ||||
|       Log.w("[clearAssetRecord] purchase clear:${purchase.productID} ${purchase.verificationData}"); | ||||
|       _inAppPurchase.completePurchase(purchase); | ||||
|     } | ||||
| 
 | ||||
|  | @ -1270,30 +1296,26 @@ class IapManager { | |||
|   Future manualConsumePurchase(PurchaseDetails purchase) async { | ||||
|     if (Platform.isAndroid) { | ||||
|       final InAppPurchaseAndroidPlatformAddition androidAddition = | ||||
|           _inAppPurchase | ||||
|               .getPlatformAddition<InAppPurchaseAndroidPlatformAddition>(); | ||||
|           _inAppPurchase.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>(); | ||||
|       await androidAddition.consumePurchase(purchase); | ||||
|       _inAppPurchase.completePurchase(purchase); | ||||
|       await GuruDB.instance.deletePendingOrderBySku(sku: purchase.productID); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future manualConsumeAllPurchases( | ||||
|       List<Tuple2<ProductId, PurchaseDetails>> tuples) async { | ||||
|   Future manualConsumeAllPurchases(List<Tuple2<ProductId, PurchaseDetails>> tuples) async { | ||||
|     for (var tuple in tuples) { | ||||
|       try { | ||||
|         final productId = tuple.item1; | ||||
|         final purchase = tuple.item2; | ||||
|         await manualConsumePurchase(purchase); | ||||
|       } catch (error, stacktrace) { | ||||
|         Log.w("consumePurchase error! $error", | ||||
|             stackTrace: stacktrace, syncFirebase: true); | ||||
|         Log.w("consumePurchase error! $error", stackTrace: stacktrace, syncFirebase: true); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future completeAllPurchases( | ||||
|       List<Tuple2<ProductId, PurchaseDetails>> tuples) async { | ||||
|   Future completeAllPurchases(List<Tuple2<ProductId, PurchaseDetails>> tuples) async { | ||||
|     for (var tuple in tuples) { | ||||
|       try { | ||||
|         final productId = tuple.item1; | ||||
|  | @ -1306,8 +1328,7 @@ class IapManager { | |||
|               "item_name": productId.sku, | ||||
|               "result": "true", | ||||
|             }); | ||||
|             final order = | ||||
|                 await _completePurchase(productId, productDetails, details); | ||||
|             final order = await _completePurchase(productId, productDetails, details); | ||||
|           } else { | ||||
|             GuruAnalytics.instance.logGuruEvent("dev_iap_action", { | ||||
|               "item_category": "pending_consume", | ||||
|  | @ -1325,8 +1346,7 @@ class IapManager { | |||
|           } | ||||
|         } | ||||
|       } catch (error, stacktrace) { | ||||
|         Log.w("consumePurchase error! $error", | ||||
|             stackTrace: stacktrace, syncFirebase: true); | ||||
|         Log.w("consumePurchase error! $error", stackTrace: stacktrace, syncFirebase: true); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | @ -1339,13 +1359,10 @@ class IapManager { | |||
| // } | ||||
| 
 | ||||
|   Map<String, ProductId> _filterProductSkus( | ||||
|       {required Set<ProductId> ids, | ||||
|       required Set<int> attrs, | ||||
|       Set<ProductId>? validIds}) { | ||||
|       {required Set<ProductId> ids, required Set<int> attrs, Set<ProductId>? validIds}) { | ||||
|     final List<MapEntry<String, ProductId>> entries = ids | ||||
|         .where((productId) => | ||||
|             (validIds?.contains(productId) != false) && | ||||
|             attrs.contains(productId.attr)) | ||||
|             (validIds?.contains(productId) != false) && attrs.contains(productId.attr)) | ||||
|         .map((productId) => MapEntry(productId.sku, productId)) | ||||
|         .toList(); | ||||
|     return Map.fromEntries(entries); | ||||
|  | @ -1393,16 +1410,13 @@ class IapManager { | |||
|     } | ||||
| 
 | ||||
|     final queryProductIds = queryOneOffChargeSkuMap.keys.toSet(); | ||||
|     queryProductIds.addAll( | ||||
|         GuruApp.instance.productProfile.subscriptionsIapIds.map((e) => e.sku)); | ||||
|     queryProductIds.addAll(GuruApp.instance.productProfile.subscriptionsIapIds.map((e) => e.sku)); | ||||
|     Log.d("refresh product:", tag: "IAP"); | ||||
|     for (var productId in queryProductIds) { | ||||
|       Log.d("  => $productId", tag: "IAP"); | ||||
|     } | ||||
|     final response = | ||||
|         await _queryProducts(queryProductIds).catchError((error, stacktrace) { | ||||
|       Log.e("getProducts($queryOneOffChargeSkuMap}) error: $error $stacktrace", | ||||
|           tag: "IAP"); | ||||
|     final response = await _queryProducts(queryProductIds).catchError((error, stacktrace) { | ||||
|       Log.e("getProducts($queryOneOffChargeSkuMap}) error: $error $stacktrace", tag: "IAP"); | ||||
|       return _emptyResponse; | ||||
|     }); | ||||
|     Log.i("refreshProduct COMPLETED:", tag: "IAP"); | ||||
|  | @ -1419,8 +1433,8 @@ class IapManager { | |||
|       detailsMap.addAll(extractProducts(details)); | ||||
|     } | ||||
| 
 | ||||
|     GuruAnalytics.instance.logGuruEvent( | ||||
|         "dev_iap_action", {"item_category": "load", "result": "true"}); | ||||
|     GuruAnalytics.instance | ||||
|         .logGuruEvent("dev_iap_action", {"item_category": "load", "result": "true"}); | ||||
|     final newProductDetails = Map.of(loadedProductDetails); | ||||
|     newProductDetails.addAll(detailsMap); | ||||
|     _productDetailsSubject.addEx(newProductDetails); | ||||
|  | @ -1440,8 +1454,7 @@ class IapManager { | |||
|       final googlePlayProductDetails = details as GooglePlayProductDetails; | ||||
|       final productDetails = googlePlayProductDetails.productDetails; | ||||
|       final subscriptionOfferDetails = productDetails.subscriptionOfferDetails; | ||||
|       final offerProductDetails = | ||||
|           GooglePlayProductDetails.fromProductDetails(productDetails); | ||||
|       final offerProductDetails = GooglePlayProductDetails.fromProductDetails(productDetails); | ||||
|       for (var id in ids) { | ||||
|         final expectBasePlan = id.basePlan; | ||||
|         final expectOfferId = id.offerId; | ||||
|  | @ -1452,8 +1465,7 @@ class IapManager { | |||
|             final offer = subscriptionOfferDetails[i]; | ||||
|             Log.d( | ||||
|                 "$i expectOfferId:$expectOfferId offerId:${offer.offerId} expectBasePlan:$expectBasePlan basePlanId:${offer.basePlanId}"); | ||||
|             if (expectBasePlan != offer.basePlanId || | ||||
|                 expectOfferId != offer.offerId) { | ||||
|             if (expectBasePlan != offer.basePlanId || expectOfferId != offer.offerId) { | ||||
|               continue; | ||||
|             } | ||||
|             detailsMap[id] = offerProductDetails[i]; | ||||
|  |  | |||
|  | @ -0,0 +1,53 @@ | |||
| import 'package:guru_app/analytics/guru_analytics.dart'; | ||||
| import 'package:guru_app/database/guru_db.dart'; | ||||
| import 'package:guru_app/financial/asset/assets_model.dart'; | ||||
| import 'package:guru_app/financial/asset/assets_store.dart'; | ||||
| import 'package:guru_app/financial/data/db/order_database.dart'; | ||||
| import 'package:guru_app/financial/igb/igb_product.dart'; | ||||
| import 'package:guru_app/financial/igc/igc_model.dart'; | ||||
| import 'package:guru_app/financial/manifest/manifest_manager.dart'; | ||||
| import 'package:guru_app/financial/product/product_model.dart'; | ||||
| import 'package:guru_app/guru_app.dart'; | ||||
| import 'package:guru_app/inventory/inventory_manager.dart'; | ||||
| import 'package:guru_app/property/app_property.dart'; | ||||
| import 'package:guru_utils/extensions/extensions.dart'; | ||||
| import 'package:guru_utils/log/log.dart'; | ||||
| import 'package:guru_utils/property/app_property.dart'; | ||||
| 
 | ||||
| /// Created by Haoyi on 2023/2/18 | ||||
| 
 | ||||
| class IgbManager { | ||||
|   static final IgbManager _instance = IgbManager._(); | ||||
| 
 | ||||
|   static IgbManager get instance => _instance; | ||||
| 
 | ||||
|   IgbManager._(); | ||||
| 
 | ||||
|   Future init() async { | ||||
|     await InventoryManager.instance.init(); | ||||
|   } | ||||
| 
 | ||||
|   Future reloadAssets() async {} | ||||
| 
 | ||||
|   Future<bool> accumulate(int igc, TransactionMethod method, {String? scene}) async { | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   Future clear() async {} | ||||
| 
 | ||||
|   Future<IgbProduct> buildIgbProduct(TransactionIntent intent) async { | ||||
|     final manifest = await ManifestManager.instance.createManifest(intent); | ||||
|     return IgbProduct(intent.productId, manifest, intent.igbCost); | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> redeem(IgbProduct product) async { | ||||
|     Log.v("Igb buy"); | ||||
|     final result = await InventoryManager.instance.consume(product.cost, product.manifest); | ||||
|     if (result) { | ||||
|       await ManifestManager.instance.deliver(product.manifest, TransactionMethod.igb); | ||||
|     } | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
|   void dispose() {} | ||||
| } | ||||
|  | @ -0,0 +1,46 @@ | |||
| import 'package:guru_app/financial/data/db/order_database.dart'; | ||||
| import 'package:guru_app/financial/product/product_model.dart'; | ||||
| import 'package:guru_app/inventory/db/inventory_database.dart'; | ||||
| import 'package:guru_app/inventory/inventory_manager.dart'; | ||||
| import 'package:guru_utils/datetime/datetime_utils.dart'; | ||||
| import 'package:guru_utils/id/id_utils.dart'; | ||||
| import 'package:guru_utils/manifest/manifest.dart'; | ||||
| 
 | ||||
| // In-game barter Product | ||||
| class IgbProduct implements Product { | ||||
|   @override | ||||
|   final ProductId productId; | ||||
| 
 | ||||
|   @override | ||||
|   final Manifest manifest; | ||||
| 
 | ||||
|   final List<StockItem> cost; | ||||
| 
 | ||||
|   String get sku => productId.sku; | ||||
| 
 | ||||
|   IgbProduct(this.productId, this.manifest, this.cost); | ||||
| 
 | ||||
|   bool isConsumable() { | ||||
|     return productId.isConsumable; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'StockProduct{productId: $productId}'; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   OrderEntity createOrder() { | ||||
|     return OrderEntity( | ||||
|         orderId: IdUtils.uuidV4(), | ||||
|         sku: productId.sku, | ||||
|         state: TransactionState.success, | ||||
|         attr: productId.attr, | ||||
|         method: TransactionMethod.igc.index, | ||||
|         currency: TransactionCurrency.igc, | ||||
|         cost: 1, | ||||
|         category: manifest.category, | ||||
|         timestamp: DateTimeUtils.currentTimeInMillis(), | ||||
|         manifest: manifest); | ||||
|   } | ||||
| } | ||||
|  | @ -52,6 +52,10 @@ class IgcManager { | |||
|     await reloadAssets(); | ||||
|   } | ||||
| 
 | ||||
|   Future switchSession() async { | ||||
|     init(); | ||||
|   } | ||||
| 
 | ||||
|   Future reloadAssets() async { | ||||
|     final orders = await GuruDB.instance | ||||
|         .selectOrders(method: TransactionMethod.igc, attrs: [TransactionAttributes.asset]); | ||||
|  |  | |||
|  | @ -2,11 +2,15 @@ import 'dart:async'; | |||
| 
 | ||||
| import 'package:guru_app/financial/igc/igc_manager.dart'; | ||||
| import 'package:guru_app/financial/product/product_model.dart'; | ||||
| import 'package:guru_app/inventory/inventory_manager.dart'; | ||||
| import 'manifest.dart'; | ||||
| 
 | ||||
| /// Created by Haoyi on 2022/8/21 | ||||
| 
 | ||||
| typedef DetailsDistributor = Future<bool> Function(Details, TransactionMethod, String scene); | ||||
| // typedef DetailsDistributor = Future<bool> Function(Details, TransactionMethod, String scene); | ||||
| 
 | ||||
| typedef DetailsDistributor = Future<List<StockItem>> Function( | ||||
|     Details, TransactionMethod, String scene); | ||||
| 
 | ||||
| typedef ManifestBuilder = Future<Manifest?> Function(TransactionIntent); | ||||
| 
 | ||||
|  | @ -27,15 +31,31 @@ class ManifestManager { | |||
| 
 | ||||
|   final List<ManifestBuilder> builders = []; | ||||
| 
 | ||||
|   static Future<bool> _deliverIgcDetails( | ||||
|   static Future<List<StockItem>> _deliverIgcDetails( | ||||
|       Details details, TransactionMethod method, String scene) async { | ||||
|     if (details.amount > 0) { | ||||
|       IgcManager.instance.accumulate(details.amount, method, scene: scene); | ||||
|       return true; | ||||
|       await IgcManager.instance.accumulate(details.amount, method, scene: scene); | ||||
|     } | ||||
|     return false; | ||||
|     return []; | ||||
|   } | ||||
| 
 | ||||
|   static Future<List<StockItem>> _deliverDefaultDetails( | ||||
|       Details details, TransactionMethod method, String scene) async { | ||||
|     if (details.amount > 0) { | ||||
|       return [StockItem.fromDetails(details)]; | ||||
|     } | ||||
|     return []; | ||||
|   } | ||||
| 
 | ||||
|   // static Future<bool> _deliverDetails( | ||||
|   //     Details details, TransactionMethod method, String scene) async { | ||||
|   //   final stock = StockItem.fromDetails(details); | ||||
|   //   if (stock.amount > 0) { | ||||
|   //     await InventoryManager.instance.acquire([stock], method, scene); | ||||
|   //   } | ||||
|   //   return false; | ||||
|   // } | ||||
| 
 | ||||
|   void addDistributor(String type, DetailsDistributor distributor) { | ||||
|     distributors[type] = distributor; | ||||
|   } | ||||
|  | @ -48,11 +68,55 @@ class ManifestManager { | |||
|     this.builders.addAll(builders); | ||||
|   } | ||||
| 
 | ||||
|   Future _acquire(List<StockItem> items, TransactionMethod method, Manifest manifest) async { | ||||
|     if (items.isNotEmpty) { | ||||
|       String specific = ""; | ||||
|       switch (method) { | ||||
|         case TransactionMethod.iap: | ||||
|           specific = manifest.contentId; | ||||
|           break; | ||||
|         case TransactionMethod.igc: | ||||
|           specific = "coin"; | ||||
|           break; | ||||
|         case TransactionMethod.igb: | ||||
|           specific = manifest.barterId; | ||||
|           break; | ||||
|         default: | ||||
|           specific = manifest.scene; | ||||
|           break; | ||||
|       } | ||||
|       await InventoryManager.instance.acquire(items, method, specific, scene: manifest.scene); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future<List<StockItem>> deliverStockItems( | ||||
|       List<StockItem> items, Manifest manifest, TransactionMethod method) async { | ||||
|     final List<StockItem> unsold = []; | ||||
|     for (var item in items) { | ||||
|       if (item.sku == DetailsReservedType.igc) { | ||||
|         await distributors[DetailsReservedType.igc] | ||||
|             ?.call(Details.define(DetailsReservedType.igc, item.amount), method, manifest.scene); | ||||
|         continue; | ||||
|       } | ||||
|       unsold.add(item); | ||||
|     } | ||||
|     return unsold; | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> deliver(Manifest manifest, TransactionMethod method) async { | ||||
|     bool result = false; | ||||
|     final List<StockItem> unsold = []; | ||||
|     for (var details in manifest.details) { | ||||
|       result |= await distributors[details.type]?.call(details, method, manifest.scene) ?? false; | ||||
|       final items = await distributors[details.type]?.call(details, method, manifest.scene) ?? | ||||
|           await _deliverDefaultDetails(details, method, manifest.scene); | ||||
|       final unsoldItems = await deliverStockItems(items, manifest, method); | ||||
|       unsold.addAll(unsoldItems); | ||||
|       result |= unsoldItems.isEmpty; | ||||
|     } | ||||
|     if (unsold.isNotEmpty) { | ||||
|       await _acquire(unsold, method, manifest); | ||||
|     } | ||||
| 
 | ||||
|     deliveredManifestStream.add(manifest); | ||||
|     return result; | ||||
|   } | ||||
|  | @ -74,4 +138,12 @@ class ManifestManager { | |||
|     final extras = <String, dynamic>{ExtraReservedField.scene: scene}; | ||||
|     return Manifest(category ?? DetailsReservedType.igc, extras: extras, details: details); | ||||
|   } | ||||
| 
 | ||||
| // Manifest createIgbManifest(int igc, {String? category, String scene = ""}) { | ||||
| //   final details = <Details>[]; | ||||
| //   details.add(Details.define(DetailsReservedType.igc, igc)); | ||||
| // | ||||
| //   final extras = <String, dynamic>{ExtraReservedField.scene: scene}; | ||||
| //   return Manifest(category ?? DetailsReservedType.igc, extras: extras, details: details); | ||||
| // } | ||||
| } | ||||
|  |  | |||
|  | @ -1,9 +1,11 @@ | |||
| import 'dart:io'; | ||||
| 
 | ||||
| import 'package:guru_app/financial/data/db/order_database.dart'; | ||||
| import 'package:guru_app/financial/igb/igb_product.dart'; | ||||
| import 'package:guru_app/financial/manifest/manifest.dart'; | ||||
| import 'package:guru_app/financial/manifest/manifest_manager.dart'; | ||||
| import 'package:guru_app/guru_app.dart'; | ||||
| import 'package:guru_app/inventory/inventory_manager.dart'; | ||||
| import 'package:guru_utils/hash/hash.dart'; | ||||
| import 'package:in_app_purchase/in_app_purchase.dart'; | ||||
| import 'package:guru_app/financial/iap/iap_model.dart'; | ||||
|  | @ -51,9 +53,9 @@ class TransactionAttributes { | |||
| 
 | ||||
|   // Offer products for sale in your app for a one-off charge | ||||
|   @deprecated | ||||
|   static const possessive = 1; | ||||
|   static const asset = 1; | ||||
|   static const consumable = 2; | ||||
|   static const possessive = DetailsAttr.permanent; | ||||
|   static const asset = DetailsAttr.permanent; | ||||
|   static const consumable = DetailsAttr.consumable; | ||||
| 
 | ||||
|   static const Set<int> oneOffChargeAttributes = <int>{asset, consumable}; | ||||
| 
 | ||||
|  | @ -174,6 +176,7 @@ class ProductId { | |||
|   TransactionIntent createIntent( | ||||
|       {required String scene, | ||||
|       int igcCost = 0, | ||||
|       List<StockItem> igbCost = const <StockItem>[], | ||||
|       bool sales = false, | ||||
|       double rate = 1.0, | ||||
|       EligibilityCriteria eligibilityCriteria = | ||||
|  | @ -193,6 +196,13 @@ class ProductId { | |||
|     final manifest = await ManifestManager.instance.createManifest(intent); | ||||
|     return IgcProduct(this, manifest, igcCost); | ||||
|   } | ||||
| 
 | ||||
|   Future<IgbProduct> createIgbProduct(List<StockItem> igbCost, String scene, | ||||
|       {Manifest? specified}) async { | ||||
|     final manifest = specified ?? | ||||
|         await ManifestManager.instance.createManifest(createIntent(scene: scene, igbCost: igbCost)); | ||||
|     return IgbProduct(this, manifest, igbCost); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| abstract class Product { | ||||
|  | @ -207,12 +217,14 @@ abstract class Product { | |||
| 
 | ||||
|   factory Product.reward(ProductId productId, Manifest manifest) = RewardProduct; | ||||
| 
 | ||||
|   factory Product.igb(ProductId productId, Manifest manifest, List<StockItem> cost) = IgbProduct; | ||||
| 
 | ||||
| // | ||||
| // factory Product.gems(ProductId productId, int price, Manifest manifest) = GemProduct; | ||||
| // | ||||
| // factory Product.reward(Reward reward) = RewardProduct; | ||||
| // | ||||
|   OrderEntity createOrder(); | ||||
| //   OrderEntity createOrder(); | ||||
| } | ||||
| 
 | ||||
| class TransactionState { | ||||
|  | @ -223,11 +235,17 @@ class TransactionState { | |||
|   static const expired = -3; | ||||
| } | ||||
| 
 | ||||
| // 交易方式 | ||||
| enum TransactionMethod { | ||||
|   iap, // IAP购买 | ||||
|   igc, // In-game currency 购买 | ||||
|   igc, // In-game currency 购买(coin/gems..) | ||||
|   reward, // 奖励获得 | ||||
|   none | ||||
| 
 | ||||
|   bonus, // 优惠 | ||||
|   igb, // In-game barter | ||||
|   free, | ||||
|   migrate, | ||||
|   unknown | ||||
| } | ||||
| 
 | ||||
| String convertTransactionMethodName(TransactionMethod method) { | ||||
|  | @ -238,14 +256,23 @@ String convertTransactionMethodName(TransactionMethod method) { | |||
|       return "igc"; | ||||
|     case TransactionMethod.reward: | ||||
|       return "reward"; | ||||
|     case TransactionMethod.bonus: | ||||
|       return "bonus"; | ||||
|     case TransactionMethod.igb: | ||||
|       return "igb"; | ||||
|     case TransactionMethod.free: | ||||
|       return "prop"; | ||||
|     case TransactionMethod.migrate: | ||||
|       return "migrate"; | ||||
|     default: | ||||
|       return "none"; | ||||
|       return "unknown"; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class TransactionIntent { | ||||
|   final ProductId productId; | ||||
|   final int igcCost; | ||||
|   final int igcCost; // In-game currency cost | ||||
|   final List<StockItem> igbCost; // In-game barter cost | ||||
|   final String scene; // 购买场景(最终会用到logEarnVirtualCurrency的item_category上) | ||||
|   final bool sales; // 是否为促销商品 | ||||
|   final double rate; // 默认1.0 指收益率,如:打折促销时设成1.2,最终的收益将是1.2倍 | ||||
|  | @ -253,6 +280,7 @@ class TransactionIntent { | |||
| 
 | ||||
|   TransactionIntent(this.productId, this.scene, | ||||
|       {this.igcCost = 0, | ||||
|       this.igbCost = const <StockItem>[], | ||||
|       this.sales = false, | ||||
|       this.rate = 1.0, | ||||
|       this.eligibilityCriteria = EligibilityCriteria.newCustomerNeverHadSubscribedThisGroup}); | ||||
|  |  | |||
|  | @ -11,6 +11,7 @@ class ProductProfile { | |||
|   final Set<ProductId> iapIds = {}; | ||||
|   final Set<ProductId> igcIds = {}; | ||||
|   final Set<ProductId> rewardIds = {}; | ||||
|   final Set<ProductId> igbIds = {}; | ||||
| 
 | ||||
|   final Map<String, String> groupMap; | ||||
| 
 | ||||
|  | @ -19,16 +20,17 @@ class ProductProfile { | |||
|   final Map<String, Set<ProductId>> _offerIds = {}; | ||||
| 
 | ||||
|   final List<Map<String, ProductId>> _idsMap = | ||||
|   List.generate(TransactionAttributes.count, (index) => <String, ProductId>{}); | ||||
|       List.generate(TransactionAttributes.count, (index) => <String, ProductId>{}); | ||||
| 
 | ||||
|   ProductProfile({required Set<ProductId> oneOffChargeIapIds, | ||||
|     required Set<ProductId> subscriptionsIapIds, | ||||
|     Set<ProductId> pointsIapIds = const <ProductId>{}, | ||||
|     Set<ProductId> igcIds = const <ProductId>{}, | ||||
|     Set<ProductId> rewardIds = const <ProductId>{}, | ||||
|     this.groupMap = const <String, String>{}, | ||||
|     List<ManifestBuilder> manifestBuilders = const <ManifestBuilder>[], | ||||
|     this.noAdsCapIds = const <ProductId>{}}) { | ||||
|   ProductProfile( | ||||
|       {required Set<ProductId> oneOffChargeIapIds, | ||||
|       required Set<ProductId> subscriptionsIapIds, | ||||
|       Set<ProductId> pointsIapIds = const <ProductId>{}, | ||||
|       Set<ProductId> igcIds = const <ProductId>{}, | ||||
|       Set<ProductId> rewardIds = const <ProductId>{}, | ||||
|       this.groupMap = const <String, String>{}, | ||||
|       List<ManifestBuilder> manifestBuilders = const <ManifestBuilder>[], | ||||
|       this.noAdsCapIds = const <ProductId>{}}) { | ||||
|     for (var productId in oneOffChargeIapIds) { | ||||
|       _define(productId, TransactionMethod.iap); | ||||
|     } | ||||
|  | @ -70,7 +72,16 @@ class ProductProfile { | |||
|       case TransactionMethod.reward: | ||||
|         rewardIds.add(definedProductId); | ||||
|         break; | ||||
|       case TransactionMethod.none: | ||||
|       case TransactionMethod.bonus: | ||||
|         break; | ||||
|       case TransactionMethod.igb: | ||||
|         igbIds.add(definedProductId); | ||||
|         break; | ||||
|       case TransactionMethod.free: | ||||
|         break; | ||||
|       case TransactionMethod.migrate: | ||||
|         break; | ||||
|       case TransactionMethod.unknown: | ||||
|         break; | ||||
|     } | ||||
|     _idsMap[productId.attr][productId.sku] = definedProductId; | ||||
|  | @ -114,11 +125,12 @@ class IapProfile { | |||
|   final List<ProductId> subscriptionsIapIds = []; | ||||
|   final List<ProductId> noAdsCapIds; | ||||
|   final List<Map<String, ProductId>> _idsMap = | ||||
|   List.generate(TransactionAttributes.count, (index) => <String, ProductId>{}); | ||||
|       List.generate(TransactionAttributes.count, (index) => <String, ProductId>{}); | ||||
| 
 | ||||
|   IapProfile({required List<ProductId> oneOffChargeIapIds, | ||||
|     required List<ProductId> subscriptionsIapIds, | ||||
|     this.noAdsCapIds = const <ProductId>[]}) { | ||||
|   IapProfile( | ||||
|       {required List<ProductId> oneOffChargeIapIds, | ||||
|       required List<ProductId> subscriptionsIapIds, | ||||
|       this.noAdsCapIds = const <ProductId>[]}) { | ||||
|     for (var productId in oneOffChargeIapIds) { | ||||
|       _define(productId); | ||||
|     } | ||||
|  | @ -130,7 +142,7 @@ class IapProfile { | |||
|   bool hasIap() => oneOffChargeIapIds.isEmpty && subscriptionsIapIds.isEmpty; | ||||
| 
 | ||||
|   static final IapProfile invalid = | ||||
|   IapProfile(oneOffChargeIapIds: [], subscriptionsIapIds: [], noAdsCapIds: []); | ||||
|       IapProfile(oneOffChargeIapIds: [], subscriptionsIapIds: [], noAdsCapIds: []); | ||||
| 
 | ||||
|   ProductId _define(ProductId productId) { | ||||
|     if (productId.isOneOffCharge) { | ||||
|  |  | |||
|  | @ -28,9 +28,13 @@ class RewardManager { | |||
|     await reloadAssets(); | ||||
|   } | ||||
| 
 | ||||
|   Future switchSession() async { | ||||
|     reloadAssets(); | ||||
|   } | ||||
| 
 | ||||
|   Future reloadAssets() async { | ||||
|     final transactions = await GuruDB.instance.selectOrders( | ||||
|         method: TransactionMethod.reward, attrs: [TransactionAttributes.asset]); | ||||
|     final transactions = await GuruDB.instance | ||||
|         .selectOrders(method: TransactionMethod.reward, attrs: [TransactionAttributes.asset]); | ||||
|     final newAssetsStore = AssetsStore<Asset>(); | ||||
|     for (var transaction in transactions) { | ||||
|       final productId = transaction.productId; | ||||
|  |  | |||
|  | @ -4,6 +4,7 @@ import 'dart:io'; | |||
| import 'package:firebase_remote_config/firebase_remote_config.dart'; | ||||
| import 'package:guru_app/ads/core/ads_config.dart'; | ||||
| import 'package:guru_app/analytics/data/analytics_model.dart'; | ||||
| import 'package:guru_app/firebase/firebase.dart'; | ||||
| import 'package:guru_app/firebase/remoteconfig/reserved_remote_config_models.dart'; | ||||
| import 'package:guru_app/guru_app.dart'; | ||||
| import 'package:guru_utils/http/http_model.dart'; | ||||
|  | @ -18,8 +19,7 @@ part "remote_config_interface.dart"; | |||
| part "remote_config_reserved_constants.dart"; | ||||
| 
 | ||||
| class RemoteConfigManager extends IRemoteConfig { | ||||
|   final BehaviorSubject<FirebaseRemoteConfig?> _subject = | ||||
|       BehaviorSubject.seeded(null); | ||||
|   final BehaviorSubject<FirebaseRemoteConfigWrapper?> _subject = BehaviorSubject.seeded(null); | ||||
|   static RemoteConfigManager? _instance; | ||||
| 
 | ||||
|   static RemoteConfigManager _getInstance() { | ||||
|  | @ -31,7 +31,7 @@ class RemoteConfigManager extends IRemoteConfig { | |||
| 
 | ||||
|   static RemoteConfigManager get instance => _getInstance(); | ||||
| 
 | ||||
|   static final RegExp _invalidABKey = RegExp('[^a-zA-Z0-9_-]'); | ||||
|   static final RegExp invalidABKeyRegExp = RegExp('[^a-zA-Z0-9_-]'); | ||||
| 
 | ||||
|   RemoteConfigManager._internal(); | ||||
| 
 | ||||
|  | @ -42,17 +42,16 @@ class RemoteConfigManager extends IRemoteConfig { | |||
|       minimumFetchInterval: const Duration(hours: 2), | ||||
|     )); | ||||
| 
 | ||||
|     _subject.add(remoteConfig); | ||||
|     _subject.add(FirebaseRemoteConfigWrapper._(remoteConfig)); | ||||
| 
 | ||||
|     try { | ||||
|       await remoteConfig.setDefaults(defaultConfigs); | ||||
|       await remoteConfig.activate(); | ||||
|     } catch (exception) { | ||||
|       Log.d( | ||||
|           "Unable to fetch remote config. Cached or default values will be used!", | ||||
|       Log.d("Unable to fetch remote config. Cached or default values will be used!", | ||||
|           error: exception); | ||||
|     } finally { | ||||
|       _subject.add(remoteConfig); | ||||
|       _subject.add(FirebaseRemoteConfigWrapper._(remoteConfig)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -61,11 +60,10 @@ class RemoteConfigManager extends IRemoteConfig { | |||
|     try { | ||||
|       await remoteConfig.fetchAndActivate(); | ||||
|     } catch (exception) { | ||||
|       Log.d( | ||||
|           "Unable to fetch remote config. Cached or default values will be used!", | ||||
|       Log.d("Unable to fetch remote config. Cached or default values will be used!", | ||||
|           error: exception); | ||||
|     } finally { | ||||
|       _subject.add(remoteConfig); | ||||
|       _subject.add(FirebaseRemoteConfigWrapper._(remoteConfig)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -95,12 +93,10 @@ class RemoteConfigManager extends IRemoteConfig { | |||
|     final data = config.getAll(); | ||||
|     final result = { | ||||
|       for (var entry in data.entries) | ||||
|         "${entry.key} [${valueSourceToString(entry.value.source)}]": | ||||
|             entry.value.asString() | ||||
|         "${entry.key} [${valueSourceToString(entry.value.source)}]": entry.value.asString() | ||||
|     }; | ||||
|     result["last_fetch_remote_config_time"] = config.lastFetchTime.toString(); | ||||
|     result["last_fetch_remote_config_status"] = | ||||
|         config.lastFetchStatus.toString(); | ||||
|     result["last_fetch_remote_config_status"] = config.lastFetchStatus.toString(); | ||||
|     return result; | ||||
|   } | ||||
| 
 | ||||
|  | @ -113,14 +109,13 @@ class RemoteConfigManager extends IRemoteConfig { | |||
|       )); | ||||
|       await remoteConfig.fetchAndActivate(); | ||||
|     } catch (exception) { | ||||
|       Log.d( | ||||
|           "Unable to fetch remote config. Cached or default values will be used $exception", | ||||
|       Log.d("Unable to fetch remote config. Cached or default values will be used $exception", | ||||
|           error: exception); | ||||
|       if (debug) { | ||||
|         rethrow; | ||||
|       } | ||||
|     } finally { | ||||
|       _subject.add(remoteConfig); | ||||
|       _subject.add(FirebaseRemoteConfigWrapper._(remoteConfig)); | ||||
|       await remoteConfig.setConfigSettings(RemoteConfigSettings( | ||||
|         fetchTimeout: const Duration(seconds: 15), | ||||
|         minimumFetchInterval: const Duration(hours: 2), | ||||
|  | @ -143,7 +138,7 @@ class RemoteConfigManager extends IRemoteConfig { | |||
|             for (var jsonEntry in jsonValue.entries) { | ||||
|               if (jsonEntry.key.contains("guru_ab_")) { | ||||
|                 String abName = jsonEntry.key.replaceFirst("guru_ab_", ""); | ||||
|                 if (abName.contains(_invalidABKey)) { | ||||
|                 if (abName.contains(invalidABKeyRegExp)) { | ||||
|                   Log.w("abName($abName) length is invalid! $abName"); | ||||
|                   invalidABKeys.add(abName); | ||||
|                 } else { | ||||
|  | @ -151,7 +146,7 @@ class RemoteConfigManager extends IRemoteConfig { | |||
|                     invalidABKeys.add(abName); | ||||
|                     abName = abName.substring(0, 20); | ||||
|                   } | ||||
|                   result["ab_$abName"] = jsonEntry.value.toString(); | ||||
|                   result[GuruAnalytics.buildVariantKey(abName)] = jsonEntry.value.toString(); | ||||
|                   Log.i("abName:ab_$abName value:${jsonEntry.value}"); | ||||
|                 } | ||||
|               } | ||||
|  | @ -164,15 +159,14 @@ class RemoteConfigManager extends IRemoteConfig { | |||
|       } | ||||
|     } | ||||
|     if (invalidABKeys.isNotEmpty) { | ||||
|       GuruAnalytics.instance.logException( | ||||
|           InvalidABPropertyKeysException(invalidABKeys, cause: cause)); | ||||
|       GuruAnalytics.instance | ||||
|           .logException(InvalidABPropertyKeysException(invalidABKeys, cause: cause)); | ||||
|     } | ||||
|     return result; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   bool? getBool(String name, {bool? defaultValue}) => | ||||
|       _subject.value?.getBool(name) ?? defaultValue; | ||||
|   bool? getBool(String name, {bool? defaultValue}) => _subject.value?.getBool(name) ?? defaultValue; | ||||
| 
 | ||||
|   @override | ||||
|   String? getString(String name, {String? defaultValue}) => | ||||
|  | @ -185,11 +179,10 @@ class RemoteConfigManager extends IRemoteConfig { | |||
|       _subject.value?.getDouble(name) ?? defaultValue; | ||||
| 
 | ||||
|   @override | ||||
|   int? getInt(String name, {int? defaultValue}) => | ||||
|       _subject.value?.getInt(name) ?? defaultValue; | ||||
|   int? getInt(String name, {int? defaultValue}) => _subject.value?.getInt(name) ?? defaultValue; | ||||
| 
 | ||||
|   Stream<FirebaseRemoteConfig> observeConfig() => | ||||
|       _subject.stream.map((config) => config ?? FirebaseRemoteConfig.instance); | ||||
|   Stream<FirebaseRemoteConfigWrapper> observeConfig() => _subject.stream | ||||
|       .map((config) => config ?? FirebaseRemoteConfigWrapper._(FirebaseRemoteConfig.instance)); | ||||
| 
 | ||||
|   @override | ||||
|   Stream<bool?> observeBool(String name, {bool? defaultValue}) => | ||||
|  | @ -207,3 +200,23 @@ class RemoteConfigManager extends IRemoteConfig { | |||
|   Stream<int?> observeInt(String name, {int? defaultValue}) => | ||||
|       observeConfig().map((config) => config.getInt(name)); | ||||
| } | ||||
| 
 | ||||
| class FirebaseRemoteConfigWrapper { | ||||
|   final FirebaseRemoteConfig config; | ||||
| 
 | ||||
|   const FirebaseRemoteConfigWrapper._(this.config); | ||||
| 
 | ||||
|   DateTime get lastFetchTime => config.lastFetchTime; | ||||
| 
 | ||||
|   RemoteConfigFetchStatus get lastFetchStatus => config.lastFetchStatus; | ||||
| 
 | ||||
|   static String _key(String key) => GuruApp.instance.getRemoteConfigKey(key); | ||||
| 
 | ||||
|   String getString(String key) => config.getString(_key(key)); | ||||
| 
 | ||||
|   bool getBool(String key) => config.getBool(_key(key)); | ||||
| 
 | ||||
|   double getDouble(String key) => config.getDouble(_key(key)); | ||||
| 
 | ||||
|   int getInt(String key) => config.getInt(_key(key)); | ||||
| } | ||||
|  |  | |||
|  | @ -41,6 +41,6 @@ extension RemoteConfigReservedConstants on RemoteConfigManager { | |||
|   }; | ||||
| 
 | ||||
|   static String? getDefaultConfigString(String key) { | ||||
|     return GuruApp.instance.defaultRemoteConfig[key]; | ||||
|     return GuruApp.instance.defaultRemoteConfig[GuruApp.instance.getRemoteConfigKey(key)]; | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,4 @@ | |||
| import 'dart:io'; | ||||
| import 'dart:io'; | ||||
| 
 | ||||
| import 'package:adjust_sdk/adjust_event.dart'; | ||||
| import 'package:firebase_analytics/firebase_analytics.dart'; | ||||
|  | @ -7,7 +6,11 @@ import 'package:firebase_core/firebase_core.dart'; | |||
| import 'package:flutter/material.dart'; | ||||
| import 'package:guru_app/account/account_data_store.dart'; | ||||
| import 'package:guru_app/account/account_manager.dart'; | ||||
| import 'package:guru_app/account/model/account.dart'; | ||||
| import 'package:guru_app/account/model/credential.dart'; | ||||
| import 'package:guru_app/account/model/user.dart'; | ||||
| import 'package:guru_app/ads/ads_manager.dart'; | ||||
| import 'package:guru_app/analytics/abtest/abtest_model.dart'; | ||||
| import 'package:guru_app/analytics/guru_analytics.dart'; | ||||
| import 'package:guru_app/app/app_models.dart'; | ||||
| import 'package:guru_app/database/guru_db.dart'; | ||||
|  | @ -18,10 +21,15 @@ import 'package:guru_app/financial/manifest/manifest_manager.dart'; | |||
| import 'package:guru_app/financial/product/product_model.dart'; | ||||
| import 'package:guru_app/financial/reward/reward_manager.dart'; | ||||
| import 'package:guru_app/firebase/dxlinks/dxlink_manager.dart'; | ||||
| import 'package:guru_app/inventory/inventory_manager.dart'; | ||||
| import 'package:guru_applovin_flutter/guru_applovin_flutter.dart'; | ||||
| import 'package:guru_app/property/app_property.dart'; | ||||
| import 'package:guru_app/property/property_keys.dart'; | ||||
| import 'package:guru_utils/auth/auth_credential_manager.dart'; | ||||
| import 'package:guru_utils/collection/collectionutils.dart'; | ||||
| import 'package:guru_utils/controller/aware/ads/overlay/ads_overlay.dart'; | ||||
| import 'package:guru_utils/datetime/datetime_utils.dart'; | ||||
| import 'package:guru_utils/device/device_utils.dart'; | ||||
| import 'package:guru_utils/lifecycle/lifecycle_manager.dart'; | ||||
| import 'package:guru_utils/network/network_utils.dart'; | ||||
| import 'package:guru_utils/property/app_property.dart'; | ||||
|  | @ -32,10 +40,13 @@ import 'package:guru_utils/log/log.dart'; | |||
| import 'package:guru_utils/packages/guru_package.dart'; | ||||
| import 'package:guru_utils/ads/ads.dart'; | ||||
| import 'package:guru_utils/guru_utils.dart'; | ||||
| import 'package:guru_utils/property/property_model.dart'; | ||||
| import 'package:logger/logger.dart' as Logger; | ||||
| import 'package:guru_utils/aigc/bi/ai_bi.dart'; | ||||
| import 'package:package_info_plus/package_info_plus.dart'; | ||||
| import 'package:guru_popup/guru_popup.dart'; | ||||
| import 'package:guru_app/analytics/abtest/abtest_model.dart'; | ||||
| 
 | ||||
| export 'package:firebase_core/firebase_core.dart'; | ||||
| export 'package:guru_app/app/app_models.dart'; | ||||
| export 'package:guru_utils/log/log.dart'; | ||||
|  | @ -49,13 +60,15 @@ export 'dart:io'; | |||
| export 'dart:math'; | ||||
| export 'package:guru_app/financial/manifest/manifest.dart'; | ||||
| export 'package:guru_app/firebase/messaging/remote_messaging_manager.dart'; | ||||
| 
 | ||||
| export 'package:guru_app/analytics/abtest/abtest_model.dart'; | ||||
| 
 | ||||
| /// Created by Haoyi on 2022/8/25 | ||||
| 
 | ||||
| abstract class AppSpec { | ||||
|   String get appName; | ||||
| 
 | ||||
|   AppCategory get appCategory; | ||||
| 
 | ||||
|   String get flavor; | ||||
| 
 | ||||
|   AppDetails get details; | ||||
|  | @ -69,6 +82,10 @@ abstract class AppSpec { | |||
|   Deployment get deployment; | ||||
| 
 | ||||
|   Map<String, dynamic> get defaultRemoteConfig; | ||||
| 
 | ||||
|   Map<String, ABTestExperiment> get localABTestExperiments; | ||||
| 
 | ||||
|   String getRemoteConfigKey(String key); | ||||
| } | ||||
| 
 | ||||
| class NotImplementationAppSpecCreatorException implements Exception { | ||||
|  | @ -83,14 +100,12 @@ class NotImplementationAppSpecCreatorException implements Exception { | |||
| class AppEnv { | ||||
|   final AppSpec spec; | ||||
|   final RootPackage package; | ||||
|   final BackgroundMessageHandler? backgroundMessageHandler; | ||||
|   final ToastDelegate? toastDelegate; | ||||
|   final IGuruSdkProtocol protocol; | ||||
| 
 | ||||
|   AppEnv( | ||||
|       {required this.spec, | ||||
|       required this.package, | ||||
|       this.backgroundMessageHandler, | ||||
|       this.toastDelegate}); | ||||
|       required this.protocol}); | ||||
| } | ||||
| 
 | ||||
| extension _GuruPackageExtension on GuruPackage { | ||||
|  | @ -130,8 +145,80 @@ extension _GuruPackageExtension on GuruPackage { | |||
|       child._dispatchInitializeAsync(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future _dispatchSwitchSession(String oldToken, String newToken) async { | ||||
|     await switchSession(oldToken, newToken); | ||||
|     children.sort((p1, p2) { | ||||
|       return p2.priority.compareTo(p1.priority); | ||||
|     }); | ||||
|     for (var child in children) { | ||||
|       if (flattenChildrenAsyncInit) { | ||||
|         child._dispatchSwitchSession(oldToken, newToken); | ||||
|       } else { | ||||
|         await child._dispatchSwitchSession(oldToken, newToken); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| enum AppCategory { game, app } | ||||
| 
 | ||||
| abstract class IGuruSdkProtocol { | ||||
|   static void _unimplementedError(String name) { | ||||
|     Log.e( | ||||
|         "[$name] It is critically important that the GuruSDK protocol be implemented with precision. \n" | ||||
|         "Failure to adhere to its standards will result in inaccuracies within our analytics data,\n" | ||||
|         "thereby severely compromising our marketing strategies and the effectiveness of our user acquisition efforts.\n" | ||||
|         "It is essential to understand that non-compliance is not an option,\n" | ||||
|         "as it poses significant risks to our operational success and strategic objectives."); | ||||
|     throw UnimplementedError("Please fully implement the IGuruSdkProtocol!"); | ||||
|   } | ||||
| 
 | ||||
|   InventoryDelegate? get inventoryDelegate => null; | ||||
| 
 | ||||
|   BackgroundMessageHandler? get backgroundMessageHandler => null; | ||||
| 
 | ||||
|   ToastDelegate? get toastDelegate => null; | ||||
| 
 | ||||
|   IAccountAuthDelegate? get accountAuthDelegate => null; | ||||
| 
 | ||||
|   String getLevelName() { | ||||
|     if (GuruApp.instance.appSpec.appCategory == AppCategory.game) { | ||||
|       _unimplementedError("getLevelName"); | ||||
|     } | ||||
|     return "app"; | ||||
|   } | ||||
| 
 | ||||
|   int getCurrentLevel() { | ||||
|     if (GuruApp.instance.appSpec.appCategory == AppCategory.game) { | ||||
|       _unimplementedError("getCurrentLevel"); | ||||
|     } | ||||
|     return 1; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| final Set<PropertyKey> _deviceSharedProperties = { | ||||
|   PropertyKeys.deviceId, | ||||
|   PropertyKeys.version, | ||||
|   PropertyKeys.buildNumber, | ||||
|   PropertyKeys.firstInstallTime, | ||||
|   PropertyKeys.firstInstallVersion, | ||||
|   PropertyKeys.prevInstallVersion, | ||||
|   PropertyKeys.latestInstallVersion, | ||||
|   PropertyKeys.previousInstalledVersion, | ||||
|   PropertyKeys.latestLtDate, | ||||
|   PropertyKeys.ltDays, | ||||
|   PropertyKeys.appInstanceId, | ||||
|   PropertyKeys.debugMode, | ||||
|   PropertyKeys.keepOnScreenDuration, | ||||
|   PropertyKeys.analyticsAdId, | ||||
|   PropertyKeys.analyticsAdjustId, | ||||
|   PropertyKeys.analyticsDeviceId, | ||||
|   PropertyKeys.analyticsIdfa, | ||||
|   PropertyKeys.analyticsFirebaseId, | ||||
|   PropertyKeys.latestAnalyticsStrategy | ||||
| }; | ||||
| 
 | ||||
| class GuruApp { | ||||
|   static late GuruApp _instance; | ||||
| 
 | ||||
|  | @ -141,6 +228,12 @@ class GuruApp { | |||
| 
 | ||||
|   final AppSpec appSpec; | ||||
| 
 | ||||
|   final IGuruSdkProtocol protocol; | ||||
|   RemoteDeployment? _remoteDeployment; | ||||
| 
 | ||||
|   RemoteDeployment get remoteDeployment => | ||||
|       _remoteDeployment ??= RemoteConfigManager.instance.getRemoteDeployment(); | ||||
| 
 | ||||
|   String get appName => appSpec.appName; | ||||
| 
 | ||||
|   String get flavor => appSpec.flavor; | ||||
|  | @ -157,8 +250,12 @@ class GuruApp { | |||
| 
 | ||||
|   Set<String> get conversionEvents => appSpec.deployment.conversionEvents; | ||||
| 
 | ||||
|   GuruApp._({required this.appSpec, required this.rootPackage, ToastDelegate? toastDelegate}) { | ||||
|     GuruUtils.toastDelegate = toastDelegate; | ||||
|   GuruApp._( | ||||
|       {required this.appSpec, | ||||
|       required this.rootPackage, | ||||
|       required this.protocol, | ||||
|   }) { | ||||
|     GuruUtils.toastDelegate = protocol.toastDelegate; | ||||
|     AdsOverlay.bind(showBanner: GuruPopup.instance.showAdsBanner); | ||||
|   } | ||||
| 
 | ||||
|  | @ -167,8 +264,15 @@ class GuruApp { | |||
|   Iterable<LocalizationsDelegate<dynamic>> get localizationsDelegates => | ||||
|       rootPackage._mergeLocalizationsDelegates(); | ||||
| 
 | ||||
|   String getRemoteConfigKey(String key) => appSpec.getRemoteConfigKey(key); | ||||
| 
 | ||||
|   bool? _check; | ||||
| 
 | ||||
|   @visibleForTesting | ||||
|   static void setMockInstance(GuruApp app) { | ||||
|     _instance = app; | ||||
|   } | ||||
| 
 | ||||
|   Future _initialize() async { | ||||
|     try { | ||||
|       await GuruDB.instance.initDatabase(); | ||||
|  | @ -182,6 +286,57 @@ class GuruApp { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future _migrateDeviceSharedData(PropertyBundle latestData) async { | ||||
|     final PropertyBundle bundle = PropertyBundle(); | ||||
|     final keys = { | ||||
|       ..._deviceSharedProperties, | ||||
|       ...(protocol.accountAuthDelegate?.deviceSharedProperties ?? {}) | ||||
|     }; | ||||
|     for (var key in keys) { | ||||
|       final value = latestData.getString(key); | ||||
|       if (value != null) { | ||||
|         bundle.setString(key, value); | ||||
|       } | ||||
|     } | ||||
|     await AppProperty.getInstance().setProperties(bundle); | ||||
|   } | ||||
| 
 | ||||
|   RemoteDeployment refreshRemoteDeployment() { | ||||
|     try { | ||||
|       return _remoteDeployment ??= RemoteConfigManager.instance.getRemoteDeployment(); | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.w("refreshRemoteDeployment error:$error, $stacktrace"); | ||||
|     } | ||||
|     return RemoteDeployment.fromJson({}); | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> switchAccount(GuruUser user, Credential credential, {GuruUser? oldUser}) async { | ||||
|     final String oldToken = oldUser?.uid ?? ""; | ||||
|     final String newToken = user.uid; | ||||
|     try { | ||||
|       final previousUserProperties = | ||||
|           PropertyBundle(map: await AppProperty.getInstance().loadAllValues()); | ||||
| 
 | ||||
|       final result = await GuruDB.instance.switchSession(oldToken, newToken); | ||||
|       if (!result) { | ||||
|         Log.w("switchSession failed"); | ||||
|         return false; | ||||
|       } | ||||
|       AppProperty.reload(cacheSize: appSpec.deployment.propertyCacheSize); | ||||
|       await _migrateDeviceSharedData(previousUserProperties); | ||||
|       await GuruSettings.instance.refresh(); | ||||
|       await DeviceUtils.reload(); | ||||
|       FinancialManager.instance.switchSession(oldToken, newToken); | ||||
|       GuruAnalytics.instance.switchSession(oldToken, newToken); | ||||
|       await AccountManager.instance.processLogin(user, credential); | ||||
|       await rootPackage._dispatchSwitchSession(oldToken, newToken); | ||||
|       return true; | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.w("switchSession error:$error, $stacktrace"); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> _checkApp() async { | ||||
|     try { | ||||
|       final pkgName = (await PackageInfo.fromPlatform()).appName; | ||||
|  | @ -210,6 +365,9 @@ class GuruApp { | |||
| 
 | ||||
|   Future _dispatchInitializeSync() async { | ||||
|     await RemoteConfigManager.instance.init(appSpec.defaultRemoteConfig); | ||||
|     refreshRemoteDeployment(); | ||||
|     await DeviceUtils.initialize(); | ||||
|     await GuruAnalytics.instance.prepare(); | ||||
|     await rootPackage._dispatchInitialize(); | ||||
|     try { | ||||
|       GuruUtils.isTablet = (await GuruApplovinFlutter.instance.isTablet()) ?? false; | ||||
|  | @ -237,7 +395,7 @@ class GuruApp { | |||
|   } | ||||
| 
 | ||||
|   static Future initialize({required AppEnv appEnv}) async { | ||||
|     final backgroundMessageHandler = appEnv.backgroundMessageHandler; | ||||
|     final backgroundMessageHandler = appEnv.protocol.backgroundMessageHandler; | ||||
|     if (backgroundMessageHandler != null) { | ||||
|       FirebaseMessaging.onBackgroundMessage(backgroundMessageHandler); | ||||
|     } | ||||
|  | @ -248,9 +406,18 @@ class GuruApp { | |||
|       Log.e("Firebase.initializeApp() error!", error: error, stackTrace: stacktrace); | ||||
|     } | ||||
|     GuruUtils.flavor = appEnv.spec.flavor; | ||||
| 
 | ||||
|     /// 这里不用担心重复初始化,因为initialize会把对应的 AuthType 重新赋值 | ||||
|     /// 如果传入的 AuthType 有重复,会覆盖之前的 AuthType | ||||
|     AuthCredentialManager.initialize([ | ||||
|       ...AccountManager.defaultSupportedAuthCredentialDelegates, | ||||
|       ...appEnv.protocol.accountAuthDelegate?.supportedAuthCredentialDelegates ?? [] | ||||
|     ]); | ||||
|     try { | ||||
|       _instance = GuruApp._( | ||||
|           appSpec: appEnv.spec, rootPackage: appEnv.package, toastDelegate: appEnv.toastDelegate); | ||||
|           appSpec: appEnv.spec, | ||||
|           rootPackage: appEnv.package, | ||||
|           protocol: appEnv.protocol); | ||||
|       Log.init(_instance.appName, | ||||
|           persistentLogFileSize: appEnv.spec.deployment.logFileSizeLimit, | ||||
|           persistentLogCount: appEnv.spec.deployment.logFileCount, | ||||
|  | @ -279,8 +446,7 @@ extension GuruAppInitializerExt on GuruApp { | |||
|     await RemoteConfigManager.instance.fetchAndActivate(); | ||||
|     final cdnConfig = RemoteConfigManager.instance.getCdnConfig(); | ||||
|     HttpEx.init(cdnConfig, GuruApp.instance.appSpec.details.storagePrefix); | ||||
| 
 | ||||
|     final remoteDeployment = RemoteConfigManager.instance.getRemoteDeployment(); | ||||
|     refreshRemoteDeployment(); | ||||
|     Settings.get() | ||||
|         .keepOnScreenDuration | ||||
|         .set(remoteDeployment.keepScreenOnDuration * DateTimeUtils.minuteInMillis); | ||||
|  |  | |||
|  | @ -0,0 +1,408 @@ | |||
| import 'dart:convert'; | ||||
| import 'dart:math'; | ||||
| 
 | ||||
| import 'package:guru_app/database/guru_db.dart'; | ||||
| import 'package:guru_app/financial/product/product_model.dart'; | ||||
| import 'package:guru_utils/database/batch/batch_data.dart'; | ||||
| import 'package:guru_utils/datetime/datetime_utils.dart'; | ||||
| import 'package:guru_utils/id/identifiable.dart'; | ||||
| import 'package:guru_utils/log/log.dart'; | ||||
| import 'package:guru_utils/manifest/manifest.dart'; | ||||
| import 'package:sqflite/sqflite.dart'; | ||||
| import 'package:json_annotation/json_annotation.dart'; | ||||
| 
 | ||||
| part 'inventory_database.g.dart'; | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class LimitedBalance { | ||||
|   @JsonKey(name: "a", defaultValue: 0) | ||||
|   final int amount; | ||||
| 
 | ||||
|   @JsonKey(name: "e", defaultValue: -1) | ||||
|   final int expireAt; | ||||
| 
 | ||||
|   @JsonKey(name: "r", defaultValue: false) | ||||
|   final bool recycle; | ||||
| 
 | ||||
|   LimitedBalance(this.amount, this.expireAt, {this.recycle = false}); | ||||
| 
 | ||||
|   factory LimitedBalance.fromJson(Map<String, dynamic> json) => _$LimitedBalanceFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$LimitedBalanceToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class TimeSensitiveData { | ||||
|   @JsonKey(name: "v", defaultValue: []) | ||||
|   final List<LimitedBalance> valid; | ||||
| 
 | ||||
|   @JsonKey(name: "e", defaultValue: []) | ||||
|   final List<LimitedBalance> expired; | ||||
| 
 | ||||
|   int get validBalance => | ||||
|       valid.isEmpty ? 0 : valid.map((e) => e.amount).reduce((total, amount) => total + amount); | ||||
| 
 | ||||
|   int get expiredBalance => | ||||
|       expired.isEmpty ? 0 : expired.map((e) => e.amount).reduce((total, amount) => total + amount); | ||||
| 
 | ||||
|   const TimeSensitiveData( | ||||
|       {this.valid = const <LimitedBalance>[], this.expired = const <LimitedBalance>[]}); | ||||
| 
 | ||||
|   TimeSensitiveData.create({LimitedBalance? balance}) | ||||
|       : valid = ((balance?.expireAt ?? 0) > DateTimeUtils.currentTimeInMillis()) ? [balance!] : [], | ||||
|         expired = []; | ||||
| 
 | ||||
|   factory TimeSensitiveData.fromJson(Map<String, dynamic> json) => | ||||
|       _$TimeSensitiveDataFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$TimeSensitiveDataToJson(this); | ||||
| 
 | ||||
|   TimeSensitiveData attachLimitedBalance(LimitedBalance balance) { | ||||
|     if (balance.expireAt > DateTimeUtils.currentTimeInMillis()) { | ||||
|       return TimeSensitiveData(valid: List.of(valid)..add(balance), expired: List.of(expired)); | ||||
|     } | ||||
|     return this; | ||||
|   } | ||||
| 
 | ||||
|   /// | ||||
|   TimeSensitiveData consume(int balance) { | ||||
|     final now = DateTimeUtils.currentTimeInMillis(); | ||||
|     final newValid = <LimitedBalance>[]; | ||||
|     for (var item in valid) { | ||||
|       int amount = item.amount; | ||||
|       if (now < item.expireAt) { | ||||
|         if (balance >= item.amount) { | ||||
|           balance -= item.amount; | ||||
|           continue; | ||||
|         } else { | ||||
|           amount -= balance; | ||||
|           balance = 0; | ||||
|         } | ||||
|       } | ||||
|       // 这里只针对未过期的道具进行处理 | ||||
|       newValid.add(LimitedBalance(amount, item.expireAt)); | ||||
|     } | ||||
|     newValid.sort((a, b) => b.expireAt.compareTo(a.expireAt)); | ||||
|     return TimeSensitiveData(valid: newValid, expired: expired); | ||||
|   } | ||||
| 
 | ||||
|   RecycleResult recycleExpiredBalance({int? transactionTs}) { | ||||
|     final now = transactionTs ?? DateTimeUtils.currentTimeInMillis(); | ||||
|     final newValid = <LimitedBalance>[]; | ||||
|     final newExpired = <LimitedBalance>[]; | ||||
|     int expiredBalance = 0; | ||||
|     for (var item in valid) { | ||||
|       if (now >= item.expireAt) { | ||||
|         expiredBalance += item.amount; | ||||
|         if (item.recycle) { | ||||
|           newExpired.add(item); | ||||
|         } | ||||
|       } else { | ||||
|         newValid.add(item); | ||||
|       } | ||||
|     } | ||||
|     newValid.sort((a, b) => b.expireAt.compareTo(a.expireAt)); | ||||
|     return RecycleResult(expiredBalance, TimeSensitiveData(valid: newValid, expired: newExpired)); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class RecycleResult { | ||||
|   final int expiredBalance; | ||||
|   final TimeSensitiveData timeSensitiveData; | ||||
| 
 | ||||
|   RecycleResult(this.expiredBalance, this.timeSensitiveData); | ||||
| } | ||||
| 
 | ||||
| class ConsumeResult { | ||||
|   final bool consumed; | ||||
|   final InventoryItem item; | ||||
| 
 | ||||
|   ConsumeResult.success(this.item) : consumed = true; | ||||
| 
 | ||||
|   ConsumeResult.error(this.item) : consumed = false; | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable(constructor: "_") | ||||
| class InventoryDetails { | ||||
|   @JsonKey(name: "a", defaultValue: {}) | ||||
|   final Map<TransactionMethod, int> acquired; | ||||
| 
 | ||||
|   @JsonKey(name: "c", defaultValue: {}) | ||||
|   final Map<String, int> consumed; | ||||
| 
 | ||||
|   @JsonKey(name: "d") | ||||
|   final Map<String, dynamic> data; | ||||
| 
 | ||||
|   const InventoryDetails._(this.acquired, this.consumed, this.data); | ||||
| 
 | ||||
|   InventoryDetails.empty() | ||||
|       : acquired = const <TransactionMethod, int>{}, | ||||
|         consumed = const <String, int>{}, | ||||
|         data = const <String, dynamic>{}; | ||||
| 
 | ||||
|   InventoryDetails.create(int amount) | ||||
|       : acquired = (amount == 0) | ||||
|             ? const <TransactionMethod, int>{} | ||||
|             : <TransactionMethod, int>{TransactionMethod.unknown: amount}, | ||||
|         consumed = const <String, int>{}, | ||||
|         data = <String, dynamic>{}; | ||||
| 
 | ||||
|   // 通过什么方式获得了多少 | ||||
|   InventoryDetails acquire(TransactionMethod method, int amount) { | ||||
|     return InventoryDetails._( | ||||
|         Map<TransactionMethod, int>.from(acquired)..[method] = (acquired[method] ?? 0) + amount, | ||||
|         Map<String, int>.from(consumed), | ||||
|         Map<String, dynamic>.from(data)); | ||||
|   } | ||||
| 
 | ||||
|   // 在什么场景消耗了多少 | ||||
|   InventoryDetails consume(String scene, int amount) { | ||||
|     return InventoryDetails._( | ||||
|         Map<TransactionMethod, int>.from(acquired), | ||||
|         Map<String, int>.from(consumed)..[scene] = (consumed[scene] ?? 0) + amount, | ||||
|         Map<String, dynamic>.from(data)); | ||||
|   } | ||||
| 
 | ||||
|   factory InventoryDetails.fromJson(Map<String, dynamic> json) => _$InventoryDetailsFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$InventoryDetailsToJson(this); | ||||
| 
 | ||||
|   void setInt(String key, int value) { | ||||
|     data[key] = value; | ||||
|   } | ||||
| 
 | ||||
|   void setDouble(String key, double value) { | ||||
|     data[key] = value; | ||||
|   } | ||||
| 
 | ||||
|   void setString(String key, String value) { | ||||
|     data[key] = value; | ||||
|   } | ||||
| 
 | ||||
|   void setBool(String key, bool value) { | ||||
|     data[key] = value; | ||||
|   } | ||||
| 
 | ||||
|   int? getInt(String key) { | ||||
|     return data[key]; | ||||
|   } | ||||
| 
 | ||||
|   double? getDouble(String key) { | ||||
|     return data[key]; | ||||
|   } | ||||
| 
 | ||||
|   String? getString(String key) { | ||||
|     return data[key]; | ||||
|   } | ||||
| 
 | ||||
|   bool? getBool(String key) { | ||||
|     return data[key]; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class InventoryDetailsStringConvert implements JsonConverter<InventoryDetails, String> { | ||||
|   const InventoryDetailsStringConvert(); | ||||
| 
 | ||||
|   @override | ||||
|   InventoryDetails fromJson(String json) { | ||||
|     if (json.isEmpty) { | ||||
|       return InventoryDetails.empty(); | ||||
|     } | ||||
|     final result = jsonDecode(json); | ||||
|     return InventoryDetails.fromJson(result); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   String toJson(InventoryDetails transaction) { | ||||
|     return jsonEncode(transaction); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class TimeSensitiveDataStringConvert implements JsonConverter<TimeSensitiveData, String> { | ||||
|   const TimeSensitiveDataStringConvert(); | ||||
| 
 | ||||
|   @override | ||||
|   TimeSensitiveData fromJson(String json) { | ||||
|     if (json.isEmpty) { | ||||
|       return const TimeSensitiveData(); | ||||
|     } | ||||
|     final result = jsonDecode(json); | ||||
|     return TimeSensitiveData.fromJson(result); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   String toJson(TimeSensitiveData transaction) { | ||||
|     return jsonEncode(transaction); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const InventoryDetailsStringConvert inventoryDetailsStringConvert = InventoryDetailsStringConvert(); | ||||
| const TimeSensitiveDataStringConvert timeSensitiveDataStringConvert = | ||||
|     TimeSensitiveDataStringConvert(); | ||||
| 
 | ||||
| class InventoryTable { | ||||
|   static const tbName = "inventory"; // Product Transaction Table | ||||
|   static const dbSku = "sku"; | ||||
|   static const dbBalance = "balance"; | ||||
|   static const dbCategory = "cat"; | ||||
|   static const dbAttr = "attr"; | ||||
|   static const dbDetails = "details"; | ||||
|   static const dbTimeSensitive = "tsv"; | ||||
|   static const dbUpdateAt = "update_at"; | ||||
|   static const dbCreateAt = "create_at"; | ||||
| 
 | ||||
|   static Future createTable(Transaction delegate) async { | ||||
|     const v1Fields = "${InventoryTable.dbSku} TEXT PRIMARY KEY," | ||||
|         "${InventoryTable.dbBalance} INTEGER NOT NULL DEFAULT 0," | ||||
|         "${InventoryTable.dbCategory} TEXT NOT NULL DEFAULT ''," | ||||
|         "${InventoryTable.dbAttr} INTEGER NOT NULL DEFAULT ${DetailsAttr.consumable}," | ||||
|         "${InventoryTable.dbDetails} TEXT NOT NULL DEFAULT ''," | ||||
|         "${InventoryTable.dbTimeSensitive} TEXT NOT NULL DEFAULT ''," | ||||
|         "${InventoryTable.dbCreateAt} INTEGER NOT NULL DEFAULT 0," | ||||
|         "${InventoryTable.dbUpdateAt} INTEGER NOT NULL DEFAULT 0"; | ||||
| 
 | ||||
|     const cmd = "CREATE TABLE ${InventoryTable.tbName} (" | ||||
|         "$v1Fields" | ||||
|         ");"; | ||||
| 
 | ||||
|     Log.v("#### cmd: $cmd"); | ||||
| 
 | ||||
|     await delegate.execute(cmd); | ||||
|     await delegate.execute( | ||||
|         "CREATE INDEX inventory_item_idx ON ${InventoryTable.tbName} (${InventoryTable.dbSku});"); | ||||
|     await delegate.execute( | ||||
|         "CREATE INDEX inventory_item_category_idx ON ${InventoryTable.tbName} (${InventoryTable.dbCategory});"); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable(constructor: "_") | ||||
| class InventoryItem implements Identifiable { | ||||
|   @JsonKey(name: InventoryTable.dbSku) | ||||
|   final String sku; | ||||
| 
 | ||||
|   @JsonKey(name: InventoryTable.dbBalance) | ||||
|   final int balance; // 永久的+时效 | ||||
| 
 | ||||
|   @JsonKey(name: InventoryTable.dbCategory) | ||||
|   final String category; | ||||
| 
 | ||||
|   @JsonKey(name: InventoryTable.dbAttr) | ||||
|   final int attr; | ||||
| 
 | ||||
|   @JsonKey(name: InventoryTable.dbTimeSensitive) | ||||
|   @timeSensitiveDataStringConvert | ||||
|   final TimeSensitiveData timeSensitive; | ||||
| 
 | ||||
|   @JsonKey(name: InventoryTable.dbUpdateAt) | ||||
|   final int updateAt; | ||||
| 
 | ||||
|   @JsonKey(name: InventoryTable.dbCreateAt) | ||||
|   final int createAt; | ||||
| 
 | ||||
|   @JsonKey(name: InventoryTable.dbDetails) | ||||
|   @inventoryDetailsStringConvert | ||||
|   final InventoryDetails details; | ||||
| 
 | ||||
|   InventoryItem._(this.sku, this.balance, this.category, this.attr, this.details, | ||||
|       this.timeSensitive, this.createAt, this.updateAt); | ||||
| 
 | ||||
|   InventoryItem.create(this.sku, this.category, this.attr, | ||||
|       {int expireAt = -1, this.balance = 0, TransactionMethod method = TransactionMethod.unknown}) | ||||
|       : updateAt = DateTimeUtils.currentTimeInMillis(), | ||||
|         createAt = DateTimeUtils.currentTimeInMillis(), | ||||
|         details = InventoryDetails.create(balance), | ||||
|         timeSensitive = TimeSensitiveData.create(balance: LimitedBalance(balance, expireAt)); | ||||
| 
 | ||||
|   InventoryItem acquire(TransactionMethod method, int amount, {int? expiredAt}) { | ||||
|     final now = DateTimeUtils.currentTimeInMillis(); | ||||
|     if (expiredAt != null && expiredAt > now) { | ||||
|       timeSensitive.valid.add(LimitedBalance(amount, expiredAt)); | ||||
|     } | ||||
|     final recycled = timeSensitive.recycleExpiredBalance(); | ||||
|     final target = balance + amount; | ||||
|     return InventoryItem._(sku, (target - recycled.expiredBalance).clamp(0, target), category, attr, | ||||
|         details.acquire(method, amount), recycled.timeSensitiveData, createAt, now); | ||||
|   } | ||||
| 
 | ||||
|   ConsumeResult consume(int amount, {String scene = ""}) { | ||||
|     final now = DateTimeUtils.currentTimeInMillis(); | ||||
|     final recycled = timeSensitive.recycleExpiredBalance(); | ||||
|     int remainBalance = (balance - recycled.expiredBalance).clamp(0, balance); | ||||
|     if (remainBalance >= amount && attr == DetailsAttr.consumable) { | ||||
|       remainBalance -= amount; | ||||
|       return ConsumeResult.success(InventoryItem._( | ||||
|           sku, | ||||
|           remainBalance, | ||||
|           category, | ||||
|           attr, | ||||
|           details.consume(scene, amount), | ||||
|           recycled.timeSensitiveData.consume(amount), | ||||
|           createAt, | ||||
|           now)); | ||||
|     } | ||||
|     return ConsumeResult.error(InventoryItem._( | ||||
|         sku, remainBalance, category, attr, details, recycled.timeSensitiveData, createAt, now)); | ||||
|   } | ||||
| 
 | ||||
|   ConsumeResult consumeTimeSensitiveOnly(int amount, {String scene = "", int? transactionTs}) { | ||||
|     final now = transactionTs ?? DateTimeUtils.currentTimeInMillis(); | ||||
|     final recycled = timeSensitive.recycleExpiredBalance(transactionTs: transactionTs); | ||||
|     final validBalance = recycled.timeSensitiveData.validBalance; | ||||
|     int remainBalance = (balance - recycled.expiredBalance).clamp(0, balance); | ||||
| 
 | ||||
|     /// 这里只是针对有时效性的道具进行消耗,如果时效性道具不足,将返回错误 | ||||
|     if (validBalance >= amount && attr == DetailsAttr.consumable) { | ||||
|       /// 虽然这里只是针对时效性道具进行消耗,但是这里的balance还是会进行更新 | ||||
|       remainBalance = (remainBalance - amount).clamp(0, remainBalance); | ||||
|       return ConsumeResult.success(InventoryItem._( | ||||
|           sku, | ||||
|           remainBalance, | ||||
|           category, | ||||
|           attr, | ||||
|           details.consume(scene, amount), | ||||
|           recycled.timeSensitiveData.consume(amount), | ||||
|           createAt, | ||||
|           now)); | ||||
|     } | ||||
|     return ConsumeResult.error(InventoryItem._( | ||||
|         sku, remainBalance, category, attr, details, recycled.timeSensitiveData, createAt, now)); | ||||
|   } | ||||
| 
 | ||||
|   factory InventoryItem.fromJson(Map<String, dynamic> json) => _$InventoryItemFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$InventoryItemToJson(this); | ||||
| 
 | ||||
|   @override | ||||
|   String get id => sku; | ||||
| } | ||||
| 
 | ||||
| extension InventoryDatabase on GuruDB { | ||||
|   Future<BatchData<InventoryItem>> loadInventoryItems() async { | ||||
|     final db = getDb(); | ||||
|     final result = await db.rawQuery("SELECT * FROM ${InventoryTable.tbName}"); | ||||
|     final batchData = BatchData<InventoryItem>.empty(); | ||||
|     if (result.isNotEmpty) { | ||||
|       batchData.queryAll([for (var item in result) InventoryItem.fromJson(item)]); | ||||
|     } | ||||
|     return batchData; | ||||
|   } | ||||
| 
 | ||||
|   Future<BatchData<InventoryItem>> updateInventoryItem(InventoryItem item) async { | ||||
|     final db = getDb(); | ||||
|     await db.insert(InventoryTable.tbName, item.toJson(), | ||||
|         conflictAlgorithm: ConflictAlgorithm.replace); | ||||
|     return BatchData<InventoryItem>.singleSuccess(BatchMethod.update, item); | ||||
|   } | ||||
| 
 | ||||
|   Future<BatchData<InventoryItem>> updateInventoryItems(List<InventoryItem> items) async { | ||||
|     final BatchData<InventoryItem> batchData = BatchData<InventoryItem>.empty(); | ||||
|     return runInTransaction((txn) async { | ||||
|       for (var item in items) { | ||||
|         await txn.insert(InventoryTable.tbName, item.toJson(), | ||||
|             conflictAlgorithm: ConflictAlgorithm.replace); | ||||
|         batchData.update(item); | ||||
|       } | ||||
|       return batchData; | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,96 @@ | |||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| 
 | ||||
| part of 'inventory_database.dart'; | ||||
| 
 | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
| 
 | ||||
| LimitedBalance _$LimitedBalanceFromJson(Map<String, dynamic> json) => | ||||
|     LimitedBalance( | ||||
|       json['a'] as int? ?? 0, | ||||
|       json['e'] as int? ?? -1, | ||||
|       recycle: json['r'] as bool? ?? false, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$LimitedBalanceToJson(LimitedBalance instance) => | ||||
|     <String, dynamic>{ | ||||
|       'a': instance.amount, | ||||
|       'e': instance.expireAt, | ||||
|       'r': instance.recycle, | ||||
|     }; | ||||
| 
 | ||||
| TimeSensitiveData _$TimeSensitiveDataFromJson(Map<String, dynamic> json) => | ||||
|     TimeSensitiveData( | ||||
|       valid: (json['v'] as List<dynamic>?) | ||||
|               ?.map((e) => LimitedBalance.fromJson(e as Map<String, dynamic>)) | ||||
|               .toList() ?? | ||||
|           [], | ||||
|       expired: (json['e'] as List<dynamic>?) | ||||
|               ?.map((e) => LimitedBalance.fromJson(e as Map<String, dynamic>)) | ||||
|               .toList() ?? | ||||
|           [], | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$TimeSensitiveDataToJson(TimeSensitiveData instance) => | ||||
|     <String, dynamic>{ | ||||
|       'v': instance.valid, | ||||
|       'e': instance.expired, | ||||
|     }; | ||||
| 
 | ||||
| InventoryDetails _$InventoryDetailsFromJson(Map<String, dynamic> json) => | ||||
|     InventoryDetails._( | ||||
|       (json['a'] as Map<String, dynamic>?)?.map( | ||||
|             (k, e) => | ||||
|                 MapEntry($enumDecode(_$TransactionMethodEnumMap, k), e as int), | ||||
|           ) ?? | ||||
|           {}, | ||||
|       (json['c'] as Map<String, dynamic>?)?.map( | ||||
|             (k, e) => MapEntry(k, e as int), | ||||
|           ) ?? | ||||
|           {}, | ||||
|       json['d'] as Map<String, dynamic>, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$InventoryDetailsToJson(InventoryDetails instance) => | ||||
|     <String, dynamic>{ | ||||
|       'a': instance.acquired | ||||
|           .map((k, e) => MapEntry(_$TransactionMethodEnumMap[k]!, e)), | ||||
|       'c': instance.consumed, | ||||
|       'd': instance.data, | ||||
|     }; | ||||
| 
 | ||||
| const _$TransactionMethodEnumMap = { | ||||
|   TransactionMethod.iap: 'iap', | ||||
|   TransactionMethod.igc: 'igc', | ||||
|   TransactionMethod.reward: 'reward', | ||||
|   TransactionMethod.bonus: 'bonus', | ||||
|   TransactionMethod.igb: 'igb', | ||||
|   TransactionMethod.free: 'free', | ||||
|   TransactionMethod.migrate: 'migrate', | ||||
|   TransactionMethod.unknown: 'unknown', | ||||
| }; | ||||
| 
 | ||||
| InventoryItem _$InventoryItemFromJson(Map<String, dynamic> json) => | ||||
|     InventoryItem._( | ||||
|       json['sku'] as String, | ||||
|       json['balance'] as int, | ||||
|       json['cat'] as String, | ||||
|       json['attr'] as int, | ||||
|       inventoryDetailsStringConvert.fromJson(json['details'] as String), | ||||
|       timeSensitiveDataStringConvert.fromJson(json['tsv'] as String), | ||||
|       json['create_at'] as int, | ||||
|       json['update_at'] as int, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$InventoryItemToJson(InventoryItem instance) => | ||||
|     <String, dynamic>{ | ||||
|       'sku': instance.sku, | ||||
|       'balance': instance.balance, | ||||
|       'cat': instance.category, | ||||
|       'attr': instance.attr, | ||||
|       'tsv': timeSensitiveDataStringConvert.toJson(instance.timeSensitive), | ||||
|       'update_at': instance.updateAt, | ||||
|       'create_at': instance.createAt, | ||||
|       'details': inventoryDetailsStringConvert.toJson(instance.details), | ||||
|     }; | ||||
|  | @ -0,0 +1,193 @@ | |||
| import 'package:guru_app/analytics/guru_analytics.dart'; | ||||
| import 'package:guru_app/database/guru_db.dart'; | ||||
| import 'package:guru_app/financial/product/product_model.dart'; | ||||
| import 'package:guru_app/guru_app.dart'; | ||||
| import 'package:guru_app/inventory/db/inventory_database.dart'; | ||||
| import 'package:guru_utils/database/batch/batch_aware.dart'; | ||||
| import 'package:guru_utils/log/log.dart'; | ||||
| import 'package:guru_utils/manifest/manifest.dart'; | ||||
| 
 | ||||
| class InventoryCategory { | ||||
|   static const String prop = "prop"; // 道具类 | ||||
| } | ||||
| 
 | ||||
| class PropCategory { | ||||
|   // 增益类:提供临时或永久的能力提升,如速度增加、力量提升、生命值恢复等。 | ||||
|   static const String boosts = 'Boosts'; | ||||
| 
 | ||||
|   // 减益类:施加在对手或玩家身上,造成能力降低、速度减慢、伤害增加等负面效果。 | ||||
|   static const String debuffs = 'Debuffs'; | ||||
| 
 | ||||
|   // 治疗类:恢复生命值或状态,解除负面效果。 | ||||
|   static const String healing = 'Healing'; | ||||
| 
 | ||||
|   // 防御类:提供防护,减少受到的伤害或完全避免某些类型的伤害。 | ||||
|   static const String defensive = 'Defensive'; | ||||
| 
 | ||||
|   // 攻击类:用于对敌人造成伤害或施加减益效果,如武器、陷阱、魔法等。 | ||||
|   static const String offensive = 'Offensive'; | ||||
| 
 | ||||
|   // 辅助类:提供团队增益、增强队友能力、提供战术优势等非直接攻击手段的道具。 | ||||
|   static const String supportive = 'Supportive'; | ||||
| 
 | ||||
|   // 探索类:用于揭示地图、发现隐藏物品、解锁新区域或提供关键信息的道具。 | ||||
|   static const String exploratory = 'Exploratory'; | ||||
| 
 | ||||
|   // 交互类:与游戏世界中的其他元素或玩家进行交互的道具,如钥匙、开关、通信设备等。 | ||||
|   static const String interactive = 'Interactive'; | ||||
| 
 | ||||
|   // 资源类:用于制造、升级或交易的材料和货币类型道具。 | ||||
|   static const String resources = 'Resources'; | ||||
| 
 | ||||
|   // 限定类:只能使用一定次数的道具,使用后消失或需要充能。 | ||||
|   static const String limitedUse = 'LimitedUse'; | ||||
| } | ||||
| 
 | ||||
| mixin InventoryDelegate { | ||||
|   // 返回指定id的库存类别 | ||||
|   // 如 hint,zoom这些道具统一返回 | ||||
|   String getInventoryCategory(String id) { | ||||
|     return InventoryCategory.prop; | ||||
|   } | ||||
| 
 | ||||
|   Future<List<StockItem>> getMigrateStockItems() async { | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class StockItem { | ||||
|   final String sku; | ||||
|   final int amount; | ||||
|   final int attr; | ||||
|   final DateTime? expired; | ||||
| 
 | ||||
|   const StockItem.consumable(this.sku, this.amount, {this.expired}) : attr = DetailsAttr.consumable; | ||||
| 
 | ||||
|   const StockItem.permanent(this.sku, this.amount, {this.expired}) : attr = DetailsAttr.permanent; | ||||
| 
 | ||||
|   const StockItem.igc(this.amount, {this.sku = "igc"}) | ||||
|       : attr = DetailsAttr.consumable, | ||||
|         expired = null; | ||||
| 
 | ||||
|   StockItem.fromDetails(Details details) | ||||
|       : sku = details.sku, | ||||
|         attr = details.attr, | ||||
|         amount = details.amount, | ||||
|         expired = null; | ||||
| } | ||||
| 
 | ||||
| class InventoryManager with BatchAware<InventoryItem> { | ||||
|   static final InventoryManager instance = InventoryManager._(); | ||||
| 
 | ||||
|   InventoryManager._(); | ||||
| 
 | ||||
|   String getInventoryCategory(String id) { | ||||
|     return GuruApp.instance.protocol.inventoryDelegate?.getInventoryCategory(id) ?? | ||||
|         InventoryCategory.prop; | ||||
|   } | ||||
| 
 | ||||
|   Future init() async { | ||||
|     final batch = await GuruDB.instance.loadInventoryItems(); | ||||
|     processBatchData(batch); | ||||
|     await _migrate(); | ||||
|   } | ||||
| 
 | ||||
|   Future _migrate() async { | ||||
|     final migrateStockItems = | ||||
|         await GuruApp.instance.protocol.inventoryDelegate?.getMigrateStockItems() ?? []; | ||||
| 
 | ||||
|     if (migrateStockItems.isNotEmpty) { | ||||
|       final needMigrateItems = migrateStockItems.where((item) => !exists(item.sku)).toList(); | ||||
|       await acquire(needMigrateItems, TransactionMethod.migrate, "migrate"); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /// | ||||
|   /// 通过[method]中的的特定[specific]方式 获得了指定的 [items] | ||||
|   /// * method: iap -> specific: sku | ||||
|   /// * method: igc -> specific: coin/gems... | ||||
|   /// * method: reward -> specific: ads/lottery/daily/... | ||||
|   /// * method: bonus -> specific: ads/other/... | ||||
|   /// * method: igb -> specific: hint/hammer/swap/magic/.. | ||||
|   /// | ||||
|   /// method 最终会在 earnVirtualCurrency 中成为 item_category | ||||
|   /// specific 最终会在 earnVirtualCurrency 中成为 item_name | ||||
|   /// | ||||
|   Future acquire(List<StockItem> items, TransactionMethod method, String specific, | ||||
|       {String? scene}) async { | ||||
|     final acquired = <InventoryItem>[]; | ||||
|     for (var item in items) { | ||||
|       final category = getInventoryCategory(item.sku); | ||||
|       final invItem = getData(item.sku) | ||||
|               ?.acquire(method, item.amount, expiredAt: item.expired?.millisecondsSinceEpoch) ?? | ||||
|           InventoryItem.create(item.sku, category, item.attr, | ||||
|               balance: item.amount, | ||||
|               method: method, | ||||
|               expireAt: item.expired?.millisecondsSinceEpoch ?? -1); | ||||
|       acquired.add(invItem); | ||||
| 
 | ||||
|       GuruAnalytics.instance.logEarnVirtualCurrency( | ||||
|           virtualCurrencyName: item.sku, | ||||
|           method: convertTransactionMethodName(method), | ||||
|           specific: specific, | ||||
|           balance: invItem.balance, | ||||
|           value: item.amount, | ||||
|           scene: scene); | ||||
|     } | ||||
|     final batchData = await GuruDB.instance.updateInventoryItems(acquired); | ||||
|     processBatchData(batchData); | ||||
|   } | ||||
| 
 | ||||
|   ConsumeResult? _consumeItem(StockItem item, Manifest redeemed, {bool timeSensitiveOnly = false}) { | ||||
|     final inventoryItem = getData(item.sku); | ||||
|     return timeSensitiveOnly | ||||
|         ? inventoryItem?.consumeTimeSensitiveOnly(item.amount, | ||||
|             scene: redeemed.scene, transactionTs: redeemed.transactionTs) | ||||
|         : inventoryItem?.consume(item.amount, scene: redeemed.scene); | ||||
|   } | ||||
| 
 | ||||
|   /// 消耗指定的道具[items],如果可以成功消费,便可得到相应的[redeemed]清单 | ||||
|   Future<bool> consume(List<StockItem> items, Manifest redeemed, | ||||
|       {bool timeSensitiveOnly = false}) async { | ||||
|     final consumed = <InventoryItem>[]; | ||||
|     bool isConsumed = true; | ||||
|     for (var item in items) { | ||||
|       /// 这里只是针对对应的 SKU 商品进行 consume标记,真正的消耗在下面的updateInventoryItems中 | ||||
|       final result = _consumeItem(item, redeemed, timeSensitiveOnly: timeSensitiveOnly); | ||||
|       if (result == null) { | ||||
|         Log.e("consume failed! Not Found inventory item: ${item.sku}"); | ||||
|         return false; | ||||
|       } | ||||
|       consumed.add(result.item); | ||||
|       // 这里如果没有消耗掉,将跳出 | ||||
|       if (!result.consumed) { | ||||
|         Log.w("consume failed: ${item.sku} ${item.amount}"); | ||||
|         isConsumed = false; | ||||
|         break; | ||||
|       } | ||||
|     } | ||||
|     if (consumed.isEmpty) { | ||||
|       return false; | ||||
|     } else { | ||||
|       if (isConsumed && consumed.length == items.length) { | ||||
|         for (int i = 0; i < items.length; ++i) { | ||||
|           GuruAnalytics.instance.logSpendCredits( | ||||
|               redeemed.contentId, redeemed.category, items[i].amount, | ||||
|               virtualCurrencyName: consumed[i].sku, | ||||
|               balance: consumed[i].balance, | ||||
|               scene: redeemed.scene); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     /// 这里会更新消耗掉的道具 | ||||
|     final batchData = await GuruDB.instance.updateInventoryItems(consumed); | ||||
|     processBatchData(batchData); | ||||
|     return isConsumed; | ||||
|   } | ||||
| 
 | ||||
|   bool canAfford(String id, int amount) { | ||||
|     final item = getData(id); | ||||
|     return item != null && item.balance >= amount; | ||||
|   } | ||||
| } | ||||
|  | @ -3,10 +3,13 @@ import 'dart:convert'; | |||
| 
 | ||||
| import 'package:guru_app/account/model/account.dart'; | ||||
| import 'package:guru_app/account/model/account_profile.dart'; | ||||
| import 'package:guru_app/account/model/credential.dart'; | ||||
| import 'package:guru_app/account/model/user.dart'; | ||||
| import 'package:guru_app/analytics/abtest/abtest_model.dart'; | ||||
| import 'package:guru_app/api/data/orders/orders_model.dart'; | ||||
| import 'package:guru_app/guru_app.dart'; | ||||
| import 'package:guru_app/property/property_keys.dart'; | ||||
| import 'package:guru_utils/auth/auth_credential_manager.dart'; | ||||
| import 'package:guru_utils/property/property_model.dart'; | ||||
| import 'package:guru_utils/datetime/datetime_utils.dart'; | ||||
| import 'package:guru_utils/device/device_info.dart'; | ||||
|  |  | |||
|  | @ -2,25 +2,25 @@ | |||
| part of "../app_property.dart"; | ||||
| 
 | ||||
| extension AccountPropertyExtension on AppProperty { | ||||
|   void setAccountSaasUser(SaasUser saasUser) { | ||||
|     final data = jsonEncode(saasUser); | ||||
|     setString(PropertyKeys.accountSaasUser, data); | ||||
|   Future setAccountGuruUser(GuruUser guruUser) async { | ||||
|     final data = jsonEncode(guruUser); | ||||
|     await setString(PropertyKeys.accountGuruUser, data); | ||||
|   } | ||||
| 
 | ||||
|   void setAccountDevice(DeviceInfo deviceInfo) { | ||||
|   Future setAccountDevice(DeviceInfo deviceInfo) async { | ||||
|     final data = jsonEncode(deviceInfo); | ||||
|     setString(PropertyKeys.accountDevice, data); | ||||
|     await setString(PropertyKeys.accountDevice, data); | ||||
|   } | ||||
| 
 | ||||
|   void setAccountProfile(AccountProfile accountProfile) { | ||||
|   Future setAccountProfile(AccountProfile accountProfile) async { | ||||
|     final data = jsonEncode(accountProfile); | ||||
|     setString(PropertyKeys.accountProfile, data); | ||||
|     await setString(PropertyKeys.accountProfile, data); | ||||
|   } | ||||
| 
 | ||||
|   // refer updateLocalProfile | ||||
|   void setDirtyAccountProfile(AccountProfile accountProfile) { | ||||
|   Future setDirtyAccountProfile(AccountProfile accountProfile) async { | ||||
|     final data = jsonEncode(accountProfile.copyWith(dirty: true)); | ||||
|     setString(PropertyKeys.accountProfile, data); | ||||
|     await setString(PropertyKeys.accountProfile, data); | ||||
|   } | ||||
| 
 | ||||
|   Future<DeviceInfo?> getAccountDevice() async { | ||||
|  | @ -37,14 +37,14 @@ extension AccountPropertyExtension on AppProperty { | |||
|       Log.v("loadValuesByTag is empty, $error"); | ||||
|       return PropertyBundle.empty(); | ||||
|     }); | ||||
|     SaasUser? saasUser; | ||||
|     GuruUser? guruUser; | ||||
|     DeviceInfo? device; | ||||
|     AccountProfile? accountProfile; | ||||
| 
 | ||||
|     final saasUserString = accountBundle.getString(PropertyKeys.accountSaasUser); | ||||
|     final saasUserString = accountBundle.getString(PropertyKeys.accountGuruUser); | ||||
|     if (DartExt.isNotBlank(saasUserString)) { | ||||
|       final map = jsonDecode(saasUserString!); | ||||
|       saasUser = SaasUser.fromJson(map); | ||||
|       guruUser = GuruUser.fromJson(map); | ||||
|     } | ||||
| 
 | ||||
|     final accountDeviceString = accountBundle.getString(PropertyKeys.accountDevice); | ||||
|  | @ -59,7 +59,28 @@ extension AccountPropertyExtension on AppProperty { | |||
|       accountProfile = AccountProfile.fromJson(map); | ||||
|     } | ||||
| 
 | ||||
|     return Account.restore(saasUser: saasUser, device: device, accountProfile: accountProfile); | ||||
|     final Map<AuthType, Credential> credentials = {}; | ||||
|     for (final authType in AuthCredentialManager.instance.supportedAuthType) { | ||||
|       final credentialString = | ||||
|           accountBundle.getString(PropertyKeys.buildAuthCredentialKey(authType)); | ||||
|       if (DartExt.isNotBlank(credentialString)) { | ||||
|         final credential = | ||||
|             AuthCredentialManager.instance.deserializeCredential(authType, credentialString!); | ||||
|         if (credential != null) { | ||||
|           credentials[authType] = credential; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     final anonymousSecretKey = accountBundle.getString(PropertyKeys.anonymousSecretKey); | ||||
|     if (DartExt.isNotBlank(anonymousSecretKey)) { | ||||
|       credentials[AuthType.anonymous] = AnonymousCredential(anonymousSecretKey!); | ||||
|     } | ||||
| 
 | ||||
|     return Account.restore( | ||||
|         guruUser: guruUser, | ||||
|         device: device, | ||||
|         accountProfile: accountProfile, | ||||
|         credentials: credentials); | ||||
|   } | ||||
| 
 | ||||
|   Future<int> getLatestReportDeviceTimestamp() async { | ||||
|  | @ -78,4 +99,34 @@ extension AccountPropertyExtension on AppProperty { | |||
|     } | ||||
|     return secret; | ||||
|   } | ||||
| 
 | ||||
|   Future clearAnonymousSecretKey() async { | ||||
|     await remove(PropertyKeys.anonymousSecretKey); | ||||
|   } | ||||
| 
 | ||||
|   Future saveCredential(Credential credential) async { | ||||
|     final data = jsonEncode(credential); | ||||
|     await setString(PropertyKeys.buildAuthCredentialKey(credential.authType), data); | ||||
|     final historicalSocialAuths = await getHistoricalSocialAuths(); | ||||
|     final authName = getAuthName(credential.authType); | ||||
|     if (!historicalSocialAuths.contains(authName)) { | ||||
|       historicalSocialAuths.add(authName); | ||||
|       setHistoricalSocialAuths(historicalSocialAuths); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future deleteCredential(AuthType authType) async { | ||||
|     await remove(PropertyKeys.buildAuthCredentialKey(authType)); | ||||
|   } | ||||
| 
 | ||||
|   Future<Set<String>> getHistoricalSocialAuths() async { | ||||
|     final data = await getString(PropertyKeys.historicalSocialAuths, defValue: ""); | ||||
|     return data.isNotEmpty ? (data.split("|").toSet()..remove("")) : {}; | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> setHistoricalSocialAuths(Set<String> historicalSocialAuths) async { | ||||
|     historicalSocialAuths.remove(""); | ||||
|     await setString(PropertyKeys.historicalSocialAuths, historicalSocialAuths.join("|")); | ||||
|     return true; | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -38,4 +38,73 @@ extension AnalyticsPropertyExtension on AppProperty { | |||
|       await setString(PropertyKeys.analyticsIdfa, idfa); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future<Map<String, ABTestExperiment>> loadRunningExperiments() async { | ||||
|     final bundle = await AppProperty.getInstance().loadValuesByTag(PropertyTags.guruExperiment); | ||||
|     final result = <String, ABTestExperiment>{}; | ||||
|     bundle.forEach((key, value) { | ||||
|       try { | ||||
|         if (value.isNotEmpty) { | ||||
|           final map = json.decode(value); | ||||
|           final experiment = ABTestExperiment.fromJson(map); | ||||
|           result[key.name] = experiment; | ||||
|           Log.d("loadRunningExperiments: ${key.name} => $experiment"); | ||||
|         } | ||||
|       } catch (error, stacktrace) { | ||||
|         Log.w("getExperiment error! $error"); | ||||
|       } | ||||
|     }); | ||||
|     return result; | ||||
|   } | ||||
| 
 | ||||
|   Future<ABTestExperiment?> getExperiment(String experimentName, {PropertyBundle? bundle}) async { | ||||
|     final experimentKey = PropertyKeys.buildExperimentProperty(experimentName); | ||||
|     final result = bundle?.getString(experimentKey) ?? await getString(experimentKey, defValue: ""); | ||||
|     try { | ||||
|       if (result.isNotEmpty) { | ||||
|         final map = json.decode(result); | ||||
|         return ABTestExperiment.fromJson(map); | ||||
|       } | ||||
|     } catch (error, stacktrace) { | ||||
|       Log.w("getExperiment error! $error"); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   Future<String> getExperimentVariant(String experimentName) async { | ||||
|     final variantKey = | ||||
|         PropertyKeys.buildABTestProperty(GuruAnalytics.buildVariantKey(experimentName)); | ||||
|     return await getString(variantKey, defValue: ""); | ||||
|   } | ||||
| 
 | ||||
|   Future<String> setExperiment(ABTestExperiment experiment) async { | ||||
|     PropertyBundle propertyBundle = PropertyBundle(); | ||||
| 
 | ||||
|     final variantKey = | ||||
|         PropertyKeys.buildABTestProperty(GuruAnalytics.buildVariantKey(experiment.name)); | ||||
|     final variantName = experiment.variantName; | ||||
|     propertyBundle.setString(variantKey, variantName); | ||||
| 
 | ||||
|     final experimentKey = PropertyKeys.buildExperimentProperty(experiment.name); | ||||
|     propertyBundle.setString(experimentKey, json.encode(experiment)); | ||||
|     await setProperties(propertyBundle); | ||||
|     return variantName; | ||||
|   } | ||||
| 
 | ||||
|   removeExperiment(String experimentName) async { | ||||
|     final experimentKey = PropertyKeys.buildExperimentProperty(experimentName); | ||||
|     await remove(experimentKey); | ||||
|     final variantKey = PropertyKeys.buildABTestProperty(experimentName); | ||||
|     await remove(variantKey); | ||||
|   } | ||||
| 
 | ||||
|   Future<bool> refreshGoogleDma(String googleDma) async { | ||||
|     final oldGoogleDma = | ||||
|         await AppProperty.getInstance().getString(PropertyKeys.googleDma, defValue: ""); | ||||
|     if (googleDma != oldGoogleDma) { | ||||
|       await AppProperty.getInstance().setString(PropertyKeys.googleDma, googleDma); | ||||
|       return true; | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -3,8 +3,8 @@ part of "../app_property.dart"; | |||
| /// Created by @Haoyi on 5/14/21 | ||||
| 
 | ||||
| extension DefaultPropertyExtension on AppProperty { | ||||
|   Future<String> getDeviceId() async { | ||||
|     return getOrCreateString(PropertyKeys.deviceId, IdUtils.uuidV4()); | ||||
|   Future<String> getDeviceId({String? forceDeviceId}) async { | ||||
|     return getOrCreateString(PropertyKeys.deviceId, forceDeviceId ?? IdUtils.uuidV4()); | ||||
|   } | ||||
| 
 | ||||
|   Future<int> getFirstInstallTime() async { | ||||
|  |  | |||
|  | @ -18,4 +18,14 @@ extension IapPropertyExtension on AppProperty { | |||
|   Future<void> removeReportSuccessOrder(PropertyKey key) async { | ||||
|     remove(key); | ||||
|   } | ||||
| 
 | ||||
|   Future<int> increaseGraceCount() async { | ||||
|     final count = await getInt(PropertyKeys.subscriptionGraceCount, defValue: 0); | ||||
|     await setInt(PropertyKeys.subscriptionGraceCount, count + 1); | ||||
|     return count + 1; | ||||
|   } | ||||
| 
 | ||||
|   Future<void> resetGraceCount() async { | ||||
|     await setInt(PropertyKeys.subscriptionGraceCount, 0); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| import 'package:guru_app/guru_app.dart'; | ||||
| import 'package:guru_app/property/app_property.dart'; | ||||
| import 'package:guru_utils/auth/auth_credential_manager.dart'; | ||||
| import 'package:guru_utils/id/id_utils.dart'; | ||||
| import 'package:guru_utils/property/property_model.dart'; | ||||
| import 'package:guru_utils/settings/settings.dart'; | ||||
|  | @ -21,9 +22,12 @@ class PropertyKeys { | |||
|   static const PropertyKey debugMode = UtilsSettingsKeys.debugMode; | ||||
|   static const PropertyKey latestLtDate = UtilsSettingsKeys.latestLtDate; | ||||
|   static const PropertyKey ltDays = UtilsSettingsKeys.ltDays; | ||||
|   static const PropertyKey keepOnScreenDuration = UtilsSettingsKeys.keepOnScreenDuration; | ||||
| 
 | ||||
|   static const PropertyKey accountSaasUser = | ||||
|   static const PropertyKey accountGuruUser = | ||||
|       PropertyKey.general("account_saas_user", tag: PropertyTags.account); | ||||
|   @Deprecated("use accountGuruUser instead") | ||||
|   static const PropertyKey accountSaasUser = accountGuruUser; | ||||
|   static const PropertyKey accountDevice = | ||||
|       PropertyKey.general("account_device", tag: PropertyTags.account); | ||||
|   static const PropertyKey accountProfile = | ||||
|  | @ -33,11 +37,18 @@ class PropertyKeys { | |||
|   static const PropertyKey anonymousSecretKey = | ||||
|       PropertyKey.general("anonymous_secret_key", tag: PropertyTags.account); | ||||
| 
 | ||||
|   static const PropertyKey historicalSocialAuths = | ||||
|       PropertyKey.general("historical_social_auths", tag: PropertyTags.account); | ||||
| 
 | ||||
|   static const PropertyKey isNoAds = PropertyKey.setting("no_ads", tag: PropertyTags.ads); | ||||
| 
 | ||||
|   static const PropertyKey totalRevenue = | ||||
|       PropertyKey.general("total_revenue", tag: PropertyTags.financial); | ||||
| 
 | ||||
|   /// 020的 revenue | ||||
|   static const PropertyKey totalRevenue020 = | ||||
|       PropertyKey.general("total_revenue_020", tag: PropertyTags.financial); | ||||
| 
 | ||||
|   static const PropertyKey userRewardedCount = | ||||
|       PropertyKey.general("user_rewarded_count", tag: PropertyTags.ads); | ||||
| 
 | ||||
|  | @ -49,6 +60,8 @@ class PropertyKeys { | |||
|   static const PropertyKey iapIgc = PropertyKey.general("iap_igc", tag: PropertyTags.iap); | ||||
|   static const PropertyKey noIapIgc = PropertyKey.general("no_iap_igc", tag: PropertyTags.iap); | ||||
| 
 | ||||
|   static const PropertyKey subscriptionGraceCount = | ||||
|       PropertyKey.general("subscription_grace_count", tag: PropertyTags.iap); | ||||
|   static const PropertyKey admobConsentTestDeviceId = | ||||
|       PropertyKey.general("admob_consent_test_device_id", tag: PropertyTags.ads); | ||||
|   static const PropertyKey admobConsentDebugGeography = | ||||
|  | @ -97,6 +110,9 @@ class PropertyKeys { | |||
|   static const PropertyKey analyticsIdfa = | ||||
|       PropertyKey.general("analytics_idfa", tag: PropertyTags.analytics); | ||||
| 
 | ||||
|   static const PropertyKey googleDma = | ||||
|       PropertyKey.general("google_dma_result", tag: PropertyTags.analytics); | ||||
| 
 | ||||
|   static const PropertyKey currentIgcBalance = | ||||
|       PropertyKey.general("current_balance", tag: PropertyTags.igc); | ||||
|   static const PropertyKey currentIgcBalanceValidation = | ||||
|  | @ -106,6 +122,10 @@ class PropertyKeys { | |||
|     return PropertyKey.general("abtest_$key", tag: PropertyTags.guruAB); | ||||
|   } | ||||
| 
 | ||||
|   static PropertyKey buildExperimentProperty(String key) { | ||||
|     return PropertyKey.general("exp_$key", tag: PropertyTags.guruExperiment); | ||||
|   } | ||||
| 
 | ||||
|   static PropertyKey requestNotificationPermissionTimes = | ||||
|       const PropertyKey.general("request_notification_permission_times"); | ||||
| 
 | ||||
|  | @ -114,4 +134,9 @@ class PropertyKeys { | |||
| 
 | ||||
|   static PropertyKey deniedNotificationPermissionTimes = | ||||
|       const PropertyKey.general("denied_notification_permission_times"); | ||||
| 
 | ||||
|   static PropertyKey buildAuthCredentialKey(AuthType authType) { | ||||
|     return PropertyKey.general("${getAuthName(authType)}_auth_credential", | ||||
|         tag: PropertyTags.account); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -11,6 +11,7 @@ class PropertyTags { | |||
|   static const String failedOrders = "failed_orders"; | ||||
|   static const String strategyAds = "StrategyAds"; | ||||
|   static const String guruAB = "GuruAB"; | ||||
|   static const String guruExperiment = "guru_experiment"; | ||||
| 
 | ||||
|   static const String iap = UtilsPropertyTags.iap; | ||||
|   static const String ads = UtilsPropertyTags.ads; | ||||
|  |  | |||
|  | @ -6,4 +6,4 @@ mixin GlobalSettings { | |||
|   final SettingBoolData isNoAds = SettingBoolData(PropertyKeys.isNoAds, defaultValue: false); | ||||
| 
 | ||||
|   final SettingIntData bestLevel = SettingIntData(PropertyKeys.bestLevel, defaultValue: 1); | ||||
| } | ||||
| } | ||||
|  | @ -1,5 +1,6 @@ | |||
| import 'dart:math'; | ||||
| 
 | ||||
| import 'package:guru_app/analytics/abtest/abtest_model.dart'; | ||||
| import 'package:guru_app/financial/data/db/order_database.dart'; | ||||
| import 'package:guru_app/financial/manifest/manifest.dart'; | ||||
| import 'package:guru_app/guru_app.dart'; | ||||
|  | @ -11,6 +12,4 @@ part 'test_guru_app_creator.g.dart'; | |||
| @guruSpecCreator | ||||
| AppSpec createSampleAppSpec(String flavor) { | ||||
|   return _GuruSpecFactory.create(flavor); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -24,6 +24,8 @@ class _Guru_testRemoteConfigConstants { | |||
|   }; | ||||
| 
 | ||||
|   static String getDefaultConfigString(String key) => _defaultConfigs[key]; | ||||
| 
 | ||||
|   static String getKey(String key) => key; | ||||
| } | ||||
| 
 | ||||
| class _Guru_testAppSpec extends AppSpec { | ||||
|  | @ -34,6 +36,9 @@ class _Guru_testAppSpec extends AppSpec { | |||
|   @override | ||||
|   final appName = 'GuruApp'; | ||||
| 
 | ||||
|   @override | ||||
|   final appCategory = AppCategory.app; | ||||
| 
 | ||||
|   @override | ||||
|   final flavor = 'guru_test'; | ||||
| 
 | ||||
|  | @ -95,6 +100,9 @@ class _Guru_testAppSpec extends AppSpec { | |||
|     trackingNotificationPermissionPassLimitTimes: 10, | ||||
|     allowInterstitialAsAlternativeReward: false, | ||||
|     showInternalAdsWhenBannerUnavailable: true, | ||||
|     subscriptionRestoreGraceCount: 3, | ||||
|     fullscreenAdsMinInterval: 60, | ||||
|     enabledSyncAccountProfile: false, | ||||
|   ); | ||||
| 
 | ||||
|   @override | ||||
|  | @ -158,6 +166,13 @@ class _Guru_testAppSpec extends AppSpec { | |||
| 
 | ||||
|   @override | ||||
|   final defaultRemoteConfig = _Guru_testRemoteConfigConstants._defaultConfigs; | ||||
| 
 | ||||
|   @override | ||||
|   final localABTestExperiments = _GuruTestABTestExperiments.experiments; | ||||
| 
 | ||||
|   @override | ||||
|   String getRemoteConfigKey(String key) => | ||||
|       _Guru_testRemoteConfigConstants.getKey(key); | ||||
| } | ||||
| 
 | ||||
| class _Guru_testProducts { | ||||
|  | @ -165,8 +180,6 @@ class _Guru_testProducts { | |||
| 
 | ||||
|   static final propRegExp = RegExp(r"^theme_(.*)_(.*)$"); | ||||
| 
 | ||||
|   static final themeMulRegExp = RegExp(r"^theme_(.*)_(.*)$"); | ||||
| 
 | ||||
|   static const noAds = ProductId( | ||||
|       android: 'so.a.iap.noads.699', | ||||
|       ios: 'so.i.iap.noads.699', | ||||
|  | @ -314,8 +327,7 @@ class _Guru_testProducts { | |||
|     buildCoin200Manifest, | ||||
|     buildStagePackManifest, | ||||
|     buildPremiumWeekManifest, | ||||
|     buildPremiumYearManifest, | ||||
|     buildThemeMulManifest | ||||
|     buildPremiumYearManifest | ||||
|   ]; | ||||
| 
 | ||||
|   static Future<Manifest?> buildNoAdsManifest(TransactionIntent intent) async { | ||||
|  | @ -323,6 +335,7 @@ class _Guru_testProducts { | |||
|       return null; | ||||
|     } | ||||
|     final extras = <String, dynamic>{ | ||||
|       ExtraReservedField.contentId: intent.productId.sku, | ||||
|       ExtraReservedField.scene: intent.scene, | ||||
|       ExtraReservedField.rate: intent.rate, | ||||
|       ExtraReservedField.sales: intent.sales, | ||||
|  | @ -344,17 +357,21 @@ class _Guru_testProducts { | |||
|       return null; | ||||
|     } | ||||
|     final extras = <String, dynamic>{ | ||||
|       ExtraReservedField.contentId: intent.productId.sku, | ||||
|       ExtraReservedField.scene: intent.scene, | ||||
|       ExtraReservedField.rate: intent.rate, | ||||
|       ExtraReservedField.sales: intent.sales, | ||||
|     }; | ||||
|     final details = <Details>[]; | ||||
|     details.add(Details.define( | ||||
|         'igc', intent.sales ? max(500, (intent.rate * 500).toInt()) : 500)); | ||||
|         'igc', intent.sales ? max(500, (intent.rate * 500).toInt()) : 500, | ||||
|         sku: 'igc')); | ||||
|     details.add(Details.define( | ||||
|         'cup', intent.sales ? max(1, (intent.rate * 1).toInt()) : 1)); | ||||
|         'cup', intent.sales ? max(1, (intent.rate * 1).toInt()) : 1, | ||||
|         sku: 'cup')); | ||||
|     details.add(Details.define( | ||||
|         'frag', intent.sales ? max(20, (intent.rate * 20).toInt()) : 20)); | ||||
|         'frag', intent.sales ? max(20, (intent.rate * 20).toInt()) : 20, | ||||
|         sku: 'frag')); | ||||
|     return Manifest('no_ads', extras: extras, details: details); | ||||
|   } | ||||
| 
 | ||||
|  | @ -379,6 +396,7 @@ class _Guru_testProducts { | |||
|       return null; | ||||
|     } | ||||
|     final extras = <String, dynamic>{ | ||||
|       ExtraReservedField.contentId: intent.productId.sku, | ||||
|       ExtraReservedField.scene: intent.scene, | ||||
|       ExtraReservedField.rate: intent.rate, | ||||
|       ExtraReservedField.sales: intent.sales, | ||||
|  | @ -413,17 +431,20 @@ class _Guru_testProducts { | |||
|       return null; | ||||
|     } | ||||
|     final extras = <String, dynamic>{ | ||||
|       ExtraReservedField.contentId: intent.productId.sku, | ||||
|       ExtraReservedField.scene: intent.scene, | ||||
|       ExtraReservedField.rate: intent.rate, | ||||
|       ExtraReservedField.sales: intent.sales, | ||||
|     }; | ||||
|     final details = <Details>[]; | ||||
|     details.add(Details.define( | ||||
|         'prop', intent.sales ? max(1, (intent.rate * 1).toInt()) : 1) | ||||
|       ..setString('prop_id', matches.first.group(1)!)); | ||||
|         'prop', intent.sales ? max(1, (intent.rate * 1).toInt()) : 1, | ||||
|         sku: '${matches.first.group(1)!}_${matches.first.group(2)!}') | ||||
|       ..setString('theme_id', matches.first.group(1)!)); | ||||
|     details.add(Details.define( | ||||
|         'pc', intent.sales ? max(1, (intent.rate * 1).toInt()) : 1) | ||||
|       ..setString('prop_id', matches.first.group(2)!)); | ||||
|         'pc', intent.sales ? max(1, (intent.rate * 1).toInt()) : 1, | ||||
|         sku: 'pc') | ||||
|       ..setString('theme_id', matches.first.group(2)!)); | ||||
|     return Manifest('prop', extras: extras, details: details); | ||||
|   } | ||||
| 
 | ||||
|  | @ -444,12 +465,13 @@ class _Guru_testProducts { | |||
|       return null; | ||||
|     } | ||||
|     final extras = <String, dynamic>{ | ||||
|       ExtraReservedField.contentId: intent.productId.sku, | ||||
|       ExtraReservedField.scene: intent.scene, | ||||
|       ExtraReservedField.rate: intent.rate, | ||||
|       ExtraReservedField.sales: intent.sales, | ||||
|     }; | ||||
|     final details = <Details>[]; | ||||
|     details.add(Details.define('no_ads', 1)); | ||||
|     details.add(Details.define('no_ads', 1, sku: 'no_ads')); | ||||
|     return Manifest('no_ads', extras: extras, details: details); | ||||
|   } | ||||
| 
 | ||||
|  | @ -467,13 +489,15 @@ class _Guru_testProducts { | |||
|       return null; | ||||
|     } | ||||
|     final extras = <String, dynamic>{ | ||||
|       ExtraReservedField.contentId: intent.productId.sku, | ||||
|       ExtraReservedField.scene: intent.scene, | ||||
|       ExtraReservedField.rate: intent.rate, | ||||
|       ExtraReservedField.sales: intent.sales, | ||||
|     }; | ||||
|     final details = <Details>[]; | ||||
|     details.add(Details.define( | ||||
|         'coin', intent.sales ? max(200, (intent.rate * 200).toInt()) : 200)); | ||||
|         'coin', intent.sales ? max(200, (intent.rate * 200).toInt()) : 200, | ||||
|         sku: 'coin')); | ||||
|     return Manifest('coin', extras: extras, details: details); | ||||
|   } | ||||
| 
 | ||||
|  | @ -483,13 +507,15 @@ class _Guru_testProducts { | |||
|       return null; | ||||
|     } | ||||
|     final extras = <String, dynamic>{ | ||||
|       ExtraReservedField.contentId: intent.productId.sku, | ||||
|       ExtraReservedField.scene: intent.scene, | ||||
|       ExtraReservedField.rate: intent.rate, | ||||
|       ExtraReservedField.sales: intent.sales, | ||||
|     }; | ||||
|     final details = <Details>[]; | ||||
|     details.add(Details.define( | ||||
|         'stage', intent.sales ? max(1, (intent.rate * 1).toInt()) : 1) | ||||
|         'stage', intent.sales ? max(1, (intent.rate * 1).toInt()) : 1, | ||||
|         sku: 'stage') | ||||
|       ..setInt('stage', 1)); | ||||
|     return Manifest('stage_1', extras: extras, details: details); | ||||
|   } | ||||
|  | @ -500,6 +526,7 @@ class _Guru_testProducts { | |||
|       return null; | ||||
|     } | ||||
|     final extras = <String, dynamic>{ | ||||
|       ExtraReservedField.contentId: intent.productId.sku, | ||||
|       ExtraReservedField.scene: intent.scene, | ||||
|       ExtraReservedField.rate: intent.rate, | ||||
|       ExtraReservedField.sales: intent.sales, | ||||
|  | @ -510,7 +537,8 @@ class _Guru_testProducts { | |||
|     } | ||||
|     final details = <Details>[]; | ||||
|     details.add(Details.define( | ||||
|         'igc', intent.sales ? max(8000, (intent.rate * 8000).toInt()) : 8000)); | ||||
|         'igc', intent.sales ? max(8000, (intent.rate * 8000).toInt()) : 8000, | ||||
|         sku: 'igc')); | ||||
|     return Manifest('sub', extras: extras, details: details); | ||||
|   } | ||||
| 
 | ||||
|  | @ -520,6 +548,7 @@ class _Guru_testProducts { | |||
|       return null; | ||||
|     } | ||||
|     final extras = <String, dynamic>{ | ||||
|       ExtraReservedField.contentId: intent.productId.sku, | ||||
|       ExtraReservedField.scene: intent.scene, | ||||
|       ExtraReservedField.rate: intent.rate, | ||||
|       ExtraReservedField.sales: intent.sales, | ||||
|  | @ -529,51 +558,54 @@ class _Guru_testProducts { | |||
|       extras[ExtraReservedField.offerId] = intent.productId.offerId; | ||||
|     } | ||||
|     final details = <Details>[]; | ||||
|     details.add(Details.define('igc', | ||||
|         intent.sales ? max(16000, (intent.rate * 16000).toInt()) : 16000)); | ||||
|     details.add(Details.define( | ||||
|         'igc', intent.sales ? max(16000, (intent.rate * 16000).toInt()) : 16000, | ||||
|         sku: 'igc')); | ||||
|     return Manifest('sub', extras: extras, details: details); | ||||
|   } | ||||
| 
 | ||||
|   static ProductId themeMul( | ||||
|     String category, | ||||
|     String themeId, | ||||
|   ) => | ||||
|       GuruApp.instance.defineProductId('theme_${category}_${themeId}', | ||||
|           TransactionAttributes.possessive, TransactionMethod.igc); | ||||
| 
 | ||||
|   static Future<Manifest?> buildThemeMulManifest( | ||||
|       TransactionIntent intent) async { | ||||
|     final matches = themeMulRegExp.allMatches(intent.productId.sku); | ||||
|     if (matches.isEmpty) { | ||||
|       return null; | ||||
|     } | ||||
|     final extras = <String, dynamic>{ | ||||
|       ExtraReservedField.scene: intent.scene, | ||||
|       ExtraReservedField.rate: intent.rate, | ||||
|       ExtraReservedField.sales: intent.sales, | ||||
|       'theme_id': matches.first.group(2)!, | ||||
|       'cate': matches.first.group(1)!, | ||||
|     }; | ||||
|     return Manifest('${matches.first.group(1)!}', extras: extras); | ||||
|   } | ||||
| 
 | ||||
|   static bool isOwnThemeMul( | ||||
|     OrderEntity entity, | ||||
|     String category, | ||||
|     String themeId, | ||||
|   ) { | ||||
|     if (entity.state == TransactionState.success && | ||||
|         entity.category == 'theme_mul') { | ||||
|       final match = themeMulRegExp.firstMatch(entity.sku); | ||||
|       return match?.group(1) == category && match?.group(2) == themeId; | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   static Set<ProductId> get iapIds => | ||||
|       {...oneOffChargeIapIds, ...subscriptionsIapIds}; | ||||
| } | ||||
| 
 | ||||
| class _GuruTestABTestExperiments { | ||||
|   static final test = ABTestExperiment( | ||||
|       name: 'test', | ||||
|       startTs: 1706457600000, // 2024-01-29 00:00:00.000 | ||||
|       endTs: 1706457600000, // 2024-01-29 00:00:00.000 | ||||
|       audience: ABTestAudience(filters: [ | ||||
|         VersionFilter.lessThan('2.3.0'), | ||||
|         CountryFilter.excluded({'us', 'cn', 'en'}), | ||||
|         PlatformFilter( | ||||
|           androidCondition: AndroidCondition( | ||||
|               opt: ConditionOpt.greaterThanOrEquals, sdkInt: 33), | ||||
|           iosCondition: | ||||
|               IosCondition(opt: ConditionOpt.greaterThanOrEquals, version: 14), | ||||
|         ), | ||||
|       ], variant: 2)); | ||||
| 
 | ||||
|   static final test2 = ABTestExperiment( | ||||
|       name: 'test2', | ||||
|       startTs: 1706457600000, // 2024-01-29 00:00:00.000 | ||||
|       endTs: 1706457600000, // 2024-01-29 00:00:00.000 | ||||
|       audience: ABTestAudience(filters: [ | ||||
|         VersionFilter.lessThan('2.3.0'), | ||||
|         CountryFilter.included({'cn'}), | ||||
|         PlatformFilter( | ||||
|           androidCondition: | ||||
|               AndroidCondition(opt: ConditionOpt.lessThan, sdkInt: 24), | ||||
|           iosCondition: | ||||
|               IosCondition(opt: ConditionOpt.greaterThanOrEquals, version: 14), | ||||
|         ), | ||||
|         NewUserFilter(), | ||||
|       ], variant: 5)); | ||||
| 
 | ||||
|   static final experiments = <String, ABTestExperiment>{ | ||||
|     'test': test, | ||||
|     'test2': test2, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| class _SpiderRemoteConfigConstants { | ||||
|   static const iadsConfig = 'iads_config'; | ||||
| 
 | ||||
|  | @ -589,6 +621,8 @@ class _SpiderRemoteConfigConstants { | |||
|   }; | ||||
| 
 | ||||
|   static String getDefaultConfigString(String key) => _defaultConfigs[key]; | ||||
| 
 | ||||
|   static String getKey(String key) => key; | ||||
| } | ||||
| 
 | ||||
| class _SpiderAppSpec extends AppSpec { | ||||
|  | @ -599,6 +633,9 @@ class _SpiderAppSpec extends AppSpec { | |||
|   @override | ||||
|   final appName = 'Spider'; | ||||
| 
 | ||||
|   @override | ||||
|   final appCategory = AppCategory.game; | ||||
| 
 | ||||
|   @override | ||||
|   final flavor = 'Spider'; | ||||
| 
 | ||||
|  | @ -680,6 +717,13 @@ class _SpiderAppSpec extends AppSpec { | |||
| 
 | ||||
|   @override | ||||
|   final defaultRemoteConfig = _SpiderRemoteConfigConstants._defaultConfigs; | ||||
| 
 | ||||
|   @override | ||||
|   final localABTestExperiments = _SpiderABTestExperiments.experiments; | ||||
| 
 | ||||
|   @override | ||||
|   String getRemoteConfigKey(String key) => | ||||
|       _SpiderRemoteConfigConstants.getKey(key); | ||||
| } | ||||
| 
 | ||||
| class _SpiderProducts { | ||||
|  | @ -726,6 +770,7 @@ class _SpiderProducts { | |||
|       return null; | ||||
|     } | ||||
|     final extras = <String, dynamic>{ | ||||
|       ExtraReservedField.contentId: intent.productId.sku, | ||||
|       ExtraReservedField.scene: intent.scene, | ||||
|       ExtraReservedField.rate: intent.rate, | ||||
|       ExtraReservedField.sales: intent.sales, | ||||
|  | @ -733,7 +778,8 @@ class _SpiderProducts { | |||
|     }; | ||||
|     final details = <Details>[]; | ||||
|     details.add(Details.define( | ||||
|         'theme', intent.sales ? max(1, (intent.rate * 1).toInt()) : 1)); | ||||
|         'theme', intent.sales ? max(1, (intent.rate * 1).toInt()) : 1, | ||||
|         sku: 'theme')); | ||||
|     return Manifest('${matches.first.group(1)!}', | ||||
|         extras: extras, details: details); | ||||
|   } | ||||
|  | @ -754,6 +800,10 @@ class _SpiderProducts { | |||
|       {...oneOffChargeIapIds, ...subscriptionsIapIds}; | ||||
| } | ||||
| 
 | ||||
| class _SpiderABTestExperiments { | ||||
|   static final experiments = <String, ABTestExperiment>{}; | ||||
| } | ||||
| 
 | ||||
| class RemoteConfigConstants { | ||||
|   static const iadsConfig = 'iads_config'; | ||||
| 
 | ||||
|  | @ -764,6 +814,18 @@ class RemoteConfigConstants { | |||
|   static const analyticsConfig = 'analytics_config'; | ||||
| } | ||||
| 
 | ||||
| class ABTestExperimentConstants { | ||||
|   static Map<String, ABTestExperiment> get experiments { | ||||
|     if (GuruApp.instance.flavor == 'guru_test') { | ||||
|       return _GuruTestABTestExperiments.experiments; | ||||
|     } | ||||
|     if (GuruApp.instance.flavor == 'Spider') { | ||||
|       return _SpiderABTestExperiments.experiments; | ||||
|     } | ||||
|     return {}; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class ProductIds { | ||||
|   static ProductId get noAds { | ||||
|     if (GuruApp.instance.flavor == 'guru_test') { | ||||
|  | @ -872,16 +934,6 @@ class ProductIds { | |||
|     return ProductId.invalid; | ||||
|   } | ||||
| 
 | ||||
|   static ProductId themeMul( | ||||
|     String category, | ||||
|     String themeId, | ||||
|   ) { | ||||
|     if (GuruApp.instance.flavor == 'guru_test') { | ||||
|       return _Guru_testProducts.themeMul(category, themeId); | ||||
|     } | ||||
|     return ProductId.invalid; | ||||
|   } | ||||
| 
 | ||||
|   static Set<ProductId> get noAdsCapIds { | ||||
|     if (GuruApp.instance.flavor == 'guru_test') { | ||||
|       return _Guru_testProducts.noAdsCapIds; | ||||
|  | @ -921,10 +973,6 @@ class ProductCategory { | |||
|   static theme(String themeId) { | ||||
|     "theme_${themeId}"; | ||||
|   } | ||||
| 
 | ||||
|   static themeMul(String category) { | ||||
|     "${category}"; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class _GuruSpecFactory { | ||||
|  |  | |||
|  | @ -0,0 +1,29 @@ | |||
| # Miscellaneous | ||||
| *.class | ||||
| *.log | ||||
| *.pyc | ||||
| *.swp | ||||
| .DS_Store | ||||
| .atom/ | ||||
| .buildlog/ | ||||
| .history | ||||
| .svn/ | ||||
| migrate_working_dir/ | ||||
| 
 | ||||
| # IntelliJ related | ||||
| *.iml | ||||
| *.ipr | ||||
| *.iws | ||||
| .idea/ | ||||
| 
 | ||||
| # The .vscode folder contains launch configuration and tasks you configure in | ||||
| # VS Code which you may wish to be included in version control, so this line | ||||
| # is commented out by default. | ||||
| #.vscode/ | ||||
| 
 | ||||
| # Flutter/Dart/Pub related | ||||
| # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. | ||||
| /pubspec.lock | ||||
| **/doc/api/ | ||||
| .dart_tool/ | ||||
| build/ | ||||
|  | @ -0,0 +1,3 @@ | |||
| ## 0.0.1 | ||||
| 
 | ||||
| * TODO: Describe initial release. | ||||
|  | @ -0,0 +1 @@ | |||
| TODO: Add your license here. | ||||
|  | @ -0,0 +1,39 @@ | |||
| <!-- | ||||
| This README describes the package. If you publish this package to pub.dev, | ||||
| this README's contents appear on the landing page for your package. | ||||
| 
 | ||||
| For information about how to write a good package README, see the guide for | ||||
| [writing package pages](https://dart.dev/guides/libraries/writing-package-pages). | ||||
| 
 | ||||
| For general information about developing packages, see the Dart guide for | ||||
| [creating packages](https://dart.dev/guides/libraries/create-library-packages) | ||||
| and the Flutter guide for | ||||
| [developing packages and plugins](https://flutter.dev/developing-packages). | ||||
| --> | ||||
| 
 | ||||
| TODO: Put a short description of the package here that helps potential users | ||||
| know whether this package might be useful for them. | ||||
| 
 | ||||
| ## Features | ||||
| 
 | ||||
| TODO: List what your package can do. Maybe include images, gifs, or videos. | ||||
| 
 | ||||
| ## Getting started | ||||
| 
 | ||||
| TODO: List prerequisites and provide or point to information on how to | ||||
| start using the package. | ||||
| 
 | ||||
| ## Usage | ||||
| 
 | ||||
| TODO: Include short and useful examples for package users. Add longer examples | ||||
| to `/example` folder. | ||||
| 
 | ||||
| ```dart | ||||
| const like = 'sample'; | ||||
| ``` | ||||
| 
 | ||||
| ## Additional information | ||||
| 
 | ||||
| TODO: Tell users more about the package: where to find more information, how to | ||||
| contribute to the package, how to file issues, what response they can expect | ||||
| from the package authors, and more. | ||||
|  | @ -0,0 +1,4 @@ | |||
| include: package:flutter_lints/flutter.yaml | ||||
| 
 | ||||
| # Additional information about this file can be found at | ||||
| # https://dart.dev/guides/language/analysis-options | ||||
|  | @ -0,0 +1,7 @@ | |||
| library guru_fiam; | ||||
| 
 | ||||
| /// A Calculator. | ||||
| class Calculator { | ||||
|   /// Returns [value] plus 1. | ||||
|   int addOne(int value) => value + 1; | ||||
| } | ||||
|  | @ -0,0 +1,54 @@ | |||
| name: guru_fiam | ||||
| description: "A new Flutter project." | ||||
| version: 3.0.0 | ||||
| homepage: | ||||
| 
 | ||||
| environment: | ||||
|   sdk: '>=3.2.3 <4.0.0' | ||||
|   flutter: ">=1.17.0" | ||||
| 
 | ||||
| dependencies: | ||||
|   flutter: | ||||
|     sdk: flutter | ||||
|   firebase_in_app_messaging: 0.7.4+8 | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|     sdk: flutter | ||||
|   flutter_lints: ^2.0.0 | ||||
| 
 | ||||
| # For information on the generic Dart part of this file, see the | ||||
| # following page: https://dart.dev/tools/pub/pubspec | ||||
| 
 | ||||
| # The following section is specific to Flutter packages. | ||||
| flutter: | ||||
| 
 | ||||
|   # To add assets to your package, add an assets section, like this: | ||||
|   # assets: | ||||
|   #   - images/a_dot_burr.jpeg | ||||
|   #   - images/a_dot_ham.jpeg | ||||
|   # | ||||
|   # For details regarding assets in packages, see | ||||
|   # https://flutter.dev/assets-and-images/#from-packages | ||||
|   # | ||||
|   # An image asset can refer to one or more resolution-specific "variants", see | ||||
|   # https://flutter.dev/assets-and-images/#resolution-aware | ||||
| 
 | ||||
|   # To add custom fonts to your package, add a fonts section here, | ||||
|   # in this "flutter" section. Each entry in this list should have a | ||||
|   # "family" key with the font family name, and a "fonts" key with a | ||||
|   # list giving the asset and other descriptors for the font. For | ||||
|   # example: | ||||
|   # fonts: | ||||
|   #   - family: Schyler | ||||
|   #     fonts: | ||||
|   #       - asset: fonts/Schyler-Regular.ttf | ||||
|   #       - asset: fonts/Schyler-Italic.ttf | ||||
|   #         style: italic | ||||
|   #   - family: Trajan Pro | ||||
|   #     fonts: | ||||
|   #       - asset: fonts/TrajanPro.ttf | ||||
|   #       - asset: fonts/TrajanPro_Bold.ttf | ||||
|   #         weight: 700 | ||||
|   # | ||||
|   # For details regarding fonts in packages, see | ||||
|   # https://flutter.dev/custom-fonts/#from-packages | ||||
|  | @ -0,0 +1,12 @@ | |||
| import 'package:flutter_test/flutter_test.dart'; | ||||
| 
 | ||||
| import 'package:guru_fiam/guru_fiam.dart'; | ||||
| 
 | ||||
| void main() { | ||||
|   test('adds one to input values', () { | ||||
|     final calculator = Calculator(); | ||||
|     expect(calculator.addOne(2), 3); | ||||
|     expect(calculator.addOne(-7), -6); | ||||
|     expect(calculator.addOne(0), 1); | ||||
|   }); | ||||
| } | ||||
|  | @ -27,7 +27,7 @@ class _RootPackage extends RootPackage { | |||
|     Log.isDebug = kDebugMode; | ||||
| 
 | ||||
|     await DebugSettings.instance.refresh(); | ||||
|      | ||||
| 
 | ||||
|     Initializer.initialPath = AppPages.initialPath; | ||||
| 
 | ||||
|     RouteCenter.initialize(routeMatchers: [ | ||||
|  | @ -42,23 +42,34 @@ class _RootPackage extends RootPackage { | |||
|     ]); | ||||
|   } | ||||
| 
 | ||||
|      | ||||
|   @override | ||||
|   // TODO: implement localizationsDelegates | ||||
|   Iterable<LocalizationsDelegate> get localizationsDelegates => const [ | ||||
|     // Built-in localization of basic text for Material widgets | ||||
|     GlobalMaterialLocalizations.delegate, | ||||
|     // Built-in localization for text direction LTR/RTL | ||||
|     GlobalWidgetsLocalizations.delegate, | ||||
|     // Built-in localization of basic text for Cupertino widgets | ||||
|     GlobalCupertinoLocalizations.delegate, | ||||
|   ]; | ||||
|         // Built-in localization of basic text for Material widgets | ||||
|         GlobalMaterialLocalizations.delegate, | ||||
|         // Built-in localization for text direction LTR/RTL | ||||
|         GlobalWidgetsLocalizations.delegate, | ||||
|         // Built-in localization of basic text for Cupertino widgets | ||||
|         GlobalCupertinoLocalizations.delegate, | ||||
|       ]; | ||||
| 
 | ||||
|   @override | ||||
|   // TODO: implement supportedLocales | ||||
|   Iterable<Locale> get supportedLocales => throw UnimplementedError(); | ||||
| } | ||||
| 
 | ||||
| class ComplianceProtocol implements IGuruSdkComplianceProtocol { | ||||
|   @override | ||||
|   int getCurrentLevel() { | ||||
|     return 1; | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   String getLevelName() { | ||||
|     return ""; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @singleton | ||||
| class Initializer { | ||||
|   // final TransactionService transactionService; | ||||
|  | @ -79,7 +90,10 @@ class Initializer { | |||
|   static AppEnv _buildAppEnv({String flavor = ""}) { | ||||
|     final rootPackage = _RootPackage(); | ||||
| 
 | ||||
|     return AppEnv(spec: createAppSpec(flavor), package: rootPackage); | ||||
|     return AppEnv( | ||||
|         spec: createAppSpec(flavor), | ||||
|         package: rootPackage, | ||||
|         complianceProtocol: ComplianceProtocol()); | ||||
|   } | ||||
| 
 | ||||
|   static Future ensureInitialized() async { | ||||
|  |  | |||
|  | @ -30,4 +30,6 @@ class _GuruSpecFactory { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| class Flavors {} | ||||
| class Flavors { | ||||
|   static const String classic = "classic"; | ||||
| } | ||||
|  |  | |||
|  | @ -14,14 +14,14 @@ packages: | |||
|       name: _flutterfire_internals | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "1.0.12" | ||||
|     version: "1.3.16" | ||||
|   adjust_sdk: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: adjust_sdk | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "4.33.0" | ||||
|     version: "4.36.0" | ||||
|   analyzer: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|  | @ -175,21 +175,21 @@ packages: | |||
|       name: cloud_firestore | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "4.3.1" | ||||
|     version: "4.13.6" | ||||
|   cloud_firestore_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: cloud_firestore_platform_interface | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "5.10.1" | ||||
|     version: "6.0.10" | ||||
|   cloud_firestore_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: cloud_firestore_web | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "3.2.1" | ||||
|     version: "3.8.10" | ||||
|   code_builder: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|  | @ -265,7 +265,7 @@ packages: | |||
|     description: | ||||
|       path: "packages/design" | ||||
|       ref: "v2.3.0" | ||||
|       resolved-ref: "57ef494b3db78c71932234986eab58a4a9063c6b" | ||||
|       resolved-ref: "8bef6934ac28e2d7c2b7ed56efa7941653ea681b" | ||||
|       url: "git@github.com:castbox/guru_ui.git" | ||||
|     source: git | ||||
|     version: "2.0.2" | ||||
|  | @ -274,7 +274,7 @@ packages: | |||
|     description: | ||||
|       path: "packages/design_generator" | ||||
|       ref: "v2.3.0" | ||||
|       resolved-ref: "57ef494b3db78c71932234986eab58a4a9063c6b" | ||||
|       resolved-ref: "8bef6934ac28e2d7c2b7ed56efa7941653ea681b" | ||||
|       url: "git@github.com:castbox/guru_ui.git" | ||||
|     source: git | ||||
|     version: "2.0.2" | ||||
|  | @ -283,7 +283,7 @@ packages: | |||
|     description: | ||||
|       path: "packages/design_spec" | ||||
|       ref: "v2.3.0" | ||||
|       resolved-ref: "57ef494b3db78c71932234986eab58a4a9063c6b" | ||||
|       resolved-ref: "8bef6934ac28e2d7c2b7ed56efa7941653ea681b" | ||||
|       url: "git@github.com:castbox/guru_ui.git" | ||||
|     source: git | ||||
|     version: "2.0.2" | ||||
|  | @ -300,7 +300,7 @@ packages: | |||
|       name: device_info_plus | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "8.2.2" | ||||
|     version: "9.1.1" | ||||
|   device_info_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|  | @ -356,147 +356,147 @@ packages: | |||
|       name: firebase_analytics | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "10.1.0" | ||||
|     version: "10.7.4" | ||||
|   firebase_analytics_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_analytics_platform_interface | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "3.3.17" | ||||
|     version: "3.9.0" | ||||
|   firebase_analytics_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_analytics_web | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "0.5.1+8" | ||||
|     version: "0.5.5+12" | ||||
|   firebase_auth: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_auth | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "4.2.4" | ||||
|     version: "4.15.3" | ||||
|   firebase_auth_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_auth_platform_interface | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "6.11.7" | ||||
|     version: "7.0.9" | ||||
|   firebase_auth_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_auth_web | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "5.2.4" | ||||
|     version: "5.8.13" | ||||
|   firebase_core: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_core | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "2.4.1" | ||||
|     version: "2.24.2" | ||||
|   firebase_core_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_core_platform_interface | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "4.8.0" | ||||
|     version: "5.0.0" | ||||
|   firebase_core_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_core_web | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "2.6.0" | ||||
|     version: "2.10.0" | ||||
|   firebase_crashlytics: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_crashlytics | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "3.0.9" | ||||
|     version: "3.4.8" | ||||
|   firebase_crashlytics_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_crashlytics_platform_interface | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "3.3.11" | ||||
|     version: "3.6.16" | ||||
|   firebase_dynamic_links: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_dynamic_links | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "5.0.11" | ||||
|     version: "5.4.8" | ||||
|   firebase_dynamic_links_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_dynamic_links_platform_interface | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "0.2.3+26" | ||||
|     version: "0.2.6+16" | ||||
|   firebase_in_app_messaging: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_in_app_messaging | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "0.7.0+10" | ||||
|     version: "0.7.4+8" | ||||
|   firebase_in_app_messaging_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_in_app_messaging_platform_interface | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "0.2.1+29" | ||||
|     version: "0.2.4+16" | ||||
|   firebase_messaging: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_messaging | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "14.2.1" | ||||
|     version: "14.7.9" | ||||
|   firebase_messaging_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_messaging_platform_interface | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "4.2.10" | ||||
|     version: "4.5.18" | ||||
|   firebase_messaging_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_messaging_web | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "3.2.11" | ||||
|     version: "3.5.18" | ||||
|   firebase_remote_config: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_remote_config | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "3.0.9" | ||||
|     version: "4.3.8" | ||||
|   firebase_remote_config_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_remote_config_platform_interface | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "1.1.29" | ||||
|     version: "1.4.16" | ||||
|   firebase_remote_config_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: firebase_remote_config_web | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "1.1.18" | ||||
|     version: "1.4.16" | ||||
|   fixnum: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|  | @ -504,11 +504,25 @@ packages: | |||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "1.0.1" | ||||
|   flame: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: flame | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "1.5.0" | ||||
|   flutter: | ||||
|     dependency: "direct main" | ||||
|     description: flutter | ||||
|     source: sdk | ||||
|     version: "0.0.0" | ||||
|   flutter_animate: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: flutter_animate | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "4.4.0" | ||||
|   flutter_blurhash: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|  | @ -605,19 +619,17 @@ packages: | |||
|     dependency: transitive | ||||
|     description: | ||||
|       path: "." | ||||
|       ref: "v2.3.1" | ||||
|       resolved-ref: e4438b7ece793a85da477b685e60c79981be281a | ||||
|       ref: "v2.3.4" | ||||
|       resolved-ref: "804fd22ddc1fc31acecdf72e936dabc0193379c5" | ||||
|       url: "git@github.com:castbox/guru_analytics_flutter.git" | ||||
|     source: git | ||||
|     version: "2.0.0" | ||||
|   guru_app: | ||||
|     dependency: "direct dev" | ||||
|     dependency: "direct overridden" | ||||
|     description: | ||||
|       path: "." | ||||
|       ref: "v2.3.0" | ||||
|       resolved-ref: "86c3ad1d5b550e01e4e5e9c56ac845c34809eeb2" | ||||
|       url: "git@github.com:castbox/guru_app.git" | ||||
|     source: git | ||||
|       path: "../../.." | ||||
|       relative: true | ||||
|     source: path | ||||
|     version: "2.1.0" | ||||
|   guru_applifecycle_flutter: | ||||
|     dependency: transitive | ||||
|  | @ -633,7 +645,7 @@ packages: | |||
|     description: | ||||
|       path: "." | ||||
|       ref: "v2.3.8" | ||||
|       resolved-ref: "6fb5191d4cffc6a5728df7ee8af793fcc27fd081" | ||||
|       resolved-ref: "4cb520a2f9bea14300b0d2b452e183bcc42779f9" | ||||
|       url: "git@github.com:castbox/guru_applovin_flutter.git" | ||||
|     source: git | ||||
|     version: "2.3.0" | ||||
|  | @ -649,7 +661,7 @@ packages: | |||
|     description: | ||||
|       path: "plugins/guru_navigator" | ||||
|       ref: "v2.3.0" | ||||
|       resolved-ref: "86c3ad1d5b550e01e4e5e9c56ac845c34809eeb2" | ||||
|       resolved-ref: a63c7acc1ed71a6e858116025bd741b2d7c554c7 | ||||
|       url: "git@github.com:castbox/guru_app.git" | ||||
|     source: git | ||||
|     version: "0.0.1" | ||||
|  | @ -658,7 +670,7 @@ packages: | |||
|     description: | ||||
|       path: "plugins/guru_platform_data" | ||||
|       ref: "v2.3.0" | ||||
|       resolved-ref: "86c3ad1d5b550e01e4e5e9c56ac845c34809eeb2" | ||||
|       resolved-ref: a63c7acc1ed71a6e858116025bd741b2d7c554c7 | ||||
|       url: "git@github.com:castbox/guru_app.git" | ||||
|     source: git | ||||
|     version: "0.0.1" | ||||
|  | @ -667,34 +679,30 @@ packages: | |||
|     description: | ||||
|       path: "packages/guru_popup" | ||||
|       ref: "v2.3.0" | ||||
|       resolved-ref: "57ef494b3db78c71932234986eab58a4a9063c6b" | ||||
|       resolved-ref: "8bef6934ac28e2d7c2b7ed56efa7941653ea681b" | ||||
|       url: "git@github.com:castbox/guru_ui.git" | ||||
|     source: git | ||||
|     version: "2.3.0" | ||||
|   guru_spec: | ||||
|     dependency: "direct dev" | ||||
|     dependency: "direct overridden" | ||||
|     description: | ||||
|       path: "packages/guru_spec" | ||||
|       ref: "v2.3.0" | ||||
|       resolved-ref: "86c3ad1d5b550e01e4e5e9c56ac845c34809eeb2" | ||||
|       url: "git@github.com:castbox/guru_app.git" | ||||
|     source: git | ||||
|       path: "../../guru_spec" | ||||
|       relative: true | ||||
|     source: path | ||||
|     version: "1.1.0" | ||||
|   guru_utils: | ||||
|     dependency: "direct dev" | ||||
|     dependency: "direct overridden" | ||||
|     description: | ||||
|       path: "packages/guru_utils" | ||||
|       ref: "v2.3.0" | ||||
|       resolved-ref: "86c3ad1d5b550e01e4e5e9c56ac845c34809eeb2" | ||||
|       url: "git@github.com:castbox/guru_app.git" | ||||
|     source: git | ||||
|       path: "../../guru_utils" | ||||
|       relative: true | ||||
|     source: path | ||||
|     version: "2.1.0" | ||||
|   guru_widgets: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
|       path: "packages/guru_widgets" | ||||
|       ref: "v2.3.0" | ||||
|       resolved-ref: "57ef494b3db78c71932234986eab58a4a9063c6b" | ||||
|       resolved-ref: "8bef6934ac28e2d7c2b7ed56efa7941653ea681b" | ||||
|       url: "git@github.com:castbox/guru_ui.git" | ||||
|     source: git | ||||
|     version: "2.2.0" | ||||
|  | @ -866,6 +874,13 @@ packages: | |||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "1.0.2" | ||||
|   ordered_set: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: ordered_set | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "5.0.2" | ||||
|   package_config: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|  | @ -997,7 +1012,7 @@ packages: | |||
|     description: | ||||
|       path: "plugins/persistent" | ||||
|       ref: "v2.3.0" | ||||
|       resolved-ref: "86c3ad1d5b550e01e4e5e9c56ac845c34809eeb2" | ||||
|       resolved-ref: a63c7acc1ed71a6e858116025bd741b2d7c554c7 | ||||
|       url: "git@github.com:castbox/guru_app.git" | ||||
|     source: git | ||||
|     version: "0.0.1" | ||||
|  | @ -1109,7 +1124,7 @@ packages: | |||
|     description: | ||||
|       path: "plugins/soundpool" | ||||
|       ref: "v2.3.0" | ||||
|       resolved-ref: "86c3ad1d5b550e01e4e5e9c56ac845c34809eeb2" | ||||
|       resolved-ref: a63c7acc1ed71a6e858116025bd741b2d7c554c7 | ||||
|       url: "git@github.com:castbox/guru_app.git" | ||||
|     source: git | ||||
|     version: "2.3.0" | ||||
|  | @ -1321,7 +1336,7 @@ packages: | |||
|     description: | ||||
|       path: "plugins/vibration" | ||||
|       ref: "v2.3.0" | ||||
|       resolved-ref: "86c3ad1d5b550e01e4e5e9c56ac845c34809eeb2" | ||||
|       resolved-ref: a63c7acc1ed71a6e858116025bd741b2d7c554c7 | ||||
|       url: "git@github.com:castbox/guru_app.git" | ||||
|     source: git | ||||
|     version: "1.7.5" | ||||
|  | @ -1388,6 +1403,13 @@ packages: | |||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "4.1.4" | ||||
|   win32_registry: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: win32_registry | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "1.1.0" | ||||
|   xdg_directories: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|  |  | |||
|  | @ -100,6 +100,16 @@ dev_dependencies: | |||
| # following page: https://dart.dev/tools/pub/pubspec | ||||
| 
 | ||||
| # The following section is specific to Flutter packages. | ||||
| 
 | ||||
| dependency_overrides: | ||||
|   guru_app: | ||||
|     path: ../../../ | ||||
| 
 | ||||
|   guru_utils: | ||||
|     path: ../../guru_utils | ||||
| 
 | ||||
|   guru_spec: | ||||
|     path: ../../guru_spec | ||||
| flutter: | ||||
| 
 | ||||
|   # The following line ensures that the Material Icons font is | ||||
|  |  | |||
|  | @ -208,7 +208,7 @@ packages: | |||
|       sha256: "73ff438fe46028f0e19f55da18b6ddc6906ab750562cd7d9ffab77ff8c0c4307" | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "6.1.0" | ||||
|     version: "6.0.10" | ||||
|   cloud_firestore_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|  | @ -216,7 +216,7 @@ packages: | |||
|       sha256: "232e45e95970d3a6baab8f50f9c3a6e2838d145d9d91ec9a7392837c44296397" | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "3.9.0" | ||||
|     version: "3.8.10" | ||||
|   code_builder: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|  | @ -578,6 +578,13 @@ packages: | |||
|     source: sdk | ||||
|     version: "0.0.0" | ||||
|   flutter_animate: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: flutter_animate | ||||
|       url: "https://pub.flutter-io.cn" | ||||
|     source: hosted | ||||
|     version: "4.4.0" | ||||
|   flutter_blurhash: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: flutter_animate | ||||
|  |  | |||
|  | @ -0,0 +1,29 @@ | |||
| # Miscellaneous | ||||
| *.class | ||||
| *.log | ||||
| *.pyc | ||||
| *.swp | ||||
| .DS_Store | ||||
| .atom/ | ||||
| .buildlog/ | ||||
| .history | ||||
| .svn/ | ||||
| migrate_working_dir/ | ||||
| 
 | ||||
| # IntelliJ related | ||||
| *.iml | ||||
| *.ipr | ||||
| *.iws | ||||
| .idea/ | ||||
| 
 | ||||
| # The .vscode folder contains launch configuration and tasks you configure in | ||||
| # VS Code which you may wish to be included in version control, so this line | ||||
| # is commented out by default. | ||||
| #.vscode/ | ||||
| 
 | ||||
| # Flutter/Dart/Pub related | ||||
| # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. | ||||
| /pubspec.lock | ||||
| **/doc/api/ | ||||
| .dart_tool/ | ||||
| build/ | ||||
|  | @ -0,0 +1,3 @@ | |||
| ## 0.0.1 | ||||
| 
 | ||||
| * TODO: Describe initial release. | ||||
|  | @ -0,0 +1 @@ | |||
| TODO: Add your license here. | ||||
|  | @ -0,0 +1,39 @@ | |||
| <!-- | ||||
| This README describes the package. If you publish this package to pub.dev, | ||||
| this README's contents appear on the landing page for your package. | ||||
| 
 | ||||
| For information about how to write a good package README, see the guide for | ||||
| [writing package pages](https://dart.dev/guides/libraries/writing-package-pages). | ||||
| 
 | ||||
| For general information about developing packages, see the Dart guide for | ||||
| [creating packages](https://dart.dev/guides/libraries/create-library-packages) | ||||
| and the Flutter guide for | ||||
| [developing packages and plugins](https://flutter.dev/developing-packages). | ||||
| --> | ||||
| 
 | ||||
| TODO: Put a short description of the package here that helps potential users | ||||
| know whether this package might be useful for them. | ||||
| 
 | ||||
| ## Features | ||||
| 
 | ||||
| TODO: List what your package can do. Maybe include images, gifs, or videos. | ||||
| 
 | ||||
| ## Getting started | ||||
| 
 | ||||
| TODO: List prerequisites and provide or point to information on how to | ||||
| start using the package. | ||||
| 
 | ||||
| ## Usage | ||||
| 
 | ||||
| TODO: Include short and useful examples for package users. Add longer examples | ||||
| to `/example` folder. | ||||
| 
 | ||||
| ```dart | ||||
| const like = 'sample'; | ||||
| ``` | ||||
| 
 | ||||
| ## Additional information | ||||
| 
 | ||||
| TODO: Tell users more about the package: where to find more information, how to | ||||
| contribute to the package, how to file issues, what response they can expect | ||||
| from the package authors, and more. | ||||
|  | @ -0,0 +1,4 @@ | |||
| include: package:flutter_lints/flutter.yaml | ||||
| 
 | ||||
| # Additional information about this file can be found at | ||||
| # https://dart.dev/guides/language/analysis-options | ||||
|  | @ -0,0 +1,43 @@ | |||
| # Miscellaneous | ||||
| *.class | ||||
| *.log | ||||
| *.pyc | ||||
| *.swp | ||||
| .DS_Store | ||||
| .atom/ | ||||
| .buildlog/ | ||||
| .history | ||||
| .svn/ | ||||
| migrate_working_dir/ | ||||
| 
 | ||||
| # IntelliJ related | ||||
| *.iml | ||||
| *.ipr | ||||
| *.iws | ||||
| .idea/ | ||||
| 
 | ||||
| # The .vscode folder contains launch configuration and tasks you configure in | ||||
| # VS Code which you may wish to be included in version control, so this line | ||||
| # is commented out by default. | ||||
| #.vscode/ | ||||
| 
 | ||||
| # Flutter/Dart/Pub related | ||||
| **/doc/api/ | ||||
| **/ios/Flutter/.last_build_id | ||||
| .dart_tool/ | ||||
| .flutter-plugins | ||||
| .flutter-plugins-dependencies | ||||
| .pub-cache/ | ||||
| .pub/ | ||||
| /build/ | ||||
| 
 | ||||
| # Symbolication related | ||||
| app.*.symbols | ||||
| 
 | ||||
| # Obfuscation related | ||||
| app.*.map.json | ||||
| 
 | ||||
| # Android Studio will place build artifacts here | ||||
| /android/app/debug | ||||
| /android/app/profile | ||||
| /android/app/release | ||||
|  | @ -0,0 +1,16 @@ | |||
| # example | ||||
| 
 | ||||
| A new Flutter project. | ||||
| 
 | ||||
| ## Getting Started | ||||
| 
 | ||||
| This project is a starting point for a Flutter application. | ||||
| 
 | ||||
| A few resources to get you started if this is your first Flutter project: | ||||
| 
 | ||||
| - [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) | ||||
| - [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) | ||||
| 
 | ||||
| For help getting started with Flutter development, view the | ||||
| [online documentation](https://docs.flutter.dev/), which offers tutorials, | ||||
| samples, guidance on mobile development, and a full API reference. | ||||
|  | @ -0,0 +1,28 @@ | |||
| # This file configures the analyzer, which statically analyzes Dart code to | ||||
| # check for errors, warnings, and lints. | ||||
| # | ||||
| # The issues identified by the analyzer are surfaced in the UI of Dart-enabled | ||||
| # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be | ||||
| # invoked from the command line by running `flutter analyze`. | ||||
| 
 | ||||
| # The following line activates a set of recommended lints for Flutter apps, | ||||
| # packages, and plugins designed to encourage good coding practices. | ||||
| include: package:flutter_lints/flutter.yaml | ||||
| 
 | ||||
| linter: | ||||
|   # The lint rules applied to this project can be customized in the | ||||
|   # section below to disable rules from the `package:flutter_lints/flutter.yaml` | ||||
|   # included above or to enable additional rules. A list of all available lints | ||||
|   # and their documentation is published at https://dart.dev/lints. | ||||
|   # | ||||
|   # Instead of disabling a lint rule for the entire project in the | ||||
|   # section below, it can also be suppressed for a single line of code | ||||
|   # or a specific dart file by using the `// ignore: name_of_lint` and | ||||
|   # `// ignore_for_file: name_of_lint` syntax on the line or in the file | ||||
|   # producing the lint. | ||||
|   rules: | ||||
|     # avoid_print: false  # Uncomment to disable the `avoid_print` rule | ||||
|     # prefer_single_quotes: true  # Uncomment to enable the `prefer_single_quotes` rule | ||||
| 
 | ||||
| # Additional information about this file can be found at | ||||
| # https://dart.dev/guides/language/analysis-options | ||||
|  | @ -0,0 +1,125 @@ | |||
| import 'package:flutter/material.dart'; | ||||
| 
 | ||||
| void main() { | ||||
|   runApp(const MyApp()); | ||||
| } | ||||
| 
 | ||||
| class MyApp extends StatelessWidget { | ||||
|   const MyApp({super.key}); | ||||
| 
 | ||||
|   // This widget is the root of your application. | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return MaterialApp( | ||||
|       title: 'Flutter Demo', | ||||
|       theme: ThemeData( | ||||
|         // This is the theme of your application. | ||||
|         // | ||||
|         // TRY THIS: Try running your application with "flutter run". You'll see | ||||
|         // the application has a purple toolbar. Then, without quitting the app, | ||||
|         // try changing the seedColor in the colorScheme below to Colors.green | ||||
|         // and then invoke "hot reload" (save your changes or press the "hot | ||||
|         // reload" button in a Flutter-supported IDE, or press "r" if you used | ||||
|         // the command line to start the app). | ||||
|         // | ||||
|         // Notice that the counter didn't reset back to zero; the application | ||||
|         // state is not lost during the reload. To reset the state, use hot | ||||
|         // restart instead. | ||||
|         // | ||||
|         // This works for code too, not just values: Most code changes can be | ||||
|         // tested with just a hot reload. | ||||
|         colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), | ||||
|         useMaterial3: true, | ||||
|       ), | ||||
|       home: const MyHomePage(title: 'Flutter Demo Home Page'), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class MyHomePage extends StatefulWidget { | ||||
|   const MyHomePage({super.key, required this.title}); | ||||
| 
 | ||||
|   // This widget is the home page of your application. It is stateful, meaning | ||||
|   // that it has a State object (defined below) that contains fields that affect | ||||
|   // how it looks. | ||||
| 
 | ||||
|   // This class is the configuration for the state. It holds the values (in this | ||||
|   // case the title) provided by the parent (in this case the App widget) and | ||||
|   // used by the build method of the State. Fields in a Widget subclass are | ||||
|   // always marked "final". | ||||
| 
 | ||||
|   final String title; | ||||
| 
 | ||||
|   @override | ||||
|   State<MyHomePage> createState() => _MyHomePageState(); | ||||
| } | ||||
| 
 | ||||
| class _MyHomePageState extends State<MyHomePage> { | ||||
|   int _counter = 0; | ||||
| 
 | ||||
|   void _incrementCounter() { | ||||
|     setState(() { | ||||
|       // This call to setState tells the Flutter framework that something has | ||||
|       // changed in this State, which causes it to rerun the build method below | ||||
|       // so that the display can reflect the updated values. If we changed | ||||
|       // _counter without calling setState(), then the build method would not be | ||||
|       // called again, and so nothing would appear to happen. | ||||
|       _counter++; | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     // This method is rerun every time setState is called, for instance as done | ||||
|     // by the _incrementCounter method above. | ||||
|     // | ||||
|     // The Flutter framework has been optimized to make rerunning build methods | ||||
|     // fast, so that you can just rebuild anything that needs updating rather | ||||
|     // than having to individually change instances of widgets. | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         // TRY THIS: Try changing the color here to a specific color (to | ||||
|         // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar | ||||
|         // change color while the other colors stay the same. | ||||
|         backgroundColor: Theme.of(context).colorScheme.inversePrimary, | ||||
|         // Here we take the value from the MyHomePage object that was created by | ||||
|         // the App.build method, and use it to set our appbar title. | ||||
|         title: Text(widget.title), | ||||
|       ), | ||||
|       body: Center( | ||||
|         // Center is a layout widget. It takes a single child and positions it | ||||
|         // in the middle of the parent. | ||||
|         child: Column( | ||||
|           // Column is also a layout widget. It takes a list of children and | ||||
|           // arranges them vertically. By default, it sizes itself to fit its | ||||
|           // children horizontally, and tries to be as tall as its parent. | ||||
|           // | ||||
|           // Column has various properties to control how it sizes itself and | ||||
|           // how it positions its children. Here we use mainAxisAlignment to | ||||
|           // center the children vertically; the main axis here is the vertical | ||||
|           // axis because Columns are vertical (the cross axis would be | ||||
|           // horizontal). | ||||
|           // | ||||
|           // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" | ||||
|           // action in the IDE, or press "p" in the console), to see the | ||||
|           // wireframe for each widget. | ||||
|           mainAxisAlignment: MainAxisAlignment.center, | ||||
|           children: <Widget>[ | ||||
|             const Text( | ||||
|               'You have pushed the button this many times:', | ||||
|             ), | ||||
|             Text( | ||||
|               '$_counter', | ||||
|               style: Theme.of(context).textTheme.headlineMedium, | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|       floatingActionButton: FloatingActionButton( | ||||
|         onPressed: _incrementCounter, | ||||
|         tooltip: 'Increment', | ||||
|         child: const Icon(Icons.add), | ||||
|       ), // This trailing comma makes auto-formatting nicer for build methods. | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,188 @@ | |||
| # Generated by pub | ||||
| # See https://dart.dev/tools/pub/glossary#lockfile | ||||
| packages: | ||||
|   async: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: async | ||||
|       sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.11.0" | ||||
|   boolean_selector: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: boolean_selector | ||||
|       sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.1" | ||||
|   characters: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: characters | ||||
|       sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.3.0" | ||||
|   clock: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: clock | ||||
|       sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.1.1" | ||||
|   collection: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: collection | ||||
|       sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.18.0" | ||||
|   cupertino_icons: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: cupertino_icons | ||||
|       sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.6" | ||||
|   fake_async: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: fake_async | ||||
|       sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.3.1" | ||||
|   flutter: | ||||
|     dependency: "direct main" | ||||
|     description: flutter | ||||
|     source: sdk | ||||
|     version: "0.0.0" | ||||
|   flutter_lints: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
|       name: flutter_lints | ||||
|       sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.3" | ||||
|   flutter_test: | ||||
|     dependency: "direct dev" | ||||
|     description: flutter | ||||
|     source: sdk | ||||
|     version: "0.0.0" | ||||
|   lints: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: lints | ||||
|       sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.1" | ||||
|   matcher: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: matcher | ||||
|       sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.12.16" | ||||
|   material_color_utilities: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: material_color_utilities | ||||
|       sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.5.0" | ||||
|   meta: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: meta | ||||
|       sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.10.0" | ||||
|   path: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: path | ||||
|       sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.8.3" | ||||
|   sky_engine: | ||||
|     dependency: transitive | ||||
|     description: flutter | ||||
|     source: sdk | ||||
|     version: "0.0.99" | ||||
|   source_span: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: source_span | ||||
|       sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.10.0" | ||||
|   stack_trace: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: stack_trace | ||||
|       sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.11.1" | ||||
|   stream_channel: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: stream_channel | ||||
|       sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.2" | ||||
|   string_scanner: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: string_scanner | ||||
|       sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.2.0" | ||||
|   term_glyph: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: term_glyph | ||||
|       sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.2.1" | ||||
|   test_api: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: test_api | ||||
|       sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.6.1" | ||||
|   vector_math: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: vector_math | ||||
|       sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.4" | ||||
|   web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: web | ||||
|       sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.3.0" | ||||
| sdks: | ||||
|   dart: ">=3.2.3 <4.0.0" | ||||
|  | @ -0,0 +1,90 @@ | |||
| name: example | ||||
| description: "A new Flutter project." | ||||
| # The following line prevents the package from being accidentally published to | ||||
| # pub.dev using `flutter pub publish`. This is preferred for private packages. | ||||
| publish_to: 'none' # Remove this line if you wish to publish to pub.dev | ||||
| 
 | ||||
| # The following defines the version and build number for your application. | ||||
| # A version number is three numbers separated by dots, like 1.2.43 | ||||
| # followed by an optional build number separated by a +. | ||||
| # Both the version and the builder number may be overridden in flutter | ||||
| # build by specifying --build-name and --build-number, respectively. | ||||
| # In Android, build-name is used as versionName while build-number used as versionCode. | ||||
| # Read more about Android versioning at https://developer.android.com/studio/publish/versioning | ||||
| # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. | ||||
| # Read more about iOS versioning at | ||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 1.0.0+1 | ||||
| 
 | ||||
| environment: | ||||
|   sdk: '>=3.2.3 <4.0.0' | ||||
| 
 | ||||
| # Dependencies specify other packages that your package needs in order to work. | ||||
| # To automatically upgrade your package dependencies to the latest versions | ||||
| # consider running `flutter pub upgrade --major-versions`. Alternatively, | ||||
| # dependencies can be manually updated by changing the version numbers below to | ||||
| # the latest version available on pub.dev. To see which dependencies have newer | ||||
| # versions available, run `flutter pub outdated`. | ||||
| dependencies: | ||||
|   flutter: | ||||
|     sdk: flutter | ||||
| 
 | ||||
| 
 | ||||
|   # The following adds the Cupertino Icons font to your application. | ||||
|   # Use with the CupertinoIcons class for iOS style icons. | ||||
|   cupertino_icons: ^1.0.2 | ||||
| 
 | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|     sdk: flutter | ||||
| 
 | ||||
|   # The "flutter_lints" package below contains a set of recommended lints to | ||||
|   # encourage good coding practices. The lint set provided by the package is | ||||
|   # activated in the `analysis_options.yaml` file located at the root of your | ||||
|   # package. See that file for information about deactivating specific lint | ||||
|   # rules and activating additional ones. | ||||
|   flutter_lints: ^2.0.0 | ||||
| 
 | ||||
| # For information on the generic Dart part of this file, see the | ||||
| # following page: https://dart.dev/tools/pub/pubspec | ||||
| 
 | ||||
| # The following section is specific to Flutter packages. | ||||
| flutter: | ||||
| 
 | ||||
|   # The following line ensures that the Material Icons font is | ||||
|   # included with your application, so that you can use the icons in | ||||
|   # the material Icons class. | ||||
|   uses-material-design: true | ||||
| 
 | ||||
|   # To add assets to your application, add an assets section, like this: | ||||
|   # assets: | ||||
|   #   - images/a_dot_burr.jpeg | ||||
|   #   - images/a_dot_ham.jpeg | ||||
| 
 | ||||
|   # An image asset can refer to one or more resolution-specific "variants", see | ||||
|   # https://flutter.dev/assets-and-images/#resolution-aware | ||||
| 
 | ||||
|   # For details regarding adding assets from package dependencies, see | ||||
|   # https://flutter.dev/assets-and-images/#from-packages | ||||
| 
 | ||||
|   # To add custom fonts to your application, add a fonts section here, | ||||
|   # in this "flutter" section. Each entry in this list should have a | ||||
|   # "family" key with the font family name, and a "fonts" key with a | ||||
|   # list giving the asset and other descriptors for the font. For | ||||
|   # example: | ||||
|   # fonts: | ||||
|   #   - family: Schyler | ||||
|   #     fonts: | ||||
|   #       - asset: fonts/Schyler-Regular.ttf | ||||
|   #       - asset: fonts/Schyler-Italic.ttf | ||||
|   #         style: italic | ||||
|   #   - family: Trajan Pro | ||||
|   #     fonts: | ||||
|   #       - asset: fonts/TrajanPro.ttf | ||||
|   #       - asset: fonts/TrajanPro_Bold.ttf | ||||
|   #         weight: 700 | ||||
|   # | ||||
|   # For details regarding fonts from package dependencies, | ||||
|   # see https://flutter.dev/custom-fonts/#from-packages | ||||
|  | @ -0,0 +1,30 @@ | |||
| // This is a basic Flutter widget test. | ||||
| // | ||||
| // To perform an interaction with a widget in your test, use the WidgetTester | ||||
| // utility in the flutter_test package. For example, you can send tap and scroll | ||||
| // gestures. You can also use WidgetTester to find child widgets in the widget | ||||
| // tree, read text, and verify that the values of widget properties are correct. | ||||
| 
 | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_test/flutter_test.dart'; | ||||
| 
 | ||||
| import 'package:example/main.dart'; | ||||
| 
 | ||||
| void main() { | ||||
|   testWidgets('Counter increments smoke test', (WidgetTester tester) async { | ||||
|     // Build our app and trigger a frame. | ||||
|     await tester.pumpWidget(const MyApp()); | ||||
| 
 | ||||
|     // Verify that our counter starts at 0. | ||||
|     expect(find.text('0'), findsOneWidget); | ||||
|     expect(find.text('1'), findsNothing); | ||||
| 
 | ||||
|     // Tap the '+' icon and trigger a frame. | ||||
|     await tester.tap(find.byIcon(Icons.add)); | ||||
|     await tester.pump(); | ||||
| 
 | ||||
|     // Verify that our counter has incremented. | ||||
|     expect(find.text('0'), findsNothing); | ||||
|     expect(find.text('1'), findsOneWidget); | ||||
|   }); | ||||
| } | ||||
|  | @ -0,0 +1,102 @@ | |||
| let globalLogger; | ||||
| 
 | ||||
| class EventLogger { | ||||
|  appId = ''; | ||||
|  deviceInfo = {}; | ||||
|  version = 10 | ||||
|  deviceStr = ''; | ||||
|  events = []; | ||||
|  info = {}; //用户信息
 | ||||
| 
 | ||||
| 
 | ||||
|  constructor({ appId, deviceInfo, info }) { | ||||
|   this.appId = appId; | ||||
|   let u = navigator.userAgent; | ||||
|   let isAndroid = u.indexOf('Android') > -1 || u.indexOf('Adr') > -1; | ||||
|   let isIOS = !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/); | ||||
|   let tzOffset = new Date().valueOf(); | ||||
|   this.deviceInfo = { | ||||
|     platform: isAndroid ? 'ANDROID' : 'IOS', | ||||
|     country: navigator.languages[1], | ||||
|     tzOffset, | ||||
|     deviceType: 'other', | ||||
|     brand: '', | ||||
|     model: '', | ||||
|     screenH: document.body.clientHeight, | ||||
|     screenW: document.body.clientWidth, | ||||
|     osVersion: '', | ||||
|     language: navigator.language, | ||||
|     ...deviceInfo | ||||
|   }; | ||||
|   for (let key in this.deviceInfo) this.deviceStr += `${key}=${this.deviceInfo[key]};`; | ||||
|   this.info = info; | ||||
|  } | ||||
| 
 | ||||
| 
 | ||||
|  getHeaders() { | ||||
|   let headers = new Headers(); | ||||
|   headers.append('X-APP-ID', this.appId); | ||||
|   headers.append('X-DEVICE-INFO', this.deviceStr) | ||||
|   headers.append('content-type', 'application/json'); | ||||
|   headers.append('Content-Encoding', 'gzip'); | ||||
|   return headers; | ||||
|  } | ||||
| 
 | ||||
|  zip(str) { | ||||
|   const binaryString = pako.gzip(str) | ||||
|   return binaryString; | ||||
|  } | ||||
| 
 | ||||
|  log(event) { | ||||
|   event = { | ||||
|    ...event, | ||||
|    timestamp: Date.now(), | ||||
|    info: this.info | ||||
|   }; | ||||
|   this.events.push(event); | ||||
|   if (this.events.length > 0) this.logEvent(); | ||||
|  } | ||||
| 
 | ||||
| 
 | ||||
|  async logEvent() { | ||||
|   const requestBody = { | ||||
|    version: this.version, | ||||
|    deviceInfo: this.deviceInfo, | ||||
|    events: [...this.events] | ||||
|   }; | ||||
|   const bodyStr = JSON.stringify(requestBody); | ||||
|   const gzippedStr = this.zip(bodyStr); | ||||
|   const config = { | ||||
|    headers: this.getHeaders(), | ||||
|    body: gzippedStr, | ||||
|    method: 'POST', | ||||
|   }; | ||||
|   await fetch('https://collect.saas.castbox.fm/event', config); | ||||
|   this.events = []; | ||||
|  } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  *  | ||||
|  * appInfo { | ||||
|  *  appId | ||||
|  *  deviceInfo | ||||
|  *  version, | ||||
|  *  info | ||||
|  * } | ||||
|  */ | ||||
| function initEventLogger(appInfo) {   | ||||
|  globalLogger = new EventLogger(JSON.parse(appInfo)); | ||||
| } | ||||
| 
 | ||||
| function castboxLogEvent(eventName, param, properties) { | ||||
|  if (globalLogger) { | ||||
|   globalLogger.log({ | ||||
|    event: eventName, | ||||
|    param: JSON.parse(param), | ||||
|    properties: JSON.parse(properties) | ||||
|   }); | ||||
|  } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 917 B | 
|  | @ -0,0 +1,331 @@ | |||
| /** | ||||
|  * 获取facebook 用户基本信息 | ||||
|  * @returns  | ||||
|  */ | ||||
| function getFBProfile() { | ||||
| 	const photo = FBInstant.player.getPhoto() | ||||
| 	const name = FBInstant.player.getName() | ||||
| 	const id = FBInstant.player.getID() | ||||
| 	const contextType = FBInstant.context.getType() | ||||
| 	return JSON.stringify({ photo, name, id, contextType }) | ||||
| } | ||||
| 
 | ||||
| function getFBProfileWithUid(success, error) { | ||||
| 	const photo = FBInstant.player.getPhoto() | ||||
| 	const name = FBInstant.player.getName() | ||||
| 	const id = FBInstant.player.getID() | ||||
| 	const contextType = FBInstant.context.getType() | ||||
| 	FBInstant.player.getASIDAsync().then((userId) => { | ||||
| 		success(JSON.stringify({ photo, name, id, contextType, userId })) | ||||
| 	}).catch(err => { | ||||
| 		error(JSON.stringify(err)) | ||||
| 		console.error(err.message) | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| function getEntryPointData() { | ||||
| 	const data = FBInstant.getEntryPointData(); | ||||
| 	return JSON.stringify(data); | ||||
| } | ||||
| 
 | ||||
| function getLocale() { | ||||
| 	return FBInstant.getLocale(); | ||||
| } | ||||
| 
 | ||||
| //IOS, Android, WEB
 | ||||
| function getPlatform() { | ||||
| 	return FBInstant.getPlatform() | ||||
| } | ||||
| 
 | ||||
| // 分享游戏
 | ||||
| function shareGame(data, success, error) { | ||||
| 	FBInstant.shareAsync(JSON.parse(data)) | ||||
| 		.then(success) | ||||
| 		.catch((err) => { | ||||
| 			error(JSON.stringify(err)) | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| 
 | ||||
| function logEvent(eventName, valueToSum, parameters) { | ||||
| 	FBInstant.logEvent(eventName, valueToSum, JSON.parse(parameters)) | ||||
| } | ||||
| 
 | ||||
| function inviteUser(data, success, error) { | ||||
| 	FBInstant.inviteAsync(JSON.parse(data)) | ||||
| 		.then(success) | ||||
| 		.catch((err) => { | ||||
| 			error(JSON.stringify(err)) | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| 
 | ||||
| // 检测当前支持广告类型 ["getInterstitialAdAsync", "getRewardedVideoAsync"]
 | ||||
| function checkSupportedAds() { | ||||
| 	let supportedAPIs = FBInstant.getSupportedAPIs() | ||||
| 	return JSON.stringify(supportedAPIs); | ||||
| } | ||||
| 
 | ||||
| // 加载,展示插屏广告
 | ||||
| let preloadedInterstitial = null | ||||
| function loadInterstitial(id, success, error) { | ||||
| 	//插屏预载
 | ||||
| 	FBInstant.getInterstitialAdAsync(id) | ||||
| 		.then((res) => { | ||||
| 			preloadedInterstitial = res | ||||
| 			return preloadedInterstitial.loadAsync | ||||
| 		}).then(res => { | ||||
| 			success() | ||||
| 		}).catch((err) => { | ||||
| 			error(err.message); | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| function showInterstitial(success, error) { | ||||
| 	if (preloadedInterstitial == null) { | ||||
| 		return error("showInterstitial preloadedInterstitial is null") | ||||
| 	} | ||||
| 	//插屏播放
 | ||||
| 	preloadedInterstitial | ||||
| 		.showAsync() | ||||
| 		.then(success) | ||||
| 		.catch((err) => { | ||||
| 			error(JSON.stringify(err)) | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| // 加载,展示激励视频
 | ||||
| let preloadedRewardedVideo = null | ||||
| function loadRewarded(id, success, error) { | ||||
| 	//激励视频预载
 | ||||
| 	FBInstant.getRewardedVideoAsync(id) | ||||
| 		.then((res) => { | ||||
| 			preloadedRewardedVideo = res | ||||
| 			return preloadedRewardedVideo.loadAsync | ||||
| 		}).then(res => { | ||||
| 			success() | ||||
| 		}).catch((err) => { | ||||
| 			error(err.message); | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| function showRewarded(success, error) { | ||||
| 	if (preloadedRewardedVideo == null) { | ||||
| 		return error("showInterstitial preloadedRewardedVideo is null") | ||||
| 	} | ||||
| 	//插屏播放
 | ||||
| 	preloadedRewardedVideo | ||||
| 		.showAsync() | ||||
| 		.then(success) | ||||
| 		.catch((err) => { | ||||
| 			error(JSON.stringify(err)) | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| // 展示,隐藏banner广告
 | ||||
| function showBannerAds(id, success, error) { | ||||
| 	FBInstant.loadBannerAdAsync(id) | ||||
| 		.then(success) | ||||
| 		.catch((err) => { | ||||
| 			error(JSON.stringify(err)) | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| function hideBannerAds(id, success, error) { | ||||
| 	FBInstant.hideBannerAdAsync(id) | ||||
| 		.then(success) | ||||
| 		.catch((err) => { | ||||
| 			error(JSON.stringify(err)) | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| 
 | ||||
| // tournament 相关
 | ||||
| 
 | ||||
| function createTournament(initialScore, config, data, success, error) { | ||||
| 	/** | ||||
| 	 * initialScore: number | ||||
| 	 * config: { | ||||
| 	 * title?: string | ||||
| 	 * image?: base64, | ||||
| 	 * sortOrder?: "HIGHER_IS_BETTER"| "LOWER_IS_BETTER" | ||||
| 	 * scoreFormat?: "NUMERIC" | "TIME" | ||||
| 	 * endTime?: unix timestamp (seconds) | ||||
| 	 * } | ||||
| 	 * data: {} customized | ||||
| 	 */ | ||||
| 	FBInstant.tournament | ||||
| 		.createAsync({ | ||||
| 			initialScore, | ||||
| 			config: JSON.parse(config), | ||||
| 			data: JSON.parse(data), | ||||
| 		}) | ||||
| 		.then(success) | ||||
| 		.catch((err) => { | ||||
| 			error(JSON.stringify(err)) | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| 
 | ||||
| function postTournamentScore(id, score, success, error) { | ||||
| 	FBInstant.tournament | ||||
| 		.joinAsync(id) | ||||
| 		.then(function () { | ||||
| 			return FBInstant.tournament.postScoreAsync(score); | ||||
| 		}) | ||||
| 		.then(success) | ||||
| 		.catch((err) => { | ||||
| 			error(JSON.stringify(err)) | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| 
 | ||||
| function shareTournament(score, data, success, error) { | ||||
| 	//data: customized {}
 | ||||
| 	FBInstant.tournament | ||||
| 		.shareAsync({ | ||||
| 			score, | ||||
| 			data: JSON.parse(data), | ||||
| 		}) | ||||
| 		.then(success) | ||||
| 		.catch((err) => { | ||||
| 			error(JSON.stringify(err)) | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| 
 | ||||
| function getCurrentTournament(success, error) { | ||||
| 	FBInstant.getTournamentAsync() | ||||
| 		.then(function (tournament) { | ||||
| 			success( | ||||
| 				JSON.stringify({ | ||||
| 					id: tournament.getContextID(), | ||||
| 					endTime: tournament.getEndTime(), | ||||
| 					title: tournament.getTitle(), | ||||
| 					payLoad: tournament.getPayload(), | ||||
| 				}) | ||||
| 			) | ||||
| 		}) | ||||
| 		.catch((err) => { | ||||
| 			error(JSON.stringify(err)) | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| 
 | ||||
| // player 相关
 | ||||
| 
 | ||||
| function fbSetData(data) { | ||||
| 	FBInstant.player.setDataAsync(JSON.parse(data)) | ||||
| } | ||||
| 
 | ||||
| function fbGetData(keys, success, error) { | ||||
| 	FBInstant.player | ||||
| 		.getDataAsync(JSON.parse(keys)) | ||||
| 		.then((res) => { | ||||
| 			success(JSON.stringify(res)) | ||||
| 		}) | ||||
| 		.catch((err) => { | ||||
| 			error(JSON.stringify(err)) | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| 
 | ||||
| function subscribeBot(success, error) { | ||||
| 	FBInstant.player | ||||
| 		.canSubscribeBotAsync() | ||||
| 		.then((can_subscribe) => { | ||||
| 			if (can_subscribe) { | ||||
| 				return FBInstant.player.subscribeBotAsync() | ||||
| 			} | ||||
| 		}) | ||||
| 		.then(success) | ||||
| 		.catch((err) => { | ||||
| 			error(JSON.stringify(err)) | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| 
 | ||||
| function getConnectedPlayersAsync(success, error) { | ||||
| 	//Fetches an array of ConnectedPlayer objects containing information about active players (people who played the game in the last 90 days) that are connected to the current player.
 | ||||
| 	FBInstant.player.getConnectedPlayersAsync() | ||||
| 		.then(function (res) { | ||||
| 			var players = res.map(function (player) { | ||||
| 				return { | ||||
| 					player_id: player.getID(), | ||||
| 					name: player.getName(), | ||||
| 					picUrl: player.getPhoto() | ||||
| 				} | ||||
| 			}) | ||||
| 			success(JSON.stringify(players)) | ||||
| 		}) | ||||
| 		.catch((err) => { | ||||
| 			error(JSON.stringify(err)) | ||||
| 			console.error(err.message) | ||||
| 		}) | ||||
| } | ||||
| 
 | ||||
| // context 相关
 | ||||
| 
 | ||||
| function getContextInfo() { | ||||
| 	return JSON.stringify({ | ||||
| 		id: FBInstant.context.getID(), | ||||
| 		type: FBInstant.context.getType(), | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| function chooseContext(success, error) { | ||||
| 	FBInstant.context.chooseAsync({ | ||||
| 		filters: ['INCLUDE_EXISTING_CHALLENGES'], | ||||
| 		minSize: 2, | ||||
| 		maxSize: 2, | ||||
| 	}).then(function () { | ||||
| 		var contextInfo = { | ||||
| 			id: FBInstant.context.getID(), | ||||
| 			type: FBInstant.context.getType(), | ||||
| 		} | ||||
| 		console.log("chooseContext after", contextInfo) | ||||
| 		success(JSON.stringify(contextInfo)) | ||||
| 	}) | ||||
| 	.catch((err) => { | ||||
| 		error(JSON.stringify(err)) | ||||
| 		console.error(err.message) | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| function createAsync(playerId, success, error) { | ||||
| 	FBInstant.context | ||||
| 		.createAsync(playerId) | ||||
| 		.then(success) | ||||
| 		.catch((err) => { | ||||
| 			console.log("createAsync error", JSON.stringify(err)) | ||||
| 			error(JSON.stringify(err)) | ||||
| 		}) | ||||
| } | ||||
| 
 | ||||
| function sendPlayWith(data, success, error) { | ||||
| 	FBInstant.context | ||||
| 		.chooseAsync(JSON.parse(data)) | ||||
| 		.then(success) | ||||
| 		.catch((err) => { | ||||
| 			console.log(err.message) | ||||
| 			error(JSON.stringify(err)) | ||||
| 		}) | ||||
| } | ||||
| 
 | ||||
| function updateAsync(img) { | ||||
| 	FBInstant.updateAsync({ | ||||
| 		action: 'CUSTOM', | ||||
| 		cta: 'Join The Fight', | ||||
| 		image: img, | ||||
| 		text: { | ||||
| 			default: 'X just invaded Y\'s village!', | ||||
| 		}, | ||||
| 		template: 'VILLAGE_INVASION', | ||||
| 		data: { myReplayData: 'test', date: new Date().getMilliseconds() }, | ||||
| 		strategy: 'IMMEDIATE', | ||||
| 		notification: 'NO_PUSH', | ||||
| 	}).then(function () { | ||||
| 		// closes the game after the update is posted.
 | ||||
| 
 | ||||
| 	}); | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 5.2 KiB | 
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 8.1 KiB | 
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 5.5 KiB | 
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 20 KiB | 
|  | @ -0,0 +1,187 @@ | |||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|   <!-- | ||||
|     If you are serving your web app in a path other than the root, change the | ||||
|     href value below to reflect the base path you are serving from. | ||||
| 
 | ||||
|     The path provided below has to start and end with a slash "/" in order for | ||||
|     it to work correctly. | ||||
| 
 | ||||
|     For more details: | ||||
|     * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base | ||||
| 
 | ||||
|     This is a placeholder for base href that will be replaced by the value of | ||||
|     the `--base-href` argument provided to `flutter build`. | ||||
|   --> | ||||
|   <script async src="https://www.googletagmanager.com/gtag/js?id=GT-K5856LV"></script> | ||||
|   <script> | ||||
|     window.dataLayer = window.dataLayer || []; | ||||
|     function gtag() { | ||||
|       dataLayer.push(arguments); | ||||
|     } | ||||
|     gtag("js", new Date()); | ||||
| 
 | ||||
|     gtag("config", "G-K57CGD10ST", { | ||||
|       cookie_flags: "max-age=7200;secure;samesite=none", | ||||
|       cookieDomain: 'none' | ||||
|     }); | ||||
|     function gaEventLogger(eventName, eventParameters) { | ||||
|       gtag("event", eventName, JSON.parse(eventParameters)); | ||||
|     } | ||||
|   </script> | ||||
|   <base href="$FLUTTER_BASE_HREF"> | ||||
| 
 | ||||
|   <meta charset="UTF-8"> | ||||
|   <meta content="IE=Edge" http-equiv="X-UA-Compatible"> | ||||
|   <meta name="description" content="A new Flutter project."> | ||||
| 
 | ||||
|   <!-- iOS meta tags & icons --> | ||||
|   <meta name="apple-mobile-web-app-capable" content="yes"> | ||||
|   <meta name="apple-mobile-web-app-status-bar-style" content="black"> | ||||
|   <meta name="apple-mobile-web-app-title" content="example"> | ||||
|   <link rel="apple-touch-icon" href="icons/Icon-192.png"> | ||||
| 
 | ||||
|   <!-- Favicon --> | ||||
|   <link rel="icon" type="image/png" href="favicon.png"/> | ||||
| 
 | ||||
|   <title>example</title> | ||||
|   <link rel="manifest" href="manifest.json"> | ||||
| 
 | ||||
|   <script> | ||||
|     // The value below is injected by flutter build, do not touch. | ||||
|     const serviceWorkerVersion = null; | ||||
|   </script> | ||||
|   <!-- This script adds the flutter initialization JS code --> | ||||
|   <script src="flutter.js" defer></script> | ||||
|   <!-- Event logger--> | ||||
|   <script src="pako.min.js"></script> | ||||
|   <script src="event-logger.js"></script> | ||||
|   <style> | ||||
|     * { | ||||
|       margin: 0; | ||||
|       padding: 0; | ||||
|     } | ||||
| 
 | ||||
|     flutter-view { | ||||
|       display: none; | ||||
|     } | ||||
| 
 | ||||
|     #launch_view { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       align-items: center; | ||||
|       width: 100vw; | ||||
|       height: 100vh; | ||||
|       position: relative; | ||||
|       overflow: hidden; | ||||
|       background: #fff; | ||||
|     } | ||||
| 
 | ||||
|     #launch_view .logo { | ||||
|       position: absolute; | ||||
|       top: 48%; | ||||
|       max-width: 75vw; | ||||
|       width: 50vw; | ||||
|       top: 30vh; | ||||
|     } | ||||
| 
 | ||||
|     #launch_view .cover { | ||||
|       position: absolute; | ||||
|       width: 100%; | ||||
|       top: 0; | ||||
|     } | ||||
| 
 | ||||
|     #launch_view .loading { | ||||
|       position: absolute; | ||||
|       top: 54%; | ||||
|       border: 4px solid rgba(255, 255, 255, 0.3); | ||||
|       border-top: 4px solid #fff; | ||||
|       border-radius: 50%; | ||||
|       width: 7vw; | ||||
|       height: 7vw; | ||||
|       transform: translate(-50%, -50%); | ||||
|       animation: spin 1s linear infinite; | ||||
|     } | ||||
| 
 | ||||
|     #launch_view .guru_text { | ||||
|       position: absolute; | ||||
|       width: 100%; | ||||
|       top: 85%; | ||||
|       color: #000; | ||||
|       text-align: center; | ||||
|       font-weight: 600; | ||||
|     } | ||||
| 
 | ||||
|     @keyframes spin { | ||||
|       0% { | ||||
|         transform: rotate(0deg); | ||||
|       } | ||||
| 
 | ||||
|       100% { | ||||
|         transform: rotate(360deg); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     #debug_dialog { | ||||
|       position: fixed; | ||||
|       top: 0; | ||||
|       bottom: 0; | ||||
|       left: 0; | ||||
|       right: 0; | ||||
|       display: none; | ||||
|       align-items: center; | ||||
|       justify-content: center; | ||||
|       z-index: 999; | ||||
|     } | ||||
| 
 | ||||
|     #debug_dialog .content { | ||||
|       width: 80%; | ||||
|       background-color: #fff; | ||||
|       padding: 12px; | ||||
|       height: 80%; | ||||
|       overflow: auto; | ||||
|     } | ||||
|   </style> | ||||
| </head> | ||||
| <body> | ||||
|   <script src="https://connect.facebook.net/en_US/fbinstant.7.0.js"></script> | ||||
|   <script src="index.js"></script> | ||||
|   <script> | ||||
|     showLaunchView() | ||||
|     window.flutterConfiguration = { | ||||
|       canvasKitBaseUrl: "./canvaskit/", | ||||
|     }; | ||||
|     async function updateProgress() { | ||||
|       for (let i = 0; i < 9; i++) { | ||||
|         await new Promise((res) => setTimeout(res, 200)); | ||||
|         FBInstant.setLoadingProgress((i / 10) * 100); | ||||
|       } | ||||
|     } | ||||
|     function onStart() { | ||||
|       // Download main.dart.js | ||||
|       window.gtag && gtag("event", "app_open", { source: "" }); | ||||
|       _flutter.loader.loadEntrypoint({ | ||||
|         serviceWorker: { | ||||
|           serviceWorkerVersion: serviceWorkerVersion, | ||||
|         }, | ||||
|         onEntrypointLoaded: function(engineInitializer) { | ||||
|           engineInitializer.initializeEngine().then(function(appRunner) { | ||||
|             appRunner.runApp(); | ||||
|           }); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|     window.onload = function () { | ||||
|       updateProgress(); | ||||
|       //onStart(); | ||||
|       FBInstant.initializeAsync().then(async () => { | ||||
|         return FBInstant.startGameAsync().then(onStart); | ||||
|       }); | ||||
|     }; | ||||
|   </script> | ||||
|   <div id="debug_dialog" onclick="closeDebugeDialog(event)"> | ||||
|     <div class="content"></div> | ||||
|   </div> | ||||
| </body> | ||||
| </html> | ||||
|  | @ -0,0 +1,68 @@ | |||
| 
 | ||||
| /** | ||||
|  * showLaunchView 展示首页 | ||||
|  * @param {Object} options  | ||||
|  * @param {string} options.cover  顶部图片url,有则显示,没有不显示 | ||||
|  * @param {string} options.logo  logo url,有则显示,没有不显示 | ||||
|  * @param {boolean} options.loading  loading,有则显示,没有不显示 | ||||
|  * @param {string} options.guruLimited  guru limited 文案,默认"@ Guru network limited" | ||||
|  */ | ||||
| function showLaunchView(options = { | ||||
| 	guruLimited: '@ Guru network limited' | ||||
| }) { | ||||
| 	const view =  document.createElement('div') | ||||
| 	view.id = 'launch_view' | ||||
| 
 | ||||
| 	if (options.cover) { | ||||
| 		const img = document.createElement('img') | ||||
| 		img.src = options.cover | ||||
| 		img.className = 'cover' | ||||
| 		view.appendChild(img) | ||||
| 	} | ||||
| 
 | ||||
| 	if (options.logo) { | ||||
| 		const img = document.createElement('img') | ||||
| 		img.src = options.logo | ||||
| 		img.className = 'logo' | ||||
| 		view.appendChild(img) | ||||
| 	} | ||||
| 
 | ||||
| 	if (options.loading) { | ||||
| 		const loading = document.createElement('div') | ||||
| 		loading.className = 'loading' | ||||
| 		view.appendChild(loading) | ||||
| 	} | ||||
| 
 | ||||
| 	const text = document.createElement('p') | ||||
| 	text.className = 'guru_text' | ||||
| 	view.appendChild(text) | ||||
| 
 | ||||
| 	document.body.append(view) | ||||
| } | ||||
| 
 | ||||
| function hideLaunchView() { | ||||
| 	const view = document.querySelector('#launch_view') | ||||
| 	if (view) { | ||||
| 		view.style.display = 'none' | ||||
| 	} | ||||
| 	showFlutterView() | ||||
| } | ||||
| 
 | ||||
| function showFlutterView() { | ||||
| 	document.querySelector("flutter-view").style.display = "block" | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 打开debug dialog | ||||
|  * @param {string} message 展示内容 | ||||
|  */ | ||||
| function showDebugDialog(message) { | ||||
| 	const view = document.querySelector("#debug_dialog") | ||||
| 	if (view) view.style.display = "flex" | ||||
| 	const content = document.querySelector(".content") | ||||
| 	content.innerHTML = message | ||||
| } | ||||
| 
 | ||||
| function closeDebugeDialog(e) { | ||||
| 	e.target.style.display = "none" | ||||
| } | ||||
|  | @ -0,0 +1,35 @@ | |||
| { | ||||
|     "name": "example", | ||||
|     "short_name": "example", | ||||
|     "start_url": ".", | ||||
|     "display": "standalone", | ||||
|     "background_color": "#0175C2", | ||||
|     "theme_color": "#0175C2", | ||||
|     "description": "A new Flutter project.", | ||||
|     "orientation": "portrait-primary", | ||||
|     "prefer_related_applications": false, | ||||
|     "icons": [ | ||||
|         { | ||||
|             "src": "icons/Icon-192.png", | ||||
|             "sizes": "192x192", | ||||
|             "type": "image/png" | ||||
|         }, | ||||
|         { | ||||
|             "src": "icons/Icon-512.png", | ||||
|             "sizes": "512x512", | ||||
|             "type": "image/png" | ||||
|         }, | ||||
|         { | ||||
|             "src": "icons/Icon-maskable-192.png", | ||||
|             "sizes": "192x192", | ||||
|             "type": "image/png", | ||||
|             "purpose": "maskable" | ||||
|         }, | ||||
|         { | ||||
|             "src": "icons/Icon-maskable-512.png", | ||||
|             "sizes": "512x512", | ||||
|             "type": "image/png", | ||||
|             "purpose": "maskable" | ||||
|         } | ||||
|     ] | ||||
| } | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -0,0 +1,59 @@ | |||
| library guru_fb_game; | ||||
| import 'package:guru_fb_game/model/model.dart'; | ||||
| import 'package:guru_fb_game/utils.dart'; | ||||
| 
 | ||||
| class AppConfig { | ||||
|   final String appVersion; | ||||
|   final String appBuildNumber; | ||||
|   final String guruLogAppId; | ||||
|   final String bundleId; | ||||
| 
 | ||||
|   final String? adUnitIdBanner; | ||||
|   final String? adUnitIdInter; | ||||
|   final String? adUnitIdRewardVideo; | ||||
| 
 | ||||
|   const AppConfig({ | ||||
|     required this.appVersion, | ||||
|     this.appBuildNumber = "1", | ||||
|     required this.guruLogAppId, | ||||
|     required this.bundleId, | ||||
|     this.adUnitIdBanner, | ||||
|     this.adUnitIdInter, | ||||
|     this.adUnitIdRewardVideo | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| class GuruFbGame { | ||||
|   static final GuruFbGame _instance = GuruFbGame._(); | ||||
| 
 | ||||
|   static GuruFbGame get instance => _instance; | ||||
| 
 | ||||
|   GuruFbGame._(); | ||||
| 
 | ||||
|   FbProfile? fbProfile; | ||||
| 
 | ||||
|   Map<String, String>? fbtournament; | ||||
| 
 | ||||
|   Future _initialize(AppConfig config) async { | ||||
|     // setUrlStrategy(null); | ||||
|     fbProfile = await FbGameGlobalUtils.getFBProfile(); | ||||
|     final fbPlatform = FbGameGlobalUtils.getPlatform(); | ||||
|     final deviceInfo = GuruLogDeviceData(appId: config.bundleId, version: config.appVersion, fbPlatform: fbPlatform); | ||||
|     final logInfoData = GuruLogInfoData(deveiceId: '', uid: fbProfile?.userId ?? ''); | ||||
|     final initData = InitGuruLogEventData(appId: config.guruLogAppId, deviceInfo: deviceInfo, info: logInfoData); | ||||
|     FbGameLogUtils.initGuruLogEvent(initData); | ||||
|     fbtournament = await FbGameTournamentUtils.getCurrentTournament(); | ||||
|     await initAds(config); | ||||
|   } | ||||
| 
 | ||||
|   Future initAds(AppConfig config) async{ | ||||
|     List<String> list = FbGameAdUtils.checkSupportedAds(); | ||||
|     if (list.contains('getInterstitialAdAsync') && config.adUnitIdInter != null) { | ||||
|       FbGameAdUtils.loadInterstitial(config.adUnitIdInter!); | ||||
|     } | ||||
| 
 | ||||
|     if (list.contains('getRewardedVideoAsync') && config.adUnitIdRewardVideo != null) { | ||||
|       FbGameAdUtils.loadRewarded(config.adUnitIdRewardVideo!); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,232 @@ | |||
| import 'package:json_annotation/json_annotation.dart'; | ||||
| part 'model.g.dart'; | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class FbProfile { | ||||
|   final String photo; | ||||
|   final String name;  | ||||
|   final String id; | ||||
|   final String contextType; | ||||
|   final String? userId; | ||||
| 
 | ||||
|   const FbProfile({ | ||||
|     required this.photo, | ||||
|     required this.name, | ||||
|     required this.id, | ||||
|     required this.contextType, | ||||
|     this.userId | ||||
|   }); | ||||
| 
 | ||||
|   factory FbProfile.fromJson(Map<String, dynamic> json) => _$FbProfileFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$FbProfileToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class FbShareData { | ||||
|   final String image; | ||||
|   final String text; | ||||
|   final Map<String, String> data; | ||||
|   final List<String> shareDestination; | ||||
|   final bool switchContext; | ||||
| 
 | ||||
|   const FbShareData({ | ||||
|     this.image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAMAAADQmBKKAAAAAXNSR0IArs4c6QAAAkxQTFRFAAAAJCQkGhoaDAwMFxcXFhYWFBQUDAwMDAwMEBAQEBAQDw8PDg4ODg4ODQ0NDQ0NDQ0NDAwMDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDAwMDAwMDAwMBwcHCAgICQkJCgoKCwsLDAwMDg4ODw8PEBAQEhISFxcXGhoaGxsbHR0dHh4eHx8fICAgISEhIyMjJCQkJSUlJiYmLCwsLS0tMDAwMjIyMzMzNjY2Nzc3ODg4QEBAQUFBRERERkZGTk5OT09PU1NTV1dXWFhYW1tbXFxcXV1dX19fYGBgYWFhZmZmaGhoaWlpbGxsbm5ucXFxcnJydXV1dnZ2eHh4enp6e3t7fHx8fX19fn5+f39/gICAgoKCg4ODhISEhYWFiYmJi4uLjIyMjY2Njo6Oj4+PkJCQkZGRk5OTlZWVl5eXmZmZmpqam5ubnZ2dnp6eoKCgoaGho6OjpKSkp6enqKioqampqqqqrKysra2trq6ur6+vsLCwsrKys7OztLS0tbW1tra2t7e3uLi4ubm5urq6u7u7vLy8vr6+v7+/wMDAwcHBwsLCxMTExcXFxsbGx8fHyMjIycnJysrKy8vLzc3Nzs7Oz8/P0NDQ0dHR0tLS09PT1NTU1dXV1tbW2NjY2dnZ2tra29vb3t7e39/f4ODg4eHh4uLi4+Pj5OTk5eXl5ubm5+fn6Ojo6enp6urq6+vr7Ozs7e3t7u7u7+/v8PDw8fHx8vLy8/Pz9PT09fX19vb29/f3+Pj4+fn5+vr6+/v7/Pz8/f39/v7+////CfwD0AAAAB10Uk5TAAcKFRYXGT4/QEFGSXBzeaCms8LDxsff7PD5+/2lKaEhAAAGb0lEQVR42s3cCV8UZRwH8BWNI0CWQ46FnZ0ZyJJIs+ygsrSsqLD7wizKCsvMsuzGsEM6sBDIEkKJQwoRxJVjYVnYnXljLbvM7szu8zzzzDPPs/P8XsDO9/N/ZmafeS6XC5is/CJ3eUWlR2YQT2VFubsoP8uFnU2FpUwkKa7Swk1YnJySajlDqS7JMeXcWCZnNGV5SE52sZzxFGfDPQVVsgOpKoB5NtfIjqRmM5CzwS07FveGdM/GLbKD2bIxrT6OeqKi1Bq5ZYfjTrmfZcdjuLMLCJ8viSSwZ0339GdXZciCJFUl35DFGcQgRMWaJy+zGniraf9rZbY5IkYwRGVxTy45R7QYsxLlxkAlhBzdhXzmMYogVyiJ9Q+rSTj4EiMKDape60MWWucYMQJeDCLYZQqjoFKrnqRGsBadCHad0uj3hceaJ1XjxY0mQoI8Wa58Io5FjEYyBcn5riILHj0nfo1b9rR82jsy6V8Mq1gxBRWZ9zvSPBpn29MnJxTVWrSbCNELKbfiSXLqW4cjqvVobQa9XLmrAtMTL886Z3/vqkoUU1CFqxLbEy+P1yu+ekUljSmo0uWx5IlyWmdUlR3I48L3xMrzyGVVZQmSXZY8O39VVA5A6x6v99klVXUQlOqp/VZVnQSleu4dV7kAaZ69iyoNkEgMSvE8FVIpgSQyUIqnJazyAop5Dih0PKpICjIWqJlSfWyD1j17Qyo9kEQEMjRY45JKDyTZAa0VSBBq/6XnUSUykKFA36u8gOKe53Cuo0RWljESCpGB9AW6I2hiiUy0t+yuozP6gQbFC3QGrRl+5SaKwzEYoH2oN6LSu4vq8BAMpPNIiN6zMrybPscM9DbcM/+YhatYCAwUewdJ16Ge0XoWGiQoWqDXoZ5OkQ0HBNK12FXYw3VIZMRBgoRnYPVpY+eBgWIF+gvi+UVkxkGCGiC9oFFZZOdBgIQ2sGeugaUnHZR86C+BQfvNO1ksQNEWA/9rjIpMC4QAPQ/+w3iIbYEQoE4g6E/GHgQI+FZU7nMMVA+8hcbMv/RYgR4GttibrAsEB7UCW2w76wKBQWuvoXYQaMrnFMjnGwD2OhwEAQc32xwEXQOBHncQtAAC3e1z7qZeBoFudQi0ViLQ1IoicAty4MUIHKSq9zn31xEAge7y4ZdoYhoQGyA/CPSoBRCwxDZAk6Dfe0swn3pnBboA+r3vkvPKpiLKILED9HsTAn6JaIE00SHgN3QDfolog/YBO2gHBYwlJWxA4K+gIUFfIilzoKgI+NxH7jGKpAyCuoBtdkYwNBpCRA2ktdnL4A/FB1NFUqZAt4E/pQe9aSIpE6CoaBI82NAEEIFM9EFHwKDpbcK6SESO/gYjgBANC2uiHZDlL/0+4/ItSAIrxqxGEyYaOE+UaAgypPd1fLGUT0RWaXbJkGAwuBxasTO1IIkvwgY9DyRXlMFN07P6zM3NLwQWl8hAiRLNQUCrL2kiH3xV5/gVXaampq/O+GfnbU7gvQMdOP8ssewOahoa1Wfs0vh/k9PXJHtTnLUBqOjczXGSAF3WeW5Al78HL1wcHhmftAeSxA/gky+X96wvToShurp1OdvT98f5wX/GSCeBEyWaRUxPnW30Jk3p6fhRl9OdP3f93td/kXSaPFGiJ1ETeOGO23XLOFNz4ktdvvrm5KnOrp7zou2lFv3IKc6V7mYJtq7zw491Of7JiS/af+jqsw+602x9YGjgaHOjCAAdfl+XI0ePHf+8/fRv5ItREqKDWNPkoXl/amZBL0YboISom+ZCAhsLmhJLvrb6aYJoLIq7f4UTUELUFOYMJL2g8AFKit5V+ADRF9kF6VotwgcoKXpilQ9QUvTAdT5ASdHWHmff1Oki6Y1VCiDJNkgn2jWo8ADSk5oXWIM8FkV1x4I2QKYdNI/JNgqASKo7HGC39LTSbKMJkCS1jBC+J02/OipMt+JASDs/miG5v02/y8qtbZI2fLzveK1nQaENcmNs54KTJGl703s/jfgDy2GFEqgIZ8MbkkR5KWM+3pZA2ib4U5+FuWmSLgr+q6XY20opqlA/WGhh420mEtt4i781mX1K4ofF8APKsb69nWnKtANseAHlkR6RwCjFtg6RoB/dIRLEx2zQTE0BzweR8HdUC3+H2XB33A9/ByLxd2QUh4dq8Xfs2NrBNnwdzBY7SiaTR9flYp2mdwNfh/s5fPzh/zcuYJd5bFPWAAAAAElFTkSuQmCC', | ||||
|     required this.text, | ||||
|     this.data = const {}, | ||||
|     this.shareDestination = const [ | ||||
|       'NEWSFEED', | ||||
|       'GROUP', | ||||
|       'COPY_LINK', | ||||
|       'MESSENGER' | ||||
|     ], | ||||
|     this.switchContext = false, | ||||
|   }); | ||||
| 
 | ||||
|   factory FbShareData.fromJson(Map<String, dynamic> json) => _$FbShareDataFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$FbShareDataToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class FbInviteTextData { | ||||
|   final String text; | ||||
|   // localizations: { | ||||
|   //   ar_AR: 'X \u0641\u0642\u0637 \u063A\u0632\u062A ' + | ||||
|   //     '\u0642\u0631\u064A\u0629 Y!', | ||||
|   //   en_US: 'X just invaded Y\'s village!', | ||||
|   //   es_LA: '\u00A1X acaba de invadir el pueblo de Y!' | ||||
|   // } | ||||
|   final Map<String, String> localizations; | ||||
| 
 | ||||
|   const FbInviteTextData({required this.text, required this.localizations}); | ||||
| 
 | ||||
|   Map<String, dynamic> toMap() { | ||||
|     return { | ||||
|       'default': text, | ||||
|       'localizations': localizations, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   factory FbInviteTextData.fromJson(Map<String, dynamic> json) => _$FbInviteTextDataFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$FbInviteTextDataToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class FbInviteData { | ||||
|   final String image; | ||||
|   final FbInviteTextData text; | ||||
|   final FbInviteTextData? cta; | ||||
|   final FbInviteTextData? dialogTitle; | ||||
|   final List<String>? filters;  // ['NEW_CONTEXT_ONLY', 'EXISTING_PLAYERS_ONLY'] | ||||
|   // sections: [ | ||||
|   //   {sectionType: 'GROUPS', maxResults: 2}, | ||||
|   //   {sectionType: 'USERS'} | ||||
|   // ], | ||||
|   final List<Map<String, dynamic>>? sections; | ||||
| 
 | ||||
|   const FbInviteData({ | ||||
|     required this.image, | ||||
|     required this.text, | ||||
|     this.cta, | ||||
|     this.dialogTitle, | ||||
|     this.filters, | ||||
|     this.sections, | ||||
|   }); | ||||
| 
 | ||||
|   factory FbInviteData.fromJson(Map<String, dynamic> json) => _$FbInviteDataFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$FbInviteDataToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class GuruLogDeviceData{ | ||||
|   final String appId; | ||||
|   final String version; | ||||
|   final String fbPlatform; | ||||
| 
 | ||||
|   GuruLogDeviceData({ | ||||
|     required this.appId, | ||||
|     this.version = '1.0.0', | ||||
|     required this.fbPlatform | ||||
|   }); | ||||
| 
 | ||||
|   factory GuruLogDeviceData.fromJson(Map<String, dynamic> json) => _$GuruLogDeviceDataFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$GuruLogDeviceDataToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class GuruLogInfoData { | ||||
|   final String? deveiceId; | ||||
|   final String? uid; | ||||
|   final String? adjustId; | ||||
|   final String? adId; | ||||
|   final String? firebaseId; | ||||
| 
 | ||||
|   const GuruLogInfoData({ | ||||
|     this.deveiceId, | ||||
|     this.uid, | ||||
|     this.adjustId, | ||||
|     this.adId, | ||||
|     this.firebaseId | ||||
|   }); | ||||
| 
 | ||||
|   factory GuruLogInfoData.fromJson(Map<String, dynamic> json) => _$GuruLogInfoDataFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$GuruLogInfoDataToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class InitGuruLogEventData { | ||||
|   final String appId; | ||||
|   final GuruLogDeviceData deviceInfo; | ||||
|   final GuruLogInfoData info; | ||||
| 
 | ||||
|   const InitGuruLogEventData({ | ||||
|     required this.appId, | ||||
|     required this.deviceInfo, | ||||
|     required this.info | ||||
|   }); | ||||
| 
 | ||||
|   factory InitGuruLogEventData.fromJson(Map<String, dynamic> json) => _$InitGuruLogEventDataFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$InitGuruLogEventDataToJson(this); | ||||
| } | ||||
| 
 | ||||
| enum FBTournamentSortOrder{ | ||||
|   HIGHER_IS_BETTER, LOWER_IS_BETTER; | ||||
| } | ||||
| 
 | ||||
| enum FBTournamentSortFormat{ | ||||
|   NUMERIC, TIME; | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class FbTournamentConfig{ | ||||
|   final String? title; | ||||
|   final String? image; | ||||
|   //  "HIGHER_IS_BETTER"| "LOWER_IS_BETTER" | ||||
|   final FBTournamentSortOrder? sortOrder; | ||||
|   // "NUMERIC" | "TIME" | ||||
|   final FBTournamentSortFormat? scoreFormat; | ||||
|   // timestamp | ||||
|   final num? endTime; | ||||
| 
 | ||||
|   const FbTournamentConfig({ | ||||
|     this.title, | ||||
|     this.image, | ||||
|     this.sortOrder, | ||||
|     this.scoreFormat, | ||||
|     this.endTime | ||||
|   }); | ||||
| 
 | ||||
|   factory FbTournamentConfig.fromJson(Map<String, dynamic> json) => _$FbTournamentConfigFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$FbTournamentConfigToJson(this); | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class FbUserInfo { | ||||
|   @JsonKey(name: 'player_id', defaultValue: "") | ||||
|   final String id; | ||||
| 
 | ||||
|   @JsonKey(name: 'user_id', defaultValue: "") | ||||
|   final String userId; | ||||
| 
 | ||||
|   @JsonKey(name: 'name', defaultValue: "") | ||||
|   final String name; | ||||
| 
 | ||||
|   @JsonKey(name: 'picUrl', defaultValue: "") | ||||
|   final String picUrl; | ||||
| 
 | ||||
|   FbUserInfo(this.id, this.userId, this.name, this.picUrl); | ||||
| 
 | ||||
|   factory FbUserInfo.fromJson(Map<String, dynamic> json) => _$FbUserInfoFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$FbUserInfoToJson(this); | ||||
| } | ||||
| 
 | ||||
| enum FBContextType { | ||||
|   @JsonValue("POST") POST, | ||||
|   @JsonValue("THREAD") THREAD, | ||||
|   @JsonValue("GROUP") GROUP, | ||||
|   @JsonValue("SOLO") SOLO | ||||
| } | ||||
| 
 | ||||
| @JsonSerializable() | ||||
| class FBContextInfo { | ||||
|   final String id; | ||||
|   final FBContextType type; | ||||
| 
 | ||||
|   const FBContextInfo({ | ||||
|     required this.id, | ||||
|     required this.type, | ||||
|   }); | ||||
| 
 | ||||
|   factory FBContextInfo.fromJson(Map<String, dynamic> json) => _$FBContextInfoFromJson(json); | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() => _$FBContextInfoToJson(this); | ||||
| } | ||||
|  | @ -0,0 +1,199 @@ | |||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| 
 | ||||
| part of 'model.dart'; | ||||
| 
 | ||||
| // ************************************************************************** | ||||
| // JsonSerializableGenerator | ||||
| // ************************************************************************** | ||||
| 
 | ||||
| FbProfile _$FbProfileFromJson(Map<String, dynamic> json) => FbProfile( | ||||
|       photo: json['photo'] as String, | ||||
|       name: json['name'] as String, | ||||
|       id: json['id'] as String, | ||||
|       contextType: json['contextType'] as String, | ||||
|       userId: json['userId'] as String?, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$FbProfileToJson(FbProfile instance) => <String, dynamic>{ | ||||
|       'photo': instance.photo, | ||||
|       'name': instance.name, | ||||
|       'id': instance.id, | ||||
|       'contextType': instance.contextType, | ||||
|       'userId': instance.userId, | ||||
|     }; | ||||
| 
 | ||||
| FbShareData _$FbShareDataFromJson(Map<String, dynamic> json) => FbShareData( | ||||
|       image: json['image'] as String? ?? | ||||
|           'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAMAAADQmBKKAAAAAXNSR0IArs4c6QAAAkxQTFRFAAAAJCQkGhoaDAwMFxcXFhYWFBQUDAwMDAwMEBAQEBAQDw8PDg4ODg4ODQ0NDQ0NDQ0NDAwMDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDAwMDAwMDAwMBwcHCAgICQkJCgoKCwsLDAwMDg4ODw8PEBAQEhISFxcXGhoaGxsbHR0dHh4eHx8fICAgISEhIyMjJCQkJSUlJiYmLCwsLS0tMDAwMjIyMzMzNjY2Nzc3ODg4QEBAQUFBRERERkZGTk5OT09PU1NTV1dXWFhYW1tbXFxcXV1dX19fYGBgYWFhZmZmaGhoaWlpbGxsbm5ucXFxcnJydXV1dnZ2eHh4enp6e3t7fHx8fX19fn5+f39/gICAgoKCg4ODhISEhYWFiYmJi4uLjIyMjY2Njo6Oj4+PkJCQkZGRk5OTlZWVl5eXmZmZmpqam5ubnZ2dnp6eoKCgoaGho6OjpKSkp6enqKioqampqqqqrKysra2trq6ur6+vsLCwsrKys7OztLS0tbW1tra2t7e3uLi4ubm5urq6u7u7vLy8vr6+v7+/wMDAwcHBwsLCxMTExcXFxsbGx8fHyMjIycnJysrKy8vLzc3Nzs7Oz8/P0NDQ0dHR0tLS09PT1NTU1dXV1tbW2NjY2dnZ2tra29vb3t7e39/f4ODg4eHh4uLi4+Pj5OTk5eXl5ubm5+fn6Ojo6enp6urq6+vr7Ozs7e3t7u7u7+/v8PDw8fHx8vLy8/Pz9PT09fX19vb29/f3+Pj4+fn5+vr6+/v7/Pz8/f39/v7+////CfwD0AAAAB10Uk5TAAcKFRYXGT4/QEFGSXBzeaCms8LDxsff7PD5+/2lKaEhAAAGb0lEQVR42s3cCV8UZRwH8BWNI0CWQ46FnZ0ZyJJIs+ygsrSsqLD7wizKCsvMsuzGsEM6sBDIEkKJQwoRxJVjYVnYnXljLbvM7szu8zzzzDPPs/P8XsDO9/N/ZmafeS6XC5is/CJ3eUWlR2YQT2VFubsoP8uFnU2FpUwkKa7Swk1YnJySajlDqS7JMeXcWCZnNGV5SE52sZzxFGfDPQVVsgOpKoB5NtfIjqRmM5CzwS07FveGdM/GLbKD2bIxrT6OeqKi1Bq5ZYfjTrmfZcdjuLMLCJ8viSSwZ0339GdXZciCJFUl35DFGcQgRMWaJy+zGniraf9rZbY5IkYwRGVxTy45R7QYsxLlxkAlhBzdhXzmMYogVyiJ9Q+rSTj4EiMKDape60MWWucYMQJeDCLYZQqjoFKrnqRGsBadCHad0uj3hceaJ1XjxY0mQoI8Wa58Io5FjEYyBcn5riILHj0nfo1b9rR82jsy6V8Mq1gxBRWZ9zvSPBpn29MnJxTVWrSbCNELKbfiSXLqW4cjqvVobQa9XLmrAtMTL886Z3/vqkoUU1CFqxLbEy+P1yu+ekUljSmo0uWx5IlyWmdUlR3I48L3xMrzyGVVZQmSXZY8O39VVA5A6x6v99klVXUQlOqp/VZVnQSleu4dV7kAaZ69iyoNkEgMSvE8FVIpgSQyUIqnJazyAop5Dih0PKpICjIWqJlSfWyD1j17Qyo9kEQEMjRY45JKDyTZAa0VSBBq/6XnUSUykKFA36u8gOKe53Cuo0RWljESCpGB9AW6I2hiiUy0t+yuozP6gQbFC3QGrRl+5SaKwzEYoH2oN6LSu4vq8BAMpPNIiN6zMrybPscM9DbcM/+YhatYCAwUewdJ16Ge0XoWGiQoWqDXoZ5OkQ0HBNK12FXYw3VIZMRBgoRnYPVpY+eBgWIF+gvi+UVkxkGCGiC9oFFZZOdBgIQ2sGeugaUnHZR86C+BQfvNO1ksQNEWA/9rjIpMC4QAPQ/+w3iIbYEQoE4g6E/GHgQI+FZU7nMMVA+8hcbMv/RYgR4GttibrAsEB7UCW2w76wKBQWuvoXYQaMrnFMjnGwD2OhwEAQc32xwEXQOBHncQtAAC3e1z7qZeBoFudQi0ViLQ1IoicAty4MUIHKSq9zn31xEAge7y4ZdoYhoQGyA/CPSoBRCwxDZAk6Dfe0swn3pnBboA+r3vkvPKpiLKILED9HsTAn6JaIE00SHgN3QDfolog/YBO2gHBYwlJWxA4K+gIUFfIilzoKgI+NxH7jGKpAyCuoBtdkYwNBpCRA2ktdnL4A/FB1NFUqZAt4E/pQe9aSIpE6CoaBI82NAEEIFM9EFHwKDpbcK6SESO/gYjgBANC2uiHZDlL/0+4/ItSAIrxqxGEyYaOE+UaAgypPd1fLGUT0RWaXbJkGAwuBxasTO1IIkvwgY9DyRXlMFN07P6zM3NLwQWl8hAiRLNQUCrL2kiH3xV5/gVXaampq/O+GfnbU7gvQMdOP8ssewOahoa1Wfs0vh/k9PXJHtTnLUBqOjczXGSAF3WeW5Al78HL1wcHhmftAeSxA/gky+X96wvToShurp1OdvT98f5wX/GSCeBEyWaRUxPnW30Jk3p6fhRl9OdP3f93td/kXSaPFGiJ1ETeOGO23XLOFNz4ktdvvrm5KnOrp7zou2lFv3IKc6V7mYJtq7zw491Of7JiS/af+jqsw+602x9YGjgaHOjCAAdfl+XI0ePHf+8/fRv5ItREqKDWNPkoXl/amZBL0YboISom+ZCAhsLmhJLvrb6aYJoLIq7f4UTUELUFOYMJL2g8AFKit5V+ADRF9kF6VotwgcoKXpilQ9QUvTAdT5ASdHWHmff1Oki6Y1VCiDJNkgn2jWo8ADSk5oXWIM8FkV1x4I2QKYdNI/JNgqASKo7HGC39LTSbKMJkCS1jBC+J02/OipMt+JASDs/miG5v02/y8qtbZI2fLzveK1nQaENcmNs54KTJGl703s/jfgDy2GFEqgIZ8MbkkR5KWM+3pZA2ib4U5+FuWmSLgr+q6XY20opqlA/WGhh420mEtt4i781mX1K4ofF8APKsb69nWnKtANseAHlkR6RwCjFtg6RoB/dIRLEx2zQTE0BzweR8HdUC3+H2XB33A9/ByLxd2QUh4dq8Xfs2NrBNnwdzBY7SiaTR9flYp2mdwNfh/s5fPzh/zcuYJd5bFPWAAAAAElFTkSuQmCC', | ||||
|       text: json['text'] as String, | ||||
|       data: (json['data'] as Map<String, dynamic>?)?.map( | ||||
|             (k, e) => MapEntry(k, e as String), | ||||
|           ) ?? | ||||
|           const {}, | ||||
|       shareDestination: (json['shareDestination'] as List<dynamic>?) | ||||
|               ?.map((e) => e as String) | ||||
|               .toList() ?? | ||||
|           const ['NEWSFEED', 'GROUP', 'COPY_LINK', 'MESSENGER'], | ||||
|       switchContext: json['switchContext'] as bool? ?? false, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$FbShareDataToJson(FbShareData instance) => | ||||
|     <String, dynamic>{ | ||||
|       'image': instance.image, | ||||
|       'text': instance.text, | ||||
|       'data': instance.data, | ||||
|       'shareDestination': instance.shareDestination, | ||||
|       'switchContext': instance.switchContext, | ||||
|     }; | ||||
| 
 | ||||
| FbInviteTextData _$FbInviteTextDataFromJson(Map<String, dynamic> json) => | ||||
|     FbInviteTextData( | ||||
|       text: json['text'] as String, | ||||
|       localizations: Map<String, String>.from(json['localizations'] as Map), | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$FbInviteTextDataToJson(FbInviteTextData instance) => | ||||
|     <String, dynamic>{ | ||||
|       'text': instance.text, | ||||
|       'localizations': instance.localizations, | ||||
|     }; | ||||
| 
 | ||||
| FbInviteData _$FbInviteDataFromJson(Map<String, dynamic> json) => FbInviteData( | ||||
|       image: json['image'] as String, | ||||
|       text: FbInviteTextData.fromJson(json['text'] as Map<String, dynamic>), | ||||
|       cta: json['cta'] == null | ||||
|           ? null | ||||
|           : FbInviteTextData.fromJson(json['cta'] as Map<String, dynamic>), | ||||
|       dialogTitle: json['dialogTitle'] == null | ||||
|           ? null | ||||
|           : FbInviteTextData.fromJson( | ||||
|               json['dialogTitle'] as Map<String, dynamic>), | ||||
|       filters: | ||||
|           (json['filters'] as List<dynamic>?)?.map((e) => e as String).toList(), | ||||
|       sections: (json['sections'] as List<dynamic>?) | ||||
|           ?.map((e) => e as Map<String, dynamic>) | ||||
|           .toList(), | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$FbInviteDataToJson(FbInviteData instance) => | ||||
|     <String, dynamic>{ | ||||
|       'image': instance.image, | ||||
|       'text': instance.text, | ||||
|       'cta': instance.cta, | ||||
|       'dialogTitle': instance.dialogTitle, | ||||
|       'filters': instance.filters, | ||||
|       'sections': instance.sections, | ||||
|     }; | ||||
| 
 | ||||
| GuruLogDeviceData _$GuruLogDeviceDataFromJson(Map<String, dynamic> json) => | ||||
|     GuruLogDeviceData( | ||||
|       appId: json['appId'] as String, | ||||
|       version: json['version'] as String? ?? '1.0.0', | ||||
|       fbPlatform: json['fbPlatform'] as String, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$GuruLogDeviceDataToJson(GuruLogDeviceData instance) => | ||||
|     <String, dynamic>{ | ||||
|       'appId': instance.appId, | ||||
|       'version': instance.version, | ||||
|       'fbPlatform': instance.fbPlatform, | ||||
|     }; | ||||
| 
 | ||||
| GuruLogInfoData _$GuruLogInfoDataFromJson(Map<String, dynamic> json) => | ||||
|     GuruLogInfoData( | ||||
|       deveiceId: json['deveiceId'] as String?, | ||||
|       uid: json['uid'] as String?, | ||||
|       adjustId: json['adjustId'] as String?, | ||||
|       adId: json['adId'] as String?, | ||||
|       firebaseId: json['firebaseId'] as String?, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$GuruLogInfoDataToJson(GuruLogInfoData instance) => | ||||
|     <String, dynamic>{ | ||||
|       'deveiceId': instance.deveiceId, | ||||
|       'uid': instance.uid, | ||||
|       'adjustId': instance.adjustId, | ||||
|       'adId': instance.adId, | ||||
|       'firebaseId': instance.firebaseId, | ||||
|     }; | ||||
| 
 | ||||
| InitGuruLogEventData _$InitGuruLogEventDataFromJson( | ||||
|         Map<String, dynamic> json) => | ||||
|     InitGuruLogEventData( | ||||
|       appId: json['appId'] as String, | ||||
|       deviceInfo: GuruLogDeviceData.fromJson( | ||||
|           json['deviceInfo'] as Map<String, dynamic>), | ||||
|       info: GuruLogInfoData.fromJson(json['info'] as Map<String, dynamic>), | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$InitGuruLogEventDataToJson( | ||||
|         InitGuruLogEventData instance) => | ||||
|     <String, dynamic>{ | ||||
|       'appId': instance.appId, | ||||
|       'deviceInfo': instance.deviceInfo, | ||||
|       'info': instance.info, | ||||
|     }; | ||||
| 
 | ||||
| FbTournamentConfig _$FbTournamentConfigFromJson(Map<String, dynamic> json) => | ||||
|     FbTournamentConfig( | ||||
|       title: json['title'] as String?, | ||||
|       image: json['image'] as String?, | ||||
|       sortOrder: $enumDecodeNullable( | ||||
|           _$FBTournamentSortOrderEnumMap, json['sortOrder']), | ||||
|       scoreFormat: $enumDecodeNullable( | ||||
|           _$FBTournamentSortFormatEnumMap, json['scoreFormat']), | ||||
|       endTime: json['endTime'] as num?, | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$FbTournamentConfigToJson(FbTournamentConfig instance) => | ||||
|     <String, dynamic>{ | ||||
|       'title': instance.title, | ||||
|       'image': instance.image, | ||||
|       'sortOrder': _$FBTournamentSortOrderEnumMap[instance.sortOrder], | ||||
|       'scoreFormat': _$FBTournamentSortFormatEnumMap[instance.scoreFormat], | ||||
|       'endTime': instance.endTime, | ||||
|     }; | ||||
| 
 | ||||
| const _$FBTournamentSortOrderEnumMap = { | ||||
|   FBTournamentSortOrder.HIGHER_IS_BETTER: 'HIGHER_IS_BETTER', | ||||
|   FBTournamentSortOrder.LOWER_IS_BETTER: 'LOWER_IS_BETTER', | ||||
| }; | ||||
| 
 | ||||
| const _$FBTournamentSortFormatEnumMap = { | ||||
|   FBTournamentSortFormat.NUMERIC: 'NUMERIC', | ||||
|   FBTournamentSortFormat.TIME: 'TIME', | ||||
| }; | ||||
| 
 | ||||
| FbUserInfo _$FbUserInfoFromJson(Map<String, dynamic> json) => FbUserInfo( | ||||
|       json['player_id'] as String? ?? '', | ||||
|       json['user_id'] as String? ?? '', | ||||
|       json['name'] as String? ?? '', | ||||
|       json['picUrl'] as String? ?? '', | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$FbUserInfoToJson(FbUserInfo instance) => | ||||
|     <String, dynamic>{ | ||||
|       'player_id': instance.id, | ||||
|       'user_id': instance.userId, | ||||
|       'name': instance.name, | ||||
|       'picUrl': instance.picUrl, | ||||
|     }; | ||||
| 
 | ||||
| FBContextInfo _$FBContextInfoFromJson(Map<String, dynamic> json) => | ||||
|     FBContextInfo( | ||||
|       id: json['id'] as String, | ||||
|       type: $enumDecode(_$FBContextTypeEnumMap, json['type']), | ||||
|     ); | ||||
| 
 | ||||
| Map<String, dynamic> _$FBContextInfoToJson(FBContextInfo instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'type': _$FBContextTypeEnumMap[instance.type]!, | ||||
|     }; | ||||
| 
 | ||||
| const _$FBContextTypeEnumMap = { | ||||
|   FBContextType.POST: 'POST', | ||||
|   FBContextType.THREAD: 'THREAD', | ||||
|   FBContextType.GROUP: 'GROUP', | ||||
|   FBContextType.SOLO: 'SOLO', | ||||
| }; | ||||
|  | @ -0,0 +1,312 @@ | |||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| import 'dart:js' as js; | ||||
| 
 | ||||
| import 'package:guru_fb_game/model/model.dart'; | ||||
| 
 | ||||
| class FbGameGlobalUtils { | ||||
|   static Future<dynamic> getFBProfile() { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('getFBProfileWithUid', [ | ||||
|       (success) => completer.complete(), | ||||
|       (error) => completer.completeError(error), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> shareGame(FbShareData data) { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('shareGame', [ | ||||
|       json.encode(data.toJson()), | ||||
|       () => completer.complete(), | ||||
|       (error) => completer.completeError(error), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> inviteUser(FbInviteData data) { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('inviteUser', [ | ||||
|       json.encode(data.toJson()), | ||||
|       () => completer.complete(), | ||||
|       (error) => completer.completeError(error), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   static Map<String, dynamic> getEntryPointData() { | ||||
|     final result = js.context.callMethod('getEntryPointData'); | ||||
|     return json.decode(result); | ||||
|   } | ||||
| 
 | ||||
|   static String getLocale() { | ||||
|     final result = js.context.callMethod('getLocale'); | ||||
|     return result ?? ""; | ||||
|   } | ||||
| 
 | ||||
|   static String getPlatform() { | ||||
|     String result = "web"; | ||||
|     result = js.context.callMethod('getPlatform'); | ||||
|     return result; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class FbGamePlayerUtils { | ||||
|   static void saveData(Map<String, dynamic> data) { | ||||
|     js.context.callMethod('fbSetData', [json.encode(data)]); | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> getData(List<String> keys) { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('fbGetData', [ | ||||
|       json.encode(keys), | ||||
|       (success) => completer.complete(json.decode(success)), | ||||
|       (error) => completer.completeError(json.decode(error)), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   //获取当前环境下的用户信息,包括用户id,用户名称,用户头像 | ||||
|   Future<dynamic> getPlayersAsync() async { | ||||
|     final completer = Completer<dynamic>(); | ||||
|     js.context.callMethod('getPlayersAsync', [ | ||||
|       (value) { | ||||
|         List<Map<String, dynamic>> players = json.decode(value); | ||||
|         final users = players.map((e) => FbUserInfo.fromJson(e)).toList(); | ||||
|         completer.complete(users); | ||||
|       }, | ||||
|       (error) => completer.completeError(json.decode(error)), | ||||
|     ]); | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * 获取玩家同玩好友的信息 | ||||
|    * 返回的值是数组 | ||||
|    */ | ||||
|   Future<dynamic> getConnectedPlayersAsync() { | ||||
|     final completer = Completer<dynamic>(); | ||||
|     js.context.callMethod('getConnectedPlayersAsync', [ | ||||
|       (value) { | ||||
|         List<Map<String, dynamic>> players = json.decode(value); | ||||
|         final users = players.map((e) => FbUserInfo.fromJson(e)).toList(); | ||||
|         completer.complete(users); | ||||
|       }, | ||||
|       (error) => completer.completeError(json.decode(error)), | ||||
|     ]); | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> subscribeBot() { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('subscribeBot', [ | ||||
|       () => completer.complete(), | ||||
|       (error) => completer.completeError(json.decode(error)), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class FbGameTournamentUtils { | ||||
|   static Future<dynamic> createTournament(num initialScore, FbTournamentConfig config, Map<String, String> data) { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('createTournament', [ | ||||
|       initialScore, | ||||
|       json.encode(config.toJson()), | ||||
|       json.encode(data), | ||||
|       (value) => completer.complete(), | ||||
|       (error) => completer.completeError(error), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> postTournamentScore(String id, num score) { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('postTournamentScore', [ | ||||
|       id, | ||||
|       score, | ||||
|       () => completer.complete(), | ||||
|       (error) => completer.completeError(error), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> shareTournament(num score, Map<String, dynamic> data) { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('shareTournament', [ | ||||
|       score, | ||||
|       json.encode(data), | ||||
|       (value) => completer.complete(), | ||||
|       (error) => completer.completeError(error), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> getCurrentTournament() { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('getCurrentTournament', [ | ||||
|       (success) => completer.complete(json.decode(success)), | ||||
|       (error) => completer.completeError(error), | ||||
|     ]); | ||||
| 
 | ||||
|     // success 返回 map | ||||
|     // { | ||||
|     //         id: tournament.getContextID(), | ||||
|     //         endTime: tournament.getEndTime(), | ||||
|     //         title: tournament.getTitle(), | ||||
|     //         payLoad: tournament.getPayload() | ||||
|     //       } | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class FbGameContextUtils { | ||||
|   static Future<dynamic> createAsync(String playerId) { | ||||
|     final completer = Completer<dynamic>(); | ||||
|     js.context.callMethod('createAsync', [ | ||||
|       playerId, | ||||
|       (value) => completer.complete(), | ||||
|       (error) => completer.completeError(json.decode(error)), | ||||
|     ]); | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   static void updateAsync(String img) { | ||||
|     js.context.callMethod('updateAsync', [img]); | ||||
|   } | ||||
| 
 | ||||
|   static FBContextInfo getContext() { | ||||
|     final result = js.context.callMethod('getContextInfo'); | ||||
|     return FBContextInfo.fromJson(json.decode(result)); | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> contextSwitchAsync(String contextId) { | ||||
|     final completer = Completer<dynamic>(); | ||||
|     js.context.callMethod('contextSwitchAsync', [ | ||||
|       contextId, | ||||
|       () => completer.complete(), | ||||
|       (error) => completer.completeError(json.decode(error)), | ||||
|     ]); | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   //切换当前环境,如果选择朋友,则可以获取到朋友的信息 | ||||
|   static Future<dynamic> chooseContext() async { | ||||
|     final completer = Completer<dynamic>(); | ||||
|     js.context.callMethod('chooseContext', [ | ||||
|       (value) => completer.complete(FBContextInfo.fromJson(json.decode(value))), | ||||
|       (error) => completer.completeError(json.decode(error)), | ||||
|     ]); | ||||
|     return completer.future; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class FbGameLogUtils { | ||||
|   static void fbLogEvent(String eventName, int valueToSum, Map<String, dynamic> params) { | ||||
|     js.context.callMethod('logEvent', [eventName, valueToSum, json.encode(params)]); | ||||
|   } | ||||
| 
 | ||||
|   static void initGuruLogEvent(InitGuruLogEventData data) { | ||||
|     js.context.callMethod('initEventLogger', [json.encode(data.toJson())]); | ||||
|   } | ||||
| 
 | ||||
|   static void guruLogEvent(String eventName, Map<String, dynamic> params, Map<String, dynamic> properties) { | ||||
|     js.context.callMethod('castboxLogEvent', [eventName, json.encode(params), json.encode(properties)]); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class FbGameAdUtils { | ||||
|   static List<String> checkSupportedAds() { | ||||
|     final String listStr = js.context.callMethod("checkSupportedAds"); | ||||
|     List<String> list = json.decode(listStr); | ||||
|     return list; | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> showBanner(String id) { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('showBannerAds', [ | ||||
|       id, | ||||
|       () => completer.complete(), | ||||
|       (error) => completer.completeError(error), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> hideBanner(String id) { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('hideBannerAds', [ | ||||
|       id, | ||||
|       () => completer.complete(), | ||||
|       (error) => completer.completeError(error), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> loadInterstitial(String id) { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('loadInterstitial', [ | ||||
|       id, | ||||
|       () => completer.complete(), | ||||
|       (error) => completer.completeError(error), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> showInterstitial() { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('showInterstitial', [ | ||||
|       () => completer.complete(), | ||||
|       (error) => completer.completeError(error), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> loadRewarded(String id) { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('loadRewarded', [ | ||||
|       id, | ||||
|       () => completer.complete(), | ||||
|       (error) => completer.completeError(error), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| 
 | ||||
|   static Future<dynamic> showRewarded() { | ||||
|     final completer = Completer<dynamic>(); | ||||
| 
 | ||||
|     js.context.callMethod('showRewarded', [ | ||||
|       () => completer.complete(), | ||||
|       (error) => completer.completeError(error), | ||||
|     ]); | ||||
| 
 | ||||
|     return completer.future; | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,56 @@ | |||
| name: guru_fb_game | ||||
| description: "A new Flutter package project." | ||||
| version: 0.0.1 | ||||
| homepage: | ||||
| 
 | ||||
| environment: | ||||
|   sdk: '>=3.2.3 <4.0.0' | ||||
|   flutter: ">=1.17.0" | ||||
| 
 | ||||
| dependencies: | ||||
|   flutter: | ||||
|     sdk: flutter | ||||
| 
 | ||||
| dev_dependencies: | ||||
|   build_runner: 2.4.7 | ||||
|   json_serializable: 6.7.1 | ||||
|   flutter_test: | ||||
|     sdk: flutter | ||||
|   flutter_lints: ^2.0.0 | ||||
| 
 | ||||
| # For information on the generic Dart part of this file, see the | ||||
| # following page: https://dart.dev/tools/pub/pubspec | ||||
| 
 | ||||
| # The following section is specific to Flutter packages. | ||||
| flutter: | ||||
| 
 | ||||
|   # To add assets to your package, add an assets section, like this: | ||||
|   # assets: | ||||
|   #   - images/a_dot_burr.jpeg | ||||
|   #   - images/a_dot_ham.jpeg | ||||
|   # | ||||
|   # For details regarding assets in packages, see | ||||
|   # https://flutter.dev/assets-and-images/#from-packages | ||||
|   # | ||||
|   # An image asset can refer to one or more resolution-specific "variants", see | ||||
|   # https://flutter.dev/assets-and-images/#resolution-aware | ||||
| 
 | ||||
|   # To add custom fonts to your package, add a fonts section here, | ||||
|   # in this "flutter" section. Each entry in this list should have a | ||||
|   # "family" key with the font family name, and a "fonts" key with a | ||||
|   # list giving the asset and other descriptors for the font. For | ||||
|   # example: | ||||
|   # fonts: | ||||
|   #   - family: Schyler | ||||
|   #     fonts: | ||||
|   #       - asset: fonts/Schyler-Regular.ttf | ||||
|   #       - asset: fonts/Schyler-Italic.ttf | ||||
|   #         style: italic | ||||
|   #   - family: Trajan Pro | ||||
|   #     fonts: | ||||
|   #       - asset: fonts/TrajanPro.ttf | ||||
|   #       - asset: fonts/TrajanPro_Bold.ttf | ||||
|   #         weight: 700 | ||||
|   # | ||||
|   # For details regarding fonts in packages, see | ||||
|   # https://flutter.dev/custom-fonts/#from-packages | ||||
|  | @ -0,0 +1,9 @@ | |||
| import 'package:flutter_test/flutter_test.dart'; | ||||
| 
 | ||||
| import 'package:guru_fb_game/guru_fb_game.dart'; | ||||
| 
 | ||||
| void main() { | ||||
|   test('adds one to input values', () { | ||||
|      | ||||
|   }); | ||||
| } | ||||
|  | @ -0,0 +1,30 @@ | |||
| # Miscellaneous | ||||
| *.class | ||||
| *.log | ||||
| *.pyc | ||||
| *.swp | ||||
| .DS_Store | ||||
| .atom/ | ||||
| .buildlog/ | ||||
| .history | ||||
| .svn/ | ||||
| migrate_working_dir/ | ||||
| 
 | ||||
| # IntelliJ related | ||||
| *.iml | ||||
| *.ipr | ||||
| *.iws | ||||
| .idea/ | ||||
| 
 | ||||
| # The .vscode folder contains launch configuration and tasks you configure in | ||||
| # VS Code which you may wish to be included in version control, so this line | ||||
| # is commented out by default. | ||||
| #.vscode/ | ||||
| 
 | ||||
| # Flutter/Dart/Pub related | ||||
| # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. | ||||
| /pubspec.lock | ||||
| **/doc/api/ | ||||
| .dart_tool/ | ||||
| .packages | ||||
| build/ | ||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue