diff --git a/module/logging/overlay/src/androidMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/DraggableContainer.kt b/main-core/src/androidMain/kotlin/ru/bartwell/kick/core/presentation/overlay/DraggableContainer.kt similarity index 74% rename from module/logging/overlay/src/androidMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/DraggableContainer.kt rename to main-core/src/androidMain/kotlin/ru/bartwell/kick/core/presentation/overlay/DraggableContainer.kt index 5593225e..7a6b0c77 100644 --- a/module/logging/overlay/src/androidMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/DraggableContainer.kt +++ b/main-core/src/androidMain/kotlin/ru/bartwell/kick/core/presentation/overlay/DraggableContainer.kt @@ -1,6 +1,7 @@ -package ru.bartwell.kick.module.overlay.core.overlay +package ru.bartwell.kick.core.presentation.overlay import android.annotation.SuppressLint +import android.content.Context import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration @@ -8,11 +9,12 @@ import android.widget.FrameLayout import kotlin.math.abs @Suppress("ReturnCount") -internal class DraggableContainer( - context: android.content.Context +public class DraggableContainer public constructor( + context: Context, + private val onPositionChanged: ((Float, Float) -> Unit)? = null, ) : FrameLayout(context) { - var dragTarget: View? = null + public var dragTarget: View? = null private var downX = 0f private var downY = 0f @@ -22,9 +24,9 @@ internal class DraggableContainer( private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop @Suppress("EmptyFunctionBlock") - override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {} + public override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {} - override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + public override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { val t = dragTarget ?: return false val hit = ev.x >= t.x && ev.x <= t.x + t.width && @@ -39,8 +41,8 @@ internal class DraggableContainer( return false } MotionEvent.ACTION_MOVE -> { - val dx = abs(ev.rawX - downX) - val dy = abs(ev.rawY - downY) + val dx = kotlin.math.abs(ev.rawX - downX) + val dy = kotlin.math.abs(ev.rawY - downY) if (dx > touchSlop || dy > touchSlop) { dragging = true lastX = ev.rawX @@ -58,7 +60,7 @@ internal class DraggableContainer( } @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { + public override fun onTouchEvent(event: MotionEvent): Boolean { val t = dragTarget ?: return false if (!dragging) return false @@ -70,6 +72,7 @@ internal class DraggableContainer( lastY = event.rawY t.translationX += dx t.translationY += dy + onPositionChanged?.invoke(t.translationX, t.translationY) return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { diff --git a/main-core/src/commonMain/kotlin/ru/bartwell/kick/core/data/ModuleDescription.kt b/main-core/src/commonMain/kotlin/ru/bartwell/kick/core/data/ModuleDescription.kt index fe0801ef..5efdc30f 100644 --- a/main-core/src/commonMain/kotlin/ru/bartwell/kick/core/data/ModuleDescription.kt +++ b/main-core/src/commonMain/kotlin/ru/bartwell/kick/core/data/ModuleDescription.kt @@ -44,6 +44,10 @@ public enum class ModuleDescription( title = "Firebase Cloud Messaging", description = "Inspect FCM tokens, installation id, and delivered pushes." ), + FIREBASE_ANALYTICS( + title = "Firebase Analytics", + description = "Watch Firebase Analytics events, user ids, and properties as they happen." + ), RUNNER( title = "Runner", description = "Register debug actions and view their rendered results." diff --git a/main-core/src/iosMain/kotlin/ru/bartwell/kick/core/presentation/overlay/ButtonTarget.kt b/main-core/src/iosMain/kotlin/ru/bartwell/kick/core/presentation/overlay/ButtonTarget.kt new file mode 100644 index 00000000..7c517cb5 --- /dev/null +++ b/main-core/src/iosMain/kotlin/ru/bartwell/kick/core/presentation/overlay/ButtonTarget.kt @@ -0,0 +1,14 @@ +package ru.bartwell.kick.core.presentation.overlay + +import kotlinx.cinterop.ObjCAction +import platform.UIKit.UIButton +import platform.darwin.NSObject + +@kotlinx.cinterop.BetaInteropApi +public class ButtonTarget public constructor(private val action: () -> Unit) : NSObject() { + @Suppress("UnusedParameter") + @ObjCAction + public fun invoke(sender: UIButton?) { + action() + } +} diff --git a/module/logging/overlay/src/iosMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/PanTarget.kt b/main-core/src/iosMain/kotlin/ru/bartwell/kick/core/presentation/overlay/PanTarget.kt similarity index 78% rename from module/logging/overlay/src/iosMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/PanTarget.kt rename to main-core/src/iosMain/kotlin/ru/bartwell/kick/core/presentation/overlay/PanTarget.kt index 58d65ec9..cf643191 100644 --- a/module/logging/overlay/src/iosMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/PanTarget.kt +++ b/main-core/src/iosMain/kotlin/ru/bartwell/kick/core/presentation/overlay/PanTarget.kt @@ -1,4 +1,4 @@ -package ru.bartwell.kick.module.overlay.core.overlay +package ru.bartwell.kick.core.presentation.overlay import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ObjCAction @@ -8,11 +8,12 @@ import platform.UIKit.UIPanGestureRecognizer import platform.darwin.NSObject @OptIn(ExperimentalForeignApi::class) -internal class PanTarget( +@kotlinx.cinterop.BetaInteropApi +public class PanTarget public constructor( private val onDelta: (dx: Double, dy: Double) -> Unit ) : NSObject() { @ObjCAction - fun onPan(gr: UIPanGestureRecognizer) { + public fun onPan(gr: UIPanGestureRecognizer) { val v = gr.view ?: return val container = v.superview ?: return val t = gr.translationInView(container) diff --git a/module/logging/overlay/src/iosMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/PassThroughWindow.kt b/main-core/src/iosMain/kotlin/ru/bartwell/kick/core/presentation/overlay/PassThroughWindow.kt similarity index 59% rename from module/logging/overlay/src/iosMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/PassThroughWindow.kt rename to main-core/src/iosMain/kotlin/ru/bartwell/kick/core/presentation/overlay/PassThroughWindow.kt index 54050bb4..93ec08b9 100644 --- a/module/logging/overlay/src/iosMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/PassThroughWindow.kt +++ b/main-core/src/iosMain/kotlin/ru/bartwell/kick/core/presentation/overlay/PassThroughWindow.kt @@ -1,4 +1,4 @@ -package ru.bartwell.kick.module.overlay.core.overlay +package ru.bartwell.kick.core.presentation.overlay import kotlinx.cinterop.CValue import kotlinx.cinterop.ExperimentalForeignApi @@ -11,14 +11,14 @@ import platform.UIKit.UIWindow import platform.UIKit.UIWindowScene @OptIn(ExperimentalForeignApi::class) -internal class PassThroughWindow : UIWindow { +public class PassThroughWindow : UIWindow { - var panel: UIView? = null + public var panel: UIView? = null - constructor(frame: CValue) : super(frame) - constructor(windowScene: UIWindowScene) : super(windowScene = windowScene) + public constructor(frame: CValue) : super(frame) + public constructor(windowScene: UIWindowScene) : super(windowScene = windowScene) - override fun pointInside(point: CValue, withEvent: UIEvent?): Boolean { + public override fun pointInside(point: CValue, withEvent: UIEvent?): Boolean { val p = panel ?: return false val rectInWindow = p.convertRect(p.bounds, toView = null) return CGRectContainsPoint(rectInWindow, point) diff --git a/module/firebase/firebase-analytics-stub/build.gradle.kts b/module/firebase/firebase-analytics-stub/build.gradle.kts new file mode 100644 index 00000000..1ea7a2a9 --- /dev/null +++ b/module/firebase/firebase-analytics-stub/build.gradle.kts @@ -0,0 +1,89 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.publish.plugin) + id("publish-convention") +} + +group = "ru.bartwell.kick" +version = extra["libraryVersionName"] as String + +kotlin { + androidTarget { + compilations.all { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } + } + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "firebase-analytics-stub" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + implementation(projects.mainCore) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + implementation(libs.decompose) + implementation(libs.decompose.extensions.compose) + implementation(libs.decompose.essenty.lifecycle.coroutines) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + androidMain.dependencies { + implementation(libs.androidx.activity.compose) + } + appleMain.dependencies { + } + iosTest.dependencies { + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + implementation(libs.kotlin.test) + } + wasmJsTest.dependencies { + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + implementation(libs.kotlin.test) + } + } + + explicitApi() +} + +android { + namespace = "ru.bartwell.kick" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildFeatures { + compose = true + } +} diff --git a/module/firebase/firebase-analytics-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.android.kt b/module/firebase/firebase-analytics-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.android.kt new file mode 100644 index 00000000..bd4d64ac --- /dev/null +++ b/module/firebase/firebase-analytics-stub/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.android.kt @@ -0,0 +1,12 @@ +package ru.bartwell.kick.module.firebase.analytics + +import android.os.Bundle + +@Suppress("UnusedParameter", "EmptyFunctionBlock") +public fun FirebaseAnalyticsAccessor.logEvent(name: String, params: Bundle?) {} + +@Suppress("UnusedParameter", "EmptyFunctionBlock") +public fun FirebaseAnalyticsAccessor.setUserId(id: String?) {} + +@Suppress("UnusedParameter", "EmptyFunctionBlock") +public fun FirebaseAnalyticsAccessor.setUserProperty(name: String, value: String) {} diff --git a/module/firebase/firebase-analytics-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.kt b/module/firebase/firebase-analytics-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.kt new file mode 100644 index 00000000..762af772 --- /dev/null +++ b/module/firebase/firebase-analytics-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.kt @@ -0,0 +1,3 @@ +package ru.bartwell.kick.module.firebase.analytics + +public class FirebaseAnalyticsAccessor internal constructor() diff --git a/module/firebase/firebase-analytics-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsModule.kt b/module/firebase/firebase-analytics-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsModule.kt new file mode 100644 index 00000000..7b754b4f --- /dev/null +++ b/module/firebase/firebase-analytics-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsModule.kt @@ -0,0 +1,32 @@ +package ru.bartwell.kick.module.firebase.analytics + +import androidx.compose.runtime.Composable +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.stack.StackNavigation +import kotlinx.serialization.modules.PolymorphicModuleBuilder +import ru.bartwell.kick.core.component.Child +import ru.bartwell.kick.core.component.Config +import ru.bartwell.kick.core.component.StubConfig +import ru.bartwell.kick.core.data.Module +import ru.bartwell.kick.core.data.ModuleDescription +import ru.bartwell.kick.core.data.PlatformContext + +@Suppress("EmptyFunctionBlock", "UNUSED_PARAMETER") +public class FirebaseAnalyticsModule( + context: PlatformContext, +) : Module { + + override val description: ModuleDescription = ModuleDescription.FIREBASE_ANALYTICS + override val startConfig: Config = StubConfig(description) + + override fun getComponent( + componentContext: ComponentContext, + nav: StackNavigation, + config: Config, + ): Child<*>? = null + + @Composable + override fun Content(instance: Child<*>) {} + + override fun registerSubclasses(builder: PolymorphicModuleBuilder) {} +} diff --git a/module/firebase/firebase-analytics-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/Kick.kt b/module/firebase/firebase-analytics-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/Kick.kt new file mode 100644 index 00000000..009544a8 --- /dev/null +++ b/module/firebase/firebase-analytics-stub/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/Kick.kt @@ -0,0 +1,6 @@ +package ru.bartwell.kick.module.firebase.analytics + +import ru.bartwell.kick.Kick + +public val Kick.Companion.firebaseAnalytics: FirebaseAnalyticsAccessor + get() = FirebaseAnalyticsAccessor() diff --git a/module/firebase/firebase-analytics-stub/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.ios.kt b/module/firebase/firebase-analytics-stub/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.ios.kt new file mode 100644 index 00000000..74324faa --- /dev/null +++ b/module/firebase/firebase-analytics-stub/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.ios.kt @@ -0,0 +1,15 @@ +package ru.bartwell.kick.module.firebase.analytics + +import platform.Foundation.NSDictionary + +@Suppress("UnusedParameter", "EmptyFunctionBlock") +public fun FirebaseAnalyticsAccessor.logEvent(name: String, params: NSDictionary?) {} + +@Suppress("UnusedParameter", "EmptyFunctionBlock") +public fun FirebaseAnalyticsAccessor.logEvent(name: String, params: Map?) {} + +@Suppress("UnusedParameter", "EmptyFunctionBlock") +public fun FirebaseAnalyticsAccessor.setUserId(id: String?) {} + +@Suppress("UnusedParameter", "EmptyFunctionBlock") +public fun FirebaseAnalyticsAccessor.setUserProperty(name: String, value: String) {} diff --git a/module/firebase/firebase-analytics/build.gradle.kts b/module/firebase/firebase-analytics/build.gradle.kts new file mode 100644 index 00000000..695d8fa1 --- /dev/null +++ b/module/firebase/firebase-analytics/build.gradle.kts @@ -0,0 +1,107 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.sqldelight) + alias(libs.plugins.publish.plugin) + id("publish-convention") +} + +group = "ru.bartwell.kick" +version = extra["libraryVersionName"] as String + +kotlin { + androidTarget { + compilations.all { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } + } + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "firebase-analytics" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + implementation(projects.mainCore) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + implementation(libs.decompose) + implementation(libs.decompose.extensions.compose) + implementation(libs.decompose.essenty.lifecycle.coroutines) + implementation(libs.settings) + implementation(libs.settings.make.observable) + implementation(libs.settings.coroutines) + implementation(libs.settings.noArg) + implementation(libs.kotlinx.serialization.json) + implementation(libs.sqldelight.coroutines.extensions) + implementation(libs.sqldelight.async.extensions) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + androidMain.dependencies { + implementation(libs.androidx.activity.compose) + implementation(libs.sqldelight.android.driver) + } + appleMain.dependencies { + implementation(libs.sqldelight.native.driver) + } + iosTest.dependencies { + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + implementation(libs.kotlin.test) + } + } + + explicitApi() +} + +android { + namespace = "ru.bartwell.kick" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildFeatures { + compose = true + } +} + +dependencies { + debugImplementation(libs.androidx.compose.ui.test.manifest) +} + +sqldelight { + databases { + create("FirebaseAnalyticsDb") { + packageName.set("ru.bartwell.kick.module.firebase.analytics.db") + generateAsync.set(true) + } + } +} diff --git a/module/firebase/firebase-analytics/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.android.kt b/module/firebase/firebase-analytics/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.android.kt new file mode 100644 index 00000000..82e511de --- /dev/null +++ b/module/firebase/firebase-analytics/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.android.kt @@ -0,0 +1,23 @@ +package ru.bartwell.kick.module.firebase.analytics + +import android.os.Bundle +import ru.bartwell.kick.module.firebase.analytics.core.util.FirebaseAnalyticsLogger + +public fun FirebaseAnalyticsAccessor.logEvent(name: String, params: Bundle?) { + FirebaseAnalyticsLogger.logEvent(name = name, params = params.toParameterMap()) +} + +public fun FirebaseAnalyticsAccessor.setUserId(id: String?) { + FirebaseAnalyticsLogger.setUserId(id) +} + +public fun FirebaseAnalyticsAccessor.setUserProperty(name: String, value: String) { + FirebaseAnalyticsLogger.setUserProperty(name, value) +} + +private fun Bundle?.toParameterMap(): Map { + if (this == null) return emptyMap() + return keySet().associateWith { key -> + get(key)?.toString().orEmpty() + } +} diff --git a/module/firebase/firebase-analytics/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/overlay/FirebaseFloatingWindowHost.android.kt b/module/firebase/firebase-analytics/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/overlay/FirebaseFloatingWindowHost.android.kt new file mode 100644 index 00000000..62139b59 --- /dev/null +++ b/module/firebase/firebase-analytics/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/overlay/FirebaseFloatingWindowHost.android.kt @@ -0,0 +1,185 @@ +package ru.bartwell.kick.module.firebase.analytics.core.overlay + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.view.isVisible +import ru.bartwell.kick.Kick +import ru.bartwell.kick.core.data.ModuleDescription +import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.core.data.StartScreen +import ru.bartwell.kick.core.data.get +import ru.bartwell.kick.core.presentation.overlay.DraggableContainer +import ru.bartwell.kick.module.firebase.analytics.core.component.config.FirebaseAnalyticsConfig +import ru.bartwell.kick.module.firebase.analytics.core.persist.FirebaseFloatingWindowSettings +import java.lang.ref.WeakReference +import java.util.WeakHashMap + +private const val TAG = "FirebaseAnalyticsFloatingWindow" +private const val INITIAL_X = 48f +private const val INITIAL_Y = 144f + +internal actual object FirebaseFloatingWindowHost { + private var callbacks: FloatingWindowCallbacks? = null + private var platformContext: PlatformContext? = null + + actual fun init(context: PlatformContext) { + platformContext = context + val app = context.get().applicationContext as? Application ?: return + if (callbacks != null) return + callbacks = FloatingWindowCallbacks(app) { openAnalyticsModule() } + app.registerActivityLifecycleCallbacks(callbacks) + } + + actual fun setVisible(enabled: Boolean) { + callbacks?.setVisible(enabled) + } + + private fun openAnalyticsModule() { + platformContext?.let { context -> + Kick.launch( + context = context, + startScreen = StartScreen(FirebaseAnalyticsConfig, ModuleDescription.FIREBASE_ANALYTICS), + ) + } + } +} + +private class FloatingWindowCallbacks( + private val app: Application, + private val onClick: () -> Unit, +) : Application.ActivityLifecycleCallbacks { + + private val overlays = WeakHashMap() + private var currentActivity: WeakReference = WeakReference(null) + private var visible = false + + fun setVisible(enabled: Boolean) { + visible = enabled + overlays.values.forEach { container -> + container.isVisible = enabled + applyStoredTranslation(container.dragTarget) + } + if (enabled) { + currentActivity.get()?.let { attach(it) } + } + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + override fun onActivityStarted(activity: Activity) { + currentActivity = WeakReference(activity) + if (visible) { + attach(activity) + } + } + + override fun onActivityResumed(activity: Activity) { + currentActivity = WeakReference(activity) + overlays[activity]?.let { container -> + container.isVisible = visible + applyStoredTranslation(container.dragTarget) + } + } + + override fun onActivityPaused(activity: Activity) = Unit + + override fun onActivityStopped(activity: Activity) { + overlays[activity]?.isVisible = false + } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + + override fun onActivityDestroyed(activity: Activity) { + detach(activity) + } + + private fun attach(activity: Activity) { + if (!visible) { + overlays[activity]?.isVisible = false + return + } + + overlays[activity]?.let { container -> + container.isVisible = true + applyStoredTranslation(container.dragTarget) + } ?: run { + val root = activity.window?.decorView as? ViewGroup ?: return + val existing = root.findViewWithTag(TAG) + when (existing) { + is DraggableContainer -> { + existing.isVisible = true + overlays[activity] = existing + applyStoredTranslation(existing.dragTarget) + } + null -> { + val container = createDraggableContainer(activity) + root.addView(container) + overlays[activity] = container + } + else -> existing.isVisible = true + } + } + } + + private fun detach(activity: Activity) { + val root = activity.window?.decorView as? ViewGroup ?: return + overlays.remove(activity)?.let { view -> + runCatching { root.removeView(view) } + } + } + + private fun applyStoredTranslation(target: View?) { + val x = FirebaseFloatingWindowSettings.getPositionX() + val y = FirebaseFloatingWindowSettings.getPositionY() + target?.let { + if (x.isValid()) it.translationX = x + if (y.isValid()) it.translationY = y + } + } + + private fun createDraggableContainer(activity: Activity): DraggableContainer { + val container = DraggableContainer(activity) { x, y -> + FirebaseFloatingWindowSettings.setPosition(x, y) + }.apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + tag = TAG + isClickable = true + } + + val composeView = ComposeView(activity).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) + setContent { + MaterialTheme { + FirebaseFloatingWindowContent(onClick = onClick) + } + } + isClickable = true + translationX = FirebaseFloatingWindowSettings.getPositionX().takeIf { it.isValid() } ?: INITIAL_X + translationY = FirebaseFloatingWindowSettings.getPositionY().takeIf { it.isValid() } ?: INITIAL_Y + } + + container.dragTarget = composeView + + container.addView( + composeView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.TOP or Gravity.START, + ) + ) + return container + } +} + +private fun Float.isValid(): Boolean = !this.isNaN() && this.isFinite() diff --git a/module/firebase/firebase-analytics/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/DatabaseBuilder.kt b/module/firebase/firebase-analytics/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/DatabaseBuilder.kt new file mode 100644 index 00000000..45b2f50f --- /dev/null +++ b/module/firebase/firebase-analytics/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/DatabaseBuilder.kt @@ -0,0 +1,31 @@ +package ru.bartwell.kick.module.firebase.analytics.core.persist + +import app.cash.sqldelight.async.coroutines.synchronous +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.core.data.get +import ru.bartwell.kick.module.firebase.analytics.core.persist.adapter.stringMapAdapter +import ru.bartwell.kick.module.firebase.analytics.db.AnalyticsEvent +import ru.bartwell.kick.module.firebase.analytics.db.FirebaseAnalyticsDb + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +internal actual class DatabaseBuilder { + actual fun createDatabase(context: PlatformContext): FirebaseAnalyticsDatabase { + val appContext = context.get().applicationContext + val driver = AndroidSqliteDriver( + schema = FirebaseAnalyticsDb.Schema.synchronous(), + context = appContext, + name = "kick_firebase_analytics.db" + ) + try { + FirebaseAnalyticsDb.Schema.synchronous().create(driver) + } catch (_: RuntimeException) {} + val db = FirebaseAnalyticsDb( + driver = driver, + analyticsEventAdapter = AnalyticsEvent.Adapter( + paramsAdapter = stringMapAdapter, + ) + ) + return FirebaseAnalyticsDatabase(db) + } +} diff --git a/module/firebase/firebase-analytics/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/PlatformSettingsFactory.android.kt b/module/firebase/firebase-analytics/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/PlatformSettingsFactory.android.kt new file mode 100644 index 00000000..3fcea1ef --- /dev/null +++ b/module/firebase/firebase-analytics/src/androidMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/PlatformSettingsFactory.android.kt @@ -0,0 +1,13 @@ +package ru.bartwell.kick.module.firebase.analytics.core.persist + +import com.russhwolf.settings.Settings +import com.russhwolf.settings.SharedPreferencesSettings +import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.core.data.get + +@Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) +internal actual object PlatformSettingsFactory { + actual fun create(context: PlatformContext, name: String): Settings { + return SharedPreferencesSettings.Factory(context.get()).create(name) + } +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.kt new file mode 100644 index 00000000..762af772 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.kt @@ -0,0 +1,3 @@ +package ru.bartwell.kick.module.firebase.analytics + +public class FirebaseAnalyticsAccessor internal constructor() diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsModule.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsModule.kt new file mode 100644 index 00000000..23e3753a --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsModule.kt @@ -0,0 +1,96 @@ +package ru.bartwell.kick.module.firebase.analytics + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.pushNew +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.modules.PolymorphicModuleBuilder +import ru.bartwell.kick.core.component.Child +import ru.bartwell.kick.core.component.Config +import ru.bartwell.kick.core.data.Module +import ru.bartwell.kick.core.data.ModuleDescription +import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.module.firebase.analytics.core.component.child.FirebaseAnalyticsChild +import ru.bartwell.kick.module.firebase.analytics.core.component.child.FirebaseAnalyticsPropertiesChild +import ru.bartwell.kick.module.firebase.analytics.core.component.config.FirebaseAnalyticsConfig +import ru.bartwell.kick.module.firebase.analytics.core.component.config.FirebaseAnalyticsPropertiesConfig +import ru.bartwell.kick.module.firebase.analytics.core.overlay.FirebaseFloatingWindowHost +import ru.bartwell.kick.module.firebase.analytics.core.persist.DatabaseBuilder +import ru.bartwell.kick.module.firebase.analytics.core.persist.FirebaseAnalyticsDatabase +import ru.bartwell.kick.module.firebase.analytics.core.persist.FirebaseFloatingWindowSettings +import ru.bartwell.kick.module.firebase.analytics.core.util.DatabaseHolder +import ru.bartwell.kick.module.firebase.analytics.core.util.FirebaseFloatingWindowState +import ru.bartwell.kick.module.firebase.analytics.feature.main.presentation.DefaultFirebaseAnalyticsComponent +import ru.bartwell.kick.module.firebase.analytics.feature.main.presentation.FirebaseAnalyticsContent +import ru.bartwell.kick.module.firebase.analytics.feature.properties.presentation.DefaultFirebaseAnalyticsPropertiesComponent +import ru.bartwell.kick.module.firebase.analytics.feature.properties.presentation.FirebaseAnalyticsPropertiesContent + +public class FirebaseAnalyticsModule( + platformContext: PlatformContext, +) : Module { + + override val description: ModuleDescription = ModuleDescription.FIREBASE_ANALYTICS + override val startConfig: Config = FirebaseAnalyticsConfig + private val database: FirebaseAnalyticsDatabase = DatabaseBuilder().createDatabase(platformContext) + + init { + FirebaseFloatingWindowSettings(platformContext) + FirebaseFloatingWindowHost.init(platformContext) + FirebaseFloatingWindowState.initialize() + DatabaseHolder.database = database + CoroutineScope(Dispatchers.Default).launch { + database.getPropertyDao().deleteAll() + } + } + + override fun getComponent( + componentContext: ComponentContext, + nav: StackNavigation, + config: Config, + ): Child<*>? = when (config) { + FirebaseAnalyticsConfig -> FirebaseAnalyticsChild( + DefaultFirebaseAnalyticsComponent( + componentContext = componentContext, + database = database, + onFinished = { nav.pop() }, + onPropertiesClickCallback = { nav.pushNew(FirebaseAnalyticsPropertiesConfig) }, + ) + ) + + FirebaseAnalyticsPropertiesConfig -> FirebaseAnalyticsPropertiesChild( + DefaultFirebaseAnalyticsPropertiesComponent( + componentContext = componentContext, + database = database, + onFinished = { nav.pop() }, + ) + ) + + else -> null + } + + @Composable + override fun Content(instance: Child<*>) { + when (val child = instance) { + is FirebaseAnalyticsChild -> FirebaseAnalyticsContent( + component = child.component, + modifier = Modifier.fillMaxSize(), + ) + + is FirebaseAnalyticsPropertiesChild -> FirebaseAnalyticsPropertiesContent( + component = child.component, + modifier = Modifier.fillMaxSize(), + ) + } + } + + override fun registerSubclasses(builder: PolymorphicModuleBuilder) { + builder.subclass(FirebaseAnalyticsConfig::class, FirebaseAnalyticsConfig.serializer()) + builder.subclass(FirebaseAnalyticsPropertiesConfig::class, FirebaseAnalyticsPropertiesConfig.serializer()) + } +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/Kick.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/Kick.kt new file mode 100644 index 00000000..009544a8 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/Kick.kt @@ -0,0 +1,6 @@ +package ru.bartwell.kick.module.firebase.analytics + +import ru.bartwell.kick.Kick + +public val Kick.Companion.firebaseAnalytics: FirebaseAnalyticsAccessor + get() = FirebaseAnalyticsAccessor() diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/component/child/FirebaseAnalyticsChild.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/component/child/FirebaseAnalyticsChild.kt new file mode 100644 index 00000000..3b15493a --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/component/child/FirebaseAnalyticsChild.kt @@ -0,0 +1,8 @@ +package ru.bartwell.kick.module.firebase.analytics.core.component.child + +import ru.bartwell.kick.core.component.Child +import ru.bartwell.kick.module.firebase.analytics.feature.main.presentation.FirebaseAnalyticsComponent + +internal class FirebaseAnalyticsChild( + override val component: FirebaseAnalyticsComponent +) : Child diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/component/child/FirebaseAnalyticsPropertiesChild.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/component/child/FirebaseAnalyticsPropertiesChild.kt new file mode 100644 index 00000000..66df159a --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/component/child/FirebaseAnalyticsPropertiesChild.kt @@ -0,0 +1,8 @@ +package ru.bartwell.kick.module.firebase.analytics.core.component.child + +import ru.bartwell.kick.core.component.Child +import ru.bartwell.kick.module.firebase.analytics.feature.properties.presentation.FirebaseAnalyticsPropertiesComponent + +internal class FirebaseAnalyticsPropertiesChild( + override val component: FirebaseAnalyticsPropertiesComponent +) : Child diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/component/config/FirebaseAnalyticsConfig.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/component/config/FirebaseAnalyticsConfig.kt new file mode 100644 index 00000000..75fcec08 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/component/config/FirebaseAnalyticsConfig.kt @@ -0,0 +1,9 @@ +package ru.bartwell.kick.module.firebase.analytics.core.component.config + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.bartwell.kick.core.component.Config + +@Serializable +@SerialName("FirebaseAnalytics") +public data object FirebaseAnalyticsConfig : Config diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/component/config/FirebaseAnalyticsPropertiesConfig.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/component/config/FirebaseAnalyticsPropertiesConfig.kt new file mode 100644 index 00000000..98d3103b --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/component/config/FirebaseAnalyticsPropertiesConfig.kt @@ -0,0 +1,9 @@ +package ru.bartwell.kick.module.firebase.analytics.core.component.config + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.bartwell.kick.core.component.Config + +@Serializable +@SerialName("FirebaseAnalyticsProperties") +public data object FirebaseAnalyticsPropertiesConfig : Config diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/data/AnalyticsEvent.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/data/AnalyticsEvent.kt new file mode 100644 index 00000000..b4273686 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/data/AnalyticsEvent.kt @@ -0,0 +1,11 @@ +package ru.bartwell.kick.module.firebase.analytics.core.data + +import kotlinx.serialization.Serializable + +@Serializable +internal data class AnalyticsEvent( + val id: Long = 0, + val timestamp: Long, + val name: String, + val params: Map = emptyMap(), +) diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/data/UserProperty.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/data/UserProperty.kt new file mode 100644 index 00000000..9260c489 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/data/UserProperty.kt @@ -0,0 +1,10 @@ +package ru.bartwell.kick.module.firebase.analytics.core.data + +import kotlinx.serialization.Serializable + +@Serializable +internal data class UserProperty( + val name: String, + val value: String, + val timestamp: Long, +) diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/overlay/FirebaseFloatingWindowContent.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/overlay/FirebaseFloatingWindowContent.kt new file mode 100644 index 00000000..201d2ab1 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/overlay/FirebaseFloatingWindowContent.kt @@ -0,0 +1,60 @@ +package ru.bartwell.kick.module.firebase.analytics.core.overlay + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import ru.bartwell.kick.module.firebase.analytics.core.util.FirebaseFloatingWindowState + +@Composable +internal fun FirebaseFloatingWindowContent(onClick: () -> Unit) { + val lines by FirebaseFloatingWindowState.lines.collectAsState() + Surface( + tonalElevation = 8.dp, + shadowElevation = 12.dp, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + modifier = Modifier.clickable(onClick = onClick), + ) { + Box( + modifier = Modifier + .padding(vertical = 6.dp, horizontal = 12.dp) + .widthIn(min = 150.dp, max = 280.dp), + ) { + Column( + modifier = Modifier + .align(Alignment.CenterStart), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (lines.isEmpty()) { + Text( + text = "No events", + style = MaterialTheme.typography.bodySmall, + ) + } else { + lines.forEach { line -> + Text( + text = line, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + ) + } + } + } + } + } +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/overlay/FirebaseFloatingWindowHost.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/overlay/FirebaseFloatingWindowHost.kt new file mode 100644 index 00000000..90ab1785 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/overlay/FirebaseFloatingWindowHost.kt @@ -0,0 +1,8 @@ +package ru.bartwell.kick.module.firebase.analytics.core.overlay + +import ru.bartwell.kick.core.data.PlatformContext + +internal expect object FirebaseFloatingWindowHost { + fun init(context: PlatformContext) + fun setVisible(enabled: Boolean) +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/AnalyticsEventDao.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/AnalyticsEventDao.kt new file mode 100644 index 00000000..989e718d --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/AnalyticsEventDao.kt @@ -0,0 +1,32 @@ +package ru.bartwell.kick.module.firebase.analytics.core.persist + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import ru.bartwell.kick.module.firebase.analytics.core.data.AnalyticsEvent +import ru.bartwell.kick.module.firebase.analytics.db.FirebaseAnalyticsDb + +internal class AnalyticsEventDao(private val db: FirebaseAnalyticsDb) { + + suspend fun insert(item: AnalyticsEventEntity) = withContext(Dispatchers.Default) { + db.firebase_analyticsQueries.insertEvent( + timestamp = item.timestamp, + name = item.name, + params = item.params, + ) + } + + fun getAllAsFlow(): Flow> = + db.firebase_analyticsQueries + .selectEvents() + .asFlow() + .mapToList(Dispatchers.Default) + .map { list -> list.map { it.toEntity().toDomain() } } + + suspend fun deleteAll() = withContext(Dispatchers.Default) { + db.firebase_analyticsQueries.deleteEvents() + } +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/AnalyticsEventEntity.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/AnalyticsEventEntity.kt new file mode 100644 index 00000000..3aa7f533 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/AnalyticsEventEntity.kt @@ -0,0 +1,32 @@ +package ru.bartwell.kick.module.firebase.analytics.core.persist + +import ru.bartwell.kick.module.firebase.analytics.core.data.AnalyticsEvent +import ru.bartwell.kick.module.firebase.analytics.db.AnalyticsEvent as DbAnalyticsEvent + +internal data class AnalyticsEventEntity( + val id: Long = 0, + val timestamp: Long, + val name: String, + val params: Map = emptyMap(), +) + +internal fun AnalyticsEventEntity.toDomain(): AnalyticsEvent = AnalyticsEvent( + id = id, + timestamp = timestamp, + name = name, + params = params, +) + +internal fun AnalyticsEvent.toEntity(): AnalyticsEventEntity = AnalyticsEventEntity( + id = id, + timestamp = timestamp, + name = name, + params = params, +) + +internal fun DbAnalyticsEvent.toEntity(): AnalyticsEventEntity = AnalyticsEventEntity( + id = id, + timestamp = timestamp, + name = name, + params = params, +) diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/DatabaseBuilder.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/DatabaseBuilder.kt new file mode 100644 index 00000000..df3bf574 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/DatabaseBuilder.kt @@ -0,0 +1,8 @@ +package ru.bartwell.kick.module.firebase.analytics.core.persist + +import ru.bartwell.kick.core.data.PlatformContext + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +internal expect class DatabaseBuilder() { + fun createDatabase(context: PlatformContext): FirebaseAnalyticsDatabase +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/FirebaseAnalyticsDatabase.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/FirebaseAnalyticsDatabase.kt new file mode 100644 index 00000000..e906dada --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/FirebaseAnalyticsDatabase.kt @@ -0,0 +1,8 @@ +package ru.bartwell.kick.module.firebase.analytics.core.persist + +import ru.bartwell.kick.module.firebase.analytics.db.FirebaseAnalyticsDb + +internal class FirebaseAnalyticsDatabase(private val db: FirebaseAnalyticsDb) { + fun getEventDao(): AnalyticsEventDao = AnalyticsEventDao(db) + fun getPropertyDao(): UserPropertyDao = UserPropertyDao(db) +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/FirebaseFloatingWindowSettings.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/FirebaseFloatingWindowSettings.kt new file mode 100644 index 00000000..041aca90 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/FirebaseFloatingWindowSettings.kt @@ -0,0 +1,58 @@ +package ru.bartwell.kick.module.firebase.analytics.core.persist + +import com.russhwolf.settings.ExperimentalSettingsApi +import com.russhwolf.settings.ObservableSettings +import com.russhwolf.settings.coroutines.getBooleanFlow +import com.russhwolf.settings.observable.makeObservable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import ru.bartwell.kick.core.data.PlatformContext + +private const val KEY_ENABLED = "firebase_floating_window_enabled" +private const val KEY_POSITION_X = "firebase_floating_window_position_x" +private const val KEY_POSITION_Y = "firebase_floating_window_position_y" +private const val KEY_USER_ID = "firebase_analytics_user_id" + +@OptIn(ExperimentalSettingsApi::class) +internal object FirebaseFloatingWindowSettings { + private lateinit var settings: ObservableSettings + private val userId = MutableStateFlow(null) + + operator fun invoke(context: PlatformContext) { + settings = PlatformSettingsFactory.create( + context = context, + name = "kick_firebase_analytics_overlay", + ).makeObservable() + userId.value = settings.getStringOrNull(KEY_USER_ID) + } + + fun isEnabled(): Boolean = settings.getBoolean(KEY_ENABLED, false) + + @OptIn(ExperimentalSettingsApi::class) + fun observeEnabled(): Flow = settings.getBooleanFlow(KEY_ENABLED, false) + + fun setEnabled(value: Boolean) { + settings.putBoolean(KEY_ENABLED, value) + } + + fun setPosition(x: Float, y: Float) { + settings.putFloat(KEY_POSITION_X, x) + settings.putFloat(KEY_POSITION_Y, y) + } + + fun getPositionX(): Float = settings.getFloat(KEY_POSITION_X, Float.NaN) + + fun getPositionY(): Float = settings.getFloat(KEY_POSITION_Y, Float.NaN) + + fun observeUserId(): Flow = userId.asStateFlow() + + fun setUserId(value: String?) { + if (value == null) { + settings.remove(KEY_USER_ID) + } else { + settings.putString(KEY_USER_ID, value) + } + userId.value = value + } +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/PlatformSettingsFactory.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/PlatformSettingsFactory.kt new file mode 100644 index 00000000..045dcc69 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/PlatformSettingsFactory.kt @@ -0,0 +1,9 @@ +package ru.bartwell.kick.module.firebase.analytics.core.persist + +import com.russhwolf.settings.Settings +import ru.bartwell.kick.core.data.PlatformContext + +@Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) +internal expect object PlatformSettingsFactory { + fun create(context: PlatformContext, name: String): Settings +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/UserPropertyDao.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/UserPropertyDao.kt new file mode 100644 index 00000000..00da43ab --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/UserPropertyDao.kt @@ -0,0 +1,32 @@ +package ru.bartwell.kick.module.firebase.analytics.core.persist + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import ru.bartwell.kick.module.firebase.analytics.core.data.UserProperty +import ru.bartwell.kick.module.firebase.analytics.db.FirebaseAnalyticsDb + +internal class UserPropertyDao(private val db: FirebaseAnalyticsDb) { + + suspend fun upsert(item: UserPropertyEntity) = withContext(Dispatchers.Default) { + db.firebase_analyticsQueries.insertProperty( + name = item.name, + value_ = item.value, + timestamp = item.timestamp, + ) + } + + fun getAllAsFlow(): Flow> = + db.firebase_analyticsQueries + .selectProperties() + .asFlow() + .mapToList(Dispatchers.Default) + .map { list -> list.map { it.toEntity().toDomain() } } + + suspend fun deleteAll() = withContext(Dispatchers.Default) { + db.firebase_analyticsQueries.deleteProperties() + } +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/UserPropertyEntity.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/UserPropertyEntity.kt new file mode 100644 index 00000000..c977ffa1 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/UserPropertyEntity.kt @@ -0,0 +1,28 @@ +package ru.bartwell.kick.module.firebase.analytics.core.persist + +import ru.bartwell.kick.module.firebase.analytics.core.data.UserProperty +import ru.bartwell.kick.module.firebase.analytics.db.UserProperty as DbUserProperty + +internal data class UserPropertyEntity( + val name: String, + val value: String, + val timestamp: Long, +) + +internal fun UserPropertyEntity.toDomain(): UserProperty = UserProperty( + name = name, + value = value, + timestamp = timestamp, +) + +internal fun UserProperty.toEntity(): UserPropertyEntity = UserPropertyEntity( + name = name, + value = value, + timestamp = timestamp, +) + +internal fun DbUserProperty.toEntity(): UserPropertyEntity = UserPropertyEntity( + name = name, + value = value_, + timestamp = timestamp, +) diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/adapter/FirebaseAnalyticsAdapters.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/adapter/FirebaseAnalyticsAdapters.kt new file mode 100644 index 00000000..915984d0 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/adapter/FirebaseAnalyticsAdapters.kt @@ -0,0 +1,19 @@ +package ru.bartwell.kick.module.firebase.analytics.core.persist.adapter + +import app.cash.sqldelight.ColumnAdapter +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json + +private val json = Json { ignoreUnknownKeys = true } +private val mapSerializer = MapSerializer(String.serializer(), String.serializer()) + +internal val stringMapAdapter: ColumnAdapter, String> = + object : ColumnAdapter, String> { + override fun decode(databaseValue: String): Map = + runCatching { json.decodeFromString(mapSerializer, databaseValue) } + .getOrDefault(emptyMap()) + + override fun encode(value: Map): String = + json.encodeToString(mapSerializer, value) + } diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/util/DatabaseHolder.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/util/DatabaseHolder.kt new file mode 100644 index 00000000..dd73cd8a --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/util/DatabaseHolder.kt @@ -0,0 +1,7 @@ +package ru.bartwell.kick.module.firebase.analytics.core.util + +import ru.bartwell.kick.module.firebase.analytics.core.persist.FirebaseAnalyticsDatabase + +internal object DatabaseHolder { + var database: FirebaseAnalyticsDatabase? = null +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/util/FirebaseAnalyticsLogger.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/util/FirebaseAnalyticsLogger.kt new file mode 100644 index 00000000..0fd2e088 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/util/FirebaseAnalyticsLogger.kt @@ -0,0 +1,98 @@ +package ru.bartwell.kick.module.firebase.analytics.core.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import ru.bartwell.kick.core.util.DateUtils +import ru.bartwell.kick.module.firebase.analytics.core.data.AnalyticsEvent +import ru.bartwell.kick.module.firebase.analytics.core.data.UserProperty +import ru.bartwell.kick.module.firebase.analytics.core.persist.FirebaseFloatingWindowSettings +import ru.bartwell.kick.module.firebase.analytics.core.persist.toEntity + +private const val MAX_BUFFER = 1_000 + +internal object FirebaseAnalyticsLogger { + + private val loggerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val eventFlow = MutableSharedFlow( + replay = 0, + extraBufferCapacity = MAX_BUFFER, + onBufferOverflow = BufferOverflow.SUSPEND, + ) + + private val propertyFlow = MutableSharedFlow( + replay = 0, + extraBufferCapacity = MAX_BUFFER, + onBufferOverflow = BufferOverflow.SUSPEND, + ) + + init { + loggerScope.launch { + eventFlow.collect { entry -> + runCatching { + DatabaseHolder.database + ?.getEventDao() + ?.insert(entry.toEntity()) + }.onFailure { it.printStackTrace() } + } + } + loggerScope.launch { + propertyFlow.collect { entry -> + runCatching { + DatabaseHolder.database + ?.getPropertyDao() + ?.upsert(entry.toEntity()) + }.onFailure { it.printStackTrace() } + } + } + } + + fun logEvent(name: String, params: Map) { + if (name.isBlank()) return + val event = AnalyticsEvent( + timestamp = DateUtils.currentTimeMillis(), + name = name, + params = params, + ) + FirebaseFloatingWindowState.append(buildEventLine(name, params)) + loggerScope.launch { eventFlow.emit(event) } + } + + fun setUserId(id: String?) { + FirebaseFloatingWindowState.append(buildUserIdLine(id)) + loggerScope.launch { FirebaseFloatingWindowSettings.setUserId(id) } + } + + fun setUserProperty(name: String, value: String) { + if (name.isBlank()) return + val property = UserProperty( + name = name, + value = value, + timestamp = DateUtils.currentTimeMillis(), + ) + FirebaseFloatingWindowState.append(buildPropertyLine(name, value)) + loggerScope.launch { propertyFlow.emit(property) } + } +} + +private fun buildEventLine(name: String, params: Map): String { + val suffix = if (params.isEmpty()) { + "" + } else { + params.entries.joinToString( + prefix = " {", + postfix = "}" + ) { "${it.key}=${it.value}" } + } + return "Event $name$suffix" +} + +private fun buildUserIdLine(id: String?): String = + if (id.isNullOrBlank()) "User ID cleared" else "User ID $id" + +private fun buildPropertyLine(name: String, value: String): String = + "Property $name=$value" diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/util/FirebaseFloatingWindowState.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/util/FirebaseFloatingWindowState.kt new file mode 100644 index 00000000..46cf65ca --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/util/FirebaseFloatingWindowState.kt @@ -0,0 +1,63 @@ +package ru.bartwell.kick.module.firebase.analytics.core.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.bartwell.kick.module.firebase.analytics.core.overlay.FirebaseFloatingWindowHost +import ru.bartwell.kick.module.firebase.analytics.core.persist.FirebaseFloatingWindowSettings +import kotlin.time.Duration.Companion.seconds + +private data class FloatingEntry(val text: String) +private const val MAX_VISIBLE_ENTRIES = 3 +private const val ENTRY_LIFETIME_SECONDS = 3 + +internal object FirebaseFloatingWindowState { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _entries = MutableStateFlow>(emptyList()) + private val _visible = MutableStateFlow(false) + + val visible: StateFlow = _visible.asStateFlow() + val lines: StateFlow> = _entries + .map { entries -> entries.map { it.text } } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + internal fun initialize() { + val enabled = FirebaseFloatingWindowSettings.isEnabled() + _visible.value = enabled + FirebaseFloatingWindowHost.setVisible(enabled) + } + + fun setVisible(enabled: Boolean) { + _visible.value = enabled + FirebaseFloatingWindowHost.setVisible(enabled) + FirebaseFloatingWindowSettings.setEnabled(enabled) + if (!enabled) { + _entries.value = emptyList() + } + } + + fun append(text: String) { + if (!_visible.value) return + val entry = FloatingEntry(text = text) + _entries.update { entries -> + (entries + entry).takeLast(MAX_VISIBLE_ENTRIES) + } + scope.launch { + delay(ENTRY_LIFETIME_SECONDS.seconds) + _entries.update { entries -> entries.filterNot { it === entry } } + } + } + + fun clear() { + _entries.value = emptyList() + } +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/main/presentation/DefaultFirebaseAnalyticsComponent.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/main/presentation/DefaultFirebaseAnalyticsComponent.kt new file mode 100644 index 00000000..0440f8a7 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/main/presentation/DefaultFirebaseAnalyticsComponent.kt @@ -0,0 +1,64 @@ +package ru.bartwell.kick.module.firebase.analytics.feature.main.presentation + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import ru.bartwell.kick.module.firebase.analytics.core.persist.FirebaseAnalyticsDatabase +import ru.bartwell.kick.module.firebase.analytics.core.persist.FirebaseFloatingWindowSettings + +internal class DefaultFirebaseAnalyticsComponent( + componentContext: ComponentContext, + private val database: FirebaseAnalyticsDatabase, + private val onFinished: () -> Unit, + private val onPropertiesClickCallback: () -> Unit, +) : FirebaseAnalyticsComponent, ComponentContext by componentContext { + + private val uiScope = coroutineScope() + private val _model: MutableValue = MutableValue(FirebaseAnalyticsState()) + override val model: Value = _model + + init { + subscribeEvents() + subscribeProperties() + subscribeUserId() + } + + override fun onBackPressed() = onFinished() + + override fun onClearEvents() { + uiScope.launch { database.getEventDao().deleteAll() } + } + + override fun onPropertiesClick() = onPropertiesClickCallback() + + private fun subscribeEvents() { + database.getEventDao() + .getAllAsFlow() + .onEach { updateState { copy(events = it, error = null) } } + .catch { updateState { copy(error = it.toString()) } } + .launchIn(uiScope) + } + + private fun subscribeProperties() { + database.getPropertyDao() + .getAllAsFlow() + .onEach { updateState { copy(properties = it, error = null) } } + .catch { updateState { copy(error = it.toString()) } } + .launchIn(uiScope) + } + + private fun subscribeUserId() { + FirebaseFloatingWindowSettings.observeUserId() + .onEach { updateState { copy(userId = it) } } + .launchIn(uiScope) + } + + private fun updateState(block: FirebaseAnalyticsState.() -> FirebaseAnalyticsState) { + _model.value = _model.value.block() + } +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/main/presentation/FirebaseAnalyticsComponent.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/main/presentation/FirebaseAnalyticsComponent.kt new file mode 100644 index 00000000..805b5df0 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/main/presentation/FirebaseAnalyticsComponent.kt @@ -0,0 +1,12 @@ +package ru.bartwell.kick.module.firebase.analytics.feature.main.presentation + +import com.arkivanov.decompose.value.Value +import ru.bartwell.kick.core.component.Component + +internal interface FirebaseAnalyticsComponent : Component { + val model: Value + + fun onBackPressed() + fun onClearEvents() + fun onPropertiesClick() +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/main/presentation/FirebaseAnalyticsContent.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/main/presentation/FirebaseAnalyticsContent.kt new file mode 100644 index 00000000..c1644e84 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/main/presentation/FirebaseAnalyticsContent.kt @@ -0,0 +1,111 @@ +package ru.bartwell.kick.module.firebase.analytics.feature.main.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ClearAll +import androidx.compose.material.icons.outlined.List +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import ru.bartwell.kick.core.presentation.BackOrCloseButton +import ru.bartwell.kick.core.presentation.ErrorBox +import ru.bartwell.kick.core.util.DateUtils +import ru.bartwell.kick.module.firebase.analytics.core.data.AnalyticsEvent +import ru.bartwell.kick.module.firebase.analytics.core.util.FirebaseFloatingWindowState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun FirebaseAnalyticsContent( + component: FirebaseAnalyticsComponent, + modifier: Modifier = Modifier, +) { + val state by component.model.subscribeAsState() + val floatingVisible by FirebaseFloatingWindowState.visible.collectAsState() + Column(modifier = modifier) { + TopAppBar( + title = { Text("Firebase Analytics") }, + navigationIcon = { BackOrCloseButton(onBack = component::onBackPressed) }, + actions = { + IconButton(onClick = component::onClearEvents) { + Icon(Icons.Default.ClearAll, contentDescription = "Clear events") + } + IconButton(onClick = component::onPropertiesClick) { + Icon(Icons.Outlined.List, contentDescription = "Properties") + } + } + ) + FloatingWindowToggle(visible = floatingVisible, onToggle = FirebaseFloatingWindowState::setVisible) + ErrorBox(modifier = Modifier.fillMaxSize(), error = state.error) { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (state.events.isEmpty()) { + item { + Text( + text = "No analytics events yet", + style = MaterialTheme.typography.bodyLarge, + ) + } + } else { + items(state.events) { event -> + AnalyticsEventItem(event) + } + } + } + } + } +} + +@Composable +private fun FloatingWindowToggle(visible: Boolean, onToggle: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Floating window", style = MaterialTheme.typography.bodyLarge) + Switch(checked = visible, onCheckedChange = onToggle) + } +} + +@Composable +private fun AnalyticsEventItem(event: AnalyticsEvent) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = "${DateUtils.formatLogTime(event.timestamp)} ยท ${event.name}", + style = MaterialTheme.typography.titleMedium, + ) + if (event.params.isNotEmpty()) { + event.params.forEach { (key, value) -> + Text( + text = "$key = $value", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/main/presentation/FirebaseAnalyticsState.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/main/presentation/FirebaseAnalyticsState.kt new file mode 100644 index 00000000..902b55c2 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/main/presentation/FirebaseAnalyticsState.kt @@ -0,0 +1,11 @@ +package ru.bartwell.kick.module.firebase.analytics.feature.main.presentation + +import ru.bartwell.kick.module.firebase.analytics.core.data.AnalyticsEvent +import ru.bartwell.kick.module.firebase.analytics.core.data.UserProperty + +internal data class FirebaseAnalyticsState( + val events: List = emptyList(), + val properties: List = emptyList(), + val userId: String? = null, + val error: String? = null, +) diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/properties/presentation/DefaultFirebaseAnalyticsPropertiesComponent.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/properties/presentation/DefaultFirebaseAnalyticsPropertiesComponent.kt new file mode 100644 index 00000000..3b3f434f --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/properties/presentation/DefaultFirebaseAnalyticsPropertiesComponent.kt @@ -0,0 +1,49 @@ +package ru.bartwell.kick.module.firebase.analytics.feature.properties.presentation + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import ru.bartwell.kick.module.firebase.analytics.core.persist.FirebaseAnalyticsDatabase +import ru.bartwell.kick.module.firebase.analytics.core.persist.FirebaseFloatingWindowSettings + +internal class DefaultFirebaseAnalyticsPropertiesComponent( + componentContext: ComponentContext, + private val database: FirebaseAnalyticsDatabase, + private val onFinished: () -> Unit, +) : FirebaseAnalyticsPropertiesComponent, ComponentContext by componentContext { + + private val uiScope = coroutineScope() + private val _model: MutableValue = + MutableValue(FirebaseAnalyticsPropertiesState()) + override val model: Value = _model + + init { + subscribeProperties() + subscribeUserId() + } + + override fun onBackPressed() = onFinished() + + private fun subscribeProperties() { + database.getPropertyDao() + .getAllAsFlow() + .onEach { updateState { copy(properties = it, error = null) } } + .catch { updateState { copy(error = it.toString()) } } + .launchIn(uiScope) + } + + private fun subscribeUserId() { + FirebaseFloatingWindowSettings.observeUserId() + .onEach { updateState { copy(userId = it) } } + .catch { updateState { copy(error = it.toString()) } } + .launchIn(uiScope) + } + + private fun updateState(block: FirebaseAnalyticsPropertiesState.() -> FirebaseAnalyticsPropertiesState) { + _model.value = _model.value.block() + } +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/properties/presentation/FirebaseAnalyticsPropertiesComponent.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/properties/presentation/FirebaseAnalyticsPropertiesComponent.kt new file mode 100644 index 00000000..27f7d12b --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/properties/presentation/FirebaseAnalyticsPropertiesComponent.kt @@ -0,0 +1,10 @@ +package ru.bartwell.kick.module.firebase.analytics.feature.properties.presentation + +import com.arkivanov.decompose.value.Value +import ru.bartwell.kick.core.component.Component + +internal interface FirebaseAnalyticsPropertiesComponent : Component { + val model: Value + + fun onBackPressed() +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/properties/presentation/FirebaseAnalyticsPropertiesContent.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/properties/presentation/FirebaseAnalyticsPropertiesContent.kt new file mode 100644 index 00000000..b274b35e --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/properties/presentation/FirebaseAnalyticsPropertiesContent.kt @@ -0,0 +1,117 @@ +package ru.bartwell.kick.module.firebase.analytics.feature.properties.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import ru.bartwell.kick.core.presentation.ErrorBox +import ru.bartwell.kick.core.util.DateUtils +import ru.bartwell.kick.module.firebase.analytics.core.data.UserProperty + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun FirebaseAnalyticsPropertiesContent( + component: FirebaseAnalyticsPropertiesComponent, + modifier: Modifier = Modifier, +) { + val state by component.model.subscribeAsState() + + Column(modifier = modifier) { + TopAppBar( + title = { Text("Firebase Analytics Properties") }, + navigationIcon = { + IconButton(onClick = component::onBackPressed) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") + } + } + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + ) { + UserIdCard(userId = state.userId, modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(12.dp)) + ErrorBox( + error = state.error, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (state.properties.isEmpty()) { + item { + Text( + text = "No properties", + style = MaterialTheme.typography.bodyLarge, + ) + } + } else { + items(state.properties) { property -> + PropertyItem(property) + } + } + } + } + } + } +} + +@Composable +private fun UserIdCard(userId: String?, modifier: Modifier = Modifier) { + Card(modifier = modifier) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { + Text( + text = "User id", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = userId ?: "Not set", + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +private fun PropertyItem(property: UserProperty) { + Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { + Text( + text = property.name, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = property.value, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = DateUtils.formatLogTime(property.timestamp), + style = MaterialTheme.typography.bodySmall, + ) + } + } +} diff --git a/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/properties/presentation/FirebaseAnalyticsPropertiesState.kt b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/properties/presentation/FirebaseAnalyticsPropertiesState.kt new file mode 100644 index 00000000..ff218d34 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/kotlin/ru/bartwell/kick/module/firebase/analytics/feature/properties/presentation/FirebaseAnalyticsPropertiesState.kt @@ -0,0 +1,9 @@ +package ru.bartwell.kick.module.firebase.analytics.feature.properties.presentation + +import ru.bartwell.kick.module.firebase.analytics.core.data.UserProperty + +internal data class FirebaseAnalyticsPropertiesState( + val properties: List = emptyList(), + val userId: String? = null, + val error: String? = null, +) diff --git a/module/firebase/firebase-analytics/src/commonMain/sqldelight/ru/bartwell/kick/module/firebase/analytics/db/firebase_analytics.sq b/module/firebase/firebase-analytics/src/commonMain/sqldelight/ru/bartwell/kick/module/firebase/analytics/db/firebase_analytics.sq new file mode 100644 index 00000000..f3e6b7c5 --- /dev/null +++ b/module/firebase/firebase-analytics/src/commonMain/sqldelight/ru/bartwell/kick/module/firebase/analytics/db/firebase_analytics.sq @@ -0,0 +1,33 @@ +import kotlin.collections.Map; +import kotlin.String; + +CREATE TABLE analyticsEvent ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + name TEXT NOT NULL, + params TEXT AS Map NOT NULL +); + +selectEvents: +SELECT * FROM analyticsEvent ORDER BY timestamp DESC; + +insertEvent: +INSERT INTO analyticsEvent (timestamp, name, params) VALUES (?, ?, ?); + +deleteEvents: +DELETE FROM analyticsEvent; + +CREATE TABLE userProperty ( + name TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL, + timestamp INTEGER NOT NULL +); + +selectProperties: +SELECT * FROM userProperty ORDER BY timestamp DESC; + +insertProperty: +INSERT OR REPLACE INTO userProperty (name, value, timestamp) VALUES (?, ?, ?); + +deleteProperties: +DELETE FROM userProperty; diff --git a/module/firebase/firebase-analytics/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.ios.kt b/module/firebase/firebase-analytics/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.ios.kt new file mode 100644 index 00000000..ebdc2d5d --- /dev/null +++ b/module/firebase/firebase-analytics/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/FirebaseAnalyticsAccessor.ios.kt @@ -0,0 +1,44 @@ +package ru.bartwell.kick.module.firebase.analytics + +import platform.Foundation.NSDictionary +import ru.bartwell.kick.module.firebase.analytics.core.util.FirebaseAnalyticsLogger + +public fun FirebaseAnalyticsAccessor.logEvent(name: String, params: NSDictionary?) { + FirebaseAnalyticsLogger.logEvent(name = name, params = params.toParameterMap()) +} + +public fun FirebaseAnalyticsAccessor.logEvent(name: String, params: Map?) { + FirebaseAnalyticsLogger.logEvent(name = name, params = params.toParameterMap()) +} + +public fun FirebaseAnalyticsAccessor.setUserId(id: String?) { + FirebaseAnalyticsLogger.setUserId(id) +} + +public fun FirebaseAnalyticsAccessor.setUserProperty(name: String, value: String) { + FirebaseAnalyticsLogger.setUserProperty(name, value) +} + +private fun NSDictionary?.toParameterMap(): Map { + val source = this ?: return emptyMap() + val enumerator = source.keyEnumerator() ?: return emptyMap() + + return buildMap { + generateSequence { enumerator.nextObject() } + .forEach { key -> + val value = source.objectForKey(key)?.toString() + if (value != null) { + put(key.toString(), value) + } + } + } +} + +private fun Map?.toParameterMap(): Map { + if (this == null) return emptyMap() + return mapNotNull { (key, value) -> + val keyString = key?.toString() ?: return@mapNotNull null + val valueString = value?.toString() + valueString?.let { keyString to it } + }.toMap() +} diff --git a/module/firebase/firebase-analytics/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/overlay/FirebaseFloatingWindowHost.ios.kt b/module/firebase/firebase-analytics/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/overlay/FirebaseFloatingWindowHost.ios.kt new file mode 100644 index 00000000..656e15b5 --- /dev/null +++ b/module/firebase/firebase-analytics/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/overlay/FirebaseFloatingWindowHost.ios.kt @@ -0,0 +1,337 @@ +package ru.bartwell.kick.module.firebase.analytics.core.overlay + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCAction +import kotlinx.cinterop.useContents +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import platform.CoreGraphics.CGPointMake +import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGSizeMake +import platform.Foundation.NSNotification +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +import platform.Foundation.NSSelectorFromString +import platform.UIKit.NSLineBreakByWordWrapping +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationDidBecomeActiveNotification +import platform.UIKit.UIColor +import platform.UIKit.UIFont +import platform.UIKit.UIFontWeightRegular +import platform.UIKit.UILabel +import platform.UIKit.UIPanGestureRecognizer +import platform.UIKit.UISceneActivationStateForegroundActive +import platform.UIKit.UIScreen +import platform.UIKit.UITapGestureRecognizer +import platform.UIKit.UIView +import platform.UIKit.UIViewAutoresizingFlexibleBottomMargin +import platform.UIKit.UIViewAutoresizingFlexibleHeight +import platform.UIKit.UIViewAutoresizingFlexibleLeftMargin +import platform.UIKit.UIViewAutoresizingFlexibleRightMargin +import platform.UIKit.UIViewAutoresizingFlexibleTopMargin +import platform.UIKit.UIViewAutoresizingFlexibleWidth +import platform.UIKit.UIViewController +import platform.UIKit.UIWindowDidBecomeKeyNotification +import platform.UIKit.UIWindowLevelAlert +import platform.UIKit.UIWindowScene +import platform.UIKit.frame +import platform.darwin.NSObject +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue +import ru.bartwell.kick.Kick +import ru.bartwell.kick.core.data.ModuleDescription +import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.core.data.StartScreen +import ru.bartwell.kick.core.presentation.overlay.PanTarget +import ru.bartwell.kick.core.presentation.overlay.PassThroughWindow +import ru.bartwell.kick.module.firebase.analytics.core.component.config.FirebaseAnalyticsConfig +import ru.bartwell.kick.module.firebase.analytics.core.persist.FirebaseFloatingWindowSettings +import ru.bartwell.kick.module.firebase.analytics.core.util.FirebaseFloatingWindowState +import kotlin.math.max +import kotlin.math.min + +private const val INITIAL_X = 50.0 +private const val INITIAL_Y = 200.0 +private const val PANEL_WIDTH: Double = 280.0 +private const val PANEL_MIN_HEIGHT: Double = 44.0 +private const val PANEL_MAX_HEIGHT: Double = 360.0 +private const val H_PADDING: Double = 6.0 +private const val CLOSE_MARGIN: Double = 4.0 +private const val CORNER: Double = 8.0 +private const val BORDER_WIDTH: Double = 1.0 +private const val BACKGROUND_ALPHA: Double = 0.82 +private const val BORDER_ALPHA: Double = 0.35 +private const val FONT_SIZE: Double = 12.0 +private const val EMPTY_TEXT: String = "No events" + +@OptIn(ExperimentalForeignApi::class) +internal actual object FirebaseFloatingWindowHost { + private var overlayWindow: PassThroughWindow? = null + private var panel: UIView? = null + private var label: UILabel? = null + private var scope: CoroutineScope? = null + private var panTarget: PanTarget? = null + private var tapTarget: FirebaseTapTarget? = null + private var windowObserver: platform.darwin.NSObjectProtocol? = null + private var appActiveObserver: platform.darwin.NSObjectProtocol? = null + private var visible = false + private var initialized = false + private var platformContext: PlatformContext? = null + + actual fun init(context: PlatformContext) { + platformContext = context + dispatch_async(dispatch_get_main_queue()) { + if (initialized) return@dispatch_async + initialized = true + appActiveObserver = NSNotificationCenter.defaultCenter.addObserverForName( + name = UIApplicationDidBecomeActiveNotification, + `object` = null, + queue = NSOperationQueue.mainQueue + ) { _: NSNotification? -> + if (visible) { + show() + } + } + } + } + + actual fun setVisible(enabled: Boolean) { + visible = enabled + dispatch_async(dispatch_get_main_queue()) { + if (enabled) { + show() + } else { + hide() + } + } + } + + private fun show() { + if (showExistingOverlay()) return + + val scene: UIWindowScene = activeForegroundScene() ?: run { + registerWindowObserver() + return + } + + val overlay = buildOverlay(scene) + val root = buildRootView(overlay) + overlay.setRootViewController(UIViewController().apply { setView(root) }) + + val (mainView, textLabel) = createPanelWithLabel() + root.addSubview(mainView) + overlay.panel = mainView + + overlayWindow = overlay + panel = mainView + label = textLabel + + overlay.setHidden(false) + overlay.makeKeyAndVisible() + + relayout(mainView, textLabel) + startCollectingLines(mainView, textLabel) + } + + private fun hide() { + overlayWindow?.setHidden(true) + overlayWindow?.let { + it.resignKeyWindow() + it.removeFromSuperview() + } + overlayWindow = null + panel = null + label = null + scope?.cancel() + scope = null + panTarget = null + tapTarget = null + } + + private fun relayout(panel: UIView, label: UILabel) { + val textWidth = PANEL_WIDTH - H_PADDING * 2 + val measured = label.sizeThatFits(CGSizeMake(textWidth, Double.MAX_VALUE)) + val textHeight = measured.useContents { height } + val contentWidth = PANEL_WIDTH + val contentHeight = min( + PANEL_MAX_HEIGHT, + max(PANEL_MIN_HEIGHT, textHeight + CLOSE_MARGIN + H_PADDING) + ) + + var originX = 0.0 + var originY = 0.0 + panel.frame.useContents { + val availableWidth = panel.superview?.bounds?.useContents { size.width } ?: contentWidth + val availableHeight = panel.superview?.bounds?.useContents { size.height } ?: contentHeight + originX = min(availableWidth - contentWidth, origin.x) + originY = min(availableHeight - contentHeight, origin.y) + } + + panel.setFrame(CGRectMake(originX, originY, contentWidth, contentHeight)) + label.setFrame(CGRectMake(H_PADDING, CLOSE_MARGIN, textWidth, contentHeight - CLOSE_MARGIN - H_PADDING)) + FirebaseFloatingWindowSettings.setPosition(originX.toFloat(), originY.toFloat()) + } + + private fun showExistingOverlay(): Boolean { + val window = overlayWindow ?: return false + window.setHidden(false) + window.makeKeyAndVisible() + panel?.let { applyStoredOrigin(it) } + panel?.let { pnl -> label?.let { lbl -> relayout(pnl, lbl) } } + return true + } + + private fun registerWindowObserver() { + if (windowObserver != null) return + windowObserver = NSNotificationCenter.defaultCenter.addObserverForName( + name = UIWindowDidBecomeKeyNotification, + `object` = null, + queue = NSOperationQueue.mainQueue + ) { _: NSNotification? -> + if (overlayWindow == null && visible) { + show() + } + windowObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) } + windowObserver = null + } + } + + private fun buildOverlay(scene: UIWindowScene): PassThroughWindow { + return PassThroughWindow(windowScene = scene).apply { + setFrame(UIScreen.mainScreen.bounds) + setWindowLevel(UIWindowLevelAlert) + setBackgroundColor(UIColor.clearColor) + } + } + + private fun buildRootView(overlay: PassThroughWindow): UIView { + return UIView(frame = overlay.bounds).apply { + setBackgroundColor(UIColor.clearColor) + setUserInteractionEnabled(true) + setAutoresizingMask(UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight) + } + } + + private fun createPanelWithLabel(): Pair { + val (originX, originY) = initialOrigin() + val mainView = UIView(frame = CGRectMake(originX, originY, PANEL_WIDTH, PANEL_MIN_HEIGHT)).apply { + setBackgroundColor(UIColor.whiteColor.colorWithAlphaComponent(BACKGROUND_ALPHA)) + setUserInteractionEnabled(true) + layer?.setCornerRadius(CORNER) + layer?.setBorderWidth(BORDER_WIDTH) + layer?.setBorderColor(UIColor.blackColor.colorWithAlphaComponent(BORDER_ALPHA).CGColor) + setAutoresizingMask( + UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight or + UIViewAutoresizingFlexibleLeftMargin or UIViewAutoresizingFlexibleRightMargin or + UIViewAutoresizingFlexibleTopMargin or UIViewAutoresizingFlexibleBottomMargin + ) + } + + val textLabel = createTextLabel() + mainView.addGestureRecognizer(createPanTarget(mainView)) + mainView.addGestureRecognizer(createTapTarget()) + mainView.addSubview(textLabel) + + return mainView to textLabel + } + + private fun initialOrigin(): Pair { + val storedX = FirebaseFloatingWindowSettings.getPositionX() + val storedY = FirebaseFloatingWindowSettings.getPositionY() + val originX = storedX.takeIf { !it.isNaN() } ?: INITIAL_X.toFloat() + val originY = storedY.takeIf { !it.isNaN() } ?: INITIAL_Y.toFloat() + return originX.toDouble() to originY.toDouble() + } + + private fun startCollectingLines(panel: UIView, label: UILabel) { + scope = MainScope().also { sc -> + sc.launch { + FirebaseFloatingWindowState.lines.collect { currentLines -> + val text = if (currentLines.isEmpty()) EMPTY_TEXT else currentLines.joinToString("\n") + label.setText(text) + relayout(panel, label) + } + } + } + } + + private fun applyStoredOrigin(panel: UIView) { + val x = FirebaseFloatingWindowSettings.getPositionX() + val y = FirebaseFloatingWindowSettings.getPositionY() + if (!x.isNaN() && !y.isNaN()) { + panel.frame.useContents { + panel.setFrame(CGRectMake(x.toDouble(), y.toDouble(), size.width, size.height)) + } + } + } + + private fun createTextLabel(): UILabel { + return UILabel(frame = CGRectMake(0.0, 0.0, 0.0, 0.0)).apply { + setTextColor(UIColor.blackColor) + setFont(UIFont.monospacedSystemFontOfSize(FONT_SIZE, UIFontWeightRegular)) + setNumberOfLines(0) + setLineBreakMode(NSLineBreakByWordWrapping) + setUserInteractionEnabled(false) + setAutoresizingMask(UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight) + } + } + + private fun createPanTarget(mainView: UIView): UIPanGestureRecognizer { + val pan = PanTarget { dx, dy -> + val center = mainView.center + val nx = center.useContents { x } + dx + val ny = center.useContents { y } + dy + mainView.setCenter(CGPointMake(nx, ny)) + mainView.frame.useContents { + FirebaseFloatingWindowSettings.setPosition(origin.x.toFloat(), origin.y.toFloat()) + } + } + panTarget = pan + val panGR = UIPanGestureRecognizer(target = pan, action = NSSelectorFromString("onPan:")) + panGR.setCancelsTouchesInView(false) + return panGR + } + + private fun createTapTarget(): UITapGestureRecognizer { + val tap = FirebaseTapTarget { openAnalyticsModule() } + tapTarget = tap + val tapGR = UITapGestureRecognizer(target = tap, action = NSSelectorFromString("onTap:")) + tapGR.setCancelsTouchesInView(false) + return tapGR + } + + private fun openAnalyticsModule() { + platformContext?.let { ctx -> + Kick.launch( + context = ctx, + startScreen = StartScreen( + FirebaseAnalyticsConfig, + ModuleDescription.FIREBASE_ANALYTICS + ) + ) + } + } + + private fun activeForegroundScene(): UIWindowScene? { + val scenes = UIApplication.sharedApplication.connectedScenes + scenes.iterator().forEach { scene -> + val windowScene = scene as? UIWindowScene ?: return@forEach + if (windowScene.activationState == UISceneActivationStateForegroundActive) { + return windowScene + } + } + return null + } +} + +@OptIn(ExperimentalForeignApi::class) +internal class FirebaseTapTarget(private val onTap: () -> Unit) : NSObject() { + @ObjCAction + fun onTap(recognizer: UITapGestureRecognizer?) { + recognizer?.let { _ -> } + onTap() + } +} diff --git a/module/firebase/firebase-analytics/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/DatabaseBuilder.kt b/module/firebase/firebase-analytics/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/DatabaseBuilder.kt new file mode 100644 index 00000000..38f4c490 --- /dev/null +++ b/module/firebase/firebase-analytics/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/DatabaseBuilder.kt @@ -0,0 +1,23 @@ +package ru.bartwell.kick.module.firebase.analytics.core.persist + +import app.cash.sqldelight.async.coroutines.synchronous +import app.cash.sqldelight.driver.native.NativeSqliteDriver +import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.module.firebase.analytics.core.persist.adapter.stringMapAdapter +import ru.bartwell.kick.module.firebase.analytics.db.AnalyticsEvent +import ru.bartwell.kick.module.firebase.analytics.db.FirebaseAnalyticsDb + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +internal actual class DatabaseBuilder { + actual fun createDatabase(context: PlatformContext): FirebaseAnalyticsDatabase { + val driver = + NativeSqliteDriver(schema = FirebaseAnalyticsDb.Schema.synchronous(), name = "kick_firebase_analytics.db") + val db = FirebaseAnalyticsDb( + driver = driver, + analyticsEventAdapter = AnalyticsEvent.Adapter( + paramsAdapter = stringMapAdapter, + ) + ) + return FirebaseAnalyticsDatabase(db) + } +} diff --git a/module/firebase/firebase-analytics/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/PlatformSettingsFactory.ios.kt b/module/firebase/firebase-analytics/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/PlatformSettingsFactory.ios.kt new file mode 100644 index 00000000..fdd4da94 --- /dev/null +++ b/module/firebase/firebase-analytics/src/iosMain/kotlin/ru/bartwell/kick/module/firebase/analytics/core/persist/PlatformSettingsFactory.ios.kt @@ -0,0 +1,14 @@ +package ru.bartwell.kick.module.firebase.analytics.core.persist + +import com.russhwolf.settings.NSUserDefaultsSettings +import com.russhwolf.settings.Settings +import platform.Foundation.NSUserDefaults +import ru.bartwell.kick.core.data.PlatformContext + +@Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) +internal actual object PlatformSettingsFactory { + actual fun create(context: PlatformContext, name: String): Settings { + val userDefaults = NSUserDefaults(suiteName = name) + return NSUserDefaultsSettings(userDefaults) + } +} diff --git a/module/logging/overlay/src/androidMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/InAppOverlayCallbacks.kt b/module/logging/overlay/src/androidMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/InAppOverlayCallbacks.kt index cae4564d..1b9dd8f2 100644 --- a/module/logging/overlay/src/androidMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/InAppOverlayCallbacks.kt +++ b/module/logging/overlay/src/androidMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/InAppOverlayCallbacks.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.isVisible +import ru.bartwell.kick.core.presentation.overlay.DraggableContainer import ru.bartwell.kick.module.overlay.core.persists.OverlaySettings import java.lang.ref.WeakReference import java.util.WeakHashMap diff --git a/module/logging/overlay/src/iosMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/ButtonTarget.kt b/module/logging/overlay/src/iosMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/ButtonTarget.kt deleted file mode 100644 index 1c6a7c8f..00000000 --- a/module/logging/overlay/src/iosMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/ButtonTarget.kt +++ /dev/null @@ -1,13 +0,0 @@ -package ru.bartwell.kick.module.overlay.core.overlay - -import kotlinx.cinterop.ObjCAction -import platform.UIKit.UIButton -import platform.darwin.NSObject - -internal class ButtonTarget(private val action: () -> Unit) : NSObject() { - @Suppress("UnusedParameter") - @ObjCAction - fun invoke(sender: UIButton?) { - action() - } -} diff --git a/module/logging/overlay/src/iosMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/KickOverlay.ios.kt b/module/logging/overlay/src/iosMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/KickOverlay.ios.kt index 0d853fb6..0306ed23 100644 --- a/module/logging/overlay/src/iosMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/KickOverlay.ios.kt +++ b/module/logging/overlay/src/iosMain/kotlin/ru/bartwell/kick/module/overlay/core/overlay/KickOverlay.ios.kt @@ -45,6 +45,9 @@ import platform.UIKit.setContentEdgeInsets import platform.darwin.dispatch_async import platform.darwin.dispatch_get_main_queue import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.core.presentation.overlay.ButtonTarget +import ru.bartwell.kick.core.presentation.overlay.PanTarget +import ru.bartwell.kick.core.presentation.overlay.PassThroughWindow import ru.bartwell.kick.module.overlay.core.persists.OverlaySettings import ru.bartwell.kick.module.overlay.core.store.OverlayStore import kotlin.math.max @@ -179,7 +182,7 @@ public actual object KickOverlay { private fun createTextView(): UILabel { return UILabel(frame = CGRectMake(0.0, 0.0, 0.0, 0.0)).apply { - setTextColor(UIColor.whiteColor) + setTextColor(UIColor.blackColor) setFont(UIFont.monospacedSystemFontOfSize(FONT_SIZE, UIFontWeightRegular)) setNumberOfLines(0) setLineBreakMode(NSLineBreakByWordWrapping) @@ -190,7 +193,7 @@ public actual object KickOverlay { private fun createCloseButton(): UIButton { val button = UIButton.buttonWithType(UIButtonTypeSystem) - button.setTintColor(UIColor.whiteColor.colorWithAlphaComponent(BACKGROUND_ALPHA)) + button.setTintColor(UIColor.blackColor.colorWithAlphaComponent(BACKGROUND_ALPHA)) button.setTitle("", forState = UIControlStateNormal) button.setImage(UIImage.systemImageNamed("xmark"), forState = UIControlStateNormal) button.setContentEdgeInsets(UIEdgeInsetsMake(0.0, 0.0, 0.0, 0.0)) @@ -202,10 +205,10 @@ public actual object KickOverlay { private fun createMainView(): UIView { return UIView(frame = CGRectMake(INITIAL_X, INITIAL_Y, PANEL_WIDTH, PANEL_MIN_HEIGHT)).apply { - setBackgroundColor(UIColor.blackColor.colorWithAlphaComponent(BACKGROUND_ALPHA)) + setBackgroundColor(UIColor.whiteColor.colorWithAlphaComponent(BACKGROUND_ALPHA)) layer.cornerRadius = CORNER layer.borderWidth = BORDER_WIDTH - layer.borderColor = UIColor.whiteColor.colorWithAlphaComponent(BORDER_ALPHA).CGColor + layer.borderColor = UIColor.blackColor.colorWithAlphaComponent(BORDER_ALPHA).CGColor setClipsToBounds(true) setUserInteractionEnabled(true) setAutoresizingMask( diff --git a/sample/android/build.gradle.kts b/sample/android/build.gradle.kts index 7bf50e01..219fd036 100644 --- a/sample/android/build.gradle.kts +++ b/sample/android/build.gradle.kts @@ -41,7 +41,6 @@ android { dependencies { implementation(projects.shared) - implementation(projects.firebaseCloudMessaging) implementation(libs.androidx.activity.compose) implementation(libs.firebase.messaging) } diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index c0612788..2b3b73a3 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -48,11 +48,13 @@ kotlin { export(projects.mainRuntimeStub) export(projects.controlPanelStub) export(projects.firebaseCloudMessagingStub) + export(projects.firebaseAnalyticsStub) export(projects.runnerStub) } else { export(projects.mainRuntime) export(projects.controlPanel) export(projects.firebaseCloudMessaging) + export(projects.firebaseAnalytics) export(projects.runner) } } @@ -131,18 +133,22 @@ kotlin { implementation(libs.sqldelight.android.driver) implementation(libs.ktor.client.okhttp) if (isRelease) { - implementation(projects.firebaseCloudMessaging) + api(projects.firebaseCloudMessagingStub) + api(projects.firebaseAnalyticsStub) } else { - implementation(projects.firebaseCloudMessaging) + api(projects.firebaseCloudMessaging) + api(projects.firebaseAnalytics) } } iosMain.dependencies { implementation(libs.sqldelight.native.driver) implementation(libs.ktor.client.darwin) if (isRelease) { - implementation(projects.firebaseCloudMessagingStub) + api(projects.firebaseCloudMessagingStub) + api(projects.firebaseAnalyticsStub) } else { api(projects.firebaseCloudMessaging) + api(projects.firebaseAnalytics) } } nonWasmMain.dependencies { diff --git a/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.android.kt b/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.android.kt index 0496e472..c5965fb9 100644 --- a/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.android.kt +++ b/sample/shared/src/androidMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.android.kt @@ -2,7 +2,11 @@ package ru.bartwell.kick.sample.shared import ru.bartwell.kick.core.data.Module import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.module.firebase.analytics.FirebaseAnalyticsModule import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingModule actual fun createFirebaseCloudMessagingModule(context: PlatformContext): Module? = FirebaseCloudMessagingModule(context) + +actual fun createFirebaseAnalyticsModule(context: PlatformContext): Module? = + FirebaseAnalyticsModule(context) diff --git a/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt b/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt index 17edaf84..2e252700 100644 --- a/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt +++ b/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt @@ -97,6 +97,7 @@ class TestDataInitializer(context: PlatformContext) { module(OverlayModule(context)) module(RunnerModule()) createFirebaseCloudMessagingModule(context)?.let { module(it) } + createFirebaseAnalyticsModule(context)?.let { module(it) } } registerRunnerSamples() @@ -187,3 +188,4 @@ class TestDataInitializer(context: PlatformContext) { expect fun createRoomModule(context: PlatformContext): Module? expect fun createLayoutModule(context: PlatformContext): Module? expect fun createFirebaseCloudMessagingModule(context: PlatformContext): Module? +expect fun createFirebaseAnalyticsModule(context: PlatformContext): Module? diff --git a/sample/shared/src/iosMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.ios.kt b/sample/shared/src/iosMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.ios.kt index 0496e472..c5965fb9 100644 --- a/sample/shared/src/iosMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.ios.kt +++ b/sample/shared/src/iosMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.ios.kt @@ -2,7 +2,11 @@ package ru.bartwell.kick.sample.shared import ru.bartwell.kick.core.data.Module import ru.bartwell.kick.core.data.PlatformContext +import ru.bartwell.kick.module.firebase.analytics.FirebaseAnalyticsModule import ru.bartwell.kick.module.firebase.cloudmessaging.FirebaseCloudMessagingModule actual fun createFirebaseCloudMessagingModule(context: PlatformContext): Module? = FirebaseCloudMessagingModule(context) + +actual fun createFirebaseAnalyticsModule(context: PlatformContext): Module? = + FirebaseAnalyticsModule(context) diff --git a/sample/shared/src/jvmMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.jvm.kt b/sample/shared/src/jvmMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.jvm.kt index 6828f43f..55dfeb3c 100644 --- a/sample/shared/src/jvmMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.jvm.kt +++ b/sample/shared/src/jvmMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.jvm.kt @@ -4,3 +4,4 @@ import ru.bartwell.kick.core.data.Module import ru.bartwell.kick.core.data.PlatformContext actual fun createFirebaseCloudMessagingModule(context: PlatformContext): Module? = null +actual fun createFirebaseAnalyticsModule(context: PlatformContext): Module? = null diff --git a/sample/shared/src/wasmJsMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.wasmJs.kt b/sample/shared/src/wasmJsMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.wasmJs.kt index c0040d60..6a50be5e 100644 --- a/sample/shared/src/wasmJsMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.wasmJs.kt +++ b/sample/shared/src/wasmJsMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.wasmJs.kt @@ -10,3 +10,4 @@ actual fun createRoomModule(context: PlatformContext): Module? = null actual fun createLayoutModule(context: PlatformContext): Module? = null actual fun createFirebaseCloudMessagingModule(context: PlatformContext): Module? = null +actual fun createFirebaseAnalyticsModule(context: PlatformContext): Module? = null diff --git a/settings.gradle.kts b/settings.gradle.kts index 24732677..04f4aa1e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -102,3 +102,7 @@ include(":firebase-cloud-messaging") project(":firebase-cloud-messaging").projectDir = file("module/firebase/firebase-cloud-messaging") include(":firebase-cloud-messaging-stub") project(":firebase-cloud-messaging-stub").projectDir = file("module/firebase/firebase-cloud-messaging-stub") +include(":firebase-analytics") +project(":firebase-analytics").projectDir = file("module/firebase/firebase-analytics") +include(":firebase-analytics-stub") +project(":firebase-analytics-stub").projectDir = file("module/firebase/firebase-analytics-stub")