Compare commits

...

82 Commits

Author SHA1 Message Date
misha
45d072cff4 feat(insights): first isights impemetation 2026-11-09 15:35:10 +03:00
67920b2016 Merge pull request 'fix(tf-ci)' (#1134) from apoloz/eatvio_app:feat/sbp-qr-payment into main
Reviewed-on: eatvio/eatvio_app#1134
2026-11-06 10:47:12 +00:00
Anatoliy Poloz
fc28062bb5 fix(tf-ci) 2026-11-06 13:16:36 +03:00
f947182632 Merge pull request 'fix(ci): build number' (#1133) from apoloz/eatvio_app:feat/sbp-qr-payment into main
Reviewed-on: eatvio/eatvio_app#1133
2026-11-06 09:50:23 +00:00
Anatoliy Poloz
27b8128a19 fix(ci): build number 2026-11-06 12:26:36 +03:00
924dc2c16a Merge pull request 'feat(sbp_payments): QR-код оплаты СБП с другого устройства' (#1120) from apoloz/eatvio_app:feat/sbp-qr-payment into main
Reviewed-on: eatvio/eatvio_app#1120
Reviewed-by: misha <misha@noreply.localhost>
2026-11-06 08:41:34 +00:00
53ce73b41e Merge pull request 'fix(ios): derive TestFlight build number from App Store Connect' (#1105) from apoloz/eatvio_app:fix/testflight-build-number into main
Reviewed-on: eatvio/eatvio_app#1105
Reviewed-by: misha <misha@noreply.localhost>
2026-11-02 11:16:36 +00:00
Anatoliy Poloz
1829ad611d feat(sbp_payments): QR-код оплаты СБП с другого устройства
- Новый SbpQrBottomSheet: рендер checkout_url (qr.nspk.ru/{qrcId}) через qr_flutter,
  сумма, инструкция, автоопределение оплаты polling-ом статуса chosen_plan (3 сек)
  и автозакрытие с успехом
- Кнопка «Оплатить по QR-коду» в шите выбора банков — первичная, под заголовком,
  доступна сразу после получения checkout_url, не дожидаясь списка банков
  (важно для устройств без банковских приложений, iOS в РФ)
- Блок «Симуляция оплаты» (testpay.alfabank.ru/sbp-page?qrcId=...) показывается
  только когда base URL API не продуктовый: AppConfig.isProdApi/prodApiHosts
  (хост целиком — стенды-поддомены eatvio.ru продом не считаются)
- showBottomSheetWithBlur стал генериком — возвращает значение pop(result)
- showConsultationPaymentFlow: опциональная прокидка суммы в QR-шит
- Локализация ru/en, тесты (16 новых, всего 34 платёжных зелёные)
2026-11-02 14:03:56 +03:00
2b2991012d merge upstream 2026-11-02 10:27:35 +00:00
7a456c2d9d Merge pull request 'Фикс перекрытия кнопки регистрации консультанта' (#1111) from alex_chevski/eatvio_app:fix/1107-consultant-bottom-button-inset into main
Reviewed-on: eatvio/eatvio_app#1111
Reviewed-by: misha <misha@noreply.localhost>
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-11-02 10:27:25 +00:00
36079de81c merge upstream 2026-11-02 10:00:33 +00:00
3825a974f9 Merge pull request 'открытие чата из консультации' (#1110) from alex_chevski/eatvio_app:fix/1106-consultation-chat into main
Reviewed-on: eatvio/eatvio_app#1110
Reviewed-by: misha <misha@noreply.localhost>
2026-11-02 10:00:24 +00:00
678d73932c merge upstream 2026-11-02 09:48:20 +00:00
8942e11be9 Merge pull request 'fix: android ci runner changed' (#1118) from misha/eatvio_app:android-ci-fix into main
Reviewed-on: eatvio/eatvio_app#1118
2026-11-02 09:48:08 +00:00
misha
55e1633d1c fix: android ci runner changed 2026-11-02 11:42:14 +03:00
2668907f60 merge upstream 2026-11-02 08:14:53 +00:00
56ab9b65f9 merge upstream 2026-11-02 08:14:46 +00:00
5b2ac4f228 Merge pull request 'Исправлен чужой аватар после смены аккаунта' (#1117) from fix/1113-stale-avatar-account-switch into main
Reviewed-on: eatvio/eatvio_app#1117
2026-11-02 08:14:04 +00:00
51aff6c175 test(session): cover avatar refresh and session teardown
Add regression tests for avatar reset and token-change refresh, stale 401 handling, idempotent session teardown and account-scoped preference cleanup. Update existing cubit tests for the new session dependency.

Refs: #1113
2026-10-30 15:15:36 +03:00
cba2689406 feat(session): centralize session teardown and refresh
Add SessionLifecycleService to reset user-scoped cubits, analytics, socket id and account-scoped preferences on logout, 401 or account deletion. Run the same refresh after every successful auth path, including registration v1/v2, and ignore 401 responses from stale tokens so an old request cannot terminate the new user's session.

Refs: #1113
2026-10-30 15:15:28 +03:00
5f0b7e026b fix(profile): refresh bottom-nav avatar on account switch
Reset ProfileAvatarCubit and user-scoped feed, chat and notification state so stale data from the previous account cannot be rendered. Refresh the avatar when the stored token changes and rebuild nav tabs when the profile identity or consultant role changes.

Refs: #1113
2026-10-30 15:15:21 +03:00
85bc2c6d5c test(consultant): verify bottom action stays above system navigation area 2026-10-30 13:31:07 +03:00
f58892bdeb fix(consultant): keep registration button above system navigation bar 2026-10-30 13:31:05 +03:00
f14f426381 fix(consultations): open chat via consultation chat_id
Use chat_id from the consultations payload for direct navigation and keep
the chat lookup as a fallback for legacy rows without chat_id.

- validate chat id in ChatRepository and make it nullable
- guard repeated taps while opening chat
- handle nullable chat id in ProfileCubit and EmptyChatScreen

related to #1106
2026-10-29 18:09:08 +03:00
6a31880a85 fix(consultations): open chat from consultation card
The chat icon on the consultant consultation card had an empty onTap handler.
Resolve the conversation with the client via the chat API and navigate to it.

related to #1106
2026-10-29 17:27:12 +03:00
3a41a85f9d Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-29 14:22:06 +03:00
194b8d5b16 Merge pull request 'feat(video_calls): app opening from terminated state fix' (#1109) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1109
2026-10-29 10:59:30 +00:00
misha
508c321913 version changed 2026-10-29 13:56:06 +03:00
misha
4a5c67632e feat(video_calls): app opening from terminated state fix 2026-10-29 13:55:38 +03:00
Anatoliy
f6f9764811 fix(ios): derive TestFlight build number from App Store Connect
The beta lane took the build number from pubspec (FLUTTER_BUILD_NUMBER).
Re-running CI on the same commit re-submitted an already-taken build
number and failed with 409 ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE
(run 2045: build 157 was already on TestFlight).

Now the lane queries the latest TestFlight build for the current
marketing version (falling back to the pubspec value when nothing was
uploaded yet) and uploads latest + 1, so retries can never collide.
2026-10-28 11:10:22 +03:00
e2d287eb9e Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-27 16:22:59 +03:00
cf5c42ff4b Merge pull request 'feat(video_calls): tokens registration' (#1104) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1104
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-27 13:09:45 +00:00
misha
5e6e114d5d version changed 2026-10-27 14:08:05 +03:00
misha
e9cfcb9637 feat(video_calls): video calls tokens registration fix 2026-10-27 14:06:55 +03:00
28c62bf775 Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-26 12:26:48 +03:00
67e6526552 Merge pull request 'fix: analyzer fix' (#1101) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1101
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-24 06:49:43 +00:00
misha
1c8709fc50 fix: analyzer fix 2026-10-23 15:29:38 +03:00
dedb55ecca Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-23 15:17:38 +03:00
6b4c88068e Merge pull request 'feat(video_calls): call accepting and declingn handling' (#1100) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1100
2026-10-23 11:13:50 +00:00
misha
d30c63be85 version changed 2026-10-23 14:11:52 +03:00
misha
0ddd4c6b14 feat(video_calls): call accepting and declingn handling 2026-10-23 14:11:20 +03:00
04dab02a6d Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-23 12:37:10 +03:00
c45612decb Merge pull request 'feat(video_calls): ios call declining notification for other devices fix' (#1096) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1096
2026-10-22 14:47:02 +00:00
misha
2779f79464 feat(video_calls): ios call declining notification for other devices fix 2026-10-22 17:44:46 +03:00
db293bf13f Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-22 16:23:38 +03:00
05eaa3caa9 Merge pull request 'feat(video_calls): ios calle mic fix, infinite loading after accepting call on an oppened app fix, incaming call declining after declining on a different device' (#1095) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1095
2026-10-22 11:57:57 +00:00
misha
d8d7a211bd feat(video_calls): ios calle mic fix, infinite loading after accepting call on an oppened app fix, incaming call declining after declining on a different device 2026-10-22 14:56:15 +03:00
fe54037cef Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-22 13:11:31 +03:00
e6601e2fe6 Merge pull request 'feat(video_calls): ios calle mic fix' (#1094) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1094
2026-10-22 09:42:53 +00:00
misha
992573aead feat(video_calls): ios calle mic fix 2026-10-22 12:16:09 +03:00
c70c9beb20 Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-21 17:55:55 +03:00
d2fc087699 Merge pull request 'feat(video_calls): ios calle mic fix' (#1093) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1093
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-21 14:53:27 +00:00
misha
3122394037 feat(video_calls): ios calle mic fix 2026-10-21 17:48:53 +03:00
18925fd95e Merge pull request 'feat(video_calls): ios mic fix' (#1092) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1092
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-21 14:22:04 +00:00
misha
9916828956 feat(video_calls): ios mic fix 2026-10-21 17:11:25 +03:00
37a787c8d0 Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-21 17:03:44 +03:00
fb68f02ca4 Merge pull request 'feat(video_calls): ios mic fix, tokens clearing fix' (#1091) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1091
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-21 13:28:50 +00:00
misha
8cb3a184b0 feat(video_calls): ios mic fix, tokens clearing fix 2026-10-21 16:24:23 +03:00
4b72258b1f Merge pull request 'issue-1089' (#1090) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1090
2026-10-21 12:08:54 +00:00
misha
592f387221 Merge remote-tracking branch 'upstream/main' into issue-1089 2026-10-21 15:03:59 +03:00
misha
dcf695777a version changed 2026-10-21 14:58:11 +03:00
3b8d1bf651 Merge branch 'issue-1089' of ssh://10.55.5.123:222/misha/eatvio_app 2026-10-21 14:31:39 +03:00
misha
141cf5543d feat(video_calls): continous ringing on a defferent device fix 2026-10-21 14:25:59 +03:00
40ba5f304e Merge pull request 'fix(verification): show email confirmation when 405 reports missing email' (#1088) from apoloz/eatvio_app:fix/1087-email-verification-channel into main
Reviewed-on: eatvio/eatvio_app#1088
Reviewed-by: misha <misha@noreply.localhost>
2026-10-20 11:42:45 +00:00
Anatoliy Poloz
996cf07fb8 merege from main + version bump 2026-10-20 13:05:57 +03:00
Anatoliy Poloz
1766314bbe bump version 2026-10-20 13:03:23 +03:00
d1575d7ac4 fix(verification): show email confirmation when 405 reports missing email (#1087) 2026-10-19 18:11:34 +03:00
94bca6a793 Merge pull request 'feat(video_calls): screen waking up' (#1083) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1083
2026-10-19 13:15:30 +00:00
misha
a6b94f835c feat(video_calls): ios voip push handling fix 2026-10-19 16:09:20 +03:00
misha
4c38026748 Merge remote-tracking branch 'upstream/main' 2026-10-19 12:50:07 +03:00
misha
355ec2df48 feat(video_calls): added phone waking up after it gets call push 2026-10-19 12:49:09 +03:00
04195dd95b Merge pull request 'bump version for TF' (#1082) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1082
2026-10-19 07:55:43 +00:00
Anatoliy Poloz
d3336b1263 bump version for TF 2026-10-19 10:48:32 +03:00
82f5c9a7fe Merge pull request 'fix(diary): parse commented_from_diet/reviewed_from_diet portion states, fallback unknown states to PortionState.unknown' (#1081) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1081
Reviewed-by: misha <misha@noreply.localhost>
2026-10-19 07:32:51 +00:00
Anatoliy Poloz
39a512193e fix(diary): parse commented_from_diet/reviewed_from_diet portion states, fallback unknown states to PortionState.unknown 2026-10-16 21:12:40 +03:00
4dc5839317 Merge pull request 'feat(video_calls): android repeated call after hanging up fix and ios repeated calls after call initiation fix' (#1079) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1079
2026-10-16 15:21:13 +00:00
misha
da6158625f fix: tests 2026-10-16 16:30:56 +03:00
misha
5a8f8aee3e feat(video_calls): ios repeated call fix 2026-10-16 15:41:13 +03:00
misha
86e9d0cc31 feat(video_calls): android repeated call after hanging up fix and ios repeated calls after call initiation fix 2026-10-16 15:40:41 +03:00
60080c7407 Merge pull request 'feat(video_calls): ios voip push, mix and crushes fix' (#1078) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1078
2026-10-16 10:57:37 +00:00
293601f1e1 Merge pull request 'feat(ios): env migration for ios 27' (#1077) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1077
2026-10-16 05:27:39 +00:00
6a259e5452 Merge pull request 'feat(video_calls): ios mic fix, android call fix. feat(dev_tools): base url caching' (#1076) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1076
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-15 13:32:29 +00:00
204 changed files with 13948 additions and 2481 deletions

View File

@@ -14,7 +14,7 @@ env:
jobs:
build:
runs-on: [ubuntu-latest]
runs-on: [debian-12]
steps:
- uses: https://github.com/actions/checkout@v3
@@ -155,10 +155,10 @@ jobs:
- name: Get dependencies
run: |
flutter clean
flutter pub cache repair
flutter pub get
dart run build_runner build --delete-conflicting-outputs
flutter clean
flutter pub cache repair
flutter pub get
dart run build_runner build --delete-conflicting-outputs
- name: Native Analyze
run: flutter analyze

4
.gitignore vendored
View File

@@ -16,6 +16,8 @@
*.mocks.dart
android/build/
ios/XCBuildData/
docs/
specs/
env/.env
.env
android/key.properties
@@ -25,4 +27,4 @@ backend/
.fvm/
.fvmrc
/eatvio
*.code-workspace
*.code-workspace

View File

@@ -39,7 +39,7 @@ android {
versionName = flutter.versionName
manifestPlaceholders = [
YANDEX_CLIENT_ID: yandexClientId,
applicationName: "io.flutter.app.FlutterApplication"
applicationName: "com.eatvioapp.app.EatvioApplication"
]
}

View File

@@ -87,8 +87,11 @@
android:name="flutterEmbedding"
android:value="2" />
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
Replace it with a bridge that routes to the same accept flow as Answer. -->
<!-- The incoming notification's content intent and full-screen intent both open
CallkitIncomingActivity. Replace it with a bridge: a tap on an unlocked phone
routes to the same accept flow as Answer; on a locked/dark screen it shows a
ringing screen that wakes the device. Own task affinity keeps the plugin's
CLEAR_TASK flag from killing MainActivity. -->
<activity
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
tools:node="remove" />
@@ -97,20 +100,26 @@
android:name=".EatvioCallkitIncomingBridgeActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:taskAffinity=""
android:allowTaskReparenting="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:launchMode="singleTop"
android:launchMode="singleInstance"
android:configChanges="orientation" />
<activity-alias
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
android:exported="true"
android:taskAffinity=""
android:allowTaskReparenting="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:launchMode="singleTop"
android:launchMode="singleInstance"
android:configChanges="orientation"
android:permission="${applicationId}.PERMISSION_CALL">
<intent-filter>

View File

@@ -13,14 +13,12 @@ object CallKitAcceptIntentMapper {
return null
}
return hashMapOf(
"action" to action,
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
"handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""),
"nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""),
"avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""),
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
)
return payloadMap(data, action)
}
fun declinePayloadFromBundle(data: Bundle, action: String): Map<String, Any?>? {
if (!action.contains(CallkitConstants.ACTION_CALL_DECLINE)) return null
return payloadMap(data, action)
}
fun payloadFromIntent(intent: Intent?): Map<String, Any?>? {
@@ -35,6 +33,16 @@ object CallKitAcceptIntentMapper {
return null
}
return payloadMap(data, action)
}
fun clearCallKitLaunchIntent(intent: Intent?) {
if (intent == null) return
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
intent.action = null
}
private fun payloadMap(data: Bundle, action: String): Map<String, Any?> {
return hashMapOf(
"action" to action,
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
@@ -44,10 +52,4 @@ object CallKitAcceptIntentMapper {
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
)
}
fun clearCallKitLaunchIntent(intent: Intent?) {
if (intent == null) return
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
intent.action = null
}
}

View File

@@ -16,6 +16,12 @@ object CallKitIntentBridge {
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
PendingCallKitAcceptBridge.store(payload)
}
CallKitAcceptIntentMapper.declinePayloadFromBundle(data, action)?.let { payload ->
PendingCallKitDeclineBridge.store(payload)
if (MainActivity.isInForeground) {
MainActivity.onPendingDecline?.invoke()
}
}
}
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)

View File

@@ -0,0 +1,10 @@
package com.eatvioapp.app
import android.app.Application
class EatvioApplication : Application() {
override fun onCreate() {
super.onCreate()
VideoCallDeclineReporter.install(this)
}
}

View File

@@ -1,12 +1,71 @@
package com.eatvioapp.app
import android.app.Activity
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import android.view.View
import android.view.WindowManager
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.hiennv.flutter_callkit_incoming.AppUtils
import com.hiennv.flutter_callkit_incoming.CallkitConstants
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
import com.hiennv.flutter_callkit_incoming.ImageLoaderProvider
import com.hiennv.flutter_callkit_incoming.Utils
import com.hiennv.flutter_callkit_incoming.widgets.RippleRelativeLayout
import java.lang.ref.WeakReference
import kotlin.math.abs
import com.hiennv.flutter_callkit_incoming.R as CallkitR
/// Redirects notification body / full-screen intent taps to the same accept path as
/// the Answer button, instead of showing the native Callkit incoming UI.
/// Target of the incoming notification's content intent and full-screen intent.
///
/// Unlocked, lit screen: the user tapped the notification body, route it to the same
/// accept flow as the Answer button.
///
/// Locked or dark screen: Android fired the full-screen intent on its own, so show a
/// ringing screen that wakes the device and lets the user answer or decline.
class EatvioCallkitIncomingBridgeActivity : Activity() {
companion object {
private const val TAG = "EatvioIncomingBridge"
private const val WAKE_LOCK_TAG = "eatvio:incoming_call"
private const val DEFAULT_RING_DURATION_MS = 45_000L
private const val MIN_RING_SCREEN_MS = 1_000L
private val mainHandler = Handler(Looper.getMainLooper())
private var ringingScreen: WeakReference<EatvioCallkitIncomingBridgeActivity>? = null
/**
* Closes the ringing screen once the call stopped ringing: answered or declined
* from the notification, timed out, or ended by the caller.
*/
fun dismissRingingScreen() {
mainHandler.post {
ringingScreen?.get()?.closeRingingScreen()
ringingScreen = null
}
}
fun shouldShowRingingScreen(keyguardLocked: Boolean, screenInteractive: Boolean): Boolean {
return keyguardLocked || !screenInteractive
}
}
private var callData: Bundle? = null
private var wakeLock: PowerManager.WakeLock? = null
private var ringTimeout: Runnable? = null
private var actionTaken = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -15,15 +74,256 @@ class EatvioCallkitIncomingBridgeActivity : Activity() {
finish()
return
}
callData = data
CallKitIntentBridge.dispatchCallKitAction(
this,
CallkitConstants.ACTION_CALL_ACCEPT,
data,
if (!launchedOnLockedOrDarkScreen()) {
Log.i(TAG, "tap on notification body: accept")
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
finish()
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
return
}
Log.i(TAG, "full-screen intent on locked/dark screen: show ringing screen")
showRingingScreen(data)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA) ?: return
if (ringingScreen?.get() !== this) return
callData = data
bindCallData(data)
scheduleRingTimeout(data)
}
override fun onDestroy() {
cancelRingTimeout()
releaseWakeLock()
if (ringingScreen?.get() === this) {
ringingScreen = null
}
super.onDestroy()
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
// The ringing screen is dismissed by Answer / Decline / timeout only.
}
private fun launchedOnLockedOrDarkScreen(): Boolean {
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
return shouldShowRingingScreen(
keyguardLocked = keyguardManager.isKeyguardLocked,
screenInteractive = powerManager.isInteractive,
)
}
private fun showRingingScreen(data: Bundle) {
ringingScreen = WeakReference(this)
setTheme(CallkitR.style.CallkitIncomingTheme)
requestedOrientation = if (Utils.isTablet(this)) {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setTurnScreenOn(true)
setShowWhenLocked(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED,
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
}
acquireWakeLock(ringDuration(data))
VideoCallPushBridge.keepRingingOnFullScreen()
setContentView(CallkitR.layout.activity_callkit_incoming)
bindCallData(data)
scheduleRingTimeout(data)
}
private fun bindCallData(data: Bundle) {
val background: ImageView = findViewById(CallkitR.id.ivBackground)
val ripple: RippleRelativeLayout = findViewById(CallkitR.id.llBackgroundAnimation)
val callerName: TextView = findViewById(CallkitR.id.tvNameCaller)
val callerNumber: TextView = findViewById(CallkitR.id.tvNumber)
val logo: ImageView = findViewById(CallkitR.id.ivLogo)
val avatar: ImageView = findViewById(CallkitR.id.ivAvatar)
val actions: LinearLayout = findViewById(CallkitR.id.llAction)
val acceptButton: ImageView = findViewById(CallkitR.id.ivAcceptCall)
val acceptLabel: TextView = findViewById(CallkitR.id.tvAccept)
val declineButton: ImageView = findViewById(CallkitR.id.ivDeclineCall)
val declineLabel: TextView = findViewById(CallkitR.id.tvDecline)
ripple.layoutParams.height = Utils.getScreenWidth() + Utils.getStatusBarHeight(this)
ripple.startRippleAnimation()
(actions.layoutParams as? MarginLayoutParams)?.let { params ->
params.setMargins(0, 0, 0, Utils.getNavigationBarHeight(this))
actions.layoutParams = params
}
callerName.text = data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, "")
callerNumber.visibility = View.INVISIBLE
logo.visibility = View.INVISIBLE
val textColor = parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR), Color.WHITE)
callerName.setTextColor(textColor)
acceptLabel.setTextColor(textColor)
declineLabel.setTextColor(textColor)
background.setBackgroundColor(
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR), Color.parseColor("#0955fa")),
)
finish()
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
val avatarUrl = data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, "")
if (!avatarUrl.isNullOrEmpty()) {
avatar.visibility = View.VISIBLE
@Suppress("DEPRECATION", "UNCHECKED_CAST")
val headers = data.getSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS) as? HashMap<String, Any?>
try {
ImageLoaderProvider.loadImage(this, avatarUrl, headers, CallkitR.drawable.ic_default_avatar, avatar)
} catch (error: Exception) {
Log.w(TAG, "failed to load caller avatar", error)
}
}
if (data.getInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) > 0) {
acceptButton.setImageResource(CallkitR.drawable.ic_video)
}
acceptButton.background = AppUtils.createCircleDrawable(
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_ACCEPT_COLOR), Color.parseColor("#4CAF50")),
)
declineButton.background = AppUtils.createCircleDrawable(
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_DECLINE_COLOR), Color.parseColor("#F44336")),
)
val acceptText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "")
acceptLabel.text = if (acceptText.isNullOrEmpty()) getString(CallkitR.string.text_accept) else acceptText
val declineText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "")
declineLabel.text = if (declineText.isNullOrEmpty()) getString(CallkitR.string.text_decline) else declineText
acceptButton.setOnClickListener { onAcceptClick() }
declineButton.setOnClickListener { onDeclineClick() }
}
private fun onAcceptClick() {
val data = callData ?: return
if (actionTaken) return
actionTaken = true
cancelRingTimeout()
Log.i(TAG, "ringing screen: accept")
// Same order as the plugin's own incoming activity: dispatch the accept, then ask
// the keyguard to unlock so MainActivity (which cannot show over the keyguard)
// becomes visible as soon as the user unlocks.
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (keyguardManager.isKeyguardLocked) {
keyguardManager.requestDismissKeyguard(this, null)
}
}
finishRingingTask()
}
private fun onDeclineClick() {
val data = callData ?: return
if (actionTaken) return
actionTaken = true
cancelRingTimeout()
Log.i(TAG, "ringing screen: decline")
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_DECLINE, data)
finishRingingTask()
}
private fun closeRingingScreen() {
if (isFinishing) return
cancelRingTimeout()
finishRingingTask()
}
private fun finishRingingTask() {
if (isFinishing) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
finishAndRemoveTask()
} else {
finish()
}
}
private fun ringDuration(data: Bundle): Long {
val duration = data.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, DEFAULT_RING_DURATION_MS)
return if (duration > 0L) duration else DEFAULT_RING_DURATION_MS
}
/**
* The notification was posted before this screen appeared, so ring only for the
* remaining part of the call duration.
*/
private fun scheduleRingTimeout(data: Bundle) {
cancelRingTimeout()
val now = System.currentTimeMillis()
val startedAt = data.getLong(CallkitNotificationManager.EXTRA_TIME_START_CALL, now)
val remaining = (ringDuration(data) - abs(now - startedAt)).coerceAtLeast(MIN_RING_SCREEN_MS)
val timeout = Runnable {
Log.i(TAG, "ringing screen: timed out")
closeRingingScreen()
}
ringTimeout = timeout
mainHandler.postDelayed(timeout, remaining)
}
private fun cancelRingTimeout() {
ringTimeout?.let { mainHandler.removeCallbacks(it) }
ringTimeout = null
}
@Suppress("DEPRECATION")
private fun acquireWakeLock(durationMs: Long) {
try {
val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
val lock = powerManager.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
PowerManager.ACQUIRE_CAUSES_WAKEUP or
PowerManager.ON_AFTER_RELEASE,
WAKE_LOCK_TAG,
)
lock.acquire(durationMs)
wakeLock = lock
} catch (error: Exception) {
Log.w(TAG, "failed to acquire wake lock", error)
}
}
private fun releaseWakeLock() {
val lock = wakeLock ?: return
wakeLock = null
try {
if (lock.isHeld) lock.release()
} catch (error: Exception) {
Log.w(TAG, "failed to release wake lock", error)
}
}
private fun parseColor(value: String?, fallback: Int): Int {
if (value.isNullOrBlank()) return fallback
return try {
Color.parseColor(value)
} catch (error: IllegalArgumentException) {
fallback
}
}
}

View File

@@ -16,7 +16,7 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
Log.i(TAG, "onMessageReceived: ${message.data}")
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
VideoCallPushBridge.dismissAllIncomingCalls(this)
VideoCallPushBridge.dismissEndedVideoCall(this, message.data)
return
}

View File

@@ -21,7 +21,7 @@ class HmsPushService : HmsMessageService() {
if (message == null) return
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
VideoCallPushBridge.dismissAllIncomingCalls(this)
VideoCallPushBridge.dismissEndedVideoCall(this, message.dataOfMap)
return
}

View File

@@ -25,6 +25,9 @@ class MainActivity: FlutterActivity() {
@JvmStatic
var isInForeground: Boolean = false
private set
@JvmStatic
var onPendingDecline: (() -> Unit)? = null
}
private val channelName = "com.eatvioapp/hms_push"
@@ -38,13 +41,18 @@ class MainActivity: FlutterActivity() {
override fun onResume() {
super.onResume()
isInForeground = true
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
captureCallKitLaunchIntent(intent)
if (PendingCallKitAcceptBridge.peek() != null) {
scheduleDeliverPendingCallKitAccept()
}
if (PendingCallKitDeclineBridge.peek() != null) {
scheduleDeliverPendingCallKitDecline()
}
}
override fun onPause() {
onPendingDecline = null
isInForeground = false
super.onPause()
}
@@ -55,22 +63,50 @@ class MainActivity: FlutterActivity() {
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
videoCallPushChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"dismissIncomingCalls" -> {
try {
VideoCallPushBridge.dismissAllIncomingCalls(applicationContext)
val args = call.arguments as? Map<*, *>
val callId = args?.get("call_id")?.toString()
?: args?.get("callId")?.toString()
val conversationUuid = args?.get("conversation_uuid")?.toString()
?: args?.get("conversationUuid")?.toString()
VideoCallPushBridge.dismissIncomingCalls(
applicationContext,
callId = callId,
conversationUuid = conversationUuid,
)
result.success(null)
} catch (e: Exception) {
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
}
}
"showIncomingCall" -> {
try {
val args = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
val data = HashMap<String, String>()
for ((key, value) in args) {
val name = key as? String ?: continue
if (value == null) continue
data[name] = value.toString()
}
VideoCallPushBridge.showIncomingCall(applicationContext, data)
result.success(null)
} catch (e: Exception) {
result.error("SHOW_INCOMING_CALL_FAILED", e.message, null)
}
}
"drainPendingCallKitAccept" -> {
result.success(PendingCallKitAcceptBridge.drain())
}
"peekPendingCallKitAccept" -> {
result.success(PendingCallKitAcceptBridge.peekPending())
}
"drainPendingCallKitDecline" -> {
result.success(PendingCallKitDeclineBridge.drain())
}
else -> result.notImplemented()
}
}
@@ -226,6 +262,30 @@ class MainActivity: FlutterActivity() {
channel.invokeMethod("onCallKitLaunchIntent", payload)
}
private fun scheduleDeliverPendingCallKitDecline() {
PendingCallKitDeclineBridge.scheduleDeliverToFlutter(
deliver = { deliverPendingCallKitDeclineToFlutter() },
)
}
private fun deliverPendingCallKitDeclineToFlutter() {
val payload = PendingCallKitDeclineBridge.peek() ?: return
val channel = videoCallPushChannel
if (channel == null) {
android.util.Log.w(
"VideoCallPush",
"deliverPendingCallKitDeclineToFlutter: channel not ready",
)
return
}
android.util.Log.i(
"VideoCallPush",
"deliverPendingCallKitDeclineToFlutter: id=${payload["id"]}",
)
channel.invokeMethod("onCallKitDecline", payload)
}
private fun handleIntentForActionUrl(intent: Intent?) {
if (intent == null) return

View File

@@ -0,0 +1,44 @@
package com.eatvioapp.app
import android.os.Handler
import android.os.Looper
import android.util.Log
/// Stores a decline until Flutter can POST /video-call/decline.
///
/// The plugin broadcasts the decline. If the Flutter engine is not attached,
/// that broadcast never reaches the backend and the call keeps ringing on the
/// user's other devices.
object PendingCallKitDeclineBridge {
private const val TAG = "PendingCallKitDecline"
@Volatile
private var pendingPayload: Map<String, Any?>? = null
private val mainHandler = Handler(Looper.getMainLooper())
fun store(payload: Map<String, Any?>) {
pendingPayload = payload
Log.i(TAG, "stored pending decline id=${payload["id"]}")
}
fun peek(): Map<String, Any?>? = pendingPayload
fun drain(): Map<String, Any?>? {
val payload = pendingPayload
pendingPayload = null
if (payload != null) {
Log.i(TAG, "drained pending decline id=${payload["id"]}")
}
return payload
}
fun scheduleDeliverToFlutter(
deliver: () -> Unit,
delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L),
) {
for (delayMs in delaysMs) {
mainHandler.postDelayed({ deliver() }, delayMs)
}
}
}

View File

@@ -0,0 +1,73 @@
package com.eatvioapp.app
import android.content.Context
import android.util.Log
import java.net.HttpURLConnection
import java.net.URL
internal object FlutterSharedPrefs {
const val NAME = "FlutterSharedPreferences"
private const val PREFIX = "flutter."
fun string(context: Context, key: String): String? {
return context
.getSharedPreferences(NAME, Context.MODE_PRIVATE)
.getString(PREFIX + key, null)
?.trim()
?.takeIf { it.isNotEmpty() }
}
}
internal object VideoCallDeclineClient {
private const val TAG = "VideoCallDecline"
private const val TIMEOUT_MS = 4_000
fun send(context: Context, handle: String?, extra: Map<*, *>?) {
val fields = VideoCallDeclineRequestFactory.fields(handle, extra)
val baseUrl = FlutterSharedPrefs.string(context, "apiBaseUrl")
?: FlutterSharedPrefs.string(context, "customBaseUrl")
val request = VideoCallDeclineRequestFactory.build(
baseUrl = baseUrl,
token = FlutterSharedPrefs.string(context, "token"),
hwId = FlutterSharedPrefs.string(context, "eatvio.android_install_hw_id"),
fields = fields,
)
if (request == null) {
Log.w(
TAG,
"skip decline POST conversation=${fields.conversationUuid} ended=${fields.endedPush}",
)
return
}
try {
val code = post(request)
Log.i(TAG, "decline POST status=$code conversation=${fields.conversationUuid}")
} catch (error: Exception) {
Log.w(TAG, "decline POST failed conversation=${fields.conversationUuid}", error)
}
}
private fun post(request: VideoCallDeclineRequest): Int {
val connection = (URL(request.url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
doOutput = true
setRequestProperty("Authorization", "Bearer ${request.token}")
setRequestProperty("Accept", "application/json")
setRequestProperty("Content-Type", "application/json; charset=utf-8")
}
try {
connection.outputStream.use { stream ->
stream.write(request.body.toByteArray(Charsets.UTF_8))
}
val code = connection.responseCode
val response = if (code in 200..299) connection.inputStream else connection.errorStream
response?.use { stream -> stream.readBytes() }
return code
} finally {
connection.disconnect()
}
}
}

View File

@@ -0,0 +1,74 @@
package com.eatvioapp.app
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.core.os.BundleCompat
import com.hiennv.flutter_callkit_incoming.CallkitConstants
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
/**
* Sends `POST /video-call/decline` from the process that handled the tap.
*
* The notification Decline button is an explicit broadcast into the CallKit
* plugin. That broadcast starts the process when the app was killed and does
* not start Flutter. The plugin notifies this callback before it returns, so
* the request can finish while the process is still alive.
*/
object VideoCallDeclineReporter : CallkitEventCallback {
private const val TAG = "VideoCallDecline"
private const val WAIT_FOR_HTTP_MS = 9_000L
@Volatile
private var appContext: Context? = null
@Volatile
private var installed = false
fun install(context: Context) {
if (installed) return
installed = true
appContext = context.applicationContext
FlutterCallkitIncomingPlugin.registerEventCallback(this)
Log.i(TAG, "installed")
}
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
if (event != CallkitEventCallback.CallEvent.DECLINE) return
val context = appContext ?: return
val handle = callData.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)
val extra = copyExtra(callData)
if (MainActivity.isInForeground) {
CoroutineScope(Dispatchers.IO).launch {
VideoCallDeclineClient.send(context, handle, extra)
}
return
}
// The Decline button starts this process and then the broadcast ends.
// Wait here so Android does not kill the process before the POST returns.
runBlocking(Dispatchers.IO) {
withTimeoutOrNull(WAIT_FOR_HTTP_MS) {
VideoCallDeclineClient.send(context, handle, extra)
}
}
}
private fun copyExtra(callData: Bundle): Map<String, Any?> {
val raw = BundleCompat.getSerializable(
callData,
CallkitConstants.EXTRA_CALLKIT_EXTRA,
HashMap::class.java,
) ?: return emptyMap()
return buildMap {
for ((key, value) in raw) {
if (key is String) put(key, value)
}
}
}
}

View File

@@ -0,0 +1,136 @@
package com.eatvioapp.app
/**
* Body of `POST /api/v2/chat/{conversation}/video-call/decline`.
*
* [callId] is omitted when it is missing, not a uuid, or equal to the
* conversation uuid: the server treats `call_id` as the session uuid.
*/
internal data class VideoCallDeclineRequest(
val url: String,
val token: String,
val body: String,
)
internal data class VideoCallDeclineFields(
val conversationUuid: String,
val callId: String?,
val endedPush: Boolean,
)
internal object VideoCallDeclineRequestFactory {
private val HEX = "0123456789ABCDEF".toCharArray()
private val uuidPattern =
Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
fun fields(handle: String?, extra: Map<*, *>?): VideoCallDeclineFields {
val conversation = firstString(
extra,
listOf("conversationUuid", "conversation_uuid"),
) ?: handle?.trim().orEmpty()
val rawCallId = firstString(extra, listOf("call_id", "callId"))
return VideoCallDeclineFields(
conversationUuid = conversation,
callId = backendCallId(rawCallId, conversation),
endedPush = marksEndedPush(extra),
)
}
fun build(
baseUrl: String?,
token: String?,
hwId: String?,
fields: VideoCallDeclineFields,
): VideoCallDeclineRequest? {
if (fields.endedPush) return null
val conversation = fields.conversationUuid.trim()
val authToken = token?.trim().orEmpty()
val deviceId = hwId?.trim().orEmpty()
val root = baseUrl?.trim()?.trimEnd('/').orEmpty()
if (conversation.isEmpty() || authToken.isEmpty()) return null
if (deviceId.length < 8 || deviceId.length > 64) return null
if (!root.startsWith("http://") && !root.startsWith("https://")) return null
val encoded = encodePathSegment(conversation)
return VideoCallDeclineRequest(
url = "$root/api/v2/chat/$encoded/video-call/decline",
token = authToken,
body = jsonBody(deviceId, fields.callId),
)
}
private fun backendCallId(callId: String?, conversationUuid: String): String? {
val id = callId?.trim().orEmpty()
if (!uuidPattern.matches(id)) return null
if (id.equals(conversationUuid.trim(), ignoreCase = true)) return null
return id
}
private fun firstString(extra: Map<*, *>?, keys: List<String>): String? {
if (extra == null) return null
for (key in keys) {
val value = extra[key] as? String ?: continue
val trimmed = value.trim()
if (trimmed.isNotEmpty()) return trimmed
}
return null
}
private fun marksEndedPush(extra: Map<*, *>?): Boolean {
val value = extra?.get("endedPush") ?: return false
return when (value) {
is Boolean -> value
is Number -> value.toInt() != 0
is String -> {
val normalized = value.trim().lowercase()
normalized == "true" || normalized == "1"
}
else -> false
}
}
private fun jsonBody(hwId: String, callId: String?): String {
val hw = jsonString(hwId)
if (callId == null) return """{"hw_id":$hw}"""
return """{"hw_id":$hw,"call_id":${jsonString(callId)}}"""
}
private fun encodePathSegment(value: String): String {
val bytes = value.encodeToByteArray()
return buildString(bytes.size) {
for (byte in bytes) {
val code = byte.toInt() and 0xFF
val allowed = code in 'A'.code..'Z'.code ||
code in 'a'.code..'z'.code ||
code in '0'.code..'9'.code ||
code == '-'.code ||
code == '_'.code ||
code == '.'.code ||
code == '~'.code
if (allowed) {
append(code.toChar())
} else {
append('%')
append(HEX[code ushr 4])
append(HEX[code and 0x0F])
}
}
}
}
private fun jsonString(value: String): String {
val escaped = buildString {
for (char in value) {
when (char) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(char)
}
}
}
return "\"$escaped\""
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -17,6 +18,7 @@ import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import com.hiennv.flutter_callkit_incoming.addCall
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
import com.hiennv.flutter_callkit_incoming.removeAllCalls
import com.hiennv.flutter_callkit_incoming.removeCall
import java.util.HashMap
import java.util.concurrent.atomic.AtomicInteger
@@ -24,16 +26,20 @@ object VideoCallPushBridge {
private const val TAG = "VideoCallPushBridge"
private const val ANDROID_ACCENT_COLOR = "#2787F5"
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
private const val TOMBSTONE_TTL_MS = 2 * 60 * 1000L
private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
private val mainHandler = Handler(Looper.getMainLooper())
private val playbackLock = Any()
private val playbackGeneration = AtomicInteger(0)
private val tombstonedCallIds = HashMap<String, Long>()
private var activeSoundManager: CallkitSoundPlayerManager? = null
private var activeNotificationManager: CallkitNotificationManager? = null
private var activeCallBundle: Bundle? = null
private var storedAppContext: Context? = null
private var ringtoneTimeout: Runnable? = null
private var ringWakeLock: PowerManager.WakeLock? = null
private var callbackRegistered = false
/**
@@ -115,23 +121,38 @@ object VideoCallPushBridge {
storedAppContext = appContext
ensureIncomingCallCallbackRegistered()
val serverCallId = callId(data)
if (isTombstoned(serverCallId)) {
Log.i(TAG, "showIncomingCall: skip tombstoned call_id=$serverCallId")
return
}
val callData = buildCallDataBundle(data)
if (callData == null) {
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
return
}
val generation = playbackGeneration.incrementAndGet()
Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}")
val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)
val seenGeneration = playbackGeneration.get()
Log.i(TAG, "showIncomingCall: id=$callId conversation=${conversationUuid(data)}")
// Ringtone.play() and stop() must run on the same thread. FCM delivers
// pushes on a background thread, while Answer runs on the main thread.
// A second show for the same conversation must not bump the generation:
// that would cancel the timeout of the call that is already ringing.
mainHandler.post {
synchronized(playbackLock) {
if (generation != playbackGeneration.get()) {
Log.i(TAG, "showIncomingCall: skipped stale playback generation=$generation")
if (seenGeneration != playbackGeneration.get()) {
Log.i(TAG, "showIncomingCall: skipped after silence")
return@post
}
val alreadyRinging = activeCallBundle?.getString(CallkitConstants.EXTRA_CALLKIT_ID)
if (!callId.isNullOrBlank() && callId == alreadyRinging) {
Log.i(TAG, "showIncomingCall: already ringing $callId")
return@post
}
val generation = playbackGeneration.incrementAndGet()
startIncomingCallLocked(appContext, callData, generation)
}
}
@@ -158,17 +179,53 @@ object VideoCallPushBridge {
}
}
fun dismissAllIncomingCalls(context: Context) {
/**
* The plugin's sound player stops on ACTION_SCREEN_OFF. The ringing screen turns
* the display on itself, so keep ringing through that transition.
*/
fun keepRingingOnFullScreen() {
synchronized(playbackLock) {
activeSoundManager?.keepRingingOnFullScreen()
}
}
fun dismissIncomingCalls(
context: Context,
callId: String? = null,
conversationUuid: String? = null,
) {
val normalizedCallId = callId?.trim()?.takeIf { it.isNotEmpty() }
val normalizedConversation = conversationUuid?.trim()?.takeIf { it.isNotEmpty() }
if (normalizedCallId != null) {
markTombstone(normalizedCallId)
}
val appContext = context.applicationContext
val calls = getDataActiveCalls(appContext)
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
Log.i(
TAG,
"dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " +
"conversation=${normalizedConversation ?: "-"}",
)
playbackGeneration.incrementAndGet()
val dismiss = Runnable {
synchronized(playbackLock) {
val notificationManager = resolveNotificationManager(appContext)
val bundles = calls.map { it.toBundle() }
stopPlaybackLocked(appContext)
val allBundles = calls.map { it.toBundle() }
val targeted = hasDismissTarget(normalizedCallId, normalizedConversation)
val bundles = if (targeted) {
allBundles.filter { bundleMatches(it, normalizedCallId, normalizedConversation) }
} else {
allBundles
}
val activeMatches = activeCallBundle?.let { bundle ->
!targeted || bundleMatches(bundle, normalizedCallId, normalizedConversation)
} == true
if (activeMatches || !targeted) {
stopPlaybackLocked(appContext)
}
for (bundle in bundles) {
try {
@@ -176,25 +233,39 @@ object VideoCallPushBridge {
} catch (error: Exception) {
Log.w(
TAG,
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
"dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
error,
)
}
}
try {
CallkitNotificationService.stopService(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
if (!targeted || bundles.isNotEmpty()) {
try {
CallkitNotificationService.stopService(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error)
}
}
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
if (!targeted) {
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to remove persisted calls", error)
}
} else {
for (bundle in bundles) {
try {
removeCall(appContext, Data.fromBundle(bundle))
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to remove targeted call", error)
}
}
}
stopIncomingRingtone()
if (activeMatches || !targeted) {
stopIncomingRingtone()
}
scheduleMissedCallCleanup(notificationManager, bundles)
}
}
@@ -206,6 +277,18 @@ object VideoCallPushBridge {
}
}
fun dismissAllIncomingCalls(context: Context) {
dismissIncomingCalls(context)
}
fun dismissEndedVideoCall(context: Context, data: Map<String, String>) {
dismissIncomingCalls(
context = context,
callId = callId(data),
conversationUuid = conversationUuid(data),
)
}
private fun ensureIncomingCallCallbackRegistered() {
if (callbackRegistered) return
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
@@ -230,6 +313,7 @@ object VideoCallPushBridge {
}
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L)
wakeScreenLocked(appContext, if (durationMs > 0L) durationMs else 45_000L)
if (durationMs > 0L) {
val timeout = Runnable {
synchronized(playbackLock) {
@@ -243,8 +327,43 @@ object VideoCallPushBridge {
}
}
/**
* Turns the screen on while the call rings. The full-screen intent of the
* notification does the same only when the system grants it; the wake lock
* works without any grant, so the lock screen with the call card lights up.
*/
@Suppress("DEPRECATION")
private fun wakeScreenLocked(appContext: Context, durationMs: Long) {
releaseScreenWakeLockLocked()
try {
val powerManager = appContext.getSystemService(Context.POWER_SERVICE) as PowerManager
val lock = powerManager.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
PowerManager.ACQUIRE_CAUSES_WAKEUP or
PowerManager.ON_AFTER_RELEASE,
WAKE_LOCK_TAG,
)
lock.acquire(durationMs)
ringWakeLock = lock
Log.i(TAG, "wakeScreen: screen on for ${durationMs}ms, interactive=${powerManager.isInteractive}")
} catch (error: Exception) {
Log.w(TAG, "wakeScreen: failed to acquire wake lock", error)
}
}
private fun releaseScreenWakeLockLocked() {
val lock = ringWakeLock ?: return
ringWakeLock = null
try {
if (lock.isHeld) lock.release()
} catch (error: Exception) {
Log.w(TAG, "wakeScreen: failed to release wake lock", error)
}
}
private fun stopPlaybackLocked(appContext: Context) {
cancelRingtoneTimeoutLocked()
releaseScreenWakeLockLocked()
val notificationManager = activeNotificationManager
val bundle = activeCallBundle
stopIncomingRingtone()
@@ -258,6 +377,7 @@ object VideoCallPushBridge {
activeSoundManager = null
activeNotificationManager = null
activeCallBundle = null
EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
}
private fun cancelRingtoneTimeoutLocked() {
@@ -344,6 +464,8 @@ object VideoCallPushBridge {
private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
val conversationUuid = conversationUuid(data) ?: return null
val serverCallId = callId(data)
val callKitId = serverCallId ?: conversationUuid
val callerName = data["counterpartName"]
?: data["counterpart_name"]
?: data["caller_name"]
@@ -369,6 +491,9 @@ object VideoCallPushBridge {
val extra = HashMap<String, Any?>()
extra["conversationUuid"] = conversationUuid
if (!serverCallId.isNullOrBlank()) {
extra["call_id"] = serverCallId
}
extra["counterpartName"] = callerName
if (avatarUrl.isNotBlank()) {
extra["counterpartAvatarUrl"] = avatarUrl
@@ -379,9 +504,13 @@ object VideoCallPushBridge {
if (counterpartIsConsultant) {
extra["counterpartIsConsultant"] = true
}
val messageId = data["message_id"] ?: data["messageId"]
if (!messageId.isNullOrBlank()) {
extra["message_id"] = messageId
}
return Bundle().apply {
putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid)
putString(CallkitConstants.EXTRA_CALLKIT_ID, callKitId)
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
@@ -406,6 +535,77 @@ object VideoCallPushBridge {
}
}
private fun callId(data: Map<String, String>): String? {
return data["call_id"]?.trim()?.takeIf { it.isNotEmpty() }
?: data["callId"]?.trim()?.takeIf { it.isNotEmpty() }
}
private fun hasDismissTarget(callId: String?, conversationUuid: String?): Boolean {
return !callId.isNullOrBlank() || !conversationUuid.isNullOrBlank()
}
private fun bundleMatches(
bundle: Bundle,
callId: String?,
conversationUuid: String?,
): Boolean {
val normalizedCallId = callId?.trim()?.lowercase()
if (!normalizedCallId.isNullOrEmpty()) {
val id = bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)?.trim()?.lowercase()
if (id != null && id == normalizedCallId) return true
val extraCallId = extraString(bundle, "call_id")
?: extraString(bundle, "callId")
if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) {
return true
}
// A ring that already stores its own call id must not be closed by
// another call in the same chat. Legacy rings have no call id and
// still match the conversation.
if (!extraCallId.isNullOrBlank()) return false
}
val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false
val handle = bundle.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)?.trim()?.lowercase()
if (handle != null && handle == normalizedConversation) return true
val extraConversation = extraString(bundle, "conversationUuid")
?: extraString(bundle, "conversation_uuid")
return extraConversation?.lowercase() == normalizedConversation
}
@Suppress("UNCHECKED_CAST")
private fun extraString(bundle: Bundle, key: String): String? {
val extra = bundle.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA) as? HashMap<String, Any?>
?: return null
val value = extra[key] ?: return null
val text = value.toString().trim()
return text.takeIf { it.isNotEmpty() }
}
private fun markTombstone(callId: String) {
val normalized = callId.trim().lowercase()
if (normalized.isEmpty()) return
pruneTombstones()
tombstonedCallIds[normalized] = System.currentTimeMillis() + TOMBSTONE_TTL_MS
}
private fun isTombstoned(callId: String?): Boolean {
val normalized = callId?.trim()?.lowercase() ?: return false
if (normalized.isEmpty()) return false
pruneTombstones()
return tombstonedCallIds.containsKey(normalized)
}
private fun pruneTombstones() {
val now = System.currentTimeMillis()
val iterator = tombstonedCallIds.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (entry.value <= now) {
iterator.remove()
}
}
}
private fun conversationUuid(data: Map<String, String>): String? {
val fromData = data["conversationUuid"]
?: data["conversation_uuid"]

View File

@@ -0,0 +1,103 @@
package com.eatvioapp.app
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class VideoCallDeclineRequestTest {
private val baseUrl = "https://eatvio.example"
private val token = "sanctum-token"
private val hwId = "install-hw-id-01"
private val conversation = "11111111-1111-1111-1111-111111111111"
private val callId = "22222222-2222-2222-2222-222222222222"
@Test
fun buildsDeclinePostWithCallId() {
val request = request(callId = callId)
assertEquals(
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
request?.url,
)
assertEquals(token, request?.token)
assertEquals(
"""{"hw_id":"$hwId","call_id":"$callId"}""",
request?.body,
)
}
@Test
fun omitsCallIdWhenItIsTheConversationUuid() {
val request = request(callId = conversation.uppercase())
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
}
@Test
fun omitsCallIdThatIsNotAUuid() {
val request = request(callId = "message-15")
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
}
@Test
fun readsConversationFromHandleWhenExtraHasNone() {
val fields = VideoCallDeclineRequestFactory.fields(conversation, emptyMap<String, Any?>())
val request = VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
assertTrue(request?.url?.contains(conversation) == true)
}
@Test
fun skipsEndedPush() {
val fields = VideoCallDeclineRequestFactory.fields(
conversation,
mapOf("conversationUuid" to conversation, "endedPush" to true),
)
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields))
}
@Test
fun skipsMissingTokenBaseUrlOrShortHwId() {
val fields = VideoCallDeclineRequestFactory.fields(
null,
mapOf("conversationUuid" to conversation, "call_id" to callId),
)
assertNull(VideoCallDeclineRequestFactory.build(null, token, hwId, fields))
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, " ", hwId, fields))
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, "short", fields))
assertNull(VideoCallDeclineRequestFactory.build("eatvio.example", token, hwId, fields))
}
@Test
fun trimsATrailingSlashOnTheBaseUrl() {
val request = VideoCallDeclineRequestFactory.build(
"$baseUrl/",
token,
hwId,
VideoCallDeclineRequestFactory.fields(
null,
mapOf("conversationUuid" to conversation),
),
)
assertEquals(
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
request?.url,
)
}
private fun request(callId: String?): VideoCallDeclineRequest? {
val fields = VideoCallDeclineRequestFactory.fields(
null,
mapOf(
"conversationUuid" to conversation,
"call_id" to callId,
),
)
return VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
}
}

View File

@@ -650,7 +650,7 @@ code + .copy-button {
<script type="text/javascript">
function configurationCacheProblems() { return (
// begin-report-data
{"diagnostics":[{"problem":[{"text":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10."}],"contextualLabel":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/9.0.0/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"properties-should-be-assigned-using-the-propname-value-syntax-setting-a-property-via-the-gradle-generated-propname-value-or-propname-value-syntax-in-groovy-dsl","displayName":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]}],"problemsReport":{"totalProblemCount":1,"requestedTasks":":app:compileDebugKotlin","documentationLink":"https://docs.gradle.org/9.0.0/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
{"diagnostics":[{"problem":[{"text":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10."}],"contextualLabel":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/9.0.0/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"properties-should-be-assigned-using-the-propname-value-syntax-setting-a-property-via-the-gradle-generated-propname-value-or-propname-value-syntax-in-groovy-dsl","displayName":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]}],"problemsReport":{"totalProblemCount":1,"requestedTasks":":app:testDebugUnitTest --tests com.eatvioapp.app.VideoCallDeclineRequestTest","documentationLink":"https://docs.gradle.org/9.0.0/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
// end-report-data
);}
</script>

View File

@@ -11,6 +11,7 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */; };
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -38,6 +39,7 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoCallDeclineClient.swift; sourceTree = "<group>"; };
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
938EE8FB5BAA359AAEDEFFF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
@@ -117,6 +119,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */,
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
@@ -303,6 +306,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */,
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);

View File

@@ -15,11 +15,14 @@ import flutter_foreground_task
private var voipDiagnosticsChannel: FlutterMethodChannel?
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
private let pendingAcceptedAtKey = "eatvioAcceptedAt"
private let pendingAcceptedCallTtl: TimeInterval = 120
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
private var callAudioChannel: FlutterMethodChannel?
private let appUrlScheme = "eatvio"
/// CallKit is only the ringing UI. After accept the in-app call continues,
/// so the audio session must be handed back to WebRTC when CallKit deactivates.
/// True only if CallKit ended while the in-app call must keep audio.
/// Incoming accept keeps CallKit up, so this stays false after answer.
private var resumeAudioAfterCallKitEnds = false
/// The push handler and the Flutter channel touch this map on different
/// queues. The push writes the id before CallKit has the call, so the
@@ -27,9 +30,20 @@ import flutter_foreground_task
private var reservedIncomingCallIds: [String: Date] = [:]
private let incomingCallClaimLock = NSLock()
private let incomingCallClaimTtl: TimeInterval = 90
/// Cancelled / declined server call ids. A late `video_call_started` with the
/// same id must not show ringing again (same TTL as Android / Dart).
private var tombstonedCallIds: [String: Date] = [:]
/// CallKit ids closed by an ended VoIP push. `onDecline` sees the original
/// ring, which has no `endedPush` marker, and must not POST /decline.
private var remoteEndedCallKitIds = Set<String>()
private let remoteEndedCallLock = NSLock()
private let callTombstoneTtl: TimeInterval = 120
private let voipPushQueue = DispatchQueue(label: "com.eatvioapp.voip")
private var voipCallReporterStorage: VoipCallReporter?
private var audioRestartGeneration = 0
/// CallKit owns AVAudioSession from answer until the CallKit call ends.
/// WebRTC must not call setActive(true) on top of that session.
private var callKitOwnsAudioSession = false
private var voipCallReporter: VoipCallReporter {
if let voipCallReporterStorage {
@@ -106,6 +120,7 @@ import flutter_foreground_task
name: callAudioSessionChannelName,
binaryMessenger: flutterController.binaryMessenger
)
self.callAudioChannel = callAudioChannel
callAudioChannel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(nil)
@@ -185,14 +200,19 @@ import flutter_foreground_task
]
)
if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty {
var extra: [String: Any] = ["conversationUuid": conversationUuid]
if let rawCallId = queryItems.first(where: { $0.name == "call_id" })?.value {
let serverCallId = rawCallId.trimmingCharacters(in: .whitespacesAndNewlines)
if !serverCallId.isEmpty {
extra["call_id"] = serverCallId
}
}
voipDiagnosticsChannel?.invokeMethod(
"acceptedVoipCall",
arguments: [
"id": callKitId,
"handle": conversationUuid,
"extra": [
"conversationUuid": conversationUuid
],
"extra": extra,
]
)
}
@@ -221,6 +241,78 @@ import flutter_foreground_task
return (String(conversationUuid[prefixRange]) + suffix).lowercased()
}
/// Canonical CallKit id: prefer backend `call_id`, then legacy message-derived
/// uuid, then conversation uuid (same resolve order as Dart `VideoCallKitId`).
private func resolveCallKitId(
serverCallId: String?,
conversationUuid: String,
messageId: UInt64?
) -> (id: String, source: String) {
if let serverCallId, !serverCallId.isEmpty {
return (serverCallId, "call_id")
}
if let derived = derivedCallKitId(
conversationUuid: conversationUuid,
messageId: messageId
) {
return (derived, "conversation_and_message")
}
if UUID(uuidString: conversationUuid) != nil {
return (conversationUuid.lowercased(), "conversation")
}
return (UUID().uuidString.lowercased(), "generated")
}
private func voipServerCallId(from dict: [AnyHashable: Any]) -> String? {
for key in ["call_id", "callId"] {
if let value = dict[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
}
return nil
}
private func voipEndReason(from dict: [AnyHashable: Any]) -> String? {
for key in ["reason", "end_reason", "endReason"] {
if let value = dict[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed.lowercased()
}
}
}
return nil
}
private func pruneCallTombstones(now: Date = Date()) {
tombstonedCallIds = tombstonedCallIds.filter {
now.timeIntervalSince($0.value) < callTombstoneTtl
}
}
private func markCallTombstone(_ callId: String?) {
let normalized = callId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
guard !normalized.isEmpty else { return }
pruneCallTombstones()
tombstonedCallIds[normalized] = Date()
DispatchQueue.main.async { [weak self] in
self?.voipDiagnosticsChannel?.invokeMethod(
"tombstoneCallId",
arguments: ["id": normalized]
)
}
}
private func isCallTombstoned(_ callId: String?) -> Bool {
let normalized = callId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
guard !normalized.isEmpty else { return false }
pruneCallTombstones()
return tombstonedCallIds[normalized] != nil
}
private func voipMessageId(_ value: Any?) -> UInt64? {
if let number = value as? NSNumber {
let parsed = number.uint64Value
@@ -234,6 +326,41 @@ import flutter_foreground_task
return nil
}
private func extraMarksEndedPush(_ extra: [String: Any]) -> Bool {
let value = extra["endedPush"]
if let bool = value as? Bool {
return bool
}
if let number = value as? NSNumber {
return number.intValue != 0
}
if let text = value as? String {
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized == "true" || normalized == "1"
}
return false
}
private func voipBool(_ value: Any?) -> Bool? {
if let bool = value as? Bool {
return bool
}
if let number = value as? NSNumber {
return number.intValue != 0
}
if let text = value as? String {
switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes":
return true
case "0", "false", "no":
return false
default:
return nil
}
}
return nil
}
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
guard
let actionUrl = actionUrl,
@@ -282,81 +409,49 @@ import flutter_foreground_task
}
private func storePendingAcceptedCall(_ call: Call) {
let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
var payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
payload[pendingAcceptedAtKey] = Date().timeIntervalSince1970
UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey)
}
/// An accept that Flutter never consumed (the process died before the
/// scene opened) must not open the call screen on a later launch: that
/// screen requests a token, and the token request rings the other person.
private func drainPendingAcceptedVoipCall() -> [String: Any]? {
let defaults = UserDefaults.standard
guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
guard var payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
return nil
}
defaults.removeObject(forKey: pendingAcceptedVoipCallKey)
let acceptedAt = (payload.removeValue(forKey: pendingAcceptedAtKey) as? NSNumber)?.doubleValue
if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl {
recordVoipDiagnostic(
"pending_accepted_call_expired",
data: [
"callKitId": payload["id"] as? String ?? "",
"ageSeconds": Date().timeIntervalSince1970 - acceptedAt,
]
)
return nil
}
return payload
}
private func bringAppToForeground() {
DispatchQueue.main.async {
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
for window in windowScene.windows {
window.makeKeyAndVisible()
}
}
self.window?.makeKeyAndVisible()
}
}
private func openAppForAcceptedCall(_ call: Call) {
guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else {
recordVoipDiagnostic(
"accepted_call_missing_conversation_uuid",
data: ["callKitId": call.data.uuid]
)
/// The answer callback runs before the scene is visible. A custom-scheme
/// open from here is an untrusted user action and SpringBoard denies it.
/// CallKit foregrounds a video call itself after `fulfill`. Flutter reads
/// the stored payload once that scene is actually active.
func deliverStoredAcceptedCall() {
guard voipDiagnosticsChannel != nil else { return }
guard let payload = UserDefaults.standard.dictionary(forKey: pendingAcceptedVoipCallKey) else {
return
}
var components = URLComponents()
components.scheme = appUrlScheme
components.host = "video-call"
components.queryItems = [
URLQueryItem(name: "callKitId", value: call.data.uuid),
URLQueryItem(name: "conversationUuid", value: conversationUuid)
]
guard let url = components.url else {
recordVoipDiagnostic(
"accepted_call_url_build_failed",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
]
)
let acceptedAt = (payload[pendingAcceptedAtKey] as? NSNumber)?.doubleValue
if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl {
UserDefaults.standard.removeObject(forKey: pendingAcceptedVoipCallKey)
return
}
recordVoipDiagnostic(
"accepted_call_open_app_requested",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
"url": url.absoluteString,
"appState": UIApplication.shared.applicationState.rawValue,
]
)
bringAppToForeground()
UIApplication.shared.open(url, options: [:]) { [weak self] success in
self?.recordVoipDiagnostic(
success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
"url": url.absoluteString,
]
)
}
voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: payload)
}
private func sanitizedDiagnosticValue(_ value: Any?) -> Any {
@@ -475,16 +570,31 @@ import flutter_foreground_task
}
}
/// An ended VoIP push has no message id, so its CallKit id is the
/// conversation uuid, not the ringing call. End that conversation only.
/// The first endCall while the push flag is set closes the call this push
/// just reported; the next ones close the call that is actually ringing.
private func endCallsForEndedPush(reportedId: String, conversationUuid: String) {
/// Ends the ringing CallKit call for an ended VoIP push.
///
/// A push that carries `call_id` closes that call. A ring shown from the
/// open-app socket used to have only a derived id and an empty `call_id`.
/// When nothing matches the server id, those rings still close by
/// conversation. A ring that already stores a different `call_id` stays up.
private func endCallsForEndedPush(
reportedId: String,
conversationUuid: String,
serverCallId: String? = nil,
reason: String? = nil
) {
guard let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
endReportedCallWithoutPlugin(reportedId)
releaseIncomingCallClaim(reportedId)
return
}
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedServerCallId = serverCallId?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let hasServerCallId = !normalizedServerCallId.isEmpty
if hasServerCallId {
markCallTombstone(normalizedServerCallId)
}
var ids: [String] = []
func append(_ id: String) {
let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -495,19 +605,48 @@ import flutter_foreground_task
ids.append(normalized)
}
append(reportedId)
var matchedServerCall = false
for call in plugin.activeCalls() {
let callId = (call["id"] as? String) ?? ""
let handle = (call["handle"] as? String) ?? ""
let extraUuid = extraConversationUuid(call["extra"])
let extraCallId = extraServerCallId(call["extra"])
if hasServerCallId {
let matchesServerCall =
callId.caseInsensitiveCompare(normalizedServerCallId) == .orderedSame
|| extraCallId.caseInsensitiveCompare(normalizedServerCallId) == .orderedSame
if matchesServerCall {
append(callId)
matchedServerCall = true
}
continue
}
let sameId = callId.caseInsensitiveCompare(reportedId) == .orderedSame
let sameConversation = !conversation.isEmpty && (
handle.caseInsensitiveCompare(conversation) == .orderedSame
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
let matchesConversation = sameConversation(
handle: handle,
extraUuid: extraUuid,
conversation: conversation
)
if sameId || sameConversation {
if sameId || matchesConversation {
append(callId)
}
}
if hasServerCallId && !matchedServerCall {
for call in plugin.activeCalls() {
let callId = (call["id"] as? String) ?? ""
let extraCallId = extraServerCallId(call["extra"])
guard extraCallId.isEmpty else { continue }
let handle = (call["handle"] as? String) ?? ""
let extraUuid = extraConversationUuid(call["extra"])
if sameConversation(
handle: handle,
extraUuid: extraUuid,
conversation: conversation
) {
append(callId)
}
}
}
for id in ids {
let endData = flutter_callkit_incoming.Data(
id: id,
@@ -515,22 +654,35 @@ import flutter_foreground_task
handle: conversation,
type: 1
)
// The plugin sends this payload with its "ended" event. Dart uses the
// marker to tell a remote end from a decline by the user.
var endExtra: [String: Any] = ["endedPush": true]
if !conversation.isEmpty {
endExtra["conversationUuid"] = conversation
}
if hasServerCallId {
endExtra["call_id"] = normalizedServerCallId
}
if let reason, !reason.isEmpty {
endExtra["reason"] = reason
}
endData.extra = endExtra as NSDictionary
markRemoteEndedCallKitId(id)
plugin.endCall(endData)
releaseIncomingCallClaim(id)
}
}
private func activeCallId(forConversation conversationUuid: String) -> String? {
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
guard !conversation.isEmpty else { return nil }
private func activeCallId(matchingServerCallId serverCallId: String) -> String? {
let normalized = serverCallId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return nil }
let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
for call in calls {
let callId = (call["id"] as? String) ?? ""
guard !callId.isEmpty else { continue }
let handle = (call["handle"] as? String) ?? ""
let extraUuid = extraConversationUuid(call["extra"])
if handle.caseInsensitiveCompare(conversation) == .orderedSame
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
let extraCallId = extraServerCallId(call["extra"])
if callId.caseInsensitiveCompare(normalized) == .orderedSame
|| extraCallId.caseInsensitiveCompare(normalized) == .orderedSame
{
return callId
}
@@ -538,21 +690,74 @@ import flutter_foreground_task
return nil
}
private func extraConversationUuid(_ extra: Any?) -> String {
let map: [String: Any]
private func activeCallId(
forConversation conversationUuid: String,
onlyWithoutServerCallId: Bool = false
) -> String? {
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
guard !conversation.isEmpty else { return nil }
let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
for call in calls {
let callId = (call["id"] as? String) ?? ""
guard !callId.isEmpty else { continue }
if onlyWithoutServerCallId && !extraServerCallId(call["extra"]).isEmpty {
continue
}
let handle = (call["handle"] as? String) ?? ""
let extraUuid = extraConversationUuid(call["extra"])
if sameConversation(handle: handle, extraUuid: extraUuid, conversation: conversation) {
return callId
}
}
return nil
}
private func sameConversation(handle: String, extraUuid: String, conversation: String) -> Bool {
guard !conversation.isEmpty else { return false }
return handle.caseInsensitiveCompare(conversation) == .orderedSame
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
}
private func markRemoteEndedCallKitId(_ id: String) {
let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalized.isEmpty else { return }
remoteEndedCallLock.lock()
remoteEndedCallKitIds.insert(normalized)
remoteEndedCallLock.unlock()
}
private func consumeRemoteEndedCallKitId(_ id: String) -> Bool {
let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalized.isEmpty else { return false }
remoteEndedCallLock.lock()
defer { remoteEndedCallLock.unlock() }
return remoteEndedCallKitIds.remove(normalized) != nil
}
private func endReportedCallWithoutPlugin(_ id: String) {
let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard let uuid = UUID(uuidString: normalized) else { return }
voipCallReporter.provider.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
}
private func extraMap(_ extra: Any?) -> [String: Any] {
if let typed = extra as? [String: Any] {
map = typed
} else if let object = extra as? NSDictionary {
return typed
}
if let object = extra as? NSDictionary {
var converted: [String: Any] = [:]
for (key, value) in object {
if let key = key as? String {
converted[key] = value
}
}
map = converted
} else {
return ""
return converted
}
return [:]
}
private func extraConversationUuid(_ extra: Any?) -> String {
let map = extraMap(extra)
for key in ["conversationUuid", "conversation_uuid"] {
if let value = map[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -564,6 +769,19 @@ import flutter_foreground_task
return ""
}
private func extraServerCallId(_ extra: Any?) -> String {
let map = extraMap(extra)
for key in ["call_id", "callId"] {
if let value = map[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
}
return ""
}
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate credentials: PKPushCredentials,
@@ -604,11 +822,16 @@ import flutter_foreground_task
.compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") }
let isEndedPush = pushMarkers.contains(where: { self.isEndedVideoCallMarker($0) })
let serverCallId = voipServerCallId(from: dict)
let endReason = voipEndReason(from: dict)
if isEndedPush {
markCallTombstone(serverCallId)
recordVoipDiagnostic(
"voip_end_or_cancel_received",
data: [
"markers": pushMarkers,
"call_id": serverCallId ?? "",
"reason": endReason ?? "",
"payload": dict,
]
)
@@ -634,23 +857,38 @@ import flutter_foreground_task
?? ""
let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
let parsedMessageId = voipMessageId(dict["message_id"])
let tombstonedIncoming = !isEndedPush && isCallTombstoned(serverCallId)
let id: String
let callKitIdSourceName: String
if isEndedPush, let existing = activeCallId(forConversation: trimmedConversationUuid) {
if isEndedPush, let serverCallId,
let existing = activeCallId(matchingServerCallId: serverCallId)
{
id = existing
callKitIdSourceName = "active_call_id"
} else if isEndedPush, serverCallId?.isEmpty == false,
let existing = activeCallId(
forConversation: trimmedConversationUuid,
onlyWithoutServerCallId: true
)
{
// The open app reported this ring from the chat socket before `call_id`
// was stored. End that ring instead of a new phantom call.
id = existing
callKitIdSourceName = "active_legacy_ring"
} else if isEndedPush, serverCallId == nil,
let existing = activeCallId(forConversation: trimmedConversationUuid)
{
// Legacy ended push without call_id.
id = existing
callKitIdSourceName = "active_conversation"
} else if let derived = derivedCallKitId(
conversationUuid: trimmedConversationUuid,
messageId: parsedMessageId
) {
id = derived
callKitIdSourceName = "conversation_and_message"
} else if UUID(uuidString: trimmedConversationUuid) != nil {
id = trimmedConversationUuid.lowercased()
callKitIdSourceName = "conversation"
} else {
id = UUID().uuidString.lowercased()
callKitIdSourceName = "generated"
let resolved = resolveCallKitId(
serverCallId: serverCallId,
conversationUuid: trimmedConversationUuid,
messageId: parsedMessageId
)
id = resolved.id
callKitIdSourceName = resolved.source
}
let isVideo = (dict["isVideo"] as? Bool)
?? (dict["is_video"] as? Bool)
@@ -660,6 +898,12 @@ import flutter_foreground_task
if !trimmedConversationUuid.isEmpty {
extra["conversationUuid"] = trimmedConversationUuid
}
if let serverCallId, !serverCallId.isEmpty {
extra["call_id"] = serverCallId
}
if let endReason, !endReason.isEmpty {
extra["reason"] = endReason
}
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
?? dict["avatarUrl"] as? String
?? dict["avatar_url"] as? String
@@ -670,6 +914,8 @@ import flutter_foreground_task
}
if let messageId = dict["message_id"] as? String {
extra["messageId"] = messageId
} else if let messageId = parsedMessageId {
extra["messageId"] = String(messageId)
}
if let roomName = dict["room_name"] as? String {
extra["roomName"] = roomName
@@ -686,6 +932,23 @@ import flutter_foreground_task
} else if !nameCaller.isEmpty {
extra["counterpartName"] = nameCaller
}
if let isConsultant = voipBool(dict["is_consultant"] ?? dict["isConsultant"]) {
extra["counterpartIsConsultant"] = isConsultant
}
// Closing this report looks like a user decline. Dart must not tell the
// backend, or the server sends another ended VoIP and the other phone
// shows it as a new incoming call.
if isEndedPush || tombstonedIncoming {
extra["endedPush"] = true
}
// An ended push with no ringing call still has to be reported. That
// report is closed at once, so it must not leave a trace anywhere.
// Tombstoned late incoming is treated the same way.
let matchedActiveRing =
callKitIdSourceName == "active_call_id"
|| callKitIdSourceName == "active_conversation"
|| callKitIdSourceName == "active_legacy_ring"
let isPhantomEndedReport = (isEndedPush && !matchedActiveRing) || tombstonedIncoming
let data = flutter_callkit_incoming.Data(
id: id,
@@ -693,27 +956,39 @@ import flutter_foreground_task
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
type: isVideo ? 1 : 0
)
data.appName = "Итвио"
data.handleType = "generic"
data.supportsVideo = true
data.duration = 45000
data.extra = extra as NSDictionary
data.missedNotificationSubtitle = "Пропущенный видеозвонок"
data.missedNotificationCallbackText = "Перезвонить"
data.isShowMissedCallNotification = !(isEndedPush || tombstonedIncoming)
data.includesCallsInRecents = !isPhantomEndedReport
// The plugin activates AVAudioSession itself on answer and again 1.2s
// later. That drops the ringing UI and leaves one side silent. CallKit
// and WebRTC own the session instead.
data.configureAudioSession = false
data.audioSessionActive = false
data.audioSessionMode = "videoChat"
data.ringtonePath = "system_ringtone_default"
// Do not set ringtonePath. The plugin stores that string as the ringtone
// file name. "system_ringtone_default" is not in the app, so CallKit ends
// the call at once and PushKit delivers the same push again.
// Remember the id so the in-app socket path does not report it again.
// The push itself must still be reported, or iOS kills the process.
_ = reserveIncomingCall(id)
NSLog(
"[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
"[EatvioVoIP] report incoming call id=\(id) call_id=\(serverCallId ?? "-") conversation=\(conversationUuid) reason=\(endReason ?? "-") state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
)
recordVoipDiagnostic(
"voip_incoming_received",
data: [
"callKitId": id,
"callKitIdSource": callKitIdSourceName,
"call_id": serverCallId ?? "",
"reason": endReason ?? "",
"tombstoned": tombstonedIncoming,
"conversationUuid": conversationUuid,
"nameCaller": nameCaller,
"isVideo": isVideo,
@@ -727,19 +1002,30 @@ import flutter_foreground_task
callKitId: id,
callKitIdSourceName: callKitIdSourceName,
conversationUuid: conversationUuid,
endAfterReport: isEndedPush,
serverCallId: serverCallId,
reason: endReason,
endAfterReport: isEndedPush || tombstonedIncoming,
endImmediately: isPhantomEndedReport,
completion: completion
)
}
/// PushKit kills the app unless every VoIP push is reported to CallKit and
/// its completion handler runs. Both happen here, including an "ended" push.
///
/// `endImmediately` is for an ended push that matched no ringing call. The
/// report is closed inside its own completion, before the incoming UI has a
/// chance to show. An end transaction would take one more round trip and
/// would also reach `onDecline`, which resets the in-app audio restart.
private func reportVoipPushToCallKit(
_ data: flutter_callkit_incoming.Data,
callKitId: String,
callKitIdSourceName: String,
conversationUuid: String,
serverCallId: String? = nil,
reason: String? = nil,
endAfterReport: Bool,
endImmediately: Bool,
completion: @escaping () -> Void
) {
let gate = VoipPushCompletion(completion)
@@ -749,32 +1035,50 @@ import flutter_foreground_task
}
let afterReport = {
if endAfterReport {
DispatchQueue.main.async {
if endAfterReport, endImmediately,
let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance
{
// Reason 6 is CXCallEndedReason.remoteEnded in the plugin.
plugin.saveEndCall(callKitId, 6)
}
let complete = {
if endAfterReport {
self.endCallsForEndedPush(
reportedId: callKitId,
conversationUuid: conversationUuid
conversationUuid: conversationUuid,
serverCallId: serverCallId,
reason: reason
)
}
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
let activeCalls = plugin.activeCalls()
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
self.recordVoipDiagnostic(
"callkit_report_completed",
data: [
"callKitId": callKitId,
"callKitIdSource": callKitIdSourceName,
"call_id": serverCallId ?? "",
"reason": reason ?? "",
"conversationUuid": conversationUuid,
"activeCallIds": activeIds,
"activeCallsCount": activeCalls.count,
"endAfterReport": endAfterReport,
]
)
}
finish()
}
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
let activeCalls = plugin.activeCalls()
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
self.recordVoipDiagnostic(
"callkit_report_completed",
data: [
"callKitId": callKitId,
"callKitIdSource": callKitIdSourceName,
"conversationUuid": conversationUuid,
"activeCallIds": activeIds,
"activeCallsCount": activeCalls.count,
"endAfterReport": endAfterReport,
]
)
if Thread.isMainThread {
complete()
} else {
DispatchQueue.main.async(execute: complete)
}
finish()
}
if !endAfterReport {
prepareCallAudioCategory()
}
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
plugin.showCallkitIncoming(data, fromPushKit: true, completion: afterReport)
return
@@ -792,6 +1096,9 @@ import flutter_foreground_task
finish()
return
}
if !endAfterReport {
prepareCallAudioCategory()
}
voipCallReporter.report(
uuid: uuid,
callerName: data.nameCaller,
@@ -810,9 +1117,16 @@ import flutter_foreground_task
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
resumeAudioAfterCallKitEnds = true
prepareCallAudioCategory()
// CallKit stays up for the whole incoming call. Do not take the session
// back after a later deactivate — that path is hang-up.
resumeAudioAfterCallKitEnds = false
callKitOwnsAudioSession = true
// Category only. RTCAudioSession.setConfiguration here races CallKit's
// activation and can start I/O before didActivate.
prepareAvAudioSessionCategory()
storePendingAcceptedCall(call)
let extra = call.data.extra as? [String: Any] ?? [:]
VideoCallDeclineClient.sendAccept(extra: extra, handle: call.data.handle)
recordVoipDiagnostic(
"callkit_accept_native",
data: [
@@ -821,35 +1135,68 @@ import flutter_foreground_task
"data": call.data.toJSON(),
]
)
voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON())
DispatchQueue.main.async { [weak self] in
self?.bringAppToForeground()
self?.openAppForAcceptedCall(call)
}
deliverStoredAcceptedCall()
action.fulfill()
}
func onDecline(_ call: Call, _ action: CXEndCallAction) {
resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
callKitOwnsAudioSession = false
// POST /decline from this callback. The Flutter event is only local
// cleanup and does not run when the user never opens the app.
let extra = call.data.extra as? [String: Any] ?? [:]
let markedRemoteEnd = consumeRemoteEndedCallKitId(call.data.uuid)
let closedByEndedPush = extraMarksEndedPush(extra) || markedRemoteEnd
if !closedByEndedPush {
VideoCallDeclineClient.send(extra: extra, handle: call.data.handle)
recordVoipDiagnostic(
"callkit_decline_native",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid(fromCall: call) ?? "",
]
)
voipDiagnosticsChannel?.invokeMethod(
"declinedVoipCall",
arguments: call.data.toJSON()
)
}
action.fulfill()
}
func onEnd(_ call: Call, _ action: CXEndCallAction) {
// The in-app call is already leaving, or the user hung up from CallKit.
// Do not restart the audio unit after this deactivation.
resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
callKitOwnsAudioSession = false
action.fulfill()
}
func onTimeOut(_ call: Call) {}
func didActivateAudioSession(_ audioSession: AVAudioSession) {
callKitOwnsAudioSession = true
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
rtcSession.audioSessionDidActivate(audioSession)
applyCallAudioConfiguration(active: false)
// WebRTC must start I/O inside this CallKit callback. Enabling later
// misses the session CallKit just activated; toggling after that
// does not restart the ADM, so the incoming microphone stays silent.
rtcSession.isAudioEnabled = true
// setCallConnected requests a second CXAnswerCallAction. That fires
// didActivate again after the microphone track already exists and can
// stop AudioEngine. Dart starts recording on each activation once the
// track is published. The first activation (accept, no track yet) is a
// no-op on the Dart side.
DispatchQueue.main.async { [weak self] in
self?.callAudioChannel?.invokeMethod("audioSessionActivated", arguments: nil)
}
}
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
callKitOwnsAudioSession = false
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.audioSessionDidDeactivate(audioSession)
// The audio unit is stopped, but isAudioEnabled stays true. A later
@@ -865,6 +1212,7 @@ import flutter_foreground_task
func providerDidReset() {
resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
callKitOwnsAudioSession = false
}
private func configureWebRTCAudioSession() {
@@ -872,33 +1220,62 @@ import flutter_foreground_task
RTCAudioSessionConfiguration.setWebRTC(configuration)
}
private func prepareAvAudioSessionCategory() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(
.playAndRecord,
mode: .videoChat,
options: [.allowBluetooth, .defaultToSpeaker]
)
} catch {
NSLog("[EatvioVoIP] prepare call audio category failed: \(error)")
}
}
private func prepareCallAudioCategory() {
prepareAvAudioSessionCategory()
applyCallAudioConfiguration(active: false)
}
private func activateInAppCallAudioSession() {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
if callKitOwnsAudioSession {
rtcSession.isAudioEnabled = true
return
}
applyCallAudioConfiguration(active: true)
// Starting the audio unit while the configuration lock is held deadlocks
// the capture thread, so the mic stays silent.
rtcSession.isAudioEnabled = true
}
/// Restarts capture after the previous audio graph has disconnected.
/// Toggling the unit in the same turn makes voice processing throw.
/// Outgoing (no CallKit) may need a teardown to attach capture. Incoming
/// already started the unit in didActivate — toggling it here kills ADM.
private func restartInAppCallAudioUnit() {
audioRestartGeneration += 1
let generation = audioRestartGeneration
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
if callKitOwnsAudioSession {
rtcSession.isAudioEnabled = true
return
}
audioRestartGeneration += 1
let generation = audioRestartGeneration
rtcSession.isAudioEnabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
guard let self, generation == self.audioRestartGeneration else { return }
self.activateInAppCallAudioSession()
self.enableInAppCallAudioUnit()
}
}
private func enableInAppCallAudioUnit() {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
if !callKitOwnsAudioSession {
applyCallAudioConfiguration(active: true)
}
rtcSession.isAudioEnabled = true
}
private func applyCallAudioConfiguration(active: Bool) {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.lockForConfiguration()
@@ -915,6 +1292,10 @@ import flutter_foreground_task
audioRestartGeneration += 1
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.isAudioEnabled = false
// Hang-up stops the unit first, then Dart closes CallKit. A second
// setActive(false) while CallKit still owns the session fails or
// tears the graph down in the wrong order.
if callKitOwnsAudioSession { return }
rtcSession.lockForConfiguration()
defer { rtcSession.unlockForConfiguration() }
do {

View File

@@ -19,6 +19,10 @@ class SceneDelegate: FlutterSceneDelegate {
override func sceneDidBecomeActive(_ scene: UIScene) {
super.sceneDidBecomeActive(scene)
registerCallChannels()
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
return
}
appDelegate.deliverStoredAcceptedCall()
}
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {

View File

@@ -0,0 +1,163 @@
import Foundation
import UIKit
enum VideoCallDeclineClient {
private static let uuidPattern =
#"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"#
/// `POST /video-call/decline` from the CallKit callback. The process is
/// awake here even when the user never opens the app afterwards.
static func send(extra: [String: Any], handle: String) {
post(action: "decline", extra: extra, handle: handle)
}
/// `POST /video-call/accept` from the CallKit answer callback. Flutter may
/// still be frozen on the splash snapshot, so the other device must be
/// told from here. A later Dart retry with the same hw_id is idempotent.
static func sendAccept(extra: [String: Any], handle: String) {
post(action: "accept", extra: extra, handle: handle)
}
private static func post(action: String, extra: [String: Any], handle: String) {
let fields = declineFields(extra: extra, handle: handle)
guard let request = makeRequest(
baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"),
token: prefsString("token"),
hwId: UIDevice.current.identifierForVendor?.uuidString,
fields: fields,
action: action
) else {
NSLog(
"[EatvioVoIP] \(action) POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)"
)
return
}
let task = EatvioBackgroundTask()
task.begin(name: "video-call-\(action)")
let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
NSLog(
"[EatvioVoIP] \(action) POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")"
)
task.end()
}
urlTask.resume()
}
static func makeRequest(
baseUrl: String?,
token: String?,
hwId: String?,
fields: VideoCallDeclineFields,
action: String = "decline"
) -> URLRequest? {
if fields.endedPush { return nil }
let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
let authToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let deviceId = hwId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
var root = baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
while root.hasSuffix("/") {
root.removeLast()
}
guard !conversation.isEmpty, !authToken.isEmpty else { return nil }
guard deviceId.count >= 8, deviceId.count <= 64 else { return nil }
guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil }
guard action == "accept" || action == "decline" else { return nil }
guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/\(action)")
else { return nil }
var body: [String: String] = ["hw_id": deviceId]
if let callId = fields.callId {
body["call_id"] = callId
}
guard let data = try? JSONSerialization.data(withJSONObject: body) else { return nil }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 10
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.httpBody = data
return request
}
private static func declineFields(extra: [String: Any], handle: String) -> VideoCallDeclineFields {
let conversation = firstString(extra, keys: ["conversationUuid", "conversation_uuid"])
?? handle.trimmingCharacters(in: .whitespacesAndNewlines)
let rawCallId = firstString(extra, keys: ["call_id", "callId"])
return VideoCallDeclineFields(
conversationUuid: conversation,
callId: backendCallId(rawCallId, conversation: conversation),
endedPush: marksEndedPush(extra["endedPush"])
)
}
private static func backendCallId(_ callId: String?, conversation: String) -> String? {
let id = callId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard let regex = try? NSRegularExpression(pattern: uuidPattern),
regex.firstMatch(in: id, range: NSRange(id.startIndex..., in: id)) != nil
else { return nil }
if id.compare(conversation.trimmingCharacters(in: .whitespacesAndNewlines), options: .caseInsensitive) == .orderedSame {
return nil
}
return id
}
private static func firstString(_ extra: [String: Any], keys: [String]) -> String? {
for key in keys {
guard let value = extra[key] as? String else { continue }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
return nil
}
private static func marksEndedPush(_ value: Any?) -> Bool {
if let bool = value as? Bool { return bool }
if let number = value as? NSNumber { return number.intValue != 0 }
if let text = value as? String {
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized == "true" || normalized == "1"
}
return false
}
private static func prefsString(_ key: String) -> String? {
let value = UserDefaults.standard.string(forKey: "flutter.\(key)")?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, !value.isEmpty { return value }
return nil
}
}
struct VideoCallDeclineFields {
var conversationUuid: String
var callId: String?
var endedPush: Bool
}
private final class EatvioBackgroundTask {
private let lock = NSLock()
private var id: UIBackgroundTaskIdentifier = .invalid
func begin(name: String) {
let taskId = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
self?.end()
}
lock.lock()
id = taskId
lock.unlock()
}
func end() {
lock.lock()
let current = id
id = .invalid
lock.unlock()
guard current != .invalid else { return }
UIApplication.shared.endBackgroundTask(current)
}
}

View File

@@ -43,37 +43,95 @@ platform :ios do
end
UI.message "Using auth_key_path: #{auth_key_path}"
# Authenticate to App Store Connect early:
# needed here by latest_testflight_build_number, not only by upload_to_testflight.
app_store_connect_api_key(
key_id: "325JS9JCZH",
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
key_filepath: auth_key_path,
duration: 1200,
in_house: false
)
# Configure Flutter for release mode
UI.message "Configuring Flutter for release mode..."
sh("cd .. && flutter build ios --config-only --release")
# Read build number from Flutter Generated.xcconfig
# Read version and build number from Flutter Generated.xcconfig.
# They are only a floor: the actual build number is derived from TestFlight below,
# so repeated CI runs on the same commit can never hit
# ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE ("bundle version must be higher").
generated_xcconfig_path = File.join("..", "Flutter", "Generated.xcconfig")
flutter_build_name = nil
flutter_build_number = nil
if File.exist?(generated_xcconfig_path)
File.foreach(generated_xcconfig_path) do |line|
matches = line.match(/FLUTTER_BUILD_NUMBER=(.*)/)
if matches
flutter_build_number = matches[1].strip
break
if (m = line.match(/FLUTTER_BUILD_NAME=(.*)/))
flutter_build_name = m[1].strip
elsif (m = line.match(/FLUTTER_BUILD_NUMBER=(.*)/))
flutter_build_number = m[1].strip
end
end
end
if flutter_build_number.nil?
UI.user_error!("Could not find FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
if flutter_build_name.nil? || flutter_build_number.nil?
UI.user_error!("Could not find FLUTTER_BUILD_NAME/FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
end
UI.message "Flutter build number: #{flutter_build_number}"
UI.message "Flutter version from pubspec: #{flutter_build_name} (#{flutter_build_number})"
# Apple closes a version train once that version is approved/released.
# Uploading into a closed train wastes a full ~8 min build and fails only
# at the upload step with altool 90186 ("closed for new build submissions")
# / 90062 ("must be higher than the previously approved version").
# Fail fast here, before building.
closed_train_states = [
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_DISTRIBUTION,
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::PROCESSING_FOR_DISTRIBUTION,
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::PENDING_DEVELOPER_RELEASE,
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::PENDING_APPLE_RELEASE
].join(",")
asc_app = Spaceship::ConnectAPI::App.find("com.eatvioapp.app")
closed_train = nil
if asc_app.nil?
UI.important("App com.eatvioapp.app not found in App Store Connect, skipping closed-train guard")
else
closed_train = asc_app.get_app_store_versions(
filter: {
appVersionState: closed_train_states,
platform: Spaceship::ConnectAPI::Platform::IOS
}
).find do |v|
(Gem::Version.new(v.version_string) >= Gem::Version.new(flutter_build_name))
rescue ArgumentError
false
end
end
if closed_train
UI.user_error!(
"Version train #{closed_train.version_string} is closed for new submissions " \
"(#{closed_train.app_version_state}), pubspec version is #{flutter_build_name}. " \
"Bump the version in pubspec.yaml above #{closed_train.version_string} and re-run."
)
end
asc_latest_build_number = latest_testflight_build_number(
app_identifier: "com.eatvioapp.app",
version: flutter_build_name,
initial_build_number: flutter_build_number.to_i
)
new_build_number = [flutter_build_number.to_i, asc_latest_build_number].max + 1
UI.message "Latest TestFlight build number: #{asc_latest_build_number} -> uploading build #{new_build_number}"
match(type: "appstore", readonly: true)
# Set build number explicitly instead of incrementing
increment_build_number(
xcodeproj: "Runner.xcodeproj",
build_number: flutter_build_number
build_number: new_build_number
)
update_code_signing_settings(
@@ -93,28 +151,33 @@ platform :ios do
export_team_id: "VL68C36HYS"
)
app_store_connect_api_key(
key_id: "325JS9JCZH",
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
key_filepath: auth_key_path,
duration: 1200,
in_house: false
)
begin
upload_to_testflight(
skip_waiting_for_build_processing: true,
skip_submission: true,
distribute_external: false,
notify_external_testers: false,
changelog: "TestFlight build #{flutter_build_number}",
changelog: "TestFlight build #{new_build_number}",
verbose: true
)
rescue => ex
UI.error "Upload to TestFlight failed: #{ex.message}"
UI.error "Full error: #{ex.backtrace.join("\n")}"
raise ex
# Xcode 26 altool prints a harmless "ERROR: ... CDUploaderStateInfoResumeFilePath"
# line even when the package was delivered fine (UPLOAD SUCCEEDED appears in the
# same output). fastlane < 2.240.0 treats every ERROR line as a failure
# (fastlane#30130), so the lane would die after a successful upload.
# Real Apple rejections (90186 closed train, 90062 version too low, ...) always
# contain UPLOAD FAILED and/or iris-code - those must still fail the lane.
if ex.message.include?("CDUploaderStateInfoResumeFilePath") &&
!ex.message.include?("UPLOAD FAILED") &&
!ex.message.include?("iris-code")
UI.important("altool delivered the package but reported a bogus resume-file ERROR " \
"(fastlane#30130). Treating upload as successful - " \
"verify the build appears in TestFlight.")
else
UI.error "Upload to TestFlight failed: #{ex.message}"
UI.error "Full error: #{ex.backtrace.join("\n")}"
raise ex
end
end
end
end

View File

@@ -33,6 +33,20 @@ class AppConfig {
late String _baseUrl;
String get baseUrl => _baseUrl;
/// Продуктовые хосты API. Сравнение по хосту целиком: поддомены
/// (например `*-hasan.eatvio.ru`) — это стенды разработки, не прод.
static const List<String> prodApiHosts = ['eatvio.ru', 'eatvio.com'];
/// Текущий base URL указывает на продуктовый API?
bool get isProdApi {
final host = Uri.tryParse(baseUrl)?.host.toLowerCase() ?? '';
if (host.isEmpty) return false;
return prodApiHosts.any((prodHost) => host == prodHost || host == 'www.$prodHost');
}
/// Работаем на стенде разработки/stage (не на продуктовом API)?
bool get isDevOrStageStand => !isProdApi;
void setBaseUrl(String baseUrls) {
_baseUrl = baseUrls;
_persistBaseUrl(baseUrls);
@@ -48,6 +62,7 @@ class AppConfig {
return;
}
unawaited(sl<Prefs>().setString(PrefsKeys.customBaseUrl, normalized));
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
}
/// Список глобальных BlocProvider'ов

View File

@@ -1,16 +1,30 @@
import 'dart:async';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
/// Подставляет сохранённый base URL до создания HTTP-клиента.
///
/// Действующий адрес также пишется в [PrefsKeys.apiBaseUrl], чтобы нативный
/// decline мог собрать `POST /video-call/decline`, когда Flutter не запущен.
void applyCachedBaseUrl() {
if (!sl.isRegistered<Prefs>() || !sl.isRegistered<AppConfig>()) {
return;
}
final cached = sl<Prefs>().getString(PrefsKeys.customBaseUrl)?.trim();
if (cached == null || cached.isEmpty) {
if (cached != null && cached.isNotEmpty) {
sl<AppConfig>().setBaseUrl(cached);
return;
}
sl<AppConfig>().setBaseUrl(cached);
_persistApiBaseUrl(sl<AppConfig>().baseUrl);
}
void _persistApiBaseUrl(String baseUrl) {
final normalized = baseUrl.trim();
if (normalized.isEmpty || !sl.isRegistered<Prefs>()) {
return;
}
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
}

View File

@@ -1,16 +1,70 @@
import 'dart:io';
import 'dart:math';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Stable id of this install, sent as `hw_id` on push registration and on
/// video-call accept/decline.
///
/// Android `Build.ID` is the firmware id. Two phones on the same build share
/// it, so the backend treats them as one device and a decline on one does not
/// reach the other. A per-install id stays unique.
abstract final class DeviceHwIdProvider {
static Future<String?> current() async {
final deviceInfo = DeviceInfoPlugin();
static const androidInstallIdKey = 'eatvio.android_install_hw_id';
static const androidTokenMigratedKey = 'eatvio.android_hw_id_token_migrated';
static Future<String?> current({SharedPreferences? preferences}) async {
if (Platform.isIOS) {
return (await deviceInfo.iosInfo).identifierForVendor;
return (await DeviceInfoPlugin().iosInfo).identifierForVendor;
}
if (Platform.isAndroid) {
return (await deviceInfo.androidInfo).id;
return androidInstallId(preferences: preferences);
}
return null;
}
static Future<String> androidInstallId({
SharedPreferences? preferences,
}) async {
final prefs = preferences ?? await SharedPreferences.getInstance();
final existing = prefs.getString(androidInstallIdKey)?.trim();
if (existing != null && existing.length >= 8) return existing;
final created = _newInstallId();
await prefs.setString(androidInstallIdKey, created);
return created;
}
/// The previous Android registration used `Build.ID`. Drop that token once
/// and register again with the install id.
static Future<bool> androidTokenNeedsReregister({
SharedPreferences? preferences,
}) async {
if (!Platform.isAndroid) return false;
final prefs = preferences ?? await SharedPreferences.getInstance();
return prefs.getBool(androidTokenMigratedKey) != true;
}
static Future<void> markAndroidTokenReregistered({
SharedPreferences? preferences,
}) async {
final prefs = preferences ?? await SharedPreferences.getInstance();
await prefs.setBool(androidTokenMigratedKey, true);
}
static String _newInstallId() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '${hex.substring(0, 8)}-'
'${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-'
'${hex.substring(16, 20)}-'
'${hex.substring(20)}';
}
}

View File

@@ -42,7 +42,8 @@ import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans;
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart'
as profile_plans;
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
@@ -70,69 +71,174 @@ class CubitRegistrations {
/// Регистрация Cubit'ов как синглтонов
static void _registerSingletonCubits() {
sl.registerLazySingleton<NewMessagesCubit>(() => NewMessagesCubit());
sl.registerLazySingleton<NotificationsCubit>(() => NotificationsCubit(wsManager: sl(), notificationsRepository: sl()));
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
sl.registerLazySingleton<VideoCallManager>(
() => VideoCallManager(
videoCallRepository: sl(),
sl.registerLazySingleton<NotificationsCubit>(
() => NotificationsCubit(wsManager: sl(), notificationsRepository: sl()),
);
sl.registerLazySingleton<ProfileAvatarCubit>(
() => ProfileAvatarCubit(profileRepository: sl()),
);
sl.registerLazySingleton<ChatsCubit>(
() => ChatsCubit(
chatRepository: sl(),
wsManager: sl(),
profileRepository: sl(),
),
);
sl.registerLazySingleton<InternetConnectionCubit>(
() => InternetConnectionCubit(),
);
sl.registerLazySingleton<VideoCallManager>(
() =>
VideoCallManager(videoCallRepository: sl(), profileRepository: sl()),
);
sl.registerLazySingleton<VideoCallSessionCubit>(
() => VideoCallSessionCubit(
videoCallManager: sl(),
systemPipCoordinator: sl(),
),
);
sl.registerLazySingleton<FeedCubit>(() => FeedCubit(feedRepository: sl()));
}
/// Регистрация Cubit'ов как фабрик
static void _registerFactoryCubits() {
// Auth related cubits
sl.registerFactory<AuthCubit>(() => AuthCubit(authRepository: sl(), firebaseApi: sl(), prefs: sl(), profileRepository: sl()));
sl.registerFactory<RegistrationCubit>(() => RegistrationCubit(registrationRepository: sl(), prefs: sl()));
sl.registerFactory<ResetPasswordCubit>(() => ResetPasswordCubit(resetPasswordRepository: sl()));
sl.registerFactory<VerifyEmailCubit>(() => VerifyEmailCubit(profileRepository: sl(), prefs: sl()));
sl.registerFactory<OAuthCubit>(() => OAuthCubit(authRepository: sl(), prefs: sl()));
sl.registerFactory<RegistrationCubitV2>(() => RegistrationCubitV2(registrationRepository: sl()));
sl.registerFactory<AuthCubit>(
() => AuthCubit(
authRepository: sl(),
firebaseApi: sl(),
prefs: sl(),
profileRepository: sl(),
sessionLifecycleService: sl(),
),
);
sl.registerFactory<RegistrationCubit>(
() => RegistrationCubit(
registrationRepository: sl(),
prefs: sl(),
sessionLifecycleService: sl(),
),
);
sl.registerFactory<ResetPasswordCubit>(
() => ResetPasswordCubit(resetPasswordRepository: sl()),
);
sl.registerFactory<VerifyEmailCubit>(
() => VerifyEmailCubit(profileRepository: sl(), prefs: sl()),
);
sl.registerFactory<OAuthCubit>(
() => OAuthCubit(
authRepository: sl(),
firebaseApi: sl(),
prefs: sl(),
sessionLifecycleService: sl(),
),
);
sl.registerFactory<RegistrationCubitV2>(
() => RegistrationCubitV2(
registrationRepository: sl(),
firebaseApi: sl(),
sessionLifecycleService: sl(),
),
);
// Feed related cubits
sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl()));
sl.registerFactory<RecipesCubit>(() => RecipesCubit(feedRepository: sl(), recipeRepository: sl(), aboutRecipeRepository: sl()));
sl.registerFactory<DietsCubit>(() => DietsCubit(feedRepository: sl(), dietsRepository: sl()));
sl.registerFactory<PlansCubit>(() => PlansCubit(feedRepository: sl(), planRepository: sl()));
sl.registerFactory<ConsultantsCubit>(() => ConsultantsCubit(feedRepository: sl()));
sl.registerFactory<CommentsCubit>(() => CommentsCubit(feedRepository: sl(), commentsRepository: sl()));
sl.registerFactory<FeedProfileCubit>(() => FeedProfileCubit(profileRepository: sl()));
sl.registerFactory<RecipesCubit>(
() => RecipesCubit(
feedRepository: sl(),
recipeRepository: sl(),
aboutRecipeRepository: sl(),
),
);
sl.registerFactory<DietsCubit>(
() => DietsCubit(feedRepository: sl(), dietsRepository: sl()),
);
sl.registerFactory<PlansCubit>(
() => PlansCubit(feedRepository: sl(), planRepository: sl()),
);
sl.registerFactory<ConsultantsCubit>(
() => ConsultantsCubit(feedRepository: sl()),
);
sl.registerFactory<CommentsCubit>(
() => CommentsCubit(feedRepository: sl(), commentsRepository: sl()),
);
sl.registerFactory<FeedProfileCubit>(
() => FeedProfileCubit(profileRepository: sl()),
);
// Chat related cubits
sl.registerFactory<ChatRoomCubit>(() => ChatRoomCubit(cardsRepository: sl(), chatRepository: sl(), wsManager: sl()));
sl.registerFactory<ChatRoomCubit>(
() => ChatRoomCubit(
cardsRepository: sl(),
chatRepository: sl(),
wsManager: sl(),
),
);
// Profile related cubits
sl.registerFactory<ProfileCubit>(() => ProfileCubit(profileRepository: sl(), chatRepository: sl(), consultantRepository: sl()));
sl.registerFactory<profile_plans.PlansCubit>(() => profile_plans.PlansCubit(plansRepository: sl()));
sl.registerFactory<ProfileCubit>(
() => ProfileCubit(
profileRepository: sl(),
chatRepository: sl(),
consultantRepository: sl(),
),
);
sl.registerFactory<profile_plans.PlansCubit>(
() => profile_plans.PlansCubit(plansRepository: sl()),
);
sl.registerFactory<SubCubit>(() => SubCubit(profileRepository: sl()));
// Content creation cubits
sl.registerFactory<CreateDietCubit>(() => CreateDietCubit(profileRepository: sl(), createDietRepository: sl(), glossaryRepository: sl()));
sl.registerFactory<CreatePlanCubit>(() => CreatePlanCubit(createPlanRepository: sl()));
sl.registerFactory<CreateRecipeCubit>(() => CreateRecipeCubit(recipeRepository: sl(), glossaryRepository: sl()));
sl.registerFactory<BlogPostCreateCubit>(() => BlogPostCreateCubit(blogPostRepository: sl()));
sl.registerFactory<CreateDietCubit>(
() => CreateDietCubit(
profileRepository: sl(),
createDietRepository: sl(),
glossaryRepository: sl(),
),
);
sl.registerFactory<CreatePlanCubit>(
() => CreatePlanCubit(createPlanRepository: sl()),
);
sl.registerFactory<CreateRecipeCubit>(
() => CreateRecipeCubit(recipeRepository: sl(), glossaryRepository: sl()),
);
sl.registerFactory<BlogPostCreateCubit>(
() => BlogPostCreateCubit(blogPostRepository: sl()),
);
// Content viewing cubits
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
sl.registerFactory<AboutDietCubit>(
() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()),
);
sl.registerFactory<AboutPlanCubit>(
() => AboutPlanCubit(
planRepository: sl(),
profileRepository: sl(),
dietRepository: sl(),
),
);
sl.registerFactory<AboutRecipeCubit>(
() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()),
);
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
// User content cubits
sl.registerFactory<MyDietsCubit>(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()));
sl.registerFactory<MyPlansCubit>(() => MyPlansCubit(myPlansRepository: sl()));
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl(), recipeRepository: sl()));
sl.registerFactory<MyDietsCubit>(
() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()),
);
sl.registerFactory<MyPlansCubit>(
() => MyPlansCubit(myPlansRepository: sl()),
);
sl.registerFactory<MyRecipesCubit>(
() => MyRecipesCubit(
myRecipesRepository: sl(),
aboutRecipeRepository: sl(),
recipeRepository: sl(),
),
);
// Other feature cubits
sl.registerFactory<CalendarCubit>(() => CalendarCubit(calendarRepository: sl()));
sl.registerFactory<CalendarCubit>(
() => CalendarCubit(calendarRepository: sl()),
);
sl.registerFactory<DiaryCubit>(
() => DiaryCubit(
chatRepository: sl(),
@@ -144,28 +250,74 @@ class CubitRegistrations {
),
);
sl.registerFactory<SettingsCubit>(
() => SettingsCubit(profileRepository: sl(), settingsRepository: sl(), consultantRepository: sl(), consultationsRepository: sl()),
() => SettingsCubit(
profileRepository: sl(),
settingsRepository: sl(),
consultantRepository: sl(),
consultationsRepository: sl(),
sessionLifecycleService: sl(),
),
);
sl.registerFactory<RegisterConsultantCubit>(
() => RegisterConsultantCubit(consultantRepository: sl()),
);
sl.registerFactory<ComplainingCubit>(
() => ComplainingCubit(repository: sl()),
);
sl.registerFactory<UserCardsCubit>(
() => UserCardsCubit(cardsRepository: sl()),
);
sl.registerFactory<OrderInfoCubit>(
() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()),
);
sl.registerFactory<RegisterConsultantCubit>(() => RegisterConsultantCubit(consultantRepository: sl()));
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
sl.registerFactory<ConsultationPaymentCubit>(
() => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()),
() => ConsultationPaymentCubit(
paymentRepository: sl(),
sbpRepository: sl(),
),
);
sl.registerFactory<ConsultationsCubit>(
() => ConsultationsCubit(consultationsRepository: sl(), chatRepository: sl()),
);
sl.registerFactory<ConsultantCommentsCubit>(
() => ConsultantCommentsCubit(diaryRepository: sl()),
);
sl.registerFactory<CancelPlanCubit>(
() => CancelPlanCubit(cancelPlanRepository: sl()),
);
sl.registerFactory<ConsultationsArchiveCubit>(
() => ConsultationsArchiveCubit(consultationsRepository: sl()),
);
sl.registerFactory<ConsultantFinancesCubit>(
() => ConsultantFinancesCubit(consultationsRepository: sl()),
);
sl.registerFactory<UserPurchasesCubit>(
() => UserPurchasesCubit(consultationsRepository: sl()),
);
sl.registerFactory<BlockedUsersCubit>(
() => BlockedUsersCubit(blockedUsersRepository: sl()),
);
sl.registerFactory<NotificationSettingsCubit>(
() => NotificationSettingsCubit(notificationSettingsRepository: sl()),
);
sl.registerFactory<ConsultantRatingCubit>(
() => ConsultantRatingCubit(
feedRepository: sl(),
repository: sl(),
profileRepository: sl(),
),
);
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
sl.registerFactory<ConsultationsArchiveCubit>(() => ConsultationsArchiveCubit(consultationsRepository: sl()));
sl.registerFactory<ConsultantFinancesCubit>(() => ConsultantFinancesCubit(consultationsRepository: sl()));
sl.registerFactory<UserPurchasesCubit>(() => UserPurchasesCubit(consultationsRepository: sl()));
sl.registerFactory<BlockedUsersCubit>(() => BlockedUsersCubit(blockedUsersRepository: sl()));
sl.registerFactory<NotificationSettingsCubit>(() => NotificationSettingsCubit(notificationSettingsRepository: sl()));
sl.registerFactory<ConsultantRatingCubit>(() => ConsultantRatingCubit(feedRepository: sl(), repository: sl(), profileRepository: sl()));
sl.registerFactory<ViewsCubit>(() => ViewsCubit(viewsRepository: sl()));
sl.registerFactory<UserDetailsCubit>(() => UserDetailsCubit(profileRepository: sl()));
sl.registerFactory<MenuCubit>(() => MenuCubit(sl()));
sl.registerFactory<UserDetailsCubit>(
() => UserDetailsCubit(profileRepository: sl()),
);
sl.registerFactory<MenuCubit>(() => MenuCubit(sl(), sl()));
sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl()));
sl.registerFactory<PersonalDietSuggestionCubit>(() => PersonalDietSuggestionCubit(dietRepository: sl(), personalDietSuggestionService: sl()));
sl.registerFactory<PersonalDietSuggestionCubit>(
() => PersonalDietSuggestionCubit(
dietRepository: sl(),
personalDietSuggestionService: sl(),
),
);
}
}

View File

@@ -18,6 +18,7 @@ import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart';
import 'package:eatvio_app/features/barcode/repositories/barcode_repository.dart';
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
import 'package:eatvio_app/features/menu/presentation/repositories/blocked_users_repository.dart';
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.dart';
@@ -55,6 +56,7 @@ class RepositoryRegistrations {
sl.registerLazySingleton<VideoCallRepository>(() => VideoCallRepository(videoCallService: sl()));
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: sl()));
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
sl.registerLazySingleton<SettingsRepository>(() => SettingsRepository(settingsService: sl()));
sl.registerLazySingleton<ConsultantRepository>(() => ConsultantRepository(consultantService: sl()));

View File

@@ -0,0 +1,15 @@
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
class SessionRegistrations {
static void registerAll() {
sl.registerLazySingleton<SessionLifecycleService>(
() => SessionLifecycleService(
wsManager: sl<WsManager>(),
errorReporter: sl<ErrorReporter>(),
),
);
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:developer';
import 'dart:io';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
import 'package:eatvio_app/core/push/hms_client_error.dart';
import 'package:device_info_plus/device_info_plus.dart';
@@ -44,15 +45,16 @@ class FirebaseApi {
Future<void> registerAnonymousPushToken() async {
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
final hwId = await DeviceHwIdProvider.current();
if (hwId == null || hwId.length < 8) return;
await sl<Dio>().post(
'/api/anonymous_token',
data: {
'platform': credentials.platform,
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'hw_id': hwId,
'token': token,
},
).timeout(
@@ -69,34 +71,52 @@ class FirebaseApi {
}
Future<void> registerPushToken() async {
if (await TokenStorage.haveTokenInStorage()) {
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
await sl<Dio>().post(
'/api/profile/notification/token/register',
data: {
'platform': credentials.platform,
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'token': token,
},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('registerPushToken timeout');
},
);
if (Platform.isIOS) {
await _registerIosVoipPushToken();
if (!await TokenStorage.haveTokenInStorage()) return;
try {
await _requestNotificationPermission();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
final hwId = await DeviceHwIdProvider.current();
final reregisterAndroid =
await DeviceHwIdProvider.androidTokenNeedsReregister();
if (token != null &&
token.isNotEmpty &&
hwId != null &&
hwId.length >= 8) {
if (reregisterAndroid) {
try {
await unregisterPushToken();
} catch (_) {}
}
await sl<Dio>()
.post(
'/api/profile/notification/token/register',
data: {
'platform': credentials.platform,
'hw_id': hwId,
'token': token,
},
)
.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('registerPushToken timeout');
},
);
if (reregisterAndroid) {
await DeviceHwIdProvider.markAndroidTokenReregistered();
}
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
// Не пробрасываем ошибку дальше, так как это не критичная операция
}
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
// Не пробрасываем ошибку дальше, так как это не критичная операция
}
// Incoming calls on iOS come only through the VoIP token. It does not
// depend on the FCM token, so a missing or failed FCM registration must
// not leave the device without calls.
if (Platform.isIOS) {
await _registerIosVoipPushToken();
}
}
@@ -142,12 +162,13 @@ class FirebaseApi {
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
if (token == null || token.isEmpty) return;
final deviceInfo = DeviceInfoPlugin();
final hwId = await DeviceHwIdProvider.current();
if (hwId == null || hwId.length < 8) return;
await sl<Dio>().post(
'/api/profile/notification/token/voip/register',
data: {
'platform': 'ios',
'hw_id': (await deviceInfo.iosInfo).identifierForVendor,
'hw_id': hwId,
'token': token,
},
).timeout(

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/interceptors/verification_requirement.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
@@ -9,28 +10,77 @@ import 'package:go_router/go_router.dart';
class PhoneVerifyErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 405) {
final data = err.response?.data;
if (data is Map<String, dynamic> && data['type'] == 'unverificated_phone') {
final String phone = await sl<ProfileRepository>().getMyPhone();
final String passKey = await sl<ProfileRepository>().getPassKey(routeName: 'user_detailz.change-phone-verify', routeParams: {"phone": phone});
await showErrorDialog(
AppRouter.navigatorKey.currentContext!,
title: 'Привяжите номер телефона',
description: 'Для того, что бы выполнить это действие необходимо привязать номер телефона',
);
final currentContext = AppRouter.navigatorKey.currentContext;
if (currentContext != null && currentContext.mounted && GoRouter.of(currentContext).state.path == RouteConstants.stepperBecomeConsultantScreen) {
GoRouter.of(currentContext).pop();
}
AppRouter.navigatorKey.currentContext?.pushNamed('verifyScreen', extra: passKey);
return handler.reject(
DioException(requestOptions: err.requestOptions, response: err.response, error: 'Phone number not verified', type: DioExceptionType.badResponse),
);
}
if (err.response?.statusCode != 405) {
return handler.next(err);
}
final requirement = VerificationRequirement.tryParse(err.response?.data);
if (requirement == null) {
return handler.next(err);
}
// Бэкенд может требовать подтверждения почты, телефона или обоих каналов сразу.
if (requirement.needsEmail) {
await _handleEmailVerificationRequired();
} else {
await _handlePhoneVerificationRequired();
}
return handler.reject(
DioException(
requestOptions: err.requestOptions,
response: err.response,
error: 'Phone number not verified',
type: DioExceptionType.badResponse,
),
);
}
/// Старый сценарий: показывает привязку телефона и открывает passkey-экран.
Future<void> _handlePhoneVerificationRequired() async {
final String phone = await sl<ProfileRepository>().getMyPhone();
final String passKey = await sl<ProfileRepository>().getPassKey(
routeName: 'user_detailz.change-phone-verify',
routeParams: {"phone": phone},
);
await showErrorDialog(
AppRouter.navigatorKey.currentContext!,
title: 'Привяжите номер телефона',
description:
'Для того, что бы выполнить это действие необходимо привязать номер телефона',
);
final currentContext = AppRouter.navigatorKey.currentContext;
if (currentContext != null &&
currentContext.mounted &&
GoRouter.of(currentContext).state.path ==
RouteConstants.stepperBecomeConsultantScreen) {
GoRouter.of(currentContext).pop();
}
AppRouter.navigatorKey.currentContext?.pushNamed(
'verifyScreen',
extra: passKey,
);
}
/// Новый сценарий: предлагает подтвердить e-mail и открывает экран ввода кода.
Future<void> _handleEmailVerificationRequired() async {
final context = AppRouter.navigatorKey.currentContext;
if (context == null || !context.mounted) {
// UI недоступен (например, в тестах) — просто отклоняем запрос, без падения.
return;
}
await showErrorDialog(
context,
title: 'Подтвердите e-mail',
description:
'Чтобы публиковать материалы, подтвердите адрес электронной почты. Мы отправим код на вашу почту.',
);
if (context.mounted) {
context.pushNamed('verifyEmailScreen', extra: true);
}
return handler.next(err);
}
}

View File

@@ -1,9 +1,7 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:go_router/go_router.dart';
class TokenInterceptor extends Interceptor {
@override
@@ -14,6 +12,7 @@ class TokenInterceptor extends Interceptor {
final token = await TokenStorage.getTokenFromStorage();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
options.extra['authToken'] = token;
}
super.onRequest(options, handler);
@@ -25,9 +24,18 @@ class TokenInterceptor extends Interceptor {
ErrorInterceptorHandler handler,
) async {
if (err.response?.statusCode == 401) {
await sl<WsManager>().clear();
await TokenStorage.removeTokenInStorage();
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
final requestCarriedAuth = err.requestOptions.headers.containsKey(
'Authorization',
);
final requestToken = err.requestOptions.extra['authToken'];
final currentToken = await TokenStorage.getTokenFromStorage();
if (requestCarriedAuth &&
requestToken != null &&
requestToken == currentToken) {
await sl<SessionLifecycleService>().endSession(
reason: SessionEndReason.unauthorized,
);
}
}
super.onError(err, handler);
}

View File

@@ -0,0 +1,61 @@
/// Канал верификации, которого не хватает для публичного действия.
enum VerificationChannel { phone, email }
/// Разбор 405-контракта верификации (type/reason/missing) в список каналов.
///
/// Бэкенд отвечает на публичное действие без подтверждения так:
/// `{"type": "unverificated_phone", "reason": "verification_required", "missing": ["email"]}`.
/// Старые ответы не содержали поля `missing` — тогда не хватало только телефона.
class VerificationRequirement {
const VerificationRequirement({required this.missing});
/// Каналы, подтверждение которых требует бэкенд.
final List<VerificationChannel> missing;
bool get needsEmail => missing.contains(VerificationChannel.email);
bool get needsPhone => missing.contains(VerificationChannel.phone);
/// Возвращает null, если ответ не является отказом верификации.
static VerificationRequirement? tryParse(Object? data) {
if (data is! Map) {
return null;
}
if (data['type'] != 'unverificated_phone') {
return null;
}
final Object? rawMissing = data['missing'];
if (rawMissing is! List) {
// Legacy-контракт: поля missing не было, значит не подтверждён телефон.
return const VerificationRequirement(
missing: [VerificationChannel.phone],
);
}
var hasPhone = false;
var hasEmail = false;
for (final Object? value in rawMissing) {
// Неизвестные значения игнорируем, чтобы новые каналы бэкенда не ломали разбор.
if (value == 'phone') {
hasPhone = true;
} else if (value == 'email') {
hasEmail = true;
}
}
// Пустой или нераспознанный missing трактуем как legacy-контракт «телефон».
if (!hasPhone && !hasEmail) {
return const VerificationRequirement(
missing: [VerificationChannel.phone],
);
}
return VerificationRequirement(
missing: [
if (hasPhone) VerificationChannel.phone,
if (hasEmail) VerificationChannel.email,
],
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:eatvio_app/core/di/cubit_registrations.dart';
import 'package:eatvio_app/core/di/external_registrations.dart';
import 'package:eatvio_app/core/di/repository_registrations.dart';
import 'package:eatvio_app/core/di/service_registrations.dart';
import 'package:eatvio_app/core/di/session_registrations.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:get_it/get_it.dart';
@@ -38,4 +39,6 @@ Future<void> init({required AppBuildType buildType}) async {
sl.registerLazySingleton<WsManager>(
() => WsManager(wsService: sl(), errorReporter: sl()),
);
SessionRegistrations.registerAll();
}

View File

@@ -62,12 +62,16 @@ class PushNavigationHandler {
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
if (endedPayload != null) {
developer.log(
'PushNavigationHandler: dismiss incoming call UI, source=$source',
'PushNavigationHandler: dismiss incoming call UI, source=$source '
'call_id=${endedPayload.callId ?? '-'} '
'reason=${endedPayload.reason?.name ?? '-'}',
name: 'PushNavigationHandler',
);
unawaited(
VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: endedPayload.conversationUuid,
callId: endedPayload.callId,
reason: endedPayload.reason,
),
);
return;

View File

@@ -5,4 +5,5 @@ enum PrefsKeys {
marketingBottomSheetHasBeenShown,
isEmailVerificationSkipped,
customBaseUrl,
apiBaseUrl,
}

View File

@@ -34,7 +34,7 @@ class AuthRoutes {
name: 'verifyScreen',
builder: (context, state) => VerifyScreen(passKey: state.extra as String),
),
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => const VerifyEmailScreen()),
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => VerifyEmailScreen(returnOnSuccess: state.extra == true)),
GoRoute(
path: RouteConstants.registerViaSocialScreen,
name: 'registerViaSocialScreen',

View File

@@ -1,6 +1,5 @@
import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart';
import 'package:eatvio_app/features/chat/presentation/pages/chats_screen.dart';
@@ -24,14 +23,10 @@ class MainNavigationRoutes {
return [
ShellRoute(
builder: (context, state, child) {
// Инициализация ProfileAvatarCubit при первом использовании ShellRoute
final profileAvatarCubit = context.read<ProfileAvatarCubit>();
if (profileAvatarCubit.state is ProfileAvatarStateLoading) {
// Вызываем getMyProfile() только если состояние loading (первый запуск)
WidgetsBinding.instance.addPostFrameCallback((_) {
profileAvatarCubit.getMyProfile();
});
}
WidgetsBinding.instance.addPostFrameCallback((_) {
profileAvatarCubit.ensureFreshForCurrentSession();
});
return _MainShellWithNavScope(currentLocation: state.matchedLocation, child: child);
},

View File

@@ -354,9 +354,10 @@ class OtherRoutes {
path: UserDiaryScreen.path,
name: UserDiaryScreen.name,
builder: (context, state) {
final data = state.extra as Map<String, int>;
final data = state.extra as Map<String, Object?>;
final diaryId = data['diaryId'] as int;
return UserDiaryScreen(diaryId: diaryId);
final ownerSlug = data['ownerSlug'] as String?;
return UserDiaryScreen(diaryId: diaryId, ownerSlug: ownerSlug);
},
),
GoRoute(

View File

@@ -0,0 +1,122 @@
import 'dart:async';
import 'package:appmetrica_plugin/appmetrica_plugin.dart';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:go_router/go_router.dart';
enum SessionEndReason { logout, unauthorized, accountDeleted }
class SessionLifecycleService {
SessionLifecycleService({
required WsManager wsManager,
required ErrorReporter errorReporter,
}) : _wsManager = wsManager,
_errorReporter = errorReporter;
final WsManager _wsManager;
final ErrorReporter _errorReporter;
int? _authenticatedFingerprint;
bool _endingSession = false;
Future<void> endSession({required SessionEndReason reason}) async {
if (_endingSession) return;
_endingSession = true;
try {
await _run(() => _wsManager.clear());
await _run(TokenStorage.removeTokenInStorage);
await _run(SocketIdStorage.removeSocketIdInStorage);
await _run(() => sl<ProfileAvatarCubit>().reset());
await _run(() => sl<ChatsCubit>().reset());
await _run(() => sl<NotificationsCubit>().reset());
await _run(() => sl<NewMessagesCubit>().reset());
await _run(() => sl<FeedCubit>().reset());
await _run(() async => ProfileCubit.clearMyProfileReference());
await _run(() => sl<VideoCallSessionCubit>().endCall());
await _run(() async => AppMetrica.setUserProfileID(null));
await _run(
() => sl<Prefs>().remove(PrefsKeys.isEmailVerificationSkipped),
);
await _run(() => sl<Prefs>().remove(PrefsKeys.registrationDate));
_authenticatedFingerprint = null;
await _run(() async {
final context = AppRouter.navigatorKey.currentContext;
if (context == null) return;
final path =
AppRouter.appRouter.routeInformationProvider.value.uri.path;
if (path != RouteConstants.authScreen) {
context.go(RouteConstants.authScreen);
}
});
} finally {
_endingSession = false;
}
}
Future<void> onAuthenticated() async {
String? token;
try {
token = await TokenStorage.getTokenFromStorage();
} catch (e, st) {
await _report(e, st);
return;
}
if (token == null || token.isEmpty) return;
final fingerprint = token.hashCode;
if (_authenticatedFingerprint == fingerprint) return;
_authenticatedFingerprint = fingerprint;
await _run(() async {
await _wsManager.clear();
await _wsManager.initialize();
});
await _run(() => sl<ProfileAvatarCubit>().getMyProfile());
await _run(() => sl<ChatsCubit>().getChatRoomsWithLoading());
await _run(() => sl<NotificationsCubit>().getNotifications());
await _run(() => sl<FirebaseApi>().registerPushToken());
}
Future<void> logout() async {
try {
await _run(() => sl<FirebaseApi>().unregisterPushTokens());
await _run(
() => sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout'),
);
} finally {
await endSession(reason: SessionEndReason.logout);
}
}
Future<void> _run(FutureOr<void> Function() action) async {
try {
await action();
} catch (e, st) {
await _report(e, st);
}
}
Future<void> _report(Object error, StackTrace stackTrace) {
return _errorReporter.report(
error,
stackTrace,
origin: 'SessionLifecycleService',
);
}
}

View File

@@ -18,7 +18,7 @@ class WsManager {
final ErrorReporter _errorReporter;
WsManager({required this.wsService, required ErrorReporter errorReporter})
: _errorReporter = errorReporter;
: _errorReporter = errorReporter;
PusherChannelsClient? client;
int? userId;
@@ -43,14 +43,32 @@ class WsManager {
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
StreamController<void> _reconnectController = StreamController<void>.broadcast();
StreamController<void> _reconnectController =
StreamController<void>.broadcast();
Stream<ChannelReadEvent>? _directChatStream;
Stream<ChannelReadEvent>? _clientTypingStream;
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
Stream<ChannelReadEvent>? get newMessagesStream =>
_privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
/// The callee rejected the ring. Broadcast to the caller and the callee.
Stream<ChannelReadEvent>? get videoCallDeclinedStream =>
_privateMessagesChannel?.bind('video-call-declined');
/// The call was cancelled or hung up. Same channel the web client uses.
Stream<ChannelReadEvent>? get videoCallEndedStream =>
_privateMessagesChannel?.bind('video-call-ended');
/// Another device accepted the ring. Ringing on this device must stop,
/// except on the device that accepted.
Stream<ChannelReadEvent>? get videoCallAcceptedStream =>
_privateMessagesChannel?.bind('video-call-accepted');
Stream<ChannelReadEvent>? get newNotificationsStream =>
_privateNotificationChannel?.bind(
'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated',
);
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
@@ -86,15 +104,9 @@ class WsManager {
connectionErrorHandler: (exception, trace, refresh) {
refresh();
},
minimumReconnectDelayDuration: const Duration(
seconds: 1,
),
defaultActivityDuration: const Duration(
seconds: 30,
),
waitForPongDuration: const Duration(
seconds: 30,
),
minimumReconnectDelayDuration: const Duration(seconds: 1),
defaultActivityDuration: const Duration(seconds: 30),
waitForPongDuration: const Duration(seconds: 30),
);
String? extractSocketId(String? data) {
@@ -107,7 +119,9 @@ class WsManager {
}
}
_connectionEstablishedSubscription = client?.eventStream.listen((event) async {
_connectionEstablishedSubscription = client?.eventStream.listen((
event,
) async {
if (event.name == 'pusher:connection_established') {
final data = event.data;
final socketId = extractSocketId(data);
@@ -124,7 +138,8 @@ class WsManager {
await _initPrivateMessagesChannel();
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
if (data ==
PusherChannelsClientLifeCycleState.establishedConnection) {
handleConnectionEstablished();
}
});
@@ -166,12 +181,13 @@ class WsManager {
final token = await TokenStorage.getTokenFromStorage();
_privateNotificationChannel = client?.privateChannel(
'$newNotificationsChannelName.$userId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
authorizationDelegate:
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse(
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
),
headers: {'Authorization': 'Bearer $token'},
),
);
}
@@ -179,12 +195,13 @@ class WsManager {
final token = await TokenStorage.getTokenFromStorage();
_privateMessagesChannel = client?.privateChannel(
'$newMessagesChannelName.$userId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
authorizationDelegate:
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse(
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
),
headers: {'Authorization': 'Bearer $token'},
),
);
}
@@ -193,14 +210,17 @@ class WsManager {
final token = await TokenStorage.getTokenFromStorage();
_privateDirectChatChannel = client?.privateChannel(
'$directChatChannelName.$chatId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
authorizationDelegate:
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse(
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
),
headers: {'Authorization': 'Bearer $token'},
),
);
_directChatStream = _privateDirectChatChannel?.bind(
'App\\Events\\ChatMessageUpdated',
);
_directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
}
@@ -208,12 +228,13 @@ class WsManager {
final token = await TokenStorage.getTokenFromStorage();
_presenceDirectChatChannel = client?.presenceChannel(
'$presenceDirectChatChannelName.$chatId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
authorizationDelegate:
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
authorizationEndpoint: Uri.parse(
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
),
headers: {'Authorization': 'Bearer $token'},
),
);
}
@@ -240,7 +261,8 @@ class WsManager {
void Function(bool isOnline) onCounterpartOnlineChanged,
) {
_presenceDirectChatListeners.removeWhere(
(listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
(listener) =>
listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
);
if (_presenceDirectChatListeners.isEmpty) {
unsubscribePresenceDirectChatChannel();
@@ -254,21 +276,26 @@ class WsManager {
_presenceChannelEventsBound = true;
_presenceSubscriptionSucceededSubscription =
channel.whenSubscriptionSucceeded().listen((_) {
final counterpartIds = _presenceDirectChatListeners
.map((listener) => listener.counterpartUserId)
.toSet();
for (final counterpartUserId in counterpartIds) {
_emitCounterpartPresence(counterpartUserId);
}
});
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) {
_presenceSubscriptionSucceededSubscription = channel
.whenSubscriptionSucceeded()
.listen((_) {
final counterpartIds = _presenceDirectChatListeners
.map((listener) => listener.counterpartUserId)
.toSet();
for (final counterpartUserId in counterpartIds) {
_emitCounterpartPresence(counterpartUserId);
}
});
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((
event,
) {
final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: true);
});
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) {
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((
event,
) {
final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: false);
@@ -280,11 +307,15 @@ class WsManager {
if (channel == null) return;
final members = channel.state?.members;
if (members == null) return;
final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null;
final isOnline =
members.getMemberInfo(counterpartUserId.toString()) != null;
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
}
void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) {
void _notifyPresenceListeners(
int counterpartUserId, {
required bool isOnline,
}) {
for (final listener in _presenceDirectChatListeners) {
if (listener.counterpartUserId == counterpartUserId) {
listener.onCounterpartOnlineChanged(isOnline);

View File

@@ -3,7 +3,11 @@ import 'dart:ui';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
Future<void> showBottomSheetWithBlur({
/// Показывает modal bottom sheet с blur-фоном.
///
/// Дженерик [T] пробрасывает значение, переданное в `Navigator.pop(T)`
/// внутри шита (по умолчанию `null`).
Future<T?> showBottomSheetWithBlur<T>({
required BuildContext context,
required Widget child,
double? height = 200,
@@ -11,7 +15,7 @@ Future<void> showBottomSheetWithBlur({
bool enableDrag = true,
bool userRootNavigator = false,
}) {
return showModalBottomSheet(
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
barrierColor: appTheme.transparentBlack,

View File

@@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.dart';
@@ -17,15 +18,26 @@ class AuthCubit extends Cubit<AuthState> {
final ProfileRepository profileRepository;
final FirebaseApi firebaseApi;
final Prefs prefs;
final SessionLifecycleService sessionLifecycleService;
AuthCubit({required this.authRepository, required this.prefs, required this.profileRepository, required this.firebaseApi}) : super(const AuthState());
AuthCubit({
required this.authRepository,
required this.prefs,
required this.profileRepository,
required this.firebaseApi,
required this.sessionLifecycleService,
}) : super(const AuthState());
Future<bool> getTokenByCredentials(String email, String password) async {
emit(const AuthState.loading());
try {
final token = (await authRepository.getTokenByCredentials(email, password)).token;
final token = (await authRepository.getTokenByCredentials(
email,
password,
)).token;
emit(AuthState.loaded(token: token));
await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
return true;
} catch (e, st) {
await reportFrom(e, st, this);
@@ -48,7 +60,8 @@ class AuthCubit extends Cubit<AuthState> {
Future<void> registerAnonymousPushToken() async {
try {
final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
final hasAnonymousTokenBeenSent =
prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
if (!hasAnonymousTokenBeenSent) {
await firebaseApi.registerAnonymousPushToken();
prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true);

View File

@@ -1,7 +1,9 @@
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
@@ -11,12 +13,24 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
final AuthRepository authRepository;
final FirebaseApi firebaseApi;
final SessionLifecycleService sessionLifecycleService;
OAuthCubit({required this.authRepository, required Prefs prefs}) : super(const OAuthState()) {
OAuthCubit({
required this.authRepository,
required this.firebaseApi,
required this.sessionLifecycleService,
required Prefs prefs,
}) : super(const OAuthState()) {
this.prefs = prefs;
}
Future<void> signInViaSocialMedia({required String provider, required String accessToken, required String refreshToken, required String clientId}) async {
Future<void> signInViaSocialMedia({
required String provider,
required String accessToken,
required String refreshToken,
required String clientId,
}) async {
try {
emit(const OAuthState.loading());
@@ -28,7 +42,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
);
log('OAuthCubit: Got token model: ${tokenModel.token}');
TokenStorage.saveTokenInStorage(tokenModel.token);
await TokenStorage.saveTokenInStorage(tokenModel.token);
await sessionLifecycleService.onAuthenticated();
emit(OAuthState.loaded(token: tokenModel.token));
} on DioException catch (e, st) {
await reportFrom(e, st, this);
@@ -45,7 +60,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (errorStr.contains('Некорректный формат email')) {
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
}
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) {
if (errorStr.contains('почта уже существует') ||
errorStr.contains('Эл. почта уже существует')) {
errorsList.add(RegistrationValidationErrors.emailExist);
}
}
@@ -53,14 +69,20 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (phoneErrors != null && phoneErrors.isNotEmpty) {
for (final error in phoneErrors) {
final errorStr = error.toString();
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) {
if (errorStr.contains('phone уже существует') ||
errorStr.contains('телефон уже существует')) {
errorsList.add(RegistrationValidationErrors.phoneExist);
}
}
}
if (errorsList.isNotEmpty) {
emit(OAuthState.error(errorMessage: 'validation_error', registrationValidationErrors: errorsList));
emit(
OAuthState.error(
errorMessage: 'validation_error',
registrationValidationErrors: errorsList,
),
);
} else {
emit(OAuthState.error(errorMessage: e.toString()));
}
@@ -103,7 +125,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
height: height,
clientId: clientId,
);
TokenStorage.saveTokenInStorage(token);
await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
cacheRegistrationDate(DateTime.now());
emit(OAuthState.loaded(token: token));
} on DioException catch (e, st) {
@@ -120,7 +143,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (errorStr.contains('Некорректный формат email')) {
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
}
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) {
if (errorStr.contains('почта уже существует') ||
errorStr.contains('Эл. почта уже существует')) {
errorsList.add(RegistrationValidationErrors.emailExist);
}
}
@@ -128,14 +152,17 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (phoneErrors != null && phoneErrors.isNotEmpty) {
for (final error in phoneErrors) {
final errorStr = error.toString();
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) {
if (errorStr.contains('phone уже существует') ||
errorStr.contains('телефон уже существует')) {
errorsList.add(RegistrationValidationErrors.phoneExist);
}
}
}
if (dateOfBirth != null && dateOfBirth.isNotEmpty) {
if (dateOfBirth.contains('Некорректный формат даты рождения')) {
errorsList.add(RegistrationValidationErrors.incorrectDateOfBirthFormat);
errorsList.add(
RegistrationValidationErrors.incorrectDateOfBirthFormat,
);
}
}
if (errorsList.isNotEmpty) {
@@ -146,13 +173,23 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
return null;
}
Future<OAuthVerificationRequestResponse?> requestOAuthVerification({String? email, String? phone}) async {
Future<OAuthVerificationRequestResponse?> requestOAuthVerification({
String? email,
String? phone,
}) async {
try {
emit(const OAuthState.loading());
log('OAuthCubit: Requesting verification code for email: $email, phone: $phone');
final response = await authRepository.requestOAuthVerification(email: email, phone: phone);
log(
'OAuthCubit: Requesting verification code for email: $email, phone: $phone',
);
final response = await authRepository.requestOAuthVerification(
email: email,
phone: phone,
);
if (response != null) {
log('OAuthCubit: Verification code requested successfully, masked email: ${response.email}');
log(
'OAuthCubit: Verification code requested successfully, masked email: ${response.email}',
);
}
// Сбрасываем состояние после успешного запроса кода
emit(const OAuthState());
@@ -184,7 +221,9 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
}) async {
try {
emit(const OAuthState.loading());
log('OAuthCubit: Confirming OAuth verification - provider: $provider, verificationType: $verificationType, code: $code');
log(
'OAuthCubit: Confirming OAuth verification - provider: $provider, verificationType: $verificationType, code: $code',
);
final response = await authRepository.confirmOAuthVerification(
provider: provider,
accessToken: accessToken,
@@ -202,8 +241,11 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
weight: weight,
height: height,
);
log('OAuthCubit: OAuth verification confirmed successfully, token received');
TokenStorage.saveTokenInStorage(response.token);
log(
'OAuthCubit: OAuth verification confirmed successfully, token received',
);
await TokenStorage.saveTokenInStorage(response.token);
await sessionLifecycleService.onAuthenticated();
emit(OAuthState.loaded(token: response.token));
} catch (e, st) {
await reportFrom(e, st, this);
@@ -212,4 +254,3 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
}
}
}

View File

@@ -4,12 +4,10 @@ import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/core/widgets/custom_divider.dart';
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart';
@@ -18,8 +16,6 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cub
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
import 'package:eatvio_app/core/di/cubit_registrations.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/cupertino.dart';
@@ -68,23 +64,8 @@ class _AuthScreenState extends State<AuthScreen> {
super.dispose();
}
Future<void> _reconnectWebSocket(BuildContext context) async {
try {
await sl<WsManager>().clear();
await sl<WsManager>().initialize();
} catch (e) {
rethrow;
}
if (context.mounted) {
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешный вход');
} catch (e) {
rethrow;
}
}
Future<void> _reconnectWebSocket() async {
showSuccessToast('Успешный вход');
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -202,18 +183,7 @@ class _AuthScreenState extends State<AuthScreen> {
}
Future<void> _reconnectAfterAuth() async {
final context = AppRouter.navigatorKey.currentContext;
if (context == null) return;
try {
await sl<WsManager>().clear();
await sl<WsManager>().initialize();
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешный вход');
} catch (e) {
log(e.toString());
}
showSuccessToast('Успешный вход');
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -395,7 +365,7 @@ class _AuthScreenState extends State<AuthScreen> {
final isAuthenticated = await authCubit.getTokenByCredentials(emailController.text, passwordController.text);
if (isAuthenticated) {
if (context.mounted) {
await _reconnectWebSocket(context);
await _reconnectWebSocket();
}
}
setState(() {

View File

@@ -9,11 +9,7 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_sta
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/utils/validation_utils.dart';
import 'package:flutter/gestures.dart';
@@ -62,23 +58,8 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
_initializeFieldsFromAuthResult();
}
Future<void> _reconnectWebSocket(BuildContext context) async {
try {
await sl<WsManager>().clear();
await sl<WsManager>().initialize();
} catch (e) {
rethrow;
}
if (context.mounted) {
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешная регистрация');
} catch (e) {
rethrow;
}
}
Future<void> _reconnectWebSocket() async {
showSuccessToast('Успешная регистрация');
AppRouter.appRouter.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -451,7 +432,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
Builder(
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_reconnectWebSocket(context);
_reconnectWebSocket();
});
return const SizedBox.shrink();
},
@@ -795,7 +776,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
// Перезагружаем веб-сокет и переходим на главный экран
if (widgetContext.mounted) {
_reconnectWebSocket(widgetContext);
_reconnectWebSocket();
}
} catch (e, stackTrace) {
log('OAuth Verification Confirm Error: $e', error: e, stackTrace: stackTrace);

View File

@@ -3,13 +3,10 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
@@ -39,7 +36,10 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Сканирование QR-кода'), centerTitle: true),
appBar: AppBar(
title: const Text('Сканирование QR-кода'),
centerTitle: true,
),
body: Column(
children: <Widget>[
Expanded(
@@ -60,30 +60,33 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
try {
final scannedUrl = scanData.code;
final uri = Uri.parse(scannedUrl ?? '');
final scannedBaseUrl = (uri.hasAuthority) ? '${uri.scheme}://${uri.host}' : '';
final scannedBaseUrl = (uri.hasAuthority)
? '${uri.scheme}://${uri.host}'
: '';
sl<AppConfig>().setBaseUrl(scannedBaseUrl);
sl<Dio>().options.baseUrl = scannedBaseUrl;
final response = await Dio().getUri(uri, options: Options(headers: {'accept': 'application/json', 'content-type': 'application/json'}));
final response = await Dio().getUri(
uri,
options: Options(
headers: {
'accept': 'application/json',
'content-type': 'application/json',
},
),
);
final token = response.data['token'];
await TokenStorage.saveTokenInStorage(token);
try {
await sl<WsManager>().clear();
await sl<WsManager>().initialize();
} catch (e) {
rethrow;
}
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<FirebaseApi>().registerPushToken();
} catch (e) {
rethrow;
}
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
await sl<SessionLifecycleService>().onAuthenticated();
AppRouter.navigatorKey.currentContext?.go(
'/feed-screen',
extra: {'needToInitializeWebSocket': false},
);
showSuccessToast('Добро пожаловать!');
} catch (e) {
await controller.resumeCamera();
showErrorToast('Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.');
showErrorToast(
'Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.',
);
}
});
}

View File

@@ -6,6 +6,13 @@ class NewMessagesCubit extends Cubit<NewMessagesState> {
NewMessagesCubit() : super(const NewMessagesState.empty());
void reset() {
_messagesCount = 0;
if (!isClosed) {
emit(const NewMessagesState.empty());
}
}
void incrementCounter() {
_messagesCount++;
emit(NewMessagesState.loaded(messagesCount: _messagesCount));

View File

@@ -1,18 +1,22 @@
import 'dart:async';
import 'package:appmetrica_plugin/appmetrica_plugin.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:go_router/go_router.dart';
import 'package:permission_handler/permission_handler.dart';
class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
final ProfileRepository profileRepository;
ProfileAvatarCubit({required this.profileRepository}) : super(const ProfileAvatarState.loading());
ProfileAvatarCubit({required this.profileRepository})
: super(const ProfileAvatarState.loading());
int _requestId = 0;
int? _sessionFingerprint;
bool _ensuring = false;
int _calculateAge(String birthdate) {
try {
@@ -25,7 +29,8 @@ class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
final now = DateTime.now();
int age = now.year - birthYear;
if (now.month < birthMonth || (now.month == birthMonth && now.day < birthDay)) {
if (now.month < birthMonth ||
(now.month == birthMonth && now.day < birthDay)) {
age--;
}
@@ -38,30 +43,82 @@ class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
return 0;
}
void reset() {
_requestId++;
_sessionFingerprint = null;
unawaited(AppMetrica.setUserProfileID(null).catchError((Object _) {}));
if (!isClosed) emit(const ProfileAvatarState.loading());
}
Future<void> ensureFreshForCurrentSession() async {
final token = await TokenStorage.getTokenFromStorage();
if (token == null || token.isEmpty) {
reset();
return;
}
final fingerprint = token.hashCode;
if (_sessionFingerprint == fingerprint &&
state is ProfileAvatarStateLoaded) {
return;
}
if (_ensuring) {
return;
}
_ensuring = true;
try {
await getMyProfile();
} finally {
_ensuring = false;
}
}
Future<void> getMyProfile() async {
final requestId = ++_requestId;
final token = await TokenStorage.getTokenFromStorage();
final fingerprint = token?.hashCode;
if (isClosed || requestId != _requestId) return;
emit(const ProfileAvatarState.loading());
try {
final profile = await profileRepository.getMyProfile();
if (isClosed || requestId != _requestId) return;
final profileDetails = await profileRepository.getMyProfileDetails();
if (isClosed || requestId != _requestId) return;
_sessionFingerprint = fingerprint;
emit(ProfileAvatarState.loaded(profile: profile));
try {
final notificationStatus = await Permission.notification.status;
if (isClosed || requestId != _requestId) return;
final isNotificationEnabled = notificationStatus.isGranted;
AppMetricaUserProfile userProfile = AppMetricaUserProfile([
AppMetricaNameAttribute.withValue(profile.name),
AppMetricaGenderAttribute.withValue(profileDetails.gender == 'male' ? AppMetricaGender.male : AppMetricaGender.female),
AppMetricaBirthDateAttribute.withAge(_calculateAge(profileDetails.birthdate)),
AppMetricaNotificationEnabledAttribute.withValue(isNotificationEnabled),
AppMetricaGenderAttribute.withValue(
profileDetails.gender == 'male'
? AppMetricaGender.male
: AppMetricaGender.female,
),
AppMetricaBirthDateAttribute.withAge(
_calculateAge(profileDetails.birthdate),
),
AppMetricaNotificationEnabledAttribute.withValue(
isNotificationEnabled,
),
]);
AppMetrica.setUserProfileID(profile.slug);
AppMetrica.reportUserProfile(userProfile);
} catch (e, st) {
await reportFrom(e, st, this);
// Игнорируем ошибки плагинов, профиль уже загружен
}
} catch (e, st) {
if (isClosed || requestId != _requestId) return;
await reportFrom(e, st, this);
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
if (isClosed || requestId != _requestId) return;
emit(ProfileAvatarState.error(errorMessage: e.toString()));
}
}

View File

@@ -39,6 +39,8 @@ class BottomNavigationBarWidget extends StatefulWidget {
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
int currentBottomNavBarIndex = 1;
List<String>? pages;
int? _pagesProfileId;
bool? _pagesIsConsultant;
int tapsCount = 0;
List<String> _getPages(bool isConsultant) {
@@ -179,7 +181,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
final isConsultant = profileModel.roles.contains('consultant');
// Инициализируем pages на основе profileModel
pages ??= _getPages(isConsultant);
if (pages == null ||
_pagesProfileId != profileModel.id ||
_pagesIsConsultant != isConsultant) {
_pagesProfileId = profileModel.id;
_pagesIsConsultant = isConsultant;
pages = _getPages(isConsultant);
if (currentBottomNavBarIndex >= pages!.length) {
currentBottomNavBarIndex = 0;
}
}
// Expose tab switching to descendants (e.g. notification widget)
final controller = BottomNavScope.maybeOf(context);

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
import 'package:eatvio_app/core/enums/base_state.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
@@ -10,7 +11,9 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_sta
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_socket_signal.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -23,11 +26,19 @@ class ChatsCubit extends Cubit<ChatsState> {
StreamSubscription? newIncomingMessagesSubscription;
StreamSubscription? newLastMessageSubscription;
StreamSubscription<void>? reconnectedSubscription;
StreamSubscription? videoCallDeclinedSubscription;
StreamSubscription? videoCallEndedSubscription;
StreamSubscription? videoCallAcceptedSubscription;
final Set<int> _presentedVideoCallMessageIds = {};
/// Test seam. Production uses CallKit.
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
/// Test seam. Production reads [DeviceHwIdProvider.current].
Future<String?> Function()? currentDeviceHwId;
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
Future<void> Function({String? conversationUuid, String? callId})?
endActiveVideoCall;
ChatsCubit({
required this.chatRepository,
@@ -46,6 +57,17 @@ class ChatsCubit extends Cubit<ChatsState> {
int page = 1;
late int lastPage;
/// Clears all user-scoped state. WS subscriptions stay intact so that
/// `getChatRoomsWithLoading` can re-load the new user's chats.
void reset() {
_query = null;
page = 1;
lastPage = 1;
if (!isClosed) {
emit(ChatsState());
}
}
Future<void> connectToWebSocket() async {
try {
await newIncomingMessagesSubscription?.cancel();
@@ -55,6 +77,37 @@ class ChatsCubit extends Cubit<ChatsState> {
_handleNewMessageViaSocket(event.data);
});
await videoCallDeclinedSubscription?.cancel();
videoCallDeclinedSubscription = wsManager.videoCallDeclinedStream?.listen(
(event) {
unawaited(
_closeCallFromSocket(
VideoCallSocketSignal.tryParseDeclined(event.data),
),
);
},
);
await videoCallEndedSubscription?.cancel();
videoCallEndedSubscription = wsManager.videoCallEndedStream?.listen((
event,
) {
unawaited(
_closeCallFromSocket(VideoCallSocketSignal.tryParseEnded(event.data)),
);
});
await videoCallAcceptedSubscription?.cancel();
videoCallAcceptedSubscription = wsManager.videoCallAcceptedStream?.listen(
(event) {
unawaited(
_closeCallFromSocket(
VideoCallSocketSignal.tryParseEnded(event.data),
),
);
},
);
await reconnectedSubscription?.cancel();
reconnectedSubscription = wsManager.reconnected.listen((_) {
unawaited(getChatRooms());
@@ -170,6 +223,7 @@ class ChatsCubit extends Cubit<ChatsState> {
try {
final json = jsonDecode(data) as Map<String, dynamic>;
final message = ChatMessageModel.fromJson(json);
final callId = _stringField(json, const ['call_id', 'callId']);
final currentUserId = wsManager.userId;
final isOwn = currentUserId != null && message.authorId == currentUserId;
@@ -217,6 +271,7 @@ class ChatsCubit extends Cubit<ChatsState> {
message,
isOwn: isOwn,
chatRooms: currentChatRooms,
callId: callId,
);
} catch (e, st) {
await reportFrom(e, st, this);
@@ -227,12 +282,15 @@ class ChatsCubit extends Cubit<ChatsState> {
ChatMessageModel message, {
required bool isOwn,
required List<ChatRoom> chatRooms,
String? callId,
}) {
final kind = VideoCallChatSignal.kindOf(message);
if (kind == VideoCallChatSignalKind.ended) {
unawaited(_dismissIncomingVideoCallUi(message.roomId));
unawaited(
VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId),
VideoCallEndedPushHandler.endActiveCallIfMatches(
conversationUuid: message.roomId,
),
);
return;
}
@@ -246,6 +304,7 @@ class ChatsCubit extends Cubit<ChatsState> {
message: message,
callerName: caller?.name,
callerAvatarUrl: caller?.profilePhotoUrl,
callId: callId,
);
if (payload == null) return;
unawaited(_showIncomingVideoCall(payload));
@@ -260,6 +319,35 @@ class ChatsCubit extends Cubit<ChatsState> {
}
}
Future<void> _closeCallFromSocket(VideoCallSocketSignal? signal) async {
if (signal == null) return;
if (await _acceptedOnThisDevice(signal)) return;
if (signal.endsActiveCall) {
final endActive = endActiveVideoCall;
if (endActive != null) {
await endActive(
conversationUuid: signal.conversationUuid,
callId: signal.callId,
);
} else {
await VideoCallEndedPushHandler.endActiveCallIfMatches(
conversationUuid: signal.conversationUuid,
callId: signal.callId,
);
}
}
final dismiss = dismissIncomingVideoCall;
if (dismiss != null) {
await dismiss(signal.conversationUuid);
return;
}
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: signal.conversationUuid,
callId: signal.callId,
reason: signal.reason,
);
}
Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
try {
final dismiss = dismissIncomingVideoCall;
@@ -278,6 +366,34 @@ class ChatsCubit extends Cubit<ChatsState> {
}
}
/// `video-call-accepted` also reaches the device that just answered.
/// Closing CallKit there hangs up the call that is starting.
Future<bool> _acceptedOnThisDevice(VideoCallSocketSignal signal) async {
if (signal.reason != VideoCallEndReason.accepted) return false;
final acceptedId = signal.acceptedDeviceId?.trim();
if (acceptedId == null || acceptedId.isEmpty) return false;
try {
final current =
(await (currentDeviceHwId ?? DeviceHwIdProvider.current)())?.trim();
if (current == null || current.isEmpty) return false;
return current.toLowerCase() == acceptedId.toLowerCase();
} catch (e, st) {
log(
'ChatsCubit: failed to read hw id for accepted call: $e',
stackTrace: st,
);
return false;
}
}
String? _stringField(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final value = json[key];
if (value is String && value.trim().isNotEmpty) return value.trim();
}
return null;
}
ChatUser? _callerFromRooms(
ChatMessageModel message,
List<ChatRoom> chatRooms,
@@ -306,6 +422,9 @@ class ChatsCubit extends Cubit<ChatsState> {
@override
Future<void> close() {
newIncomingMessagesSubscription?.cancel();
videoCallDeclinedSubscription?.cancel();
videoCallEndedSubscription?.cancel();
videoCallAcceptedSubscription?.cancel();
reconnectedSubscription?.cancel();
return super.close();
}

View File

@@ -30,6 +30,7 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:file_picker/file_picker.dart';
@@ -1009,6 +1010,7 @@ class _ChatScreenState extends State<ChatScreen> {
ChatRoomStateLoaded state,
ChatUserModel chatCounterpart,
) {
sl<VideoCallManager>().permitParticipantTokenRequest();
GoRouter.of(context).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{

View File

@@ -135,9 +135,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
textEditingController.clear();
});
}
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (context.mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && context.mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
}
}
},
@@ -189,9 +189,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
if (images.isNotEmpty) {
List<File> files = images.map((image) => File(image.path)).toList();
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
}
}
},
@@ -205,9 +205,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
if (images.isNotEmpty) {
List<File> files = images.map((image) => File(image.path)).toList();
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
}
}
},
@@ -243,9 +243,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
if (result != null) {
List<File> files = result.paths.map((path) => File(path!)).toList();
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
}
}
},

View File

@@ -106,7 +106,11 @@ class _NotificationBannerState extends State<NotificationBanner> {
onTap: () {
final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
if (planId != null) {
showConsultationPaymentFlow(context, planId: planId);
showConsultationPaymentFlow(
context,
planId: planId,
amount: widget.planPrice,
);
}
},
child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),

View File

@@ -137,11 +137,12 @@ class ChatRepository {
}
Future<String> getChatByUserSlug(String slug) async {
Future<String?> getChatByUserSlug(String slug) async {
final response = await chatService.getChatByUserSlug(slug: slug);
if (response.response.statusCode == 404) {
return '404';
final data = response.data;
if (data is Map && data['id'] is String && (data['id'] as String).isNotEmpty) {
return data['id'] as String;
}
return response.data['id'];
return null;
}
}

View File

@@ -14,6 +14,8 @@ class BecomeConsultantScreen extends StatefulWidget {
class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.paddingOf(context).bottom;
return Scaffold(
appBar: AppBar(
title: const Text('Стать консультантом'),
@@ -24,14 +26,18 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
icon: const Icon(Icons.close),
),
),
body: const Padding(
padding: EdgeInsets.all(16),
child: Text(
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + 52 + 8 + bottomInset),
child: const Text(
'Чтобы стать консультантом необходимо пройти короткую регистрацию. Подготовьте свои дипломы и/или сертификаты в формате jpg, они Вам пригодятся для проверки и подтверждения. Также необходимо будет указать ссылки на Ваши соц. сети и указать тип лица (физ. лицо или юр. лицо). На данный момент доступна регистрация в качестве физ. лица.\n\nВнутри Итвио разница между обычным пользователем и консультантом заключается в дополнительных возможностях последнего создавать публичные диеты, рецепты и планы питания; участвовать в ежемесячных рейтингах.',
style: TextStyle(fontSize: 16),
),
),
bottomSheet: _startRegistrationButton(),
bottomSheet: Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 8 + bottomInset),
child: _startRegistrationButton(),
),
);
}
@@ -44,7 +50,7 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
borderRadius: BorderRadius.circular(20),
child: Ink(
height: 52,
width: MediaQuery.of(context).size.width - 32,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: appTheme.blueA400,

View File

@@ -9,6 +9,7 @@ part 'consultation_model.g.dart';
abstract class ConsultationModel with _$ConsultationModel {
factory ConsultationModel({
required int id,
@JsonKey(name: 'chat_id') String? chatId,
@JsonKey(name: 'last_state') required ChosenPlanStateEnum chosenPlanState,
@JsonKey(name: 'plan_title') required String planTitle,
@JsonKey(name: 'user_name') required String userName,

View File

@@ -1,6 +1,7 @@
import 'dart:developer';
import 'package:eatvio_app/core/enums/base_state.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_state.dart';
import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -8,7 +9,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class ConsultationsCubit extends Cubit<ConsultationsState> {
final ConsultationsRepository consultationsRepository;
ConsultationsCubit({required this.consultationsRepository}) : super(ConsultationsState());
final ChatRepository chatRepository;
ConsultationsCubit({required this.consultationsRepository, required this.chatRepository}) : super(ConsultationsState());
Future<void> loadConsultations() async {
try {
@@ -20,4 +22,14 @@ class ConsultationsCubit extends Cubit<ConsultationsState> {
emit(state.copyWith(state: BaseState.error));
}
}
Future<String?> getChatIdByUserSlug(String slug) async {
try {
return await chatRepository.getChatByUserSlug(slug);
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
return null;
}
}
}

View File

@@ -3,14 +3,17 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_divider.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart';
import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart';
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class ConsultationCardWidget extends StatefulWidget {
@@ -34,6 +37,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
final List<Color> legendColors = const [Colors.white, Color(0xFFF0C497), Color(0xFFFF9500), Color(0xFF2F80ED), Color(0xFF4CD964)];
late final List<(int, DiaryModel)> givenDaysWithDayNumber;
bool _isOpeningChat = false;
final List<List<DiaryStateEnum>> legendStates = [
[DiaryStateEnum.init],
[DiaryStateEnum.started, DiaryStateEnum.lastDayStarted],
@@ -172,7 +176,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
);
return;
}
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id,});
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id, 'ownerSlug': widget.consultationsModel.userSlug});
},
borderRadius: BorderRadius.circular(100),
child: Ink(
@@ -255,7 +259,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
.where((e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished)
.lastOrNull;
if (lastActiveDay != null) {
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id,});
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id, 'ownerSlug': widget.consultationsModel.userSlug});
return;
}
showErrorDialog(
@@ -279,7 +283,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
const SizedBox(width: 8),
InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () {},
onTap: _isOpeningChat ? null : _openChat,
child: Ink(
height: 52,
width: 52,
@@ -308,6 +312,22 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
);
}
Future<void> _openChat() async {
if (_isOpeningChat) return;
setState(() => _isOpeningChat = true);
var chatId = widget.consultationsModel.chatId;
if (chatId == null || chatId.isEmpty) {
chatId = await context.read<ConsultationsCubit>().getChatIdByUserSlug(widget.consultationsModel.userSlug);
}
if (!mounted) return;
setState(() => _isOpeningChat = false);
if (chatId == null || chatId.isEmpty) {
showErrorToast(S.of(context).msg_something_went_wrong);
return;
}
GoRouter.of(context).pushNamed('chatScreen', extra: {'uuid': chatId});
}
Row _legendTitle(String text, Color color) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -18,8 +18,13 @@ enum PortionState {
@JsonValue('eaten_edited')
eatenEdited,
commented,
@JsonValue('commented_from_diet')
commentedFromDiet,
@JsonValue('reviewed_by_user')
reviewedByUser,
@JsonValue('reviewed_from_diet')
reviewedFromDiet,
unknown,
}
@freezed
@@ -138,7 +143,7 @@ abstract class PortionModel with _$PortionModel {
@JsonKey(name: "quantity") required int quantity,
@JsonKey(name: "portion") required String portion,
@JsonKey(name: "from_diet") required bool fromDiet,
@JsonKey(name: "state") required PortionState portionState,
@JsonKey(name: "state", unknownEnumValue: PortionState.unknown) required PortionState portionState,
}) = _PortionModel;
factory PortionModel.fromJson(Map<String, dynamic> json) => _$PortionModelFromJson(json);

View File

@@ -20,6 +20,7 @@ import 'package:eatvio_app/features/diary/presentation/widgets/day_comment_widge
import 'package:eatvio_app/features/diary/presentation/widgets/finish_day_button_widget.dart';
import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart';
import 'package:eatvio_app/features/diary/presentation/widgets/wakeup_and_retire_widget.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -36,18 +37,22 @@ class DiaryScreen extends StatelessWidget {
class UserDiaryScreen extends StatelessWidget {
static const path = '/user-diary-screen';
static const name = 'userDiaryScreen';
const UserDiaryScreen({super.key, required this.diaryId});
const UserDiaryScreen({super.key, required this.diaryId, this.ownerSlug});
final int diaryId;
/// Slug владельца дневника (клиента) — нужен консультанту для блока «Аналитика».
final String? ownerSlug;
@override
Widget build(BuildContext context) {
return DiaryScreenContent(diaryId: diaryId);
return DiaryScreenContent(diaryId: diaryId, ownerSlug: ownerSlug);
}
}
class DiaryScreenContent extends StatefulWidget {
const DiaryScreenContent({super.key, this.diaryId, this.date});
const DiaryScreenContent({super.key, this.diaryId, this.date, this.ownerSlug});
final int? diaryId;
final DateTime? date;
final String? ownerSlug;
@override
State<DiaryScreenContent> createState() => _DiaryScreenContentState();
}
@@ -55,6 +60,11 @@ class DiaryScreenContent extends StatefulWidget {
class _DiaryScreenContentState extends State<DiaryScreenContent> {
bool _isDiaryFinishing = false;
/// Консультант видит аналитику клиента только при известном slug владельца.
bool _canShowInsights(DiaryStateLoaded state) {
return !state.isConsultantView || widget.ownerSlug != null;
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
@@ -110,6 +120,14 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
const SizedBox(height: 60),
const Loader(),
],
if (state is DiaryStateLoaded && _canShowInsights(state))
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: InsightsCard(
slug: state.isConsultantView ? widget.ownerSlug : null,
canAdd: !state.isConsultantView,
),
),
if (state is DiaryStateLoaded)
WakeupAndRetireWidget(
isEnabled:

View File

@@ -12,9 +12,19 @@ class FeedCubit extends Cubit<FeedState> {
bool _hasMore = true;
bool isLoading = false;
String? _query;
int _requestId = 0;
FeedCubit({required this.feedRepository}) : super(const FeedState.loading());
void reset() {
_requestId++;
_nextCursor = null;
_hasMore = true;
isLoading = false;
_query = null;
if (!isClosed) emit(const FeedState.loading());
}
void updateFeedBlogPostList(FeedBlogPostModel? feedBlogPostModel, bool wasLikedByMe) {
if (feedBlogPostModel == null || isClosed || state is! FeedStateLoaded) return;
final List<FeedBlogPostModel> feedBlogPostList = [...(state as FeedStateLoaded).feedBlogPostList];
@@ -39,17 +49,18 @@ class FeedCubit extends Cubit<FeedState> {
}
Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async {
final requestId = ++_requestId;
try {
_query = query;
_nextCursor = null;
_hasMore = true;
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
if (isClosed || requestId != _requestId) return;
_nextCursor = feed.nextCursor;
_hasMore = _nextCursor != null;
if (!isClosed) {
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
}
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
} catch (e, st) {
if (isClosed || requestId != _requestId) return;
await reportFrom(e, st, this);
if (!isClosed) {
emit(FeedState.error(errorMessage: e.toString()));
@@ -62,14 +73,16 @@ class FeedCubit extends Cubit<FeedState> {
return;
}
final requestId = _requestId;
isLoading = true;
emit((state as FeedStateLoaded).copyWith(isLoading: true));
try {
final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId);
if (isClosed || requestId != _requestId) return;
_nextCursor = feed.nextCursor;
_hasMore = _nextCursor != null;
if (!isClosed && state is FeedStateLoaded) {
if (state is FeedStateLoaded) {
emit(
FeedState.loaded(
feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
@@ -78,14 +91,15 @@ class FeedCubit extends Cubit<FeedState> {
);
}
} catch (e, st) {
if (isClosed || requestId != _requestId) return;
await reportFrom(e, st, this);
if (!isClosed && state is FeedStateLoaded) {
if (state is FeedStateLoaded) {
emit((state as FeedStateLoaded).copyWith(isLoading: false));
} else if (!isClosed) {
} else {
emit(FeedState.error(errorMessage: e.toString()));
}
} finally {
isLoading = false;
if (requestId == _requestId) isLoading = false;
}
}

View File

@@ -0,0 +1,222 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
import 'package:eatvio_app/features/profile/models/weight_model.dart';
import 'package:eatvio_app/generated/l10n.dart';
/// Собирает [InsightsPayloadModel] из сырых точек мобильного API.
///
/// Зеркало веб-`UserInsightsService`: порог изменения [deltaEpsilon], минимум две
/// точки для дельты, те же категории ИМТ и формат подписей.
class InsightsTextBuilder {
InsightsTextBuilder(this.s);
final S s;
static const double deltaEpsilon = 0.05;
static const String _emptyHeadline = '—';
InsightsPayloadModel build({
required List<WeightModel> weights,
required List<CircumferenceModel> circumferences,
required BodyPeriod period,
int? heightCm,
}) {
final sortedWeights = [...weights]
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final sortedCircumferences = [...circumferences]
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return InsightsPayloadModel(
weight: buildWeight(sortedWeights, period),
bmi: buildBmi(sortedWeights, period, heightCm),
measure: buildMeasure(sortedCircumferences, period),
);
}
WeightChartModel buildWeight(List<WeightModel> weights, BodyPeriod period) {
final values = weights.map((e) => e.weight).toList();
final last = values.isEmpty ? null : values.last;
final delta = values.length < 2 ? null : _round(values.last - values.first);
return WeightChartModel(
labels: weights.map((e) => formatLabel(e.createdAt)).toList(),
values: values,
last: last,
delta: delta,
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
unit: s.insights_unit_kg,
deltaText: _weightDeltaText(values, period),
);
}
BmiChartModel buildBmi(
List<WeightModel> weights,
BodyPeriod period,
int? heightCm,
) {
final hasHeight = heightCm != null && heightCm > 0;
if (!hasHeight) {
return BmiChartModel(
headline: _emptyHeadline,
deltaText: s.insights_need_height,
hint: s.insights_need_height,
);
}
final points = weights.where((e) => e.bmi != null).toList();
final values = points.map((e) => e.bmi!).toList();
final last = values.isEmpty ? null : values.last;
final delta = values.length < 2 ? null : _round(values.last - values.first);
final category = last == null ? null : bmiCategory(last);
return BmiChartModel(
labels: points.map((e) => formatLabel(e.createdAt)).toList(),
values: values,
last: last,
delta: delta,
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
category: category,
deltaText: _bmiDeltaText(values, period, category),
hint: s.insights_bmi_hint(heightCm.toString()),
);
}
MeasureChartModel buildMeasure(
List<CircumferenceModel> circumferences,
BodyPeriod period,
) {
final waist = circumferences.map((e) => e.waistCircumference).toList();
final chest = circumferences.map((e) => e.chestCircumference).toList();
final hips = circumferences.map((e) => e.hipCircumference).toList();
return MeasureChartModel(
labels: circumferences.map((e) => formatLabel(e.createdAt)).toList(),
waist: waist,
chest: chest,
hips: hips,
headline: waist.isEmpty ? _emptyHeadline : waist.last.round().toString(),
deltaText: _measureDeltaText(
waist: waist,
chest: chest,
hips: hips,
period: period,
),
);
}
/// Категория по таблице ВОЗ из постановки #2120.
String bmiCategory(double bmi) {
if (bmi < 16) {
return s.insights_bmi_severe_underweight;
}
if (bmi < 18.5) {
return s.insights_bmi_underweight;
}
if (bmi < 25) {
return s.insights_bmi_normal;
}
if (bmi < 30) {
return s.insights_bmi_overweight;
}
if (bmi < 35) {
return s.insights_bmi_obesity_1;
}
if (bmi < 40) {
return s.insights_bmi_obesity_2;
}
return s.insights_bmi_obesity_3;
}
String periodLabel(BodyPeriod period) {
switch (period) {
case BodyPeriod.month:
return s.insights_period_label_1;
case BodyPeriod.threeMonths:
return s.insights_period_label_3;
case BodyPeriod.sixMonths:
return s.insights_period_label_6;
case BodyPeriod.year:
return s.insights_period_label_12;
case BodyPeriod.all:
return s.insights_period_label_all;
}
}
String formatLabel(DateTime date) {
final local = date.toLocal();
final day = local.day.toString().padLeft(2, '0');
final month = local.month.toString().padLeft(2, '0');
return '$day.$month';
}
/// Одна цифра после запятой без хвостового нуля: `3`, `3.5`.
String formatNumber(double value) {
final text = value.toStringAsFixed(1);
return text.endsWith('.0') ? text.substring(0, text.length - 2) : text;
}
String _weightDeltaText(List<double> values, BodyPeriod period) {
if (values.length < 2) {
return s.insights_not_enough;
}
final diff = values.last - values.first;
final periodText = periodLabel(period);
if (diff.abs() < deltaEpsilon) {
return s.insights_weight_same(periodText);
}
final value = formatNumber(diff.abs());
return diff < 0
? s.insights_weight_lost(value, periodText)
: s.insights_weight_gained(value, periodText);
}
String _bmiDeltaText(
List<double> values,
BodyPeriod period,
String? category,
) {
if (values.length < 2 || category == null) {
return s.insights_not_enough;
}
final diff = values.last - values.first;
final periodText = periodLabel(period);
if (diff.abs() < deltaEpsilon) {
return s.insights_bmi_same(periodText, category);
}
final value = formatNumber(diff.abs());
return diff < 0
? s.insights_bmi_lower(value, periodText, category)
: s.insights_bmi_higher(value, periodText, category);
}
String _measureDeltaText({
required List<double> waist,
required List<double> chest,
required List<double> hips,
required BodyPeriod period,
}) {
if (waist.length < 2) {
return s.insights_not_enough;
}
final parts = [
_measurePart(s.insights_series_waist, waist),
_measurePart(s.insights_series_chest, chest),
_measurePart(s.insights_series_hips, hips),
];
return s.insights_measures_summary(parts.join(', '), periodLabel(period));
}
String _measurePart(String name, List<double> values) {
final diff = values.last - values.first;
if (diff.abs() < deltaEpsilon) {
return s.insights_measure_same(name);
}
final value = formatNumber(diff.abs());
return diff < 0
? s.insights_measure_less(name, value)
: s.insights_measure_more(name, value);
}
double _round(double value) => (value * 10).round() / 10;
}

View File

@@ -0,0 +1,6 @@
/// Флаги поэтапного включения аналитики.
abstract final class InsightsFeatureFlags {
/// Вкладки «КБЖУ по дням» и «В течение дня» требуют бэкенд-эндпоинта
/// `GET /api/profile/insights` и включаются во второй итерации.
static const bool kbjuEnabled = false;
}

View File

@@ -0,0 +1,132 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'insights_payload_model.freezed.dart';
part 'insights_payload_model.g.dart';
/// Структура повторяет веб-`UserInsightsService::chartPayload()`, чтобы во второй
/// итерации заменить только источник данных.
@freezed
abstract class InsightsPayloadModel with _$InsightsPayloadModel {
const factory InsightsPayloadModel({
required WeightChartModel weight,
required BmiChartModel bmi,
required MeasureChartModel measure,
DailyChartModel? daily,
IntraChartModel? intra,
}) = _InsightsPayloadModel;
factory InsightsPayloadModel.fromJson(Map<String, dynamic> json) =>
_$InsightsPayloadModelFromJson(json);
}
@freezed
abstract class WeightChartModel with _$WeightChartModel {
const factory WeightChartModel({
@Default([]) List<String> labels,
@Default([]) List<double> values,
double? last,
double? delta,
@Default('') String headline,
@Default('') String unit,
@Default('') String deltaText,
}) = _WeightChartModel;
factory WeightChartModel.fromJson(Map<String, dynamic> json) =>
_$WeightChartModelFromJson(json);
}
@freezed
abstract class BmiChartModel with _$BmiChartModel {
const factory BmiChartModel({
@Default([]) List<String> labels,
@Default([]) List<double> values,
double? last,
double? delta,
@Default('') String headline,
String? category,
@Default('') String deltaText,
@Default('') String hint,
}) = _BmiChartModel;
factory BmiChartModel.fromJson(Map<String, dynamic> json) =>
_$BmiChartModelFromJson(json);
}
@freezed
abstract class MeasureChartModel with _$MeasureChartModel {
const factory MeasureChartModel({
@Default([]) List<String> labels,
@Default([]) List<double> waist,
@Default([]) List<double> chest,
@Default([]) List<double> hips,
@Default('') String headline,
@Default('') String deltaText,
}) = _MeasureChartModel;
factory MeasureChartModel.fromJson(Map<String, dynamic> json) =>
_$MeasureChartModelFromJson(json);
}
@freezed
abstract class DailyChartModel with _$DailyChartModel {
const factory DailyChartModel({
@Default([]) List<String> labels,
@Default([]) List<double> kcal,
@Default([]) List<double> proteins,
@Default([]) List<double> fats,
@Default([]) List<double> carbs,
double? average,
double? percent,
@Default('') String headline,
@Default('') String deltaText,
}) = _DailyChartModel;
factory DailyChartModel.fromJson(Map<String, dynamic> json) =>
_$DailyChartModelFromJson(json);
}
@freezed
abstract class IntraChartModel with _$IntraChartModel {
const factory IntraChartModel({
@Default([]) List<IntraBarModel> bars,
@Default([]) List<ScalePointModel> satiety,
@Default([]) List<ScalePointModel> mood,
double? wake,
double? sleep,
String? wakeLabel,
String? sleepLabel,
String? wakeText,
String? sleepText,
@Default('') String date,
@Default('') String maxDate,
@Default('') String dateLabel,
@Default(false) bool isToday,
}) = _IntraChartModel;
factory IntraChartModel.fromJson(Map<String, dynamic> json) =>
_$IntraChartModelFromJson(json);
}
/// Приём пищи: время `x` (часы 0–24) и калории из белков/жиров/углеводов.
@freezed
abstract class IntraBarModel with _$IntraBarModel {
const factory IntraBarModel({
required double x,
@Default(0) double p,
@Default(0) double f,
@Default(0) double c,
}) = _IntraBarModel;
factory IntraBarModel.fromJson(Map<String, dynamic> json) =>
_$IntraBarModelFromJson(json);
}
/// Точка шкалы 0–5 (сытость/настроение) в момент времени `x`.
@freezed
abstract class ScalePointModel with _$ScalePointModel {
const factory ScalePointModel({required double x, required double y}) =
_ScalePointModel;
factory ScalePointModel.fromJson(Map<String, dynamic> json) =>
_$ScalePointModelFromJson(json);
}

View File

@@ -0,0 +1,26 @@
/// Период для вкладок «Вес», «ИМТ» и «Замеры». Значение совпадает с `{period}` в
/// `/api/profile/measure/weight/{period}`.
enum BodyPeriod {
month('1'),
threeMonths('3'),
sixMonths('6'),
year('12'),
all('all');
const BodyPeriod(this.apiValue);
final String apiValue;
}
/// Период для вкладки «КБЖУ по дням» (используется со второй итерации).
enum DailyPeriod {
week('7'),
twoWeeks('14'),
month('30'),
threeMonths('90'),
all('all');
const DailyPeriod(this.apiValue);
final String apiValue;
}

View File

@@ -0,0 +1,84 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
/// График ИМТ с цветными зонами: норма 18.5–25, избыточная масса 25–30, ожирение 30–40.
class BmiChart extends StatelessWidget {
const BmiChart({super.key, required this.model});
final BmiChartModel model;
@override
Widget build(BuildContext context) {
final points = buildPoints(model.labels, model.values);
if (points.isEmpty) {
return InsightsChartPlaceholder(text: model.deltaText);
}
final s = S.of(context);
final range = paddedRange(points.map((e) => e.value), minPadding: 2);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: insightsChartHeight,
child: SfCartesianChart(
key: const Key('insights_bmi_chart'),
margin: EdgeInsets.zero,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsCategoryAxis(),
primaryYAxis: buildInsightsValueAxis(
unit: s.insights_unit_bmi,
min: range.min,
max: range.max,
plotBands: const [
PlotBand(
start: 18.5,
end: 25,
color: InsightsColors.bmiNormalZone,
),
PlotBand(
start: 25,
end: 30,
color: InsightsColors.bmiOverweightZone,
),
PlotBand(
start: 30,
end: 40,
color: InsightsColors.bmiObesityZone,
),
],
),
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, String>>[
SplineAreaSeries<InsightsPoint, String>(
name: s.insights_tab_bmi,
dataSource: points,
xValueMapper: (point, _) => point.label,
yValueMapper: (point, _) => point.value,
borderColor: InsightsColors.blue,
borderWidth: 3,
gradient: buildInsightsAreaGradient(InsightsColors.blue),
markerSettings: buildInsightsMarker(InsightsColors.blue),
),
],
),
),
if (model.hint.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
model.hint,
key: const Key('insights_bmi_hint'),
style: const TextStyle(
fontSize: 13,
color: InsightsColors.subtitle,
),
),
],
],
);
}
}

View File

@@ -0,0 +1,113 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
/// КБЖУ по дням: калории — площадь на левой оси («ккал»), Б/Ж/У — линии на
/// правой оси («граммы»). [visibility] — Калории, Белки, Жиры, Углеводы.
class DailyKbjuChart extends StatelessWidget {
const DailyKbjuChart({
super.key,
required this.model,
required this.visibility,
});
final DailyChartModel model;
final List<bool> visibility;
static const String _gramsAxisName = 'grams';
@override
Widget build(BuildContext context) {
final s = S.of(context);
final calories = buildPoints(model.labels, model.kcal);
if (calories.isEmpty) {
return InsightsChartPlaceholder(text: s.insights_not_enough);
}
final proteins = buildPoints(model.labels, model.proteins);
final fats = buildPoints(model.labels, model.fats);
final carbs = buildPoints(model.labels, model.carbs);
final grams = [
(
name: s.insights_series_proteins,
color: InsightsColors.proteins,
points: proteins,
),
(name: s.insights_series_fats, color: InsightsColors.fats, points: fats),
(
name: s.insights_series_carbs,
color: InsightsColors.carbs,
points: carbs,
),
];
return SizedBox(
height: insightsChartHeight,
child: SfCartesianChart(
key: const Key('insights_daily_chart'),
margin: EdgeInsets.zero,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsCategoryAxis(),
primaryYAxis: NumericAxis(
title: AxisTitle(
text: s.insights_unit_kcal,
textStyle: insightsAxisLabelStyle,
),
labelStyle: insightsAxisLabelStyle,
minimum: 0,
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(width: 0),
majorGridLines: MajorGridLines(
color: InsightsColors.border.withValues(alpha: 0.8),
dashArray: const [4, 4],
),
),
axes: <ChartAxis>[
NumericAxis(
name: _gramsAxisName,
opposedPosition: true,
minimum: 0,
title: AxisTitle(
text: s.insights_unit_grams,
textStyle: insightsAxisLabelStyle,
),
labelStyle: insightsAxisLabelStyle,
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(width: 0),
majorGridLines: const MajorGridLines(width: 0),
),
],
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, String>>[
if (_isVisible(0))
SplineAreaSeries<InsightsPoint, String>(
name: s.insights_series_calories,
dataSource: calories,
xValueMapper: (point, _) => point.label,
yValueMapper: (point, _) => point.value,
borderColor: InsightsColors.calories,
borderWidth: 3,
gradient: buildInsightsAreaGradient(InsightsColors.calories),
),
for (var i = 0; i < grams.length; i++)
if (_isVisible(i + 1))
SplineSeries<InsightsPoint, String>(
name: grams[i].name,
yAxisName: _gramsAxisName,
dataSource: grams[i].points,
xValueMapper: (point, _) => point.label,
yValueMapper: (point, _) => point.value,
color: grams[i].color,
width: 2.5,
),
],
),
);
}
bool _isVisible(int index) =>
index < visibility.length ? visibility[index] : true;
}

View File

@@ -0,0 +1,130 @@
import 'dart:math' as math;
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
/// Точка графика: подпись оси X (`dd.MM`) и значение.
class InsightsPoint {
const InsightsPoint(this.label, this.value);
final String label;
final double value;
}
/// Склеивает `labels[]` и `values[]` (по минимальной длине).
List<InsightsPoint> buildPoints(List<String> labels, List<double> values) {
final count = math.min(labels.length, values.length);
return [for (var i = 0; i < count; i++) InsightsPoint(labels[i], values[i])];
}
/// Границы оси Y с отступом, чтобы линия не прижималась к краям (и ось не начиналась с нуля).
({double min, double max}) paddedRange(
Iterable<double> values, {
double minPadding = 1,
}) {
if (values.isEmpty) {
return (min: 0, max: 1);
}
final lowest = values.reduce(math.min);
final highest = values.reduce(math.max);
final padding = math.max((highest - lowest) * 0.2, minPadding);
return (
min: (lowest - padding).floorToDouble(),
max: (highest + padding).ceilToDouble(),
);
}
const TextStyle insightsAxisLabelStyle = TextStyle(
fontSize: 11,
color: InsightsColors.subtitle,
);
CategoryAxis buildInsightsCategoryAxis() {
return const CategoryAxis(
arrangeByIndex: true,
labelStyle: insightsAxisLabelStyle,
majorGridLines: MajorGridLines(width: 0),
majorTickLines: MajorTickLines(size: 0),
axisLine: AxisLine(color: InsightsColors.border, width: 1),
edgeLabelPlacement: EdgeLabelPlacement.shift,
labelIntersectAction: AxisLabelIntersectAction.hide,
);
}
NumericAxis buildInsightsValueAxis({
required String unit,
required double min,
required double max,
List<PlotBand> plotBands = const [],
}) {
return NumericAxis(
minimum: min,
maximum: max,
labelFormat: '{value} $unit',
labelStyle: insightsAxisLabelStyle,
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(width: 0),
majorGridLines: MajorGridLines(
color: InsightsColors.border.withValues(alpha: 0.8),
dashArray: const [4, 4],
),
plotBands: plotBands,
);
}
TrackballBehavior buildInsightsTrackball() {
return TrackballBehavior(
enable: true,
activationMode: ActivationMode.singleTap,
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
lineType: TrackballLineType.vertical,
tooltipSettings: const InteractiveTooltip(),
);
}
MarkerSettings buildInsightsMarker(Color color) {
return MarkerSettings(
isVisible: true,
width: 4,
height: 4,
color: Colors.white,
borderColor: color,
borderWidth: 2,
);
}
LinearGradient buildInsightsAreaGradient(Color color) {
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [color.withValues(alpha: 0.18), color.withValues(alpha: 0)],
);
}
/// Пустое состояние графика (нет данных).
class InsightsChartPlaceholder extends StatelessWidget {
const InsightsChartPlaceholder({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
return SizedBox(
height: insightsChartHeight,
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: InsightsColors.subtitle,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,210 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
/// График «В течение дня»: столбики Б/Ж/У (ккал) по приёмам пищи на оси 0–24 ч,
/// линии сытости и настроения (шкала 0–5) и затемнение времени сна.
///
/// [visibility] — Белки, Жиры, Углеводы, Сытость, Настроение.
class IntraDayChart extends StatelessWidget {
const IntraDayChart({
super.key,
required this.model,
required this.visibility,
required this.showSleep,
});
final IntraChartModel model;
final List<bool> visibility;
final bool showSleep;
static const String _scaleAxisName = 'scale';
static const double _barWidthPx = 26;
static const double _axesReservedWidthPx = 80;
@override
Widget build(BuildContext context) {
final s = S.of(context);
if (model.bars.isEmpty && model.satiety.isEmpty && model.mood.isEmpty) {
return InsightsChartPlaceholder(text: s.insights_not_enough);
}
return SizedBox(
height: insightsChartHeight,
child: LayoutBuilder(
builder: (context, constraints) {
final plotWidth = (constraints.maxWidth - _axesReservedWidthPx).clamp(
120.0,
double.infinity,
);
final barFraction = (_barWidthPx / (plotWidth / 24)).clamp(0.1, 1.0);
return SfCartesianChart(
key: const Key('insights_intra_chart'),
margin: EdgeInsets.zero,
plotAreaBorderWidth: 0,
primaryXAxis: NumericAxis(
minimum: 0,
maximum: 24,
interval: 2,
labelStyle: insightsAxisLabelStyle,
majorGridLines: const MajorGridLines(width: 0),
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(color: InsightsColors.border, width: 1),
axisLabelFormatter: (details) => ChartAxisLabel(
'${details.value.toInt().toString().padLeft(2, '0')}:00',
details.textStyle,
),
plotBands: _sleepBands(),
),
primaryYAxis: NumericAxis(
minimum: 0,
title: AxisTitle(
text: s.insights_unit_kcal_per_meal,
textStyle: insightsAxisLabelStyle,
),
labelStyle: insightsAxisLabelStyle,
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(width: 0),
majorGridLines: MajorGridLines(
color: InsightsColors.border.withValues(alpha: 0.8),
dashArray: const [4, 4],
),
),
axes: const <ChartAxis>[
NumericAxis(
name: _scaleAxisName,
opposedPosition: true,
minimum: 0,
maximum: 5,
interval: 1,
labelStyle: insightsAxisLabelStyle,
majorTickLines: MajorTickLines(size: 0),
axisLine: AxisLine(width: 0),
majorGridLines: MajorGridLines(width: 0),
),
],
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<dynamic, double>>[
if (_isVisible(0))
_barSeries(
s.insights_series_proteins,
InsightsColors.proteins,
(bar) => bar.p,
barFraction,
),
if (_isVisible(1))
_barSeries(
s.insights_series_fats,
InsightsColors.fats,
(bar) => bar.f,
barFraction,
),
if (_isVisible(2))
_barSeries(
s.insights_series_carbs,
InsightsColors.carbs,
(bar) => bar.c,
barFraction,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(6),
),
),
if (_isVisible(3) && model.satiety.isNotEmpty)
_scaleSeries(
s.insights_series_satiety,
InsightsColors.satiety,
model.satiety,
),
if (_isVisible(4) && model.mood.isNotEmpty)
_scaleSeries(
s.insights_series_mood,
InsightsColors.mood,
model.mood,
),
],
);
},
),
);
}
bool _isVisible(int index) =>
index < visibility.length ? visibility[index] : true;
CartesianSeries<IntraBarModel, double> _barSeries(
String name,
Color color,
double Function(IntraBarModel bar) valueOf,
double widthFraction, {
BorderRadius borderRadius = BorderRadius.zero,
}) {
return StackedColumnSeries<IntraBarModel, double>(
name: name,
dataSource: model.bars,
xValueMapper: (bar, _) => bar.x,
yValueMapper: (bar, _) => valueOf(bar),
color: color,
width: widthFraction,
spacing: 0,
borderRadius: borderRadius,
);
}
CartesianSeries<ScalePointModel, double> _scaleSeries(
String name,
Color color,
List<ScalePointModel> points,
) {
return SplineSeries<ScalePointModel, double>(
name: name,
yAxisName: _scaleAxisName,
dataSource: points,
xValueMapper: (point, _) => point.x,
yValueMapper: (point, _) => point.y,
splineType: SplineType.monotonic,
color: color,
width: 2.5,
markerSettings: MarkerSettings(
isVisible: true,
width: 3,
height: 3,
color: color,
borderColor: color,
),
);
}
/// Затемнение `[0, wake]` и `[sleep, 24]` плюс пунктиры подъёма и отхода ко сну.
List<PlotBand> _sleepBands() {
if (!showSleep) {
return const [];
}
final wake = model.wake;
final sleep = model.sleep;
return [
if (wake != null && wake > 0)
PlotBand(start: 0, end: wake, color: InsightsColors.sleepShade),
if (sleep != null && sleep < 24 && (wake == null || sleep > wake))
PlotBand(start: sleep, end: 24, color: InsightsColors.sleepShade),
if (wake != null)
PlotBand(
start: wake,
end: wake,
borderColor: InsightsColors.wake,
borderWidth: 1.5,
dashArray: const [5, 4],
),
if (sleep != null)
PlotBand(
start: sleep,
end: sleep,
borderColor: InsightsColors.sleepLine,
borderWidth: 1.5,
dashArray: const [5, 4],
),
];
}
}

View File

@@ -0,0 +1,86 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
/// Три линии: талия, грудь, бёдра. [visibility] — видимость серий в этом порядке.
class MeasureChart extends StatelessWidget {
const MeasureChart({
super.key,
required this.model,
required this.visibility,
required this.emptyText,
});
final MeasureChartModel model;
final List<bool> visibility;
final String emptyText;
@override
Widget build(BuildContext context) {
final waist = buildPoints(model.labels, model.waist);
final chest = buildPoints(model.labels, model.chest);
final hips = buildPoints(model.labels, model.hips);
if (waist.isEmpty && chest.isEmpty && hips.isEmpty) {
return InsightsChartPlaceholder(text: emptyText);
}
final s = S.of(context);
final series = [
(
name: s.insights_series_waist,
color: InsightsColors.waist,
points: waist,
),
(
name: s.insights_series_chest,
color: InsightsColors.chest,
points: chest,
),
(name: s.insights_series_hips, color: InsightsColors.hips, points: hips),
];
final visibleValues = <double>[
for (var i = 0; i < series.length; i++)
if (_isVisible(i)) ...series[i].points.map((e) => e.value),
];
final range = paddedRange(
visibleValues.isEmpty
? series.expand((e) => e.points).map((e) => e.value)
: visibleValues,
minPadding: 2,
);
return SizedBox(
height: insightsChartHeight,
child: SfCartesianChart(
key: const Key('insights_measure_chart'),
margin: EdgeInsets.zero,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsCategoryAxis(),
primaryYAxis: buildInsightsValueAxis(
unit: s.insights_unit_cm,
min: range.min,
max: range.max,
),
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, String>>[
for (var i = 0; i < series.length; i++)
if (_isVisible(i))
SplineSeries<InsightsPoint, String>(
name: series[i].name,
dataSource: series[i].points,
xValueMapper: (point, _) => point.label,
yValueMapper: (point, _) => point.value,
color: series[i].color,
width: 3,
markerSettings: buildInsightsMarker(series[i].color),
),
],
),
);
}
bool _isVisible(int index) =>
index < visibility.length ? visibility[index] : true;
}

View File

@@ -0,0 +1,53 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
class WeightChart extends StatelessWidget {
const WeightChart({super.key, required this.model, required this.emptyText});
final WeightChartModel model;
/// Текст при отсутствии точек («Добавьте Ваш вес»).
final String emptyText;
@override
Widget build(BuildContext context) {
final points = buildPoints(model.labels, model.values);
if (points.isEmpty) {
return InsightsChartPlaceholder(text: emptyText);
}
final s = S.of(context);
final range = paddedRange(points.map((e) => e.value));
return SizedBox(
height: insightsChartHeight,
child: SfCartesianChart(
key: const Key('insights_weight_chart'),
margin: EdgeInsets.zero,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsCategoryAxis(),
primaryYAxis: buildInsightsValueAxis(
unit: s.insights_unit_kg,
min: range.min,
max: range.max,
),
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, String>>[
SplineAreaSeries<InsightsPoint, String>(
name: s.insights_tab_weight,
dataSource: points,
xValueMapper: (point, _) => point.label,
yValueMapper: (point, _) => point.value,
borderColor: InsightsColors.blue,
borderWidth: 3,
gradient: buildInsightsAreaGradient(InsightsColors.blue),
markerSettings: buildInsightsMarker(InsightsColors.blue),
),
],
),
);
}
}

View File

@@ -0,0 +1,184 @@
import 'dart:developer';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:eatvio_app/features/insights/insights_feature_flags.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class InsightsCubit extends Cubit<InsightsState> {
InsightsCubit({
required this.repository,
this.slug,
this.canAdd = false,
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
}) : super(const InsightsState.initial());
final InsightsRepository repository;
/// `null` — собственные данные, иначе slug владельца (консультант → клиент).
final String? slug;
/// Показывать ли кнопку «+» (только владелец).
final bool canAdd;
final bool kbjuEnabled;
bool _isHeightLoaded = false;
int? _heightCm;
int _requestId = 0;
List<InsightsPanel> get panels =>
InsightsPanel.available(kbjuEnabled: kbjuEnabled);
Future<void> load() async {
emit(const InsightsState.loading());
await _fetch(bodyPeriod: BodyPeriod.threeMonths, previous: null);
}
/// Перезапрашивает данные, сохраняя вкладку, период, серии и раскрытие.
Future<void> refresh() async {
final current = state;
if (current is! InsightsStateLoaded) {
return load();
}
emit(current.copyWith(isRefreshing: true));
await _fetch(bodyPeriod: current.bodyPeriod, previous: current);
}
Future<void> setBodyPeriod(BodyPeriod period) async {
final current = state;
if (current is! InsightsStateLoaded || current.bodyPeriod == period) {
return;
}
emit(current.copyWith(bodyPeriod: period, isRefreshing: true));
await _fetch(bodyPeriod: period, previous: current);
}
void toggleExpanded() {
final current = state;
if (current is InsightsStateLoaded) {
emit(current.copyWith(isExpanded: !current.isExpanded));
}
}
void setPanel(InsightsPanel panel) {
final current = state;
if (current is! InsightsStateLoaded || !panels.contains(panel)) {
return;
}
emit(current.copyWith(panel: panel));
}
/// Включает/выключает серию [index] на вкладке [panel].
void toggleSeries(InsightsPanel panel, int index) {
final current = state;
if (current is! InsightsStateLoaded) {
return;
}
final visibility = current.seriesVisibility;
final source = switch (panel) {
InsightsPanel.measures => visibility.measure,
InsightsPanel.daily => visibility.daily,
InsightsPanel.intra => visibility.intra,
_ => null,
};
if (source == null || index < 0 || index >= source.length) {
return;
}
final updated = [...source]..[index] = !source[index];
final nextVisibility = switch (panel) {
InsightsPanel.measures => visibility.copyWith(measure: updated),
InsightsPanel.daily => visibility.copyWith(daily: updated),
_ => visibility.copyWith(intra: updated),
};
emit(current.copyWith(seriesVisibility: nextVisibility));
}
void toggleSleep() {
final current = state;
if (current is InsightsStateLoaded) {
emit(current.copyWith(showSleep: !current.showSleep));
}
}
/// Сохраняет вес и обновляет данные. Возвращает `false`, если сохранить не удалось.
Future<bool> addWeight({required double weight}) {
return _add(() => repository.addWeight(weight: weight));
}
/// Сохраняет замеры и обновляет данные. Возвращает `false`, если сохранить не удалось.
Future<bool> addCircumference({
required int chestCircumference,
required int waistCircumference,
required int hipCircumference,
}) {
return _add(
() => repository.addCircumference(
chestCircumference: chestCircumference,
waistCircumference: waistCircumference,
hipCircumference: hipCircumference,
),
);
}
Future<bool> _add(Future<void> Function() action) async {
try {
await action();
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
return false;
}
await refresh();
return true;
}
Future<void> _fetch({
required BodyPeriod bodyPeriod,
required InsightsStateLoaded? previous,
}) async {
final requestId = ++_requestId;
try {
if (!_isHeightLoaded) {
_heightCm = await repository.getHeightCm(slug: slug);
_isHeightLoaded = true;
}
final payload = await repository.getInsights(
slug: slug,
bodyPeriod: bodyPeriod,
heightCm: _heightCm,
);
if (requestId != _requestId || isClosed) {
return;
}
emit(
previous == null
? InsightsState.loaded(payload: payload, bodyPeriod: bodyPeriod)
: previous.copyWith(
payload: payload,
bodyPeriod: bodyPeriod,
isRefreshing: false,
),
);
} on InsightsForbidden {
if (requestId == _requestId && !isClosed) {
emit(const InsightsState.hidden());
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
if (requestId != _requestId || isClosed) {
return;
}
// Ошибка перезапроса не должна стирать уже показанные данные.
emit(
previous == null
? InsightsState.error(message: e.toString())
: previous.copyWith(isRefreshing: false),
);
}
}
}

View File

@@ -0,0 +1,18 @@
/// Вкладки блока «Аналитика».
enum InsightsPanel {
weight,
bmi,
measures,
daily,
intra;
/// Вкладки КБЖУ доступны только при включённом флаге.
bool get isKbju => this == daily || this == intra;
/// Кнопка «+» есть только у вкладок с ручным вводом.
bool get supportsAdd => this == weight || this == measures;
static List<InsightsPanel> available({required bool kbjuEnabled}) {
return values.where((panel) => kbjuEnabled || !panel.isKbju).toList();
}
}

View File

@@ -0,0 +1,47 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'insights_state.freezed.dart';
/// Видимость серий (чипы под графиком): `true` — серия показана.
@freezed
abstract class InsightsSeriesVisibility with _$InsightsSeriesVisibility {
const factory InsightsSeriesVisibility({
/// Талия, Грудь, Бёдра.
@Default([true, true, true]) List<bool> measure,
/// Калории, Белки, Жиры, Углеводы.
@Default([true, true, true, true]) List<bool> daily,
/// Белки, Жиры, Углеводы, Сытость, Настроение.
@Default([true, true, true, true, true]) List<bool> intra,
}) = _InsightsSeriesVisibility;
}
@freezed
abstract class InsightsState with _$InsightsState {
const factory InsightsState.initial() = InsightsStateInitial;
const factory InsightsState.loading() = InsightsStateLoading;
/// 403: данные недоступны, блок не показывается.
const factory InsightsState.hidden() = InsightsStateHidden;
const factory InsightsState.error({required String message}) =
InsightsStateError;
const factory InsightsState.loaded({
required InsightsPayloadModel payload,
@Default(BodyPeriod.threeMonths) BodyPeriod bodyPeriod,
@Default(InsightsPanel.weight) InsightsPanel panel,
@Default(InsightsSeriesVisibility())
InsightsSeriesVisibility seriesVisibility,
@Default(true) bool showSleep,
@Default(false) bool isExpanded,
/// Идёт перезапрос (смена периода / обновление) — старые данные остаются на экране.
@Default(false) bool isRefreshing,
}) = InsightsStateLoaded;
}

View File

@@ -0,0 +1,175 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
const int _minValue = 20;
const int _maxValue = 199;
/// Значения замеров в сантиметрах.
typedef CircumferenceValues = ({int chest, int waist, int hip});
/// Выбор замеров груди, талии и бёдер. [onSave] вызывается после закрытия шторки.
Future<void> showAddCircumferenceBottomSheet(
BuildContext context, {
CircumferenceValues? initial,
required Future<void> Function(CircumferenceValues values) onSave,
}) {
return showModalBottomSheet<void>(
useRootNavigator: true,
context: context,
builder: (_) =>
AddCircumferenceBottomSheet(initial: initial, onSave: onSave),
);
}
class AddCircumferenceBottomSheet extends StatefulWidget {
const AddCircumferenceBottomSheet({
super.key,
this.initial,
required this.onSave,
});
final CircumferenceValues? initial;
final Future<void> Function(CircumferenceValues values) onSave;
@override
State<AddCircumferenceBottomSheet> createState() =>
_AddCircumferenceBottomSheetState();
}
class _AddCircumferenceBottomSheetState
extends State<AddCircumferenceBottomSheet> {
late final FixedExtentScrollController _chestController;
late final FixedExtentScrollController _waistController;
late final FixedExtentScrollController _hipController;
late int _chest;
late int _waist;
late int _hip;
@override
void initState() {
super.initState();
_chest = _clamp(widget.initial?.chest);
_waist = _clamp(widget.initial?.waist);
_hip = _clamp(widget.initial?.hip);
_chestController = FixedExtentScrollController(
initialItem: _chest - _minValue,
);
_waistController = FixedExtentScrollController(
initialItem: _waist - _minValue,
);
_hipController = FixedExtentScrollController(initialItem: _hip - _minValue);
}
@override
void dispose() {
_chestController.dispose();
_waistController.dispose();
_hipController.dispose();
super.dispose();
}
int _clamp(int? value) => (value ?? _minValue).clamp(_minValue, _maxValue);
void _save() {
final values = (chest: _chest, waist: _waist, hip: _hip);
Navigator.of(context).pop();
widget.onSave(values);
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Row(
children: [
for (final title in [
s.insights_series_chest,
s.insights_series_waist,
s.insights_series_hips,
])
Expanded(
child: Center(
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
),
],
),
),
SizedBox(
height: 200,
child: Row(
children: [
_picker(
const Key('add_circumference_chest_picker'),
_chestController,
(value) => _chest = value,
),
_picker(
const Key('add_circumference_waist_picker'),
_waistController,
(value) => _waist = value,
),
_picker(
const Key('add_circumference_hip_picker'),
_hipController,
(value) => _hip = value,
),
],
),
),
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
key: const Key('add_sheet_save'),
onTap: _save,
borderRadius: BorderRadius.circular(16),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: appTheme.blueA400,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
s.save,
style: const TextStyle(color: Colors.white),
),
),
),
),
),
],
),
);
}
Widget _picker(
Key key,
FixedExtentScrollController controller,
ValueChanged<int> onChanged,
) {
return Expanded(
child: CupertinoPicker(
key: key,
magnification: 1.22,
squeeze: 1.2,
useMagnifier: true,
scrollController: controller,
itemExtent: 24,
onSelectedItemChanged: (index) => onChanged(index + _minValue),
children: [for (int i = _minValue; i <= _maxValue; i++) Text('$i')],
),
);
}
}

View File

@@ -0,0 +1,147 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
const int _minKilo = 20;
const int _maxKilo = 299;
/// Выбор веса (кг + десятые). [onSave] вызывается после закрытия шторки.
Future<void> showAddWeightBottomSheet(
BuildContext context, {
double? initialWeight,
required Future<void> Function(double weight) onSave,
}) {
return showModalBottomSheet<void>(
useRootNavigator: true,
context: context,
builder: (_) =>
AddWeightBottomSheet(initialWeight: initialWeight, onSave: onSave),
);
}
class AddWeightBottomSheet extends StatefulWidget {
const AddWeightBottomSheet({
super.key,
this.initialWeight,
required this.onSave,
});
final double? initialWeight;
final Future<void> Function(double weight) onSave;
@override
State<AddWeightBottomSheet> createState() => _AddWeightBottomSheetState();
}
class _AddWeightBottomSheetState extends State<AddWeightBottomSheet> {
late final FixedExtentScrollController _kiloController;
late final FixedExtentScrollController _gramsController;
late int _kilo;
late int _grams;
@override
void initState() {
super.initState();
final initial = widget.initialWeight;
final tenths = initial == null
? 0
: (initial * 10).round().clamp(_minKilo * 10, _maxKilo * 10 + 9);
_kilo = initial == null ? _minKilo : tenths ~/ 10;
_grams = initial == null ? 0 : tenths % 10;
_kiloController = FixedExtentScrollController(
initialItem: _kilo - _minKilo,
);
_gramsController = FixedExtentScrollController(initialItem: _grams);
}
@override
void dispose() {
_kiloController.dispose();
_gramsController.dispose();
super.dispose();
}
void _save() {
final weight = double.parse('$_kilo.$_grams');
Navigator.of(context).pop();
widget.onSave(weight);
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 200,
child: Row(
children: [
Expanded(
child: CupertinoPicker(
key: const Key('add_weight_kilo_picker'),
magnification: 1.22,
squeeze: 1.2,
useMagnifier: true,
scrollController: _kiloController,
itemExtent: 24,
onSelectedItemChanged: (value) => _kilo = value + _minKilo,
children: [
for (int i = _minKilo; i <= _maxKilo; i++) Text('$i'),
],
),
),
Expanded(
child: CupertinoPicker(
key: const Key('add_weight_grams_picker'),
magnification: 1.22,
squeeze: 1.2,
useMagnifier: true,
scrollController: _gramsController,
itemExtent: 24,
onSelectedItemChanged: (value) => _grams = value,
children: [for (int i = 0; i < 10; i++) Text('$i')],
),
),
],
),
),
_SaveButton(onTap: _save),
],
),
);
}
}
class _SaveButton extends StatelessWidget {
const _SaveButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
key: const Key('add_sheet_save'),
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: appTheme.blueA400,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
S.of(context).save,
style: const TextStyle(color: Colors.white),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,593 @@
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/insights/insights_feature_flags.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/insights/presentation/charts/bmi_chart.dart';
import 'package:eatvio_app/features/insights/presentation/charts/daily_kbju_chart.dart';
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
import 'package:eatvio_app/features/insights/presentation/charts/intra_day_chart.dart';
import 'package:eatvio_app/features/insights/presentation/charts/measure_chart.dart';
import 'package:eatvio_app/features/insights/presentation/charts/weight_chart.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_cubit.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/add_circumference_bottom_sheet.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/add_weight_bottom_sheet.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_fab.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_head.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_tabs.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/intra_date_nav.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/intra_legend.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/period_pills.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/series_chips.dart';
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// Сворачиваемый блок «Аналитика» (вес, ИМТ, замеры).
///
/// [slug] — владелец данных (`null` — текущий пользователь). При 403 блок
/// скрывается целиком. [canAdd] включает кнопку «+» на вкладках «Вес» и «Замеры».
class InsightsCard extends StatelessWidget {
const InsightsCard({
super.key,
this.slug,
this.canAdd = false,
this.repository,
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
});
final String? slug;
final bool canAdd;
final InsightsRepository? repository;
final bool kbjuEnabled;
@override
Widget build(BuildContext context) {
return KeyedSubtree(
key: ValueKey('insights_card_$slug'),
child: BlocProvider<InsightsCubit>(
create: (_) => InsightsCubit(
repository: repository ?? sl<InsightsRepository>(),
slug: slug,
canAdd: canAdd,
kbjuEnabled: kbjuEnabled,
)..load(),
child: const _InsightsView(),
),
);
}
}
class _InsightsView extends StatelessWidget {
const _InsightsView();
@override
Widget build(BuildContext context) {
return BlocBuilder<InsightsCubit, InsightsState>(
builder: (context, state) {
return switch (state) {
InsightsStateLoaded() => _CardShell(
child: _LoadedContent(state: state),
),
InsightsStateError() => const _CardShell(child: _ErrorContent()),
_ => const SizedBox.shrink(),
};
},
);
}
}
class _CardShell extends StatelessWidget {
const _CardShell({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
key: const Key('insights_card'),
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(
color: InsightsColors.cardShadow,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: child,
);
}
}
class _ErrorContent extends StatelessWidget {
const _ErrorContent();
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Expanded(
child: Text(
s.insights_load_error,
style: const TextStyle(
fontSize: 14,
color: InsightsColors.subtitle,
),
),
),
TextButton(
key: const Key('insights_retry'),
onPressed: () => context.read<InsightsCubit>().load(),
child: Text(s.insights_retry),
),
],
),
);
}
}
class _LoadedContent extends StatelessWidget {
const _LoadedContent({required this.state});
final InsightsStateLoaded state;
@override
Widget build(BuildContext context) {
final cubit = context.read<InsightsCubit>();
final showFab = cubit.canAdd && state.panel.supportsAdd;
return Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Toggle(isExpanded: state.isExpanded, onTap: cubit.toggleExpanded),
AnimatedSize(
duration: const Duration(milliseconds: 200),
alignment: Alignment.topCenter,
child: state.isExpanded
? Padding(
padding: EdgeInsets.fromLTRB(
20,
0,
20,
showFab ? 24 + 44 : 24,
),
child: _Body(state: state),
)
: const SizedBox(width: double.infinity),
),
],
),
if (showFab && state.isExpanded)
Positioned(
right: 16,
bottom: 16,
child: InsightsFab(
tooltip: S.of(context).insights_add_record,
onPressed: () => _onAddPressed(context, cubit, state),
),
),
],
);
}
void _onAddPressed(
BuildContext context,
InsightsCubit cubit,
InsightsStateLoaded state,
) {
final s = S.of(context);
if (state.panel == InsightsPanel.weight) {
showAddWeightBottomSheet(
context,
initialWeight: state.payload.weight.last,
onSave: (weight) async {
if (!await cubit.addWeight(weight: weight)) {
showErrorToast(s.insights_save_error);
}
},
);
} else if (state.panel == InsightsPanel.measures) {
final measure = state.payload.measure;
showAddCircumferenceBottomSheet(
context,
initial:
measure.waist.isEmpty ||
measure.chest.isEmpty ||
measure.hips.isEmpty
? null
: (
chest: measure.chest.last.round(),
waist: measure.waist.last.round(),
hip: measure.hips.last.round(),
),
onSave: (values) async {
final isSaved = await cubit.addCircumference(
chestCircumference: values.chest,
waistCircumference: values.waist,
hipCircumference: values.hip,
);
if (!isSaved) {
showErrorToast(s.insights_save_error);
}
},
);
}
}
}
class _Toggle extends StatelessWidget {
const _Toggle({required this.isExpanded, required this.onTap});
final bool isExpanded;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return InkWell(
key: const Key('insights_toggle'),
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: InsightsColors.accentBackground,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.show_chart_rounded,
size: 20,
color: InsightsColors.blue,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
s.insights_title,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: InsightsColors.title,
),
),
const SizedBox(height: 2),
Text(
key: const Key('insights_subtitle'),
isExpanded
? s.insights_subtitle_open
: s.insights_subtitle_closed,
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: InsightsColors.subtitle,
),
),
],
),
),
AnimatedRotation(
turns: isExpanded ? 0 : 0.5,
duration: const Duration(milliseconds: 200),
child: const Icon(
Icons.keyboard_arrow_up_rounded,
size: 26,
color: InsightsColors.blue,
),
),
],
),
),
);
}
}
class _Body extends StatelessWidget {
const _Body({required this.state});
final InsightsStateLoaded state;
@override
Widget build(BuildContext context) {
final cubit = context.read<InsightsCubit>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InsightsTabs(
panels: cubit.panels,
selected: state.panel,
onSelected: cubit.setPanel,
),
const SizedBox(height: 12),
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: state.isRefreshing ? 0.5 : 1,
child: _PanelContent(state: state, canAdd: cubit.canAdd),
),
],
);
}
}
class _PanelContent extends StatelessWidget {
const _PanelContent({required this.state, required this.canAdd});
final InsightsStateLoaded state;
final bool canAdd;
@override
Widget build(BuildContext context) {
final cubit = context.read<InsightsCubit>();
final s = S.of(context);
final payload = state.payload;
final Widget content;
switch (state.panel) {
case InsightsPanel.weight:
content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InsightsHead(
headline: payload.weight.headline,
unit: payload.weight.unit,
deltaText: payload.weight.deltaText,
isDeltaHighlighted: payload.weight.delta != null,
),
const SizedBox(height: 12),
WeightChart(
model: payload.weight,
emptyText: canAdd
? s.insights_add_weight_empty
: s.insights_not_enough,
),
],
);
case InsightsPanel.bmi:
content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (payload.bmi.values.isNotEmpty) ...[
InsightsHead(
headline: payload.bmi.headline,
unit: s.insights_unit_bmi,
deltaText: payload.bmi.deltaText,
isDeltaHighlighted: payload.bmi.delta != null,
),
const SizedBox(height: 12),
],
BmiChart(model: payload.bmi),
],
);
case InsightsPanel.measures:
final visibility = state.seriesVisibility.measure;
content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InsightsHead(
headline: payload.measure.headline,
unit: s.insights_unit_cm_waist,
deltaText: payload.measure.deltaText,
isDeltaHighlighted: payload.measure.waist.length > 1,
),
const SizedBox(height: 12),
SeriesChips(
chips: [
SeriesChipData(
label: s.insights_series_waist,
color: InsightsColors.waist,
isVisible: visibility[0],
),
SeriesChipData(
label: s.insights_series_chest,
color: InsightsColors.chest,
isVisible: visibility[1],
),
SeriesChipData(
label: s.insights_series_hips,
color: InsightsColors.hips,
isVisible: visibility[2],
),
],
onToggle: (index) =>
cubit.toggleSeries(InsightsPanel.measures, index),
),
const SizedBox(height: 12),
MeasureChart(
model: payload.measure,
visibility: visibility,
emptyText: canAdd
? s.insights_add_measures_empty
: s.insights_not_enough,
),
],
);
case InsightsPanel.daily:
return _DailyPanel(state: state);
case InsightsPanel.intra:
return _IntraPanel(state: state);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PeriodPills<BodyPeriod>(
periods: BodyPeriod.values,
selected: state.bodyPeriod,
labelOf: (period) => period.pillLabel(s),
keyOf: (period) => period.apiValue,
onSelected: cubit.setBodyPeriod,
),
const SizedBox(height: 16),
content,
],
);
}
}
/// «КБЖУ по дням». Период пока фиксирован: перезапрос появится вместе с
/// эндпоинтом `GET /api/profile/insights` (итерация 2).
class _DailyPanel extends StatelessWidget {
const _DailyPanel({required this.state});
final InsightsStateLoaded state;
@override
Widget build(BuildContext context) {
final s = S.of(context);
final cubit = context.read<InsightsCubit>();
final daily = state.payload.daily;
final visibility = state.seriesVisibility.daily;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PeriodPills<DailyPeriod>(
periods: DailyPeriod.values,
selected: DailyPeriod.twoWeeks,
labelOf: (period) => period.pillLabel(s),
keyOf: (period) => period.apiValue,
),
const SizedBox(height: 16),
if (daily == null)
InsightsChartPlaceholder(text: s.insights_not_enough)
else ...[
InsightsHead(
headline: daily.headline,
unit: s.insights_unit_kcal_avg,
deltaText: daily.deltaText,
isDeltaHighlighted: daily.percent != null,
),
const SizedBox(height: 12),
SeriesChips(
chips: [
SeriesChipData(
label: s.insights_series_calories,
color: InsightsColors.calories,
isVisible: visibility[0],
),
SeriesChipData(
label: s.insights_series_proteins,
color: InsightsColors.proteins,
isVisible: visibility[1],
),
SeriesChipData(
label: s.insights_series_fats,
color: InsightsColors.fats,
isVisible: visibility[2],
),
SeriesChipData(
label: s.insights_series_carbs,
color: InsightsColors.carbs,
isVisible: visibility[3],
),
],
onToggle: (index) => cubit.toggleSeries(InsightsPanel.daily, index),
),
const SizedBox(height: 12),
DailyKbjuChart(model: daily, visibility: visibility),
],
],
);
}
}
/// «В течение дня». Навигация по датам неактивна до итерации 2.
class _IntraPanel extends StatelessWidget {
const _IntraPanel({required this.state});
final InsightsStateLoaded state;
@override
Widget build(BuildContext context) {
final s = S.of(context);
final cubit = context.read<InsightsCubit>();
final intra = state.payload.intra;
final visibility = state.seriesVisibility.intra;
if (intra == null) {
return InsightsChartPlaceholder(text: s.insights_not_enough);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IntraDateNav(model: intra),
const SizedBox(height: 16),
SeriesChips(
chips: [
SeriesChipData(
label: s.insights_series_proteins,
color: InsightsColors.proteins,
isVisible: visibility[0],
),
SeriesChipData(
label: s.insights_series_fats,
color: InsightsColors.fats,
isVisible: visibility[1],
),
SeriesChipData(
label: s.insights_series_carbs,
color: InsightsColors.carbs,
isVisible: visibility[2],
),
SeriesChipData(
label: s.insights_series_satiety,
color: InsightsColors.satiety,
isVisible: visibility[3],
),
SeriesChipData(
label: s.insights_series_mood,
color: InsightsColors.mood,
isVisible: visibility[4],
),
SeriesChipData(
label: s.insights_series_sleep,
color: InsightsColors.sleepLine,
isVisible: state.showSleep,
),
],
onToggle: (index) {
if (index == 5) {
cubit.toggleSleep();
} else {
cubit.toggleSeries(InsightsPanel.intra, index);
}
},
),
const SizedBox(height: 12),
IntraDayChart(
model: intra,
visibility: visibility,
showSleep: state.showSleep,
),
const SizedBox(height: 8),
IntraLegend(model: intra),
const SizedBox(height: 8),
Text(
s.insights_intra_hint,
key: const Key('insights_intra_hint'),
style: const TextStyle(fontSize: 13, color: InsightsColors.subtitle),
),
],
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/painting.dart';
/// Токены из `latest-tmpls/src/scss/pages/diary.scss` (`&__analytics*`).
abstract final class InsightsColors {
static const Color blue = Color(0xFF2F80ED);
static const Color accentBackground = Color(0xFFF6FAFF);
static const Color title = Color(0xFF323F4B);
static const Color subtitle = Color(0xFF7B8794);
static const Color border = Color(0xFFE6E6E6);
static const Color positive = Color(0xFF019A3E);
static const Color calories = Color(0xFF323F4B);
static const Color proteins = Color(0xFF2F80ED);
static const Color fats = Color(0xFFF2994A);
static const Color carbs = Color(0xFF0D6EFD);
static const Color satiety = Color(0xFF019A3E);
static const Color mood = Color(0xFFEB555C);
static const Color waist = Color(0xFF2F80ED);
static const Color chest = Color(0xFF9B51E0);
static const Color hips = Color(0xFFF2994A);
static const Color wake = Color(0xFFF2994A);
static const Color sleepLine = Color(0xFF7B8794);
static const Color sleepShade = Color(0x1A7B8794);
static const Color bmiNormalZone = Color(0x14019A3E);
static const Color bmiOverweightZone = Color(0x1AF2994A);
static const Color bmiObesityZone = Color(0x14EB555C);
static const Color cardShadow = Color(0x26000000);
static const Color fabShadow = Color(0x592F80ED);
}
/// Высота области графика.
const double insightsChartHeight = 270;

View File

@@ -0,0 +1,42 @@
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:flutter/material.dart';
/// Круглая кнопка «+» (52px) справа-снизу карточки.
class InsightsFab extends StatelessWidget {
const InsightsFab({super.key, required this.onPressed, this.tooltip});
final VoidCallback onPressed;
final String? tooltip;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip ?? '',
child: Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: InsightsColors.blue,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: InsightsColors.fabShadow,
blurRadius: 20,
offset: Offset(0, 8),
),
],
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
key: const Key('insights_fab'),
customBorder: const CircleBorder(),
onTap: onPressed,
child: const Icon(Icons.add, color: Colors.white, size: 28),
),
),
),
);
}
}

