diff --git a/.gitignore b/.gitignore
index 4f05e3a1..afeb23f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,4 +16,7 @@
*.mocks.dart
android/build/
ios/XCBuildData/
-env/.env
\ No newline at end of file
+env/.env
+.env
+android/key.properties
+app/upload-keystore.jks
\ No newline at end of file
diff --git a/android/.kotlin/sessions/kotlin-compiler-10039299475187077818.salive b/android/.kotlin/sessions/kotlin-compiler-10039299475187077818.salive
new file mode 100644
index 00000000..e69de29b
diff --git a/android/app/agconnect-services.json b/android/app/agconnect-services.json
new file mode 100644
index 00000000..8ca899db
--- /dev/null
+++ b/android/app/agconnect-services.json
@@ -0,0 +1 @@
+{"agcgw":{"url":"connect-drru.hispace.dbankcloud.ru","backurl":"connect-drru.hispace.dbankcloud.cn","websocketurl":"connect-ws-drru.hispace.dbankcloud.ru","websocketbackurl":"connect-ws-drru.hispace.dbankcloud.cn"},"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.hicloud.com","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.hispace.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-ws-dre.hispace.dbankcloud.cn","DE_back":"connect-ws-dre.hispace.dbankcloud.com"},"client":{"cp_id":"20007000036693451","product_id":"461323198429446894","client_id":"1595824543740483264","client_secret":"2A1727B0D31AA9BE1F5D9E8A5CDA1A67B9EF084AC9FB68D6C633A3CCB08ECDEE","project_id":"461323198429446894","app_id":"113238569","api_key":"DQEDAJNRITp9M3uc1rPHyT8PzLc7bnVcKJAeavZZk0TQNMx9ifvBebr+JOmZe9tEvjnLwv7c1hAHXmPGR/gWG86iABDrCVotq+NGnw==","package_name":"com.eatvioapp.app"},"oauth_client":{"client_id":"113238569","client_type":1},"app_info":{"app_id":"113238569","package_name":"com.eatvioapp.app"},"service":{"analytics":{"collector_url":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com","collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn","collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com","collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn","resource_id":"p1","channel_id":""},"ml":{"mlservice_url":"ml-api-drru.ai.dbankcloud.ru,ml-api-drru.ai.dbankcloud.cn"},"cloudstorage":{"storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg":"https://ops-dra.agcstorage.link","storage_url_de":"https://ops-dre.agcstorage.link","storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia","storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu","storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn"},"search":{"url":"https://search-drru.cloud.huawei.ru"},"edukit":{"edu_url":"edukit.cloud.huawei.com.cn","dh_url":"edukit.cloud.huawei.com.cn"}},"region":"RU","configuration_version":"3.0","appInfos":[{"package_name":"com.eatvioapp.app","client":{"app_id":"113238569"},"app_info":{"package_name":"com.eatvioapp.app","app_id":"113238569"},"oauth_client":{"client_type":1,"client_id":"113238569"}}]}
\ No newline at end of file
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 9ffa87c4..8fc22d6d 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -5,6 +5,7 @@ plugins {
// END: FlutterFire Configuration
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
+ id 'com.huawei.agconnect'
}
def keystoreProperties = new Properties()
@@ -13,6 +14,8 @@ if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
+def yandexClientId = project.hasProperty('YANDEX_CLIENT_ID') ? project.property('YANDEX_CLIENT_ID') : (System.getenv('YANDEX_CLIENT_ID') ?: "")
+
android {
namespace = "com.eatvioapp.app"
compileSdk = flutter.compileSdkVersion
@@ -33,6 +36,10 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
+ manifestPlaceholders = [
+ YANDEX_CLIENT_ID: yandexClientId,
+ applicationName: "io.flutter.app.FlutterApplication"
+ ]
}
signingConfigs {
@@ -47,6 +54,9 @@ android {
buildTypes {
release {
signingConfig signingConfigs.release
+ minifyEnabled true
+ shrinkResources true
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
signingConfig signingConfigs.debug
@@ -57,6 +67,15 @@ android {
}
}
+dependencies {
+ implementation 'com.huawei.agconnect:agconnect-core:1.9.1.301'
+ implementation 'com.huawei.hms:push:6.12.0.300'
+ // Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8
+ implementation 'com.huawei.hms:hianalytics:6.12.0.300'
+ // BouncyCastle provider required by Huawei secure utils used transitively by HMS
+ implementation 'org.bouncycastle:bcprov-jdk15to18:1.78.1'
+}
+
flutter {
source = "../.."
}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 00000000..b657fa16
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+-keep class com.huawei.** { *; }
+-dontwarn com.huawei.**
+
+-keep class org.bouncycastle.** { *; }
+-dontwarn org.bouncycastle.**
+
+-keep class io.flutter.** { *; }
+-dontwarn io.flutter.**
+
+-keep class kotlinx.coroutines.** { *; }
+-dontwarn kotlinx.coroutines.**
+
+-keep class **.R$* { *; }
+-keepclassmembers class ** { @androidx.annotation.Keep *; }
+-keep @androidx.annotation.Keep class * { *; }
+
+-keep public class * extends android.app.Application
+-keep public class * extends android.content.ContentProvider
+
+-keep class * implements android.os.Parcelable { *; }
+-keepclassmembers enum * { *; }
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0d239126..a99f3deb 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -11,6 +11,16 @@
android:usesCleartextTraffic="true"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
+
+
+
+
+
+
+ when (call.method) {
+ "getHuaweiToken" -> {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ // Try to read appId from agconnect-services.json
+ var appId: String? = try {
+ AGConnectServicesConfig.fromContext(applicationContext)
+ .getString("client/app_id")
+ } catch (_: Exception) {
+ null
+ }
+
+ // Fallback to manifest meta-data if needed
+ if (appId.isNullOrBlank()) {
+ try {
+ val pm: PackageManager = applicationContext.packageManager
+ val ai: ApplicationInfo = pm.getApplicationInfo(applicationContext.packageName, PackageManager.GET_META_DATA)
+ val raw = ai.metaData?.getString("com.huawei.hms.client.appid")
+ if (!raw.isNullOrBlank()) {
+ appId = raw.removePrefix("appid=")
+ }
+ } catch (_: Exception) {
+ // ignore and handle below
+ }
+ }
+
+ if (appId.isNullOrBlank()) {
+ runOnUiThread {
+ result.error("HMS_TOKEN_ERROR", "Missing Huawei appId. Ensure agconnect-services.json or manifest meta-data is configured.", null)
+ }
+ return@launch
+ }
+ val token = HmsInstanceId.getInstance(applicationContext)
+ .getToken(appId, "HCM")
+ runOnUiThread {
+ result.success(token)
+ }
+ } catch (e: Exception) {
+ runOnUiThread {
+ result.error("HMS_TOKEN_ERROR", e.message, null)
+ }
+ }
+ }
+ }
+ else -> result.notImplemented()
+ }
+ }
+ }
}
diff --git a/android/build.gradle b/android/build.gradle
index d2ffbffa..af06c34c 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -1,7 +1,21 @@
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ maven {url 'https://developer.huawei.com/repo/'}
+ gradlePluginPortal()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:8.12.0'
+ classpath 'com.huawei.agconnect:agcp:1.9.1.301'
+ }
+}
+
allprojects {
repositories {
google()
mavenCentral()
+ maven {url 'https://developer.huawei.com/repo/'}
}
}
@@ -13,6 +27,32 @@ subprojects {
project.evaluationDependsOn(":app")
}
+// Temporary safeguard: set missing namespaces for Android library modules based on their manifest package
+subprojects { Project subproject ->
+ subproject.pluginManager.withPlugin('com.android.library') {
+ def androidExt = subproject.extensions.findByName('android')
+ if (androidExt != null) {
+ def hasNamespaceProp = androidExt.hasProperty('namespace')
+ def currentNamespace = hasNamespaceProp ? androidExt.namespace : null
+ if (hasNamespaceProp && !currentNamespace) {
+ def manifestFile = file("${subproject.projectDir}/src/main/AndroidManifest.xml")
+ if (manifestFile.exists()) {
+ try {
+ def manifest = new groovy.xml.XmlSlurper().parse(manifestFile)
+ def manifestPackage = manifest.@package?.text()
+ if (manifestPackage) {
+ androidExt.namespace = manifestPackage
+ subproject.logger.lifecycle("[namespace-fix] Set namespace for ${subproject.path} to '${manifestPackage}' from AndroidManifest.xml")
+ }
+ } catch (Throwable t) {
+ subproject.logger.warn("[namespace-fix] Failed to read manifest for ${subproject.path}: ${t.message}")
+ }
+ }
+ }
+ }
+ }
+}
+
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html
index 4921e6e4..ce07f9e3 100644
--- a/android/build/reports/problems/problems-report.html
+++ b/android/build/reports/problems/problems-report.html
@@ -650,12 +650,12 @@ code + .copy-button {
diff --git a/android/settings.gradle b/android/settings.gradle
index e173b2c9..76846d0d 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -12,6 +12,7 @@ pluginManagement {
repositories {
google()
mavenCentral()
+ maven { url 'https://developer.huawei.com/repo/' }
gradlePluginPortal()
}
}
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 7d740632..8e9edd22 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -388,7 +388,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 71;
+ CURRENT_PROJECT_VERSION = 74;
DEVELOPMENT_TEAM = VL68C36HYS;
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
@@ -410,6 +410,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044";
+ SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -538,7 +539,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 71;
+ CURRENT_PROJECT_VERSION = 74;
DEVELOPMENT_TEAM = VL68C36HYS;
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
@@ -560,6 +561,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044";
+ SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -576,7 +578,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 71;
+ CURRENT_PROJECT_VERSION = 74;
DEVELOPMENT_TEAM = VL68C36HYS;
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
@@ -598,6 +600,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044";
+ SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 5cba2fb5..7e82c5b2 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -21,7 +21,7 @@
CFBundleSignature
????
CFBundleVersion
- 71
+ 74
ITSAppUsesNonExemptEncryption
LSRequiresIPhoneOS
diff --git a/lib/core/comments/presentation/comments_actions_bottom_sheet.dart b/lib/core/comments/presentation/comments_actions_bottom_sheet.dart
index 3c1c3859..c4f3e540 100644
--- a/lib/core/comments/presentation/comments_actions_bottom_sheet.dart
+++ b/lib/core/comments/presentation/comments_actions_bottom_sheet.dart
@@ -18,25 +18,23 @@ void showCommentsActionsBottomSheet(
required bool isMyComment,
}) {
showModalBottomSheet(
- context: context,
- isScrollControlled: true,
- backgroundColor: Colors.transparent,
- builder: (context) => Container(
- margin: EdgeInsets.only(left: 8, right: 8, bottom: MediaQuery.of(context).padding.bottom + 20),
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(12),
- color: appTheme.whiteA700,
- ),
- child: CommentsActionsBottomSheet(
- isMyComment: isMyComment,
- reply: reply,
- copy: copy,
- edit: edit,
- delete: delete,
- complain: complain,
- commentText: commentText,
- ),
- ));
+ context: context,
+ isScrollControlled: true,
+ backgroundColor: Colors.transparent,
+ builder: (context) => Container(
+ margin: EdgeInsets.only(left: 8, right: 8, bottom: MediaQuery.of(context).viewInsets.bottom + MediaQuery.of(context).padding.bottom + 20),
+ decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: appTheme.whiteA700),
+ child: CommentsActionsBottomSheet(
+ isMyComment: isMyComment,
+ reply: reply,
+ copy: copy,
+ edit: edit,
+ delete: delete,
+ complain: complain,
+ commentText: commentText,
+ ),
+ ),
+ );
}
class CommentsActionsBottomSheet extends StatefulWidget {
@@ -82,44 +80,33 @@ class _CommentsActionsBottomSheetState extends State
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
- Row(children: [
- Expanded(
- child: CustomTextFormField(
- autofocus: true,
- controller: controller,
- onChanged: (value) => setState(() {}),
+ Row(
+ children: [
+ Expanded(
+ child: CustomTextFormField(autofocus: true, controller: controller, onChanged: (value) => setState(() {})),
),
- ),
- const SizedBox(width: 8),
- GestureDetector(
- onTap: () {
- widget.edit(controller.text);
- GoRouter.of(context).pop();
- },
- child: Container(
- decoration: BoxDecoration(
- color: controller.text.length > 2 ? appTheme.blueA400 : appTheme.gray50,
- shape: BoxShape.circle,
- ),
- height: 32,
- width: 32,
- child: Icon(
- Icons.send_rounded,
- size: 18,
- color: controller.text.length > 2 ? appTheme.whiteA700 : appTheme.black,
+ const SizedBox(width: 8),
+ GestureDetector(
+ onTap: () {
+ widget.edit(controller.text);
+ GoRouter.of(context).pop();
+ },
+ child: Container(
+ decoration: BoxDecoration(color: controller.text.length > 2 ? appTheme.blueA400 : appTheme.gray50, shape: BoxShape.circle),
+ height: 32,
+ width: 32,
+ child: Icon(Icons.send_rounded, size: 18, color: controller.text.length > 2 ? appTheme.whiteA700 : appTheme.black),
),
),
- )
- ])
+ ],
+ ),
],
),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
- const SizedBox(
- height: 7,
- ),
+ const SizedBox(height: 7),
const BottomSheetHandlerBar(),
if (widget.isMyComment)
BottomSheetActionWidget(
@@ -131,43 +118,22 @@ class _CommentsActionsBottomSheetState extends State
}),
)
else if (widget.reply != null)
- BottomSheetActionWidget(
- text: s.reply,
- iconPath: 'assets/icons/reply.svg',
- onTap: widget.reply!,
- ),
+ BottomSheetActionWidget(text: s.reply, iconPath: 'assets/icons/reply.svg', onTap: widget.reply!),
if (widget.reply != null) const CustomDivider(),
- BottomSheetActionWidget(
- text: s.copy,
- iconPath: 'assets/icons/copy2.svg',
- onTap: widget.copy,
- ),
+ BottomSheetActionWidget(text: s.copy, iconPath: 'assets/icons/copy2.svg', onTap: widget.copy),
if (widget.isMyComment) ...[
if (widget.reply != null) ...[
const CustomDivider(),
- BottomSheetActionWidget(
- text: s.reply,
- iconPath: 'assets/icons/reply.svg',
- onTap: widget.reply!,
- ),
+ BottomSheetActionWidget(text: s.reply, iconPath: 'assets/icons/reply.svg', onTap: widget.reply!),
],
const CustomDivider(),
- BottomSheetActionWidget(
- text: s.deleteComment,
- iconPath: 'assets/icons/red_trash.svg',
- onTap: widget.delete,
- ),
+ BottomSheetActionWidget(text: s.deleteComment, iconPath: 'assets/icons/red_trash.svg', onTap: widget.delete),
] else ...[
const CustomDivider(),
- BottomSheetActionWidget(
- text: s.complain,
- iconPath: 'assets/icons/complain.svg',
- onTap: widget.complain,
- ),
- ]
+ BottomSheetActionWidget(text: s.complain, iconPath: 'assets/icons/complain.svg', onTap: widget.complain),
+ ],
],
),
);
}
}
-
diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart
index 572b56f6..2d89bd43 100644
--- a/lib/core/firebase_api.dart
+++ b/lib/core/firebase_api.dart
@@ -1,28 +1,39 @@
import 'dart:developer';
import 'dart:io';
+import 'package:eatvio_app/core/utils/toasts.dart';
+import 'package:flutter/services.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
+import 'package:flutter/services.dart' show MethodChannel, PlatformException;
class FirebaseApi {
final _firebaseMessaging = FirebaseMessaging.instance;
Future getToken() async {
- await _firebaseMessaging.requestPermission();
- return await _firebaseMessaging.getToken();
+ String? token;
+
+ if (await _isHuaweiDevice()) {
+ token = await _getHuaweiPushTokenNative();
+ } else {
+ await _firebaseMessaging.requestPermission();
+ token = await _firebaseMessaging.getToken();
+ }
+ return token;
}
Future registerPushToken() async {
if (await TokenStorage.haveTokenInStorage()) {
try {
final deviceInfo = DeviceInfoPlugin();
+ final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
await sl().post(
'/api/profile/notification/token/register',
data: {
- 'platform': Platform.isIOS ? 'ios' : 'android',
+ 'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'token': await getToken(),
},
@@ -32,4 +43,32 @@ class FirebaseApi {
}
}
}
+
+ Future _isHuaweiDevice() async {
+ if (!Platform.isAndroid) return false;
+ try {
+ final info = await DeviceInfoPlugin().androidInfo;
+ final brand = info.brand.toLowerCase();
+ final manufacturer = info.manufacturer.toLowerCase();
+ return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
+ } catch (_) {
+ return false;
+ }
+ }
+
+ Future _getHuaweiPushTokenNative() async {
+ try {
+ const channel = MethodChannel('com.eatvioapp/hms_push');
+ final String? token = await channel.invokeMethod('getHuaweiToken');
+ return token;
+ } on PlatformException catch (e, st) {
+ showErrorToast('$e $st');
+ log(e.toString(), stackTrace: st);
+ return null;
+ } catch (e, st) {
+ showErrorToast('$e $st');
+ log(e.toString(), stackTrace: st);
+ return null;
+ }
+ }
}
diff --git a/lib/core/locator_service.dart b/lib/core/locator_service.dart
index 0ca95d77..1b06fde9 100644
--- a/lib/core/locator_service.dart
+++ b/lib/core/locator_service.dart
@@ -5,6 +5,9 @@ import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/dio/phone_verify_error_interceptor.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
+import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
+import 'package:eatvio_app/core/views/repository/views_repository.dart';
+import 'package:eatvio_app/core/views/service/views_service.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/core/websockets/ws_service.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart';
@@ -140,7 +143,7 @@ Future init() async {
sl.registerFactory(() => DietsCubit(feedRepository: sl(), dietsRepository: sl()));
sl.registerFactory(() => CommentsCubit(feedRepository: sl(), commentsRepository: sl()));
sl.registerFactory(() => PlansCubit(feedRepository: sl(), planRepository: sl()));
- sl.registerFactory(() => SettingsCubit(profileRepository: sl(), settingsRepository: sl(), consultantRepository: sl()));
+ sl.registerFactory(() => SettingsCubit(profileRepository: sl(), settingsRepository: sl(), consultantRepository: sl(), consultationsRepository: sl()));
sl.registerFactory(() => RegisterConsultantCubit(consultantRepository: sl()));
sl.registerFactory(
() => DiaryCubit(
@@ -183,6 +186,7 @@ Future init() async {
sl.registerFactory(() => NotificationSettingsCubit(notificationSettingsRepository: sl()));
sl.registerFactory(() => ConsultantRatingCubit(sl()));
sl.registerFactory(() => PersonalDietSuggestionCubit(dietRepository: sl(), personalDietSuggestionService: sl()));
+ sl.registerFactory(() => ViewsCubit(viewsRepository: sl()));
// Repository
sl.registerLazySingleton(() => ChatRepository(chatService: sl()));
@@ -217,6 +221,7 @@ Future init() async {
sl.registerLazySingleton(() => NotificationSettingsRepository(sl()));
sl.registerFactory(() => ConsultantRatingRepository(sl()));
sl.registerLazySingleton(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
+ sl.registerFactory(() => ViewsRepository(viewsService: sl()));
// Services
sl.registerLazySingleton(() => ChatService(sl()));
@@ -253,6 +258,7 @@ Future init() async {
sl.registerLazySingleton(() => ConsultantRatingService(sl()));
sl.registerLazySingleton(() => NotificationSettingsService(sl()));
sl.registerLazySingleton(() => PersonalDietSuggestionService(sl()));
+ sl.registerLazySingleton(() => ViewsService(sl()));
// External
sl.registerLazySingleton(
diff --git a/lib/core/theme/theme_helper.dart b/lib/core/theme/theme_helper.dart
index 81087ec9..ab63a4c0 100644
--- a/lib/core/theme/theme_helper.dart
+++ b/lib/core/theme/theme_helper.dart
@@ -204,6 +204,10 @@ class PrimaryColors {
Color get darkGray => const Color(0XFFBBBCC0);
Color get commentBg => const Color(0XFFFFFDD8);
+
+ Color get cardNotificationsBG => const Color(0xFFA4A4A4);
+
+ Color get grey7B => const Color(0xFF7B7B7B);
}
PrimaryColors get appTheme => ThemeHelper().themeColor();
diff --git a/lib/core/views/cubit/views_cubit.dart b/lib/core/views/cubit/views_cubit.dart
new file mode 100644
index 00000000..ee15af22
--- /dev/null
+++ b/lib/core/views/cubit/views_cubit.dart
@@ -0,0 +1,128 @@
+import 'dart:async';
+import 'package:eatvio_app/core/views/cubit/views_state.dart';
+import 'package:eatvio_app/core/views/repository/views_repository.dart';
+import 'package:eatvio_app/features/feed/models/view_model.dart';
+import 'package:eatvio_app/features/feed/presentation/pages/feed_screen.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class ViewsCubit extends Cubit {
+ final ViewsRepository viewsRepository;
+ ViewsCubit({required this.viewsRepository}) : super(ViewsCubitState()) {
+ _listener = AppLifecycleListener(onPause: _sendViews, onInactive: _sendViews);
+ }
+
+ late final AppLifecycleListener _listener;
+
+ final Set postsIds = {};
+ final Set plansIds = {};
+ final Set dietsIds = {};
+ final Set recipesIds = {};
+
+ final Set _sentPosts = {};
+ final Set _sentPlans = {};
+ final Set _sentDiets = {};
+ final Set _sentRecipes = {};
+
+ final Map> _visibilityTimers = {
+ ViewableContentType.posts: {},
+ ViewableContentType.plans: {},
+ ViewableContentType.diets: {},
+ ViewableContentType.recipes: {},
+ };
+
+ Timer? _debounceTimer;
+
+ Set _idsForType(ViewableContentType type) {
+ switch (type) {
+ case ViewableContentType.posts:
+ return postsIds;
+ case ViewableContentType.plans:
+ return plansIds;
+ case ViewableContentType.diets:
+ return dietsIds;
+ case ViewableContentType.recipes:
+ return recipesIds;
+ }
+ }
+
+ Set _sentIdsForType(ViewableContentType type) {
+ switch (type) {
+ case ViewableContentType.posts:
+ return _sentPosts;
+ case ViewableContentType.plans:
+ return _sentPlans;
+ case ViewableContentType.diets:
+ return _sentDiets;
+ case ViewableContentType.recipes:
+ return _sentRecipes;
+ }
+ }
+
+ void handleVisibility({required double visibleFraction, required int id, required ViewableContentType type}) {
+ final timers = _visibilityTimers[type]!;
+ final ids = _idsForType(type);
+ final sent = _sentIdsForType(type);
+
+ if (sent.contains(id)) {
+ timers[id]?.cancel();
+ timers.remove(id);
+ return;
+ }
+
+ if (ids.contains(id)) return;
+
+ if (visibleFraction >= 0.5) {
+ if (timers.containsKey(id)) return;
+ timers[id] = Timer(const Duration(seconds: 1), () {
+ ids.add(id);
+ timers.remove(id);
+ _scheduleDebouncedSend();
+ });
+ } else {
+ timers[id]?.cancel();
+ timers.remove(id);
+ }
+ }
+
+ Future _viewContent({required ViewableContentType type, required List ids}) async {
+ await viewsRepository.viewContent(
+ viewModel: ViewModel(type: type.getStringifiedVersion(), ids: ids),
+ );
+ }
+
+ void _scheduleDebouncedSend() {
+ _debounceTimer?.cancel();
+ _debounceTimer = Timer(const Duration(seconds: 4), _sendViews);
+ }
+
+ void _send(Set ids, Set sent, ViewableContentType type) {
+ final newIds = ids.difference(sent);
+ if (newIds.isNotEmpty) {
+ _viewContent(type: type, ids: newIds.toList());
+ sent.addAll(newIds);
+ ids.removeAll(newIds);
+ }
+ }
+
+ void _sendViews() {
+ _send(postsIds, _sentPosts, ViewableContentType.posts);
+ _send(plansIds, _sentPlans, ViewableContentType.plans);
+ _send(dietsIds, _sentDiets, ViewableContentType.diets);
+ _send(recipesIds, _sentRecipes, ViewableContentType.recipes);
+ }
+
+ @override
+ Future close() async {
+ for (final map in _visibilityTimers.values) {
+ for (final t in map.values) {
+ t.cancel();
+ }
+ map.clear();
+ }
+ _debounceTimer?.cancel();
+ _listener.dispose();
+ _sendViews();
+ await super.close();
+ }
+}
diff --git a/lib/core/views/cubit/views_state.dart b/lib/core/views/cubit/views_state.dart
new file mode 100644
index 00000000..e43d3ff5
--- /dev/null
+++ b/lib/core/views/cubit/views_state.dart
@@ -0,0 +1,3 @@
+class ViewsCubitState {
+ ViewsCubitState();
+}
diff --git a/lib/core/views/repository/views_repository.dart b/lib/core/views/repository/views_repository.dart
new file mode 100644
index 00000000..165a4956
--- /dev/null
+++ b/lib/core/views/repository/views_repository.dart
@@ -0,0 +1,16 @@
+import 'package:eatvio_app/core/views/service/views_service.dart';
+import 'package:eatvio_app/features/feed/models/view_model.dart';
+
+class ViewsRepository {
+ final ViewsService viewsService;
+
+ ViewsRepository({required this.viewsService});
+
+ Future viewContent({required ViewModel viewModel}) async {
+ try {
+ await viewsService.viewContent(viewModel: viewModel);
+ } catch (e) {
+ rethrow;
+ }
+ }
+}
diff --git a/lib/core/views/service/views_service.dart b/lib/core/views/service/views_service.dart
new file mode 100644
index 00000000..e89990fa
--- /dev/null
+++ b/lib/core/views/service/views_service.dart
@@ -0,0 +1,15 @@
+import 'package:dio/dio.dart';
+import 'package:eatvio_app/features/feed/models/view_model.dart';
+import 'package:retrofit/retrofit.dart';
+
+part 'views_service.g.dart';
+
+@RestApi()
+abstract class ViewsService {
+ factory ViewsService(Dio dio, {String baseUrl}) = _ViewsService;
+
+ @POST('/api/views')
+ Future viewContent({
+ @Body() required ViewModel viewModel,
+ });
+}
diff --git a/lib/features/auth/models/social_exchange_request.dart b/lib/features/auth/models/social_exchange_request.dart
new file mode 100644
index 00000000..c322fab6
--- /dev/null
+++ b/lib/features/auth/models/social_exchange_request.dart
@@ -0,0 +1,12 @@
+class SocialExchangeRequest {
+ final String provider;
+ final String accessToken;
+
+ SocialExchangeRequest({required this.provider, required this.accessToken});
+
+ Map toJson() {
+ return {'provider': provider, 'access_token': accessToken};
+ }
+}
+
+
diff --git a/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart b/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart
index 20686cce..7b58a4b0 100644
--- a/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart
+++ b/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart
@@ -6,28 +6,17 @@ import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state
import 'package:eatvio_app/features/auth/repositories/auth_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
-
class AuthCubit extends Cubit {
final AuthRepository authRepository;
- AuthCubit({
- required this.authRepository,
- }) : super(const AuthState());
+ AuthCubit({required this.authRepository}) : super(const AuthState());
Future getTokenByCredentials(String email, String password) async {
emit(const AuthState.loading());
try {
- final token = (await authRepository.getTokenByCredentials(
- email,
- password,
- ))
- .token;
- emit(
- AuthState.loaded(
- token: token,
- ),
- );
- await TokenStorage.saveTokenInStorage(token);
+ final token = (await authRepository.getTokenByCredentials(email, password)).token;
+ emit(AuthState.loaded(token: token));
+ await TokenStorage.saveTokenInStorage(token);
return true;
} catch (e) {
log(e.toString());
@@ -36,4 +25,19 @@ class AuthCubit extends Cubit {
return false;
}
}
+
+ Future getTokenBySocial({required String provider, required String accessToken}) async {
+ emit(const AuthState.loading());
+ try {
+ final token = (await authRepository.getTokenBySocial(provider: provider, accessToken: accessToken)).token;
+ emit(AuthState.loaded(token: token));
+ await TokenStorage.saveTokenInStorage(token);
+ return true;
+ } catch (e) {
+ log(e.toString());
+ showErrorToast('ΠΠ΅ ΡΠ΄Π°Π»ΠΎΡΡ Π²ΠΎΠΉΡΠΈ ΡΠ΅ΡΠ΅Π· $provider');
+ emit(AuthState.error(errorMessage: '$e'));
+ return false;
+ }
+ }
}
diff --git a/lib/features/auth/presentation/pages/auth_screen.dart b/lib/features/auth/presentation/pages/auth_screen.dart
index 73162bd2..f9eeddf5 100644
--- a/lib/features/auth/presentation/pages/auth_screen.dart
+++ b/lib/features/auth/presentation/pages/auth_screen.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: unused_element, duplicate_ignore
+
import 'dart:async';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/const.dart';
@@ -24,6 +26,7 @@ import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
+import 'package:eatvio_app/features/auth/sdk/social_auth_facade.dart';
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
@@ -165,6 +168,52 @@ class _AuthScreenState extends State {
),
],
),
+ // const SizedBox(height: 16),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ _SocialButton(
+ onTap: () => _onVkPressed(context),
+ backgroundColor: const Color(0xFF2787F5),
+ tooltip: 'VK ID',
+ child: SvgPicture.asset(
+ 'assets/icons/vk.svg',
+ height: 24,
+ width: 24,
+ colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn),
+ ),
+ ),
+ _SocialButton(
+ onTap: _onOkPressed,
+ backgroundColor: const Color(0xFFF58220),
+ tooltip: 'ΠΠ΄Π½ΠΎΠΊΠ»Π°ΡΡΠ½ΠΈΠΊΠΈ',
+ child: SvgPicture.asset(
+ 'assets/icons/ok.svg',
+ height: 24,
+ width: 24,
+ colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn),
+ ),
+ ),
+ const _SocialButton(
+ onTap: _onMailRuPressed,
+ backgroundColor: Color(0xFF005FF9),
+ tooltip: 'Mail.ru',
+ child: Text(
+ 'Mail',
+ style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700),
+ ),
+ ),
+ const _SocialButton(
+ onTap: _onYandexPressed,
+ backgroundColor: Color(0xFFFFCC00),
+ tooltip: 'Π―Π½Π΄Π΅ΠΊΡ ID',
+ child: Text(
+ 'Yandex',
+ style: TextStyle(color: Colors.black, fontWeight: FontWeight.w700),
+ ),
+ ),
+ ],
+ ),
const Spacer(),
Center(
child: RichText(
@@ -317,3 +366,88 @@ class _AuthScreenState extends State {
);
}
}
+
+// ignore: unused_element
+class _SocialButton extends StatelessWidget {
+ const _SocialButton({required this.onTap, required this.child, required this.backgroundColor, required this.tooltip});
+
+ final VoidCallback onTap;
+ final Widget child;
+ final Color backgroundColor;
+ final String tooltip;
+
+ @override
+ Widget build(BuildContext context) {
+ return Tooltip(
+ message: tooltip,
+ child: InkWell(
+ borderRadius: BorderRadius.circular(12),
+ onTap: onTap,
+ child: Ink(
+ height: 48,
+ width: (MediaQuery.of(context).size.width - 16 * 2 - 12 * 3) / 4,
+ decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(12)),
+ child: Center(child: child),
+ ),
+ ),
+ );
+ }
+}
+
+// ignore: unused_element
+void _onVkPressed(BuildContext context) async {
+ final accessToken = await SocialAuthFacade.signInWithVk(context: context);
+ if (accessToken == null) return;
+ final isAuthenticated = await sl().getTokenBySocial(provider: 'vk', accessToken: accessToken);
+ if (isAuthenticated) {
+ if (AppRouter.navigatorKey.currentContext != null) {
+ // Reuse success flow
+ // ignore: use_build_context_synchronously
+ await _reconnectAfterAuth();
+ }
+ }
+}
+
+void _onOkPressed() async {
+ final accessToken = await SocialAuthFacade.signInWithOk();
+ if (accessToken == null) return;
+ final isAuthenticated = await sl().getTokenBySocial(provider: 'ok', accessToken: accessToken);
+ if (isAuthenticated) {
+ await _reconnectAfterAuth();
+ }
+}
+
+void _onMailRuPressed() async {
+ final accessToken = await SocialAuthFacade.signInWithMailRu();
+ if (accessToken == null) return;
+ final isAuthenticated = await sl().getTokenBySocial(provider: 'mailru', accessToken: accessToken);
+ if (isAuthenticated) {
+ await _reconnectAfterAuth();
+ }
+}
+
+void _onYandexPressed() async {
+ final accessToken = await SocialAuthFacade.signInWithYandex();
+ if (accessToken == null) return;
+ final isAuthenticated = await sl().getTokenBySocial(provider: 'yandex', accessToken: accessToken);
+ if (isAuthenticated) {
+ await _reconnectAfterAuth();
+ }
+}
+
+Future _reconnectAfterAuth() async {
+ final context = AppRouter.navigatorKey.currentContext;
+ if (context == null) return;
+ try {
+ await sl().clear();
+ await sl().initialize();
+ await sl().getMyProfile();
+ await sl().getChatRoomsWithLoading();
+ await sl().connectToWebSocket();
+ await sl().getNotifications();
+ await sl().connectToWebSocket();
+ await sl().registerPushToken();
+ showSuccessToast('Π£ΡΠΏΠ΅ΡΠ½ΡΠΉ Π²Ρ
ΠΎΠ΄');
+ } catch (_) {}
+ AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
+}
diff --git a/lib/features/auth/repositories/auth_repository.dart b/lib/features/auth/repositories/auth_repository.dart
index d0bfcce5..84df92f6 100644
--- a/lib/features/auth/repositories/auth_repository.dart
+++ b/lib/features/auth/repositories/auth_repository.dart
@@ -1,6 +1,7 @@
import 'package:eatvio_app/features/auth/models/credentials_model.dart';
import 'package:eatvio_app/features/auth/models/token_model.dart';
import 'package:eatvio_app/features/auth/services/auth_service.dart';
+import 'package:eatvio_app/features/auth/models/social_exchange_request.dart';
class AuthRepository {
final AuthService authService;
@@ -22,4 +23,17 @@ class AuthRepository {
rethrow;
}
}
+
+ Future getTokenBySocial({
+ required String provider,
+ required String accessToken,
+ }) async {
+ try {
+ return await authService.getTokenBySocial(
+ body: SocialExchangeRequest(provider: provider, accessToken: accessToken),
+ );
+ } catch (e) {
+ rethrow;
+ }
+ }
}
diff --git a/lib/features/auth/sdk/social_auth_facade.dart b/lib/features/auth/sdk/social_auth_facade.dart
new file mode 100644
index 00000000..e5761498
--- /dev/null
+++ b/lib/features/auth/sdk/social_auth_facade.dart
@@ -0,0 +1,157 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_dotenv/flutter_dotenv.dart';
+import 'package:flutter_login_yandex/flutter_login_yandex.dart';
+import 'package:eatvio_app/core/app_router.dart';
+import 'package:vk_id/vk_id.dart';
+import 'package:webview_flutter/webview_flutter.dart';
+
+class VkAuthResult {
+ VkAuthResult({required this.accessToken, this.refreshToken, this.userId, this.firstName, this.lastName, this.email, this.phone, this.gender, this.birthDate});
+
+ final String accessToken;
+ final String? refreshToken;
+ final String? userId;
+ final String? firstName;
+ final String? lastName;
+ final String? email;
+ final String? phone;
+ final String? gender;
+ final String? birthDate;
+}
+
+class SocialAuthFacade {
+ static Future signInWithVk({required BuildContext context}) async {
+ final result = await signInWithVkAndFetchProfile(context: context);
+ return result?.accessToken;
+ }
+
+ static Future signInWithVkAndFetchProfile({required BuildContext context}) async {
+ final clientIdStr = dotenv.maybeGet('VK_CLIENT_ID')?.trim() ?? Platform.environment['VK_CLIENT_ID'];
+ if (clientIdStr == null || clientIdStr.isEmpty) {
+ return null;
+ }
+
+ try {
+ dynamic controller = VkIDController(clID: int.parse(clientIdStr));
+ final authUri = controller.generateAuthorizeLinkWithCodeVerifier();
+ final redirectScheme = 'vk$clientIdStr://';
+ final redirectParams = await _presentOAuthWebView(context: context, initialUrl: authUri.toString(), redirectSchemePrefix: redirectScheme);
+ if (redirectParams == null) return null;
+ final code = redirectParams['code'];
+ final deviceId = redirectParams['device_id'];
+ if (code == null || deviceId == null) return null;
+
+ final tokenResponse = await controller.exchangeAuthorizationCodeForToken(code: code, deviceId: deviceId);
+ final accessToken = _extractAccessToken(tokenResponse);
+ if (accessToken == null) return null;
+ return VkAuthResult(accessToken: accessToken);
+ } catch (_) {
+ return null;
+ }
+ }
+
+ static Future signInWithOk() async {
+ final context = AppRouter.navigatorKey.currentContext;
+ if (context == null) return null;
+ return _signInWithVkIdProvider(context: context, provider: 'ok');
+ }
+
+ static Future signInWithMailRu() async {
+ final context = AppRouter.navigatorKey.currentContext;
+ if (context == null) return null;
+ return _signInWithVkIdProvider(context: context, provider: 'mail');
+ }
+
+ static Future signInWithYandex() async {
+ final clientId = dotenv.maybeGet('YANDEX_CLIENT_ID')?.trim() ?? Platform.environment['YANDEX_CLIENT_ID'];
+ final redirectUri = dotenv.maybeGet('YANDEX_REDIRECT_URI')?.trim() ?? Platform.environment['YANDEX_REDIRECT_URI'];
+
+ if (clientId == null || clientId.isEmpty || redirectUri == null || redirectUri.isEmpty) {
+ return null;
+ }
+ try {
+ final plugin = FlutterLoginYandex();
+ final response = await plugin.signIn();
+ final token = (response is Map) ? response['token'] as String? : null;
+ return (token != null && token.isNotEmpty) ? token : null;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ static Future _signInWithVkIdProvider({required BuildContext context, required String provider}) async {
+ final clientIdStr = dotenv.maybeGet('VK_CLIENT_ID')?.trim() ?? Platform.environment['VK_CLIENT_ID'];
+ if (clientIdStr == null || clientIdStr.isEmpty) {
+ return null;
+ }
+ try {
+ dynamic controller = VkIDController(clID: int.parse(clientIdStr));
+ final authUri = controller.generateAuthorizeLinkWithCodeVerifier(provider: provider);
+ final redirectScheme = 'vk$clientIdStr://';
+ final redirectParams = await _presentOAuthWebView(context: context, initialUrl: authUri.toString(), redirectSchemePrefix: redirectScheme);
+ if (redirectParams == null) return null;
+ final code = redirectParams['code'];
+ final deviceId = redirectParams['device_id'];
+ if (code == null || deviceId == null) return null;
+ final tokenResponse = await controller.exchangeAuthorizationCodeForToken(code: code, deviceId: deviceId, provider: provider);
+ final accessToken = _extractAccessToken(tokenResponse);
+ return accessToken;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ static String? _extractAccessToken(dynamic tokenResponse) {
+ if (tokenResponse == null) return null;
+ if (tokenResponse is Map) {
+ return (tokenResponse['access_token'] as String?) ?? (tokenResponse['token'] as String?);
+ }
+ try {
+ final token = (tokenResponse as dynamic).accessToken as String?;
+ if (token != null && token.isNotEmpty) return token;
+ } catch (_) {}
+ try {
+ final token = (tokenResponse as dynamic).token as String?;
+ if (token != null && token.isNotEmpty) return token;
+ } catch (_) {}
+ return null;
+ }
+
+ static Future