View File

@@ -0,0 +1,70 @@
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:flutter/material.dart';
/// Крупное число + единица + строка с дельтой.
class InsightsHead extends StatelessWidget {
const InsightsHead({
super.key,
required this.headline,
required this.unit,
required this.deltaText,
this.isDeltaHighlighted = true,
});
final String headline;
final String unit;
final String deltaText;
/// Зелёная дельта при наличии изменения, иначе нейтральный серый.
final bool isDeltaHighlighted;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
headline,
key: const Key('insights_headline'),
style: const TextStyle(
fontSize: 34,
fontWeight: FontWeight.w800,
color: InsightsColors.title,
height: 1.1,
),
),
if (unit.isNotEmpty) ...[
const SizedBox(width: 6),
Text(
unit,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: InsightsColors.subtitle,
),
),
],
],
),
if (deltaText.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
deltaText,
key: const Key('insights_delta'),
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: isDeltaHighlighted
? InsightsColors.positive
: InsightsColors.subtitle,
),
),
],
],
);
}
}

View File

@@ -0,0 +1,80 @@
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
extension InsightsPanelLabel on InsightsPanel {
String label(S s) {
switch (this) {
case InsightsPanel.weight:
return s.insights_tab_weight;
case InsightsPanel.bmi:
return s.insights_tab_bmi;
case InsightsPanel.measures:
return s.insights_tab_measures;
case InsightsPanel.daily:
return s.insights_tab_daily;
case InsightsPanel.intra:
return s.insights_tab_intra;
}
}
}
/// Горизонтально прокручиваемые вкладки (`l-tab`).
class InsightsTabs extends StatelessWidget {
const InsightsTabs({
super.key,
required this.panels,
required this.selected,
required this.onSelected,
});
final List<InsightsPanel> panels;
final InsightsPanel selected;
final ValueChanged<InsightsPanel> onSelected;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final panel in panels)
InkWell(
key: Key('insights_tab_${panel.name}'),
onTap: () => onSelected(panel),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: panel == selected
? InsightsColors.blue
: Colors.transparent,
width: 2,
),
),
),
child: Text(
panel.label(s),
style: TextStyle(
fontSize: 13,
fontWeight: panel == selected
? FontWeight.w700
: FontWeight.w600,
color: panel == selected
? InsightsColors.blue
: InsightsColors.subtitle,
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,130 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
/// Навигация по дням для вкладки «В течение дня»: `‹ дата ›` и выбор даты.
///
/// Колбэки `null` — навигация неактивна (данные других дней ещё не подключены).
class IntraDateNav extends StatelessWidget {
const IntraDateNav({
super.key,
required this.model,
this.onPreviousDay,
this.onNextDay,
this.onDatePicked,
});
final IntraChartModel model;
final VoidCallback? onPreviousDay;
final VoidCallback? onNextDay;
final ValueChanged<DateTime>? onDatePicked;
@override
Widget build(BuildContext context) {
final s = S.of(context);
final canGoNext = !model.isToday && onNextDay != null;
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: InsightsColors.accentBackground,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_NavButton(
key: const Key('insights_intra_prev'),
icon: Icons.chevron_left_rounded,
tooltip: s.insights_prev_day,
onPressed: onPreviousDay,
),
InkWell(
key: const Key('insights_intra_pick'),
borderRadius: BorderRadius.circular(999),
onTap: onDatePicked == null ? null : () => _pickDate(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 72),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.calendar_today_rounded,
size: 14,
color: InsightsColors.blue,
),
const SizedBox(width: 6),
Text(
model.dateLabel,
key: const Key('insights_intra_date_label'),
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: InsightsColors.title,
),
),
],
),
),
),
),
_NavButton(
key: const Key('insights_intra_next'),
icon: Icons.chevron_right_rounded,
tooltip: s.insights_next_day,
onPressed: canGoNext ? onNextDay : null,
),
],
),
);
}
Future<void> _pickDate(BuildContext context) async {
final lastDate = DateTime.tryParse(model.maxDate) ?? DateTime.now();
final current = DateTime.tryParse(model.date) ?? lastDate;
final picked = await showDatePicker(
context: context,
initialDate: current.isAfter(lastDate) ? lastDate : current,
firstDate: DateTime(2000),
lastDate: lastDate,
helpText: S.of(context).insights_pick_date,
);
if (picked != null) {
onDatePicked?.call(picked);
}
}
}
class _NavButton extends StatelessWidget {
const _NavButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return Opacity(
opacity: onPressed == null ? 0.35 : 1,
child: SizedBox(
width: 36,
height: 36,
child: IconButton(
padding: EdgeInsets.zero,
tooltip: tooltip,
onPressed: onPressed,
icon: Icon(icon, color: InsightsColors.blue),
),
),
);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
/// Легенда под графиком «В течение дня»: подъём, отход ко сну, затемнение сна.
class IntraLegend extends StatelessWidget {
const IntraLegend({super.key, required this.model});
final IntraChartModel model;
@override
Widget build(BuildContext context) {
final s = S.of(context);
final wakeText =
model.wakeText ??
(model.wakeLabel == null ? null : s.insights_wake_at(model.wakeLabel!));
final sleepText =
model.sleepText ??
(model.sleepLabel == null
? null
: s.insights_sleep_at(model.sleepLabel!));
return Wrap(
spacing: 16,
runSpacing: 6,
children: [
if (wakeText != null)
_LegendItem(
key: const Key('insights_legend_wake'),
color: InsightsColors.wake,
text: wakeText,
isDashed: true,
),
if (sleepText != null)
_LegendItem(
key: const Key('insights_legend_sleep'),
color: InsightsColors.sleepLine,
text: sleepText,
isDashed: true,
),
_LegendItem(
key: const Key('insights_legend_shade'),
color: InsightsColors.sleepShade,
text: s.insights_sleep_shade,
),
],
);
}
}
class _LegendItem extends StatelessWidget {
const _LegendItem({
super.key,
required this.color,
required this.text,
this.isDashed = false,
});
final Color color;
final String text;
final bool isDashed;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
isDashed
? Container(
width: 18,
height: 0,
decoration: BoxDecoration(
border: Border(top: BorderSide(color: color, width: 2)),
),
)
: Container(
width: 18,
height: 10,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(
fontSize: 12.5,
color: InsightsColors.subtitle,
),
),
],
);
}
}

View File

@@ -0,0 +1,115 @@
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
extension BodyPeriodLabel on BodyPeriod {
String pillLabel(S s) {
switch (this) {
case BodyPeriod.month:
return s.insights_period_1m;
case BodyPeriod.threeMonths:
return s.insights_period_3m;
case BodyPeriod.sixMonths:
return s.insights_period_6m;
case BodyPeriod.year:
return s.insights_period_1y;
case BodyPeriod.all:
return s.insights_period_all;
}
}
}
extension DailyPeriodLabel on DailyPeriod {
String pillLabel(S s) {
switch (this) {
case DailyPeriod.week:
return s.insights_period_1w;
case DailyPeriod.twoWeeks:
return s.insights_period_2w;
case DailyPeriod.month:
return s.insights_period_1m;
case DailyPeriod.threeMonths:
return s.insights_period_3m;
case DailyPeriod.all:
return s.insights_period_all;
}
}
}
/// Пилюли выбора периода (`1М 3М 6М 1Г Всё время`).
class PeriodPills<T> extends StatelessWidget {
const PeriodPills({
super.key,
required this.periods,
required this.selected,
required this.labelOf,
required this.keyOf,
this.onSelected,
});
final List<T> periods;
final T selected;
final String Function(T period) labelOf;
final String Function(T period) keyOf;
/// `null` — пилюли неинтерактивны (данные для периода ещё не подключены).
final ValueChanged<T>? onSelected;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final period in periods)
Padding(
padding: const EdgeInsets.only(right: 6),
child: _Pill(
key: Key('insights_period_${keyOf(period)}'),
label: labelOf(period),
isActive: period == selected,
onTap: onSelected == null ? null : () => onSelected!(period),
),
),
],
),
);
}
}
class _Pill extends StatelessWidget {
const _Pill({
super.key,
required this.label,
required this.isActive,
required this.onTap,
});
final String label;
final bool isActive;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
color: isActive ? InsightsColors.blue : InsightsColors.accentBackground,
borderRadius: BorderRadius.circular(999),
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 7),
child: Text(
label,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: isActive ? Colors.white : InsightsColors.subtitle,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:flutter/material.dart';
class SeriesChipData {
const SeriesChipData({
required this.label,
required this.color,
required this.isVisible,
});
final String label;
final Color color;
final bool isVisible;
}
/// Чипы-переключатели серий под графиком.
class SeriesChips extends StatelessWidget {
const SeriesChips({super.key, required this.chips, required this.onToggle});
final List<SeriesChipData> chips;
final ValueChanged<int> onToggle;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var i = 0; i < chips.length; i++)
Opacity(
opacity: chips[i].isVisible ? 1 : 0.45,
child: InkWell(
key: Key('insights_series_$i'),
borderRadius: BorderRadius.circular(999),
onTap: () => onToggle(i),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 7,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
border: Border.all(color: InsightsColors.border, width: 1.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: chips[i].color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
chips[i].label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: InsightsColors.title,
decoration: chips[i].isVisible
? TextDecoration.none
: TextDecoration.lineThrough,
),
),
],
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/insights/domain/insights_text_builder.dart';
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
/// Данные аналитики недоступны текущему пользователю (HTTP 403): консультация
/// не активна либо это чужой профиль без доступа.
class InsightsForbidden implements Exception {
const InsightsForbidden();
@override
String toString() => 'InsightsForbidden';
}
typedef InsightsTextBuilderFactory = InsightsTextBuilder Function();
/// Итерация 1: собирает [InsightsPayloadModel] из существующих эндпоинтов
/// профиля. Во второй итерации источник заменяется одним запросом
/// `GET /api/profile/insights`.
class InsightsRepository {
InsightsRepository({
required this.profileRepository,
InsightsTextBuilderFactory? textBuilderFactory,
}) : _textBuilderFactory =
textBuilderFactory ?? (() => InsightsTextBuilder(S.current));
final ProfileRepository profileRepository;
final InsightsTextBuilderFactory _textBuilderFactory;
/// Рост в сантиметрах для расчёта подсказки ИМТ; `null`, если не указан или
/// профиль не удалось загрузить (кроме 403 — он пробрасывается как [InsightsForbidden]).
Future<int?> getHeightCm({String? slug}) async {
try {
final details = slug == null
? await profileRepository.getMyProfileDetails()
: await profileRepository.getProfileDetailsBySlug(slug);
return details.height > 0 ? details.height : null;
} on DioException catch (e) {
_throwIfForbidden(e);
return null;
} catch (_) {
return null;
}
}
Future<InsightsPayloadModel> getInsights({
String? slug,
required BodyPeriod bodyPeriod,
int? heightCm,
}) async {
try {
final (weights, circumferences) = await (
profileRepository.getWeight(period: bodyPeriod.apiValue, slug: slug),
profileRepository.getCircumferences(
period: bodyPeriod.apiValue,
slug: slug,
),
).wait;
return _textBuilderFactory().build(
weights: weights,
circumferences: circumferences,
period: bodyPeriod,
heightCm: heightCm,
);
} on DioException catch (e) {
_throwIfForbidden(e);
rethrow;
}
}
Future<void> addWeight({required double weight}) {
return profileRepository.addWeight(weight: weight);
}
Future<void> addCircumference({
required int chestCircumference,
required int waistCircumference,
required int hipCircumference,
}) {
return profileRepository.addCircumference(
chestCircumference: chestCircumference,
waistCircumference: waistCircumference,
hipCircumference: hipCircumference,
);
}
void _throwIfForbidden(DioException e) {
if (e.response?.statusCode == 403) {
throw const InsightsForbidden();
}
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:developer';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -7,7 +8,11 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class MenuCubit extends Cubit<MenuState> {
final ProfileRepository profileRepository;
MenuCubit(this.profileRepository) : super(const MenuState.initial());
final SessionLifecycleService sessionLifecycleService;
MenuCubit(this.profileRepository, this.sessionLifecycleService)
: super(const MenuState.initial());
Future<void> logout() => sessionLifecycleService.logout();
Future<void> getMyProfile() async {
emit(const MenuState.loading());
@@ -23,7 +28,10 @@ class MenuCubit extends Cubit<MenuState> {
Future<String?> makePassKey() async {
try {
final passKey = await profileRepository.getPassKey(routeName: 'product_nutritional_value.index', routeParams: {});
final passKey = await profileRepository.getPassKey(
routeName: 'product_nutritional_value.index',
routeParams: {},
);
return passKey;
} catch (e, st) {
await reportFrom(e, st, this);

View File

@@ -1,22 +1,15 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart';
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/user_purchases_screen.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart';
@@ -155,6 +148,7 @@ class MenuScreen extends StatelessWidget {
Future<Null> Function() _onTapLogout(BuildContext context) {
return () async {
final menuCubit = context.read<MenuCubit>();
showDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
@@ -162,24 +156,8 @@ class MenuScreen extends StatelessWidget {
actions: [
CupertinoDialogAction(
onPressed: () async {
try {
await sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout');
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
} finally {
try {
// Needs Authorization header → must run before removing token.
await sl<FirebaseApi>().unregisterPushTokens();
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
}
await sl<VideoCallSessionCubit>().endCall();
await sl<WsManager>().clear();
await TokenStorage.removeTokenInStorage();
if (context.mounted) {
GoRouter.of(context).go('/auth-screen', extra: true);
}
}
Navigator.of(context).pop();
await menuCubit.logout();
},
isDestructiveAction: true,
child: const Text("Выйти"),

View File

@@ -16,6 +16,11 @@ class NotificationsCubit extends Cubit<NotificationsState> {
StreamSubscription? _newNotificationsSubscription;
NotificationsCubit({required this.wsManager, required this.notificationsRepository}) : super(const NotificationsState.empty());
void reset() {
_notifications.clear();
if (!isClosed) emit(const NotificationsState.empty());
}
Future<void> connectToWebSocket() async {
try {
await _newNotificationsSubscription?.cancel();

View File

@@ -14,6 +14,7 @@ import 'package:flutter/material.dart';
Future<void> showConsultationPaymentFlow(
BuildContext context, {
required int planId,
double? amount,
Future<bool?> Function(BuildContext)? showGate,
Future<PromoCodeResult?> Function(
BuildContext, {
@@ -21,7 +22,7 @@ Future<void> showConsultationPaymentFlow(
Future<bool> Function()? onRemove,
PromoCodeModel? initialPromo,
})? showForm,
Future<void> Function(BuildContext, {required int planId})? showSbp,
Future<void> Function(BuildContext, {required int planId, double? amount})? showSbp,
Future<PromoValidateResponse> Function({
required int planId,
required String promoCode,
@@ -65,7 +66,7 @@ Future<void> showConsultationPaymentFlow(
}
if (!context.mounted) return;
await sbp(context, planId: planId);
await sbp(context, planId: planId, amount: amount);
return;
}
@@ -92,7 +93,7 @@ Future<void> showConsultationPaymentFlow(
}
if (!context.mounted) return;
await sbp(context, planId: planId);
await sbp(context, planId: planId, amount: amount);
}
Future<bool?> _showGate(BuildContext context) {
@@ -113,8 +114,8 @@ Future<PromoCodeResult?> _showForm(
);
}
Future<void> _showSbp(BuildContext context, {required int planId}) {
return showSbpBanksBottomSheet(context, planId: planId);
Future<void> _showSbp(BuildContext context, {required int planId, double? amount}) {
return showSbpBanksBottomSheet(context, planId: planId, amount: amount);
}
Future<PromoValidateResponse> _defaultValidatePromo({

View File

@@ -8,18 +8,23 @@ import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart';
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_qr_bottom_sheet.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:sbp/models/c2bmembers_model.dart';
/// Показывает bottom sheet выбора банка СБП для оплаты консультации.
Future<void> showSbpBanksBottomSheet(BuildContext context, {required int planId}) {
Future<void> showSbpBanksBottomSheet(
BuildContext context, {
required int planId,
double? amount,
}) {
final height = MediaQuery.sizeOf(context).height * 0.55;
return showBottomSheetWithBlur(
context: context,
height: height,
child: SbpBanksBottomSheet(planId: planId),
child: SbpBanksBottomSheet(planId: planId, amount: amount),
);
}
@@ -28,9 +33,13 @@ Future<void> showSbpBanksBottomSheet(BuildContext context, {required int planId}
/// При открытии вызывает [ConsultationPaymentCubit.startPayment] с [planId]
/// и показывает [Loader], пока загружаются checkout_url и список банков.
class SbpBanksBottomSheet extends StatefulWidget {
const SbpBanksBottomSheet({super.key, required this.planId, this.cubit});
const SbpBanksBottomSheet({super.key, required this.planId, this.amount, this.cubit});
final int planId;
/// Сумма к оплате в рублях — передаётся в QR-шит для отображения.
final double? amount;
final ConsultationPaymentCubit? cubit;
@override
@@ -77,6 +86,32 @@ class _SbpBanksBottomSheetState extends State<SbpBanksBottomSheet> {
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
// QR-кнопка доступна сразу после получения checkout_url,
// не дожидаясь списка банков: на устройствах без банковских
// приложений (iOS в РФ) это единственный путь оплаты.
BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
buildWhen: (previous, current) =>
previous.checkoutUrl != current.checkoutUrl,
builder: (context, state) {
final checkoutUrl = state.checkoutUrl;
if (checkoutUrl == null || checkoutUrl.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 16),
child: CustomOutlinedButton(
text: s.pay_by_qr_code,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: _onShowQrTap,
),
);
},
),
const SizedBox(height: 16),
Expanded(
child: BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
@@ -122,6 +157,23 @@ class _SbpBanksBottomSheetState extends State<SbpBanksBottomSheet> {
);
}
/// Открывает QR-шит поверх шита банков; при подтверждённой оплате
/// закрывает и родительский шит.
Future<void> _onShowQrTap() async {
final paid = await showSbpQrBottomSheet(
context,
planId: widget.planId,
cubit: _cubit,
amount: widget.amount,
);
if (paid == true && mounted) {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
}
}
}
Future<void> _onBankTap(C2bmemberModel bank) async {
final opened = await _cubit.openBank(bank: bank);
if (opened && mounted) {

View File

@@ -0,0 +1,316 @@
import 'dart:async';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
/// Тестовый симулятор оплаты СБП (только для стендов разработки/stage).
const String _testPayUrlBase = 'https://testpay.alfabank.ru/sbp-page?qrcId=';
const String _nspkUrlPrefix = 'https://qr.nspk.ru/';
const Duration _pollInterval = Duration(seconds: 3);
const Duration _autoCloseDelay = Duration(seconds: 2);
/// Показывает bottom sheet с QR-кодом СБП для оплаты консультации
/// с другого устройства.
///
/// Возвращает `true`, если оплата подтверждена polling-ом статуса плана.
Future<bool?> showSbpQrBottomSheet(
BuildContext context, {
required int planId,
required ConsultationPaymentCubit cubit,
double? amount,
}) {
return showBottomSheetWithBlur<bool>(
context: context,
height: null,
child: SbpQrBottomSheet(
planId: planId,
cubit: cubit,
amount: amount,
),
);
}
/// QR-код оплаты СБП: рендерит [ConsultationPaymentState.checkoutUrl]
/// (payload вида `https://qr.nspk.ru/{qrcId}`), ожидает оплату через
/// polling статуса chosen_plan и показывает успех.
class SbpQrBottomSheet extends StatefulWidget {
const SbpQrBottomSheet({
super.key,
required this.planId,
required this.cubit,
this.amount,
this.chatRepository,
this.pollInterval = _pollInterval,
});
final int planId;
final ConsultationPaymentCubit cubit;
/// Сумма к оплате в рублях (показывается над QR, если передана).
final double? amount;
/// Переопределяется в тестах; по умолчанию берётся из DI.
final ChatRepository? chatRepository;
final Duration pollInterval;
@override
State<SbpQrBottomSheet> createState() => _SbpQrBottomSheetState();
}
class _SbpQrBottomSheetState extends State<SbpQrBottomSheet> {
late final ChatRepository _chatRepository;
late final Timer _pollTimer;
bool _paid = false;
@override
void initState() {
super.initState();
_chatRepository = widget.chatRepository ?? sl<ChatRepository>();
_pollTimer = Timer.periodic(widget.pollInterval, (_) => _pollStatus());
_pollStatus();
}
@override
void dispose() {
_pollTimer.cancel();
super.dispose();
}
Future<void> _pollStatus() async {
if (_paid || !mounted) return;
try {
final plan = await _chatRepository.getChosenPlan(id: widget.planId);
if (plan.chosenPlanState.isStateAfterPaidState()) {
_onPaid();
}
} catch (_) {
// Сетевые сбои polling-а игнорируем — следующий тик повторит проверку.
}
}
void _onPaid() {
if (_paid || !mounted) return;
setState(() => _paid = true);
Future.delayed(_autoCloseDelay, () {
if (!mounted) return;
Navigator.of(context).pop(true);
});
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
return BlocProvider<ConsultationPaymentCubit>.value(
value: widget.cubit,
child: BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
builder: (context, state) {
final checkoutUrl = state.checkoutUrl;
if (checkoutUrl == null || checkoutUrl.isEmpty) {
return const SizedBox.shrink();
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(
_paid ? s.sbp_qr_paid_title : s.sbp_qr_title,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (_paid)
_PaidContent(description: s.sbp_qr_paid_description)
else ...[
if (widget.amount != null) ...[
Text(
s.sbp_qr_amount_label,
style: TextStyles.textMMedium.copyWith(color: appTheme.gray600),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
'${widget.amount!.toStringAsFixed(0)} ₽',
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
],
_QrCodeCard(checkoutUrl: checkoutUrl),
const SizedBox(height: 16),
_HowToPay(
step1: s.sbp_qr_step1,
step2: s.sbp_qr_step2,
step3: s.sbp_qr_step3,
),
const SizedBox(height: 16),
_TestPayButton(checkoutUrl: checkoutUrl),
const SizedBox(height: 16),
Text(
s.sbp_qr_waiting_payment,
style: TextStyles.textMMedium.copyWith(color: appTheme.gray600),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
],
),
);
},
),
);
}
}
class _QrCodeCard extends StatelessWidget {
const _QrCodeCard({required this.checkoutUrl});
final String checkoutUrl;
@override
Widget build(BuildContext context) {
return Center(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: appTheme.blueGray300),
),
child: QrImageView(
key: const ValueKey('sbp_qr_code'),
data: checkoutUrl,
size: 200,
backgroundColor: Colors.white,
),
),
);
}
}
class _HowToPay extends StatelessWidget {
const _HowToPay({required this.step1, required this.step2, required this.step3});
final String step1;
final String step2;
final String step3;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: appTheme.blueA400.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(step1, style: TextStyles.textSMedium.copyWith(color: appTheme.black)),
const SizedBox(height: 4),
Text(step2, style: TextStyles.textSMedium.copyWith(color: appTheme.black)),
const SizedBox(height: 4),
Text(step3, style: TextStyles.textSMedium.copyWith(color: appTheme.black)),
],
),
);
}
}
/// Кнопка открытия тестового симулятора оплаты Альфа-Банка.
///
/// Видна только когда QR выдан непродовым бэкендом: тестовый qrcId от
/// продового в приложении не отличить (формат одинаковый), различает их
/// именно то, какой бэкенд выдал checkout_url — на стендах разработки/stage
/// Альфа подключена в sandbox и выдаёт QR тестового терминала. Поэтому
/// критерий — текущий base URL API не продуктовый (см. [AppConfig.isProdApi]).
class _TestPayButton extends StatelessWidget {
const _TestPayButton({required this.checkoutUrl});
final String checkoutUrl;
bool get _visible {
if (sl<AppConfig>().isProdApi) return false;
return checkoutUrl.startsWith(_nspkUrlPrefix);
}
@override
Widget build(BuildContext context) {
if (!_visible) return const SizedBox.shrink();
final s = S.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
s.sbp_qr_test_hint,
style: TextStyles.textXSSemibold.copyWith(color: appTheme.gray600),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => _openTestPayment(),
child: Text(s.sbp_qr_open_test_payment),
),
],
),
);
}
Future<void> _openTestPayment() async {
final qrcId = checkoutUrl.replaceFirst(_nspkUrlPrefix, '');
final uri = Uri.parse('$_testPayUrlBase$qrcId');
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
// Не смогли открыть браузер — пользователь оплатит по QR.
}
}
}
class _PaidContent extends StatelessWidget {
const _PaidContent({required this.description});
final String description;
@override
Widget build(BuildContext context) {
return Column(
children: [
const SizedBox(height: 8),
Container(
width: 64,
height: 64,
decoration: const BoxDecoration(color: Color(0xFFE8F5E9), shape: BoxShape.circle),
child: const Icon(Icons.check_rounded, color: Color(0xFF4CAF50), size: 40),
),
const SizedBox(height: 12),
Text(
description,
style: TextStyles.textMMedium.copyWith(color: appTheme.gray600),
textAlign: TextAlign.center,
),
],
);
}
}

View File

@@ -9,9 +9,9 @@ abstract class CircumferenceModel with _$CircumferenceModel {
const factory CircumferenceModel({
@JsonKey(name: "id") required int id,
@JsonKey(name: "user_id") required int userId,
@JsonKey(name: "chest_circumference") required int chestCircumference,
@JsonKey(name: "waist_circumference") required int waistCircumference,
@JsonKey(name: "hip_circumference") required int hipCircumference,
@JsonKey(name: "chest_circumference") required double chestCircumference,
@JsonKey(name: "waist_circumference") required double waistCircumference,
@JsonKey(name: "hip_circumference") required double hipCircumference,
@JsonKey(name: "created_at") required DateTime createdAt,
@JsonKey(name: "updated_at") required DateTime updatedAt,
}) = _CircumferenceModel;

View File

@@ -9,10 +9,10 @@ abstract class WeightModel with _$WeightModel {
const factory WeightModel({
@JsonKey(name: "id") required int id,
@JsonKey(name: "user_id") required int userId,
@JsonKey(name: "weight") required int weight,
@JsonKey(name: "weight") required double weight,
@JsonKey(name: "created_at") required DateTime createdAt,
@JsonKey(name: "updated_at") required DateTime updatedAt,
@JsonKey(name: "bmi") required double bmi,
@JsonKey(name: "bmi") double? bmi,
}) = _WeightModel;
factory WeightModel.fromJson(Map<String, dynamic> json) => _$WeightModelFromJson(json);

View File

@@ -17,11 +17,14 @@ class ProfileCubit extends Cubit<ProfileState> {
final ProfileRepository profileRepository;
final ChatRepository chatRepository;
final ConsultantRepository consultantRepository;
String _currentPeriod = '1';
/// Bottom-nav / own profile instance, used to refresh posts after create/update/delete.
static ProfileCubit? myProfileCubit;
static void clearMyProfileReference() {
myProfileCubit = null;
}
ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading());
Future<void> getProfileBySlug(String slug) async {
@@ -51,10 +54,9 @@ class ProfileCubit extends Cubit<ProfileState> {
}
}
Future<void> getMyProfile({String period = '1'}) async {
Future<void> getMyProfile() async {
myProfileCubit = this;
emit(const ProfileState.loading());
_currentPeriod = period;
try {
final profile = await profileRepository.getMyProfile();
List<ConsultantCertPhotoModel> certPhotos = await consultantRepository.getConsultantCertPhotos();
@@ -65,8 +67,6 @@ class ProfileCubit extends Cubit<ProfileState> {
isMyProfile: true,
profileDetails: await profileRepository.getMyProfileDetails(),
blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: profile.slug),
weightPoints: await profileRepository.getWeight(period: period),
circumferencePoints: await profileRepository.getCircumferences(period: period),
consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(profile.slug) : null,
certPhotos: certPhotos,
),
@@ -106,8 +106,6 @@ class ProfileCubit extends Cubit<ProfileState> {
profile: (state as ProfileStateLoaded).profile,
profileDetails: (state as ProfileStateLoaded).profileDetails,
blogPosts: feedBlogPostList,
weightPoints: (state as ProfileStateLoaded).weightPoints,
circumferencePoints: (state as ProfileStateLoaded).circumferencePoints,
consultantInfo: (state as ProfileStateLoaded).consultantInfo,
isMyProfile: (state as ProfileStateLoaded).isMyProfile,
certPhotos: (state as ProfileStateLoaded).certPhotos,
@@ -122,7 +120,6 @@ class ProfileCubit extends Cubit<ProfileState> {
waistCircumference: waistCircumference,
hipCircumference: hipCircumference,
);
await getMyProfile(period: _currentPeriod);
} catch (e, st) {
await reportFrom(e, st, this);
emit(ProfileState.error(errorMessage: e.toString()));
@@ -132,7 +129,6 @@ class ProfileCubit extends Cubit<ProfileState> {
Future<void> addWeight({required double weight}) async {
try {
await profileRepository.addWeight(weight: weight);
await getMyProfile(period: _currentPeriod);
} catch (e, st) {
await reportFrom(e, st, this);
emit(ProfileState.error(errorMessage: e.toString()));
@@ -141,13 +137,13 @@ class ProfileCubit extends Cubit<ProfileState> {
Future<String?> getChatRoomIdBySlug({required String slug}) async {
try {
final chatIdOrError = await chatRepository.getChatByUserSlug(slug);
final userProfile = await profileRepository.getProfileBySlug(slug);
if (chatIdOrError == '404') {
final chatId = await chatRepository.getChatByUserSlug(slug);
if (chatId == null || chatId.isEmpty) {
final userProfile = await profileRepository.getProfileBySlug(slug);
AppRouter.navigatorKey.currentContext?.pushNamed('emptyChatScreen', extra: userProfile);
} else {
AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatIdOrError});
return chatIdOrError;
AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatId});
return chatId;
}
} catch (e, st) {
await reportFrom(e, st, this);

View File

@@ -1,9 +1,7 @@
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/profile/models/weight_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'profile_state.freezed.dart';
@@ -20,8 +18,6 @@ abstract class ProfileState with _$ProfileState {
required List<FeedBlogPostModel> blogPosts,
required bool isMyProfile,
required List<ConsultantCertPhotoModel> certPhotos,
List<WeightModel>? weightPoints,
List<CircumferenceModel>? circumferencePoints,
ConsultationRequestModel? consultantInfo,
}) = ProfileStateLoaded;

View File

@@ -18,21 +18,17 @@ import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/prof
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/plan_widget.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
import 'package:eatvio_app/features/profile/models/weight_model.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_state.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/expandable_page_view.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/generated/l10n.dart';
@@ -60,21 +56,8 @@ class ProfileScreen extends StatefulWidget {
class _ProfileScreenState extends State<ProfileScreen> {
late final ProfileCubit profileCubit = sl<ProfileCubit>();
final PlansCubit plansCubit = sl<PlansCubit>();
final List<String> chipsLabelList = ['Вес', 'Замеры', 'ИМТ'];
final List<String> chipsIntervalLabelList = [
'1М',
'3М',
'6М',
'1Г',
'ВСЁ ВРЕМЯ',
];
final List<String> chipsIntervalList = ['1', '3', '6', '12', 'all'];
final TextEditingController controller = TextEditingController();
List<WeightModel> weightPoints = [];
List<CircumferenceModel> circumferencePoints = [];
int _selectedChipIndex = 0;
int _selectedIntervalChipIndex = 0;
late bool canSub;
bool isFirstFetch = true;
int _currentBullet = 0;
@@ -96,10 +79,6 @@ class _ProfileScreenState extends State<ProfileScreen> {
bloc: profileCubit,
builder: (BuildContext context, ProfileState state) {
if (state is ProfileStateLoaded) {
if (state.circumferencePoints != null) {
circumferencePoints = state.circumferencePoints!;
weightPoints = state.weightPoints!;
}
if (isFirstFetch) {
canSub = !state.profile.isEntitySubscribedByMe;
if (state.profile.roles.contains('consultant') && !hidePlans) {
@@ -442,10 +421,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
child: _badges(state),
),
if (state.profile.roles.contains('consultant')) _socialLinks(state),
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _charts(),
),
if (state.isMyProfile)
const Padding(
padding: EdgeInsets.only(bottom: 16),
child: InsightsCard(canAdd: true),
),
if (state.profile.roles.contains('consultant')) _education(state),
if (state.profile.roles.contains('consultant'))
_diplomasAndCertificates(state),
@@ -1722,114 +1702,4 @@ class _ProfileScreenState extends State<ProfileScreen> {
],
);
}
Material _charts() {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
child: Row(
children: [
_choiceChipWidget(0),
const SizedBox(width: 8),
_choiceChipWidget(1),
const SizedBox(width: 8),
_choiceChipWidget(2),
],
),
),
Builder(
builder: (context) {
switch (_selectedChipIndex) {
case 0:
return WeightChartWidget(
weightPoints: weightPoints,
profileCubit: profileCubit,
);
case 1:
return CircumferenceChartWidget(
circumferencePoints: circumferencePoints,
profileCubit: profileCubit,
);
case 2:
return ImtChartWidget(weightPoints: weightPoints);
default:
return const SizedBox.shrink();
}
},
),
const SizedBox(height: 8),
Row(
children: [
const SizedBox(width: 16),
_choiceIntervalChipWidget(0),
const SizedBox(width: 8),
_choiceIntervalChipWidget(1),
const SizedBox(width: 8),
_choiceIntervalChipWidget(2),
const SizedBox(width: 8),
_choiceIntervalChipWidget(3),
const SizedBox(width: 8),
_choiceIntervalChipWidget(4),
],
),
const SizedBox(height: 8),
],
),
);
}
ChoiceChip _choiceIntervalChipWidget(int index) {
return ChoiceChip(
selectedColor: Colors.grey.withValues(alpha: 0.1),
pressElevation: 0,
showCheckmark: false,
side: BorderSide.none,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
label: Text(chipsIntervalLabelList[index]),
labelStyle: TextStyle(
color: appTheme.gray600,
fontWeight: FontWeight.w500,
fontSize: 12,
),
selected: _selectedIntervalChipIndex == index,
onSelected: (bool selected) {
profileCubit.getMyProfile(period: chipsIntervalList[index]);
setState(() {
_selectedIntervalChipIndex = index;
});
},
);
}
ChoiceChip _choiceChipWidget(int index) {
return ChoiceChip(
selectedColor: appTheme.blueA400,
pressElevation: 0,
showCheckmark: false,
side: BorderSide.none,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
label: Text(chipsLabelList[index]),
labelStyle: TextStyle(
color: index == _selectedChipIndex ? Colors.white : appTheme.gray600,
fontWeight: FontWeight.w500,
fontSize: 12,
),
selected: _selectedChipIndex == index,
backgroundColor: appTheme.gray100,
onSelected: (bool selected) {
setState(() {
_selectedChipIndex = index;
});
},
);
}
}

View File

@@ -1,389 +0,0 @@
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
class CircumferenceChartWidget extends StatefulWidget {
final List<CircumferenceModel> circumferencePoints;
final ProfileCubit? profileCubit;
const CircumferenceChartWidget({
super.key,
required this.circumferencePoints,
this.profileCubit,
});
@override
State<CircumferenceChartWidget> createState() => _CircumferenceChartWidgetState();
}
class _CircumferenceChartWidgetState extends State<CircumferenceChartWidget> {
late FixedExtentScrollController chestController;
late FixedExtentScrollController waistController;
late FixedExtentScrollController hipController;
late int chest;
late int waist;
late int hip;
@override
Widget build(BuildContext context) {
return Column(
children: [
if (widget.circumferencePoints.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Text(
'${widget.circumferencePoints.last.chestCircumference}',
style: const TextStyle(
color: Colors.black,
fontSize: 40,
fontWeight: FontWeight.w500,
),
),
if (widget.circumferencePoints.length == 1)
Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
width: 8,
height: 8,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.black12,
),
),
if (widget.circumferencePoints.length > 1)
widget.circumferencePoints.last.chestCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].chestCircumference
? const Icon(
Icons.arrow_drop_up,
size: 40,
color: Colors.grey,
)
: const Icon(
Icons.arrow_drop_down,
size: 40,
color: Colors.grey,
),
Text(
'${widget.circumferencePoints.last.waistCircumference}',
style: const TextStyle(
color: Colors.black,
fontSize: 40,
fontWeight: FontWeight.w500,
),
),
if (widget.circumferencePoints.length == 1)
Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
width: 8,
height: 8,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.black12,
),
),
if (widget.circumferencePoints.length > 1)
widget.circumferencePoints.last.waistCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].waistCircumference
? const Icon(
Icons.arrow_drop_up,
size: 40,
color: Colors.grey,
)
: const Icon(
Icons.arrow_drop_down,
size: 40,
color: Colors.grey,
),
Text(
'${widget.circumferencePoints.last.hipCircumference}',
style: const TextStyle(
color: Colors.black,
fontSize: 40,
fontWeight: FontWeight.w500,
),
),
if (widget.circumferencePoints.length == 1)
Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
width: 8,
height: 8,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.black12,
),
),
if (widget.circumferencePoints.length > 1)
widget.circumferencePoints.last.hipCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].hipCircumference
? const Icon(
Icons.arrow_drop_up,
size: 40,
color: Colors.grey,
)
: const Icon(
Icons.arrow_drop_down,
size: 40,
color: Colors.grey,
),
],
),
),
Stack(
alignment: Alignment.center,
children: [
SfCartesianChart(
margin: const EdgeInsets.symmetric(horizontal: 8),
enableAxisAnimation: true,
plotAreaBorderWidth: 0,
trackballBehavior: TrackballBehavior(
enable: true,
activationMode: ActivationMode.singleTap,
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
tooltipSettings: const InteractiveTooltip(),
lineType: TrackballLineType.vertical,
),
primaryXAxis: DateTimeCategoryAxis(
tickPosition: TickPosition.inside,
majorTickLines: const MajorTickLines(
size: 0,
),
edgeLabelPlacement: EdgeLabelPlacement.shift,
dateFormat: DateFormat.MMMd('ru'),
labelStyle: const TextStyle(
color: Colors.grey,
),
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
axisLine: AxisLine(
color: Colors.grey.withValues(alpha: 0.3),
width: 0.4,
),
),
primaryYAxis: const NumericAxis(
isVisible: false,
labelFormat: '{value} см',
axisLine: AxisLine(
width: 1,
),
majorTickLines: MajorTickLines(size: 0),
),
series: _getSplineSeries(),
tooltipBehavior: TooltipBehavior(enable: true),
),
if (widget.profileCubit != null)
Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton.small(
heroTag: 'addCircumference',
onPressed: () {
if (widget.circumferencePoints.isNotEmpty) {
chest = widget.circumferencePoints.last.chestCircumference;
waist = widget.circumferencePoints.last.waistCircumference;
hip = widget.circumferencePoints.last.hipCircumference;
chestController = FixedExtentScrollController(initialItem: chest - 20);
waistController = FixedExtentScrollController(initialItem: waist - 20);
hipController = FixedExtentScrollController(initialItem: hip - 20);
} else {
chestController = FixedExtentScrollController();
waistController = FixedExtentScrollController();
hipController = FixedExtentScrollController();
}
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) {
return SafeArea(
child: Column(
children: [
Expanded(
child: Row(
children: [
Expanded(
child: CupertinoPicker(
magnification: 1.22,
squeeze: 1.2,
useMagnifier: true,
scrollController: chestController,
itemExtent: 24,
onSelectedItemChanged: (int value) {
chest = value;
},
children: [
for (int i = 20; i < 200; i++) Text('$i'),
],
),
),
Expanded(
child: CupertinoPicker(
magnification: 1.22,
squeeze: 1.2,
useMagnifier: true,
scrollController: waistController,
itemExtent: 24,
onSelectedItemChanged: (int value) {
waist = value;
},
children: [
for (int i = 20; i < 200; i++) Text('$i'),
],
),
),
Expanded(
child: CupertinoPicker(
magnification: 1.22,
squeeze: 1.2,
useMagnifier: true,
scrollController: hipController,
itemExtent: 24,
onSelectedItemChanged: (int value) {
hip = value;
},
children: [
for (int i = 20; i < 200; i++) Text('$i'),
],
),
),
],
),
),
InkWell(
onTap: () {
widget.profileCubit?.addCircumference(
chestCircumference: chest + 20,
waistCircumference: waist + 20,
hipCircumference: hip + 20,
);
Navigator.of(context).pop();
},
borderRadius: BorderRadius.circular(16),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: appTheme.blueA400,
borderRadius: BorderRadius.circular(16),
),
child: const Center(
child: Text(
'Сохранить',
style: TextStyle(
color: Colors.white,
),
),
),
),
),
],
),
);
},
);
},
backgroundColor: appTheme.blueA400,
shape: const CircleBorder(),
child: const Icon(
Icons.add,
color: Colors.white,
),
),
),
if (widget.circumferencePoints.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 48),
child: Text(
'Добавьте Ваши замеры',
textAlign: TextAlign.center,
),
),
],
),
],
);
}
List<CartesianSeries<CircumferenceModel, DateTime>> _getSplineSeries() {
const Color seriesColor1 = Colors.red;
final Color seriesColor2 = appTheme.blueA400;
const Color seriesColor3 = Colors.green;
return <CartesianSeries<CircumferenceModel, DateTime>>[
SplineAreaSeries<CircumferenceModel, DateTime>(
markerSettings: const MarkerSettings(
isVisible: true,
color: seriesColor1,
height: 8,
width: 8,
),
dataLabelSettings: const DataLabelSettings(
labelAlignment: ChartDataLabelAlignment.top,
isVisible: true,
),
dataSource: widget.circumferencePoints,
borderColor: seriesColor1,
gradient: LinearGradient(
colors: [
seriesColor1.withValues(alpha: 0.14),
Colors.transparent,
],
begin: Alignment.topCenter,
end: const Alignment(0, 0.6),
),
name: 'Грудь',
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
yValueMapper: (CircumferenceModel value, int index) => value.chestCircumference,
),
SplineAreaSeries<CircumferenceModel, DateTime>(
markerSettings: MarkerSettings(
isVisible: true,
color: seriesColor2,
height: 8,
width: 8,
),
dataLabelSettings: const DataLabelSettings(
labelAlignment: ChartDataLabelAlignment.top,
isVisible: true,
),
dataSource: widget.circumferencePoints,
borderColor: seriesColor2,
gradient: LinearGradient(
colors: [
seriesColor2.withValues(alpha: 0.14),
Colors.transparent,
],
begin: Alignment.topCenter,
end: const Alignment(0, 0.6),
),
name: 'Талия',
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
yValueMapper: (CircumferenceModel value, int index) => value.waistCircumference,
),
SplineAreaSeries<CircumferenceModel, DateTime>(
markerSettings: const MarkerSettings(
isVisible: true,
color: seriesColor3,
height: 8,
width: 8,
),
dataLabelSettings: const DataLabelSettings(
labelAlignment: ChartDataLabelAlignment.top,
isVisible: true,
),
dataSource: widget.circumferencePoints,
borderColor: seriesColor3,
gradient: LinearGradient(
colors: [
seriesColor3.withValues(alpha: 0.14),
Colors.transparent,
],
begin: Alignment.topCenter,
end: const Alignment(0, 0.6),
),
name: 'Бёдра',
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
yValueMapper: (CircumferenceModel value, int index) => value.hipCircumference,
),
];
}
}

View File

@@ -1,127 +0,0 @@
import 'package:eatvio_app/features/profile/models/weight_model.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:intl/intl.dart';
class ImtChartWidget extends StatelessWidget {
final List<WeightModel> weightPoints;
const ImtChartWidget({
super.key,
required this.weightPoints,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
if (weightPoints.isNotEmpty)
Text(
weightPoints.last.bmi.toStringAsFixed(2),
style: const TextStyle(
color: Colors.black,
fontSize: 40,
fontWeight: FontWeight.w500,
),
),
if (weightPoints.length > 1)
weightPoints.last.bmi > weightPoints[weightPoints.length - 2].bmi
? const Icon(
Icons.arrow_drop_up,
size: 40,
color: Colors.grey,
)
: const Icon(
Icons.arrow_drop_down,
size: 40,
color: Colors.grey,
),
],
),
),
Stack(
alignment: Alignment.center,
children: [
SfCartesianChart(
margin: const EdgeInsets.symmetric(horizontal: 8),
enableAxisAnimation: true,
plotAreaBorderWidth: 0,
trackballBehavior: TrackballBehavior(
enable: true,
activationMode: ActivationMode.singleTap,
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
tooltipSettings: const InteractiveTooltip(),
lineType: TrackballLineType.vertical,
),
primaryXAxis: DateTimeCategoryAxis(
tickPosition: TickPosition.inside,
majorTickLines: const MajorTickLines(
size: 0,
),
edgeLabelPlacement: EdgeLabelPlacement.shift,
dateFormat: DateFormat.MMMd('ru'),
labelStyle: const TextStyle(
color: Colors.grey,
),
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
axisLine: AxisLine(
color: Colors.grey.withValues(alpha: 0.3),
width: 0.4,
),
),
primaryYAxis: const NumericAxis(
isVisible: false,
),
series: _getSplineSeries(),
tooltipBehavior: TooltipBehavior(enable: true),
),
if (weightPoints.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Text(
'Индекс Массы Тела рассчитывается при указанных весе и росте',
textAlign: TextAlign.center,
),
),
],
),
],
);
}
List<CartesianSeries<WeightModel, DateTime>> _getSplineSeries() {
return <CartesianSeries<WeightModel, DateTime>>[
SplineAreaSeries<WeightModel, DateTime>(
markerSettings: MarkerSettings(
isVisible: true,
color: appTheme.blueA400,
height: 8,
width: 8,
),
dataLabelSettings: const DataLabelSettings(
labelAlignment: ChartDataLabelAlignment.top,
isVisible: true,
),
dataSource: weightPoints,
borderColor: appTheme.blueA400,
gradient: LinearGradient(
colors: [
appTheme.blueA400.withValues(alpha: 0.14),
Colors.transparent,
],
begin: Alignment.topCenter,
end: const Alignment(0, 0.6),
),
name: 'ИМТ',
xValueMapper: (WeightModel value, int index) => value.createdAt,
yValueMapper: (WeightModel value, int index) => double.parse(value.bmi.toStringAsFixed(2)),
),
];
}
}

Some files were not shown because too many files have changed in this diff Show More