Compare commits
11 Commits
main
...
poc/android
| Author | SHA1 | Date | |
|---|---|---|---|
|
defaa15e93
|
|||
|
43ff936b24
|
|||
|
e4405c8b9d
|
|||
|
c68787cd01
|
|||
|
db2290ba14
|
|||
|
0414ee9e4e
|
|||
|
72edb440b7
|
|||
|
ab86ed7e76
|
|||
|
e5128df5a0
|
|||
|
1391e34e69
|
|||
|
45773571aa
|
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Local application data
|
||||||
|
log/
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# IDEA
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.compose)
|
||||||
|
// necessary for using T.serializer() on a data class
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
|
namespace = "xyz.magicalbits.smsremote"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "xyz.magicalbits.smsremote"
|
||||||
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
// Important: change the keystore for a production deployment
|
||||||
|
val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore")
|
||||||
|
val localKeystore = rootProject.file("debug_2.keystore")
|
||||||
|
val hasKeyInfo = userKeystore.exists()
|
||||||
|
create("release") {
|
||||||
|
storeFile = if (hasKeyInfo) userKeystore else localKeystore
|
||||||
|
storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password")
|
||||||
|
keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias")
|
||||||
|
keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
}
|
||||||
|
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = JvmTarget.fromTarget("17")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
viewBinding = true
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging.resources {
|
||||||
|
// Multiple dependency bring these files in. Exclude them to enable
|
||||||
|
// our test APK to build (has no effect on our AARs)
|
||||||
|
excludes += "/META-INF/AL2.0"
|
||||||
|
excludes += "/META-INF/LGPL2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val composeBom = platform(libs.androidx.compose.bom)
|
||||||
|
implementation(composeBom)
|
||||||
|
androidTestImplementation(composeBom)
|
||||||
|
|
||||||
|
implementation(libs.androidx.glance.appwidget)
|
||||||
|
implementation(libs.androidx.glance.material3)
|
||||||
|
implementation(libs.kotlin.stdlib)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.appcompat)
|
||||||
|
implementation(libs.androidx.compose.runtime.livedata)
|
||||||
|
implementation(libs.androidx.lifecycle.viewModelCompose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.navigation.fragment)
|
||||||
|
implementation(libs.androidx.navigation.ui.ktx)
|
||||||
|
|
||||||
|
implementation(libs.androidx.compose.foundation.layout)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
implementation(libs.androidx.compose.ui.util)
|
||||||
|
implementation(libs.androidx.compose.ui.viewbinding)
|
||||||
|
implementation(libs.androidx.compose.ui.googlefonts)
|
||||||
|
|
||||||
|
implementation(libs.ktor.client.auth)
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
|
||||||
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
|
||||||
|
androidTestImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.androidx.test.core)
|
||||||
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
|
androidTestImplementation(libs.androidx.test.espresso.core)
|
||||||
|
androidTestImplementation(libs.androidx.test.rules)
|
||||||
|
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||||
|
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.SEND_SMS"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_SMS" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_SMS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_MMS" />
|
||||||
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.provider.Telephony.SMS_RECEIVED" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Jetchat.NoActionBar">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".NavActivity"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<receiver android:name=".widget.WidgetReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/widget_unread_messages_info" />
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.telephony"
|
||||||
|
android:required="true" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to communicate between screens.
|
||||||
|
*/
|
||||||
|
class MainViewModel : ViewModel() {
|
||||||
|
private val _drawerShouldBeOpened = MutableStateFlow(false)
|
||||||
|
val drawerShouldBeOpened = _drawerShouldBeOpened.asStateFlow()
|
||||||
|
|
||||||
|
fun openDrawer() {
|
||||||
|
_drawerShouldBeOpened.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetOpenDrawerAction() {
|
||||||
|
_drawerShouldBeOpened.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.material3.DrawerValue.Closed
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidViewBinding
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.fragment.NavHostFragment
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.builtins.serializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import xyz.magicalbits.smsremote.components.JetchatDrawer
|
||||||
|
import xyz.magicalbits.smsremote.databinding.ContentMainBinding
|
||||||
|
import xyz.magicalbits.smsremote.network.NetworkClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main activity for the app.
|
||||||
|
*/
|
||||||
|
class NavActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private val viewModel: MainViewModel by viewModels()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets -> insets }
|
||||||
|
|
||||||
|
var deviceDtoList: List<NetworkClient.DeviceDto> = listOf()
|
||||||
|
viewModel.viewModelScope.launch {
|
||||||
|
val networkClient = NetworkClient()
|
||||||
|
deviceDtoList = networkClient.getDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
setContentView(
|
||||||
|
ComposeView(this).apply {
|
||||||
|
consumeWindowInsets = false
|
||||||
|
setContent {
|
||||||
|
val drawerState = rememberDrawerState(initialValue = Closed)
|
||||||
|
val drawerOpen by viewModel.drawerShouldBeOpened
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var selectedMenu by remember { mutableStateOf("Samsung A14") }
|
||||||
|
if (drawerOpen) {
|
||||||
|
// Open drawer and reset state in VM.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
// wrap in try-finally to handle interruption whiles opening drawer
|
||||||
|
try {
|
||||||
|
drawerState.open()
|
||||||
|
} finally {
|
||||||
|
viewModel.resetOpenDrawerAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
JetchatDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
selectedMenu = selectedMenu,
|
||||||
|
deviceDtoList = deviceDtoList,
|
||||||
|
onChatClicked = {
|
||||||
|
findNavController().popBackStack(R.id.nav_device, false)
|
||||||
|
val args = Bundle(1)
|
||||||
|
args.putString("deviceId", it.access_key)
|
||||||
|
args.putString("name", it.name)
|
||||||
|
args.putString("type", it.type)
|
||||||
|
findNavController().navigate(R.id.nav_device, args)
|
||||||
|
scope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
}
|
||||||
|
selectedMenu = it.access_key
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
AndroidViewBinding(ContentMainBinding::inflate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSupportNavigateUp(): Boolean = findNavController().navigateUp() || super.onSupportNavigateUp()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://issuetracker.google.com/142847973
|
||||||
|
*/
|
||||||
|
private fun findNavController(): NavController {
|
||||||
|
val navHostFragment =
|
||||||
|
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||||
|
return navHostFragment.navController
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote
|
||||||
|
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = "Functionality not available \uD83D\uDE48",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(text = "CLOSE")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.core.updateTransition
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.util.lerp
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A layout that shows an icon and a text element used as the content for a FAB that extends with
|
||||||
|
* an animation.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AnimatingFabContent(
|
||||||
|
icon: @Composable () -> Unit,
|
||||||
|
text: @Composable () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
extended: Boolean = true,
|
||||||
|
) {
|
||||||
|
val currentState = if (extended) ExpandableFabStates.Extended else ExpandableFabStates.Collapsed
|
||||||
|
val transition = updateTransition(currentState, "fab_transition")
|
||||||
|
|
||||||
|
val textOpacity by transition.animateFloat(
|
||||||
|
transitionSpec = {
|
||||||
|
if (targetState == ExpandableFabStates.Collapsed) {
|
||||||
|
tween(
|
||||||
|
easing = LinearEasing,
|
||||||
|
durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tween(
|
||||||
|
easing = LinearEasing,
|
||||||
|
delayMillis = (transitionDuration / 3f).roundToInt(), // 4 / 12 frames
|
||||||
|
durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = "fab_text_opacity",
|
||||||
|
) { state ->
|
||||||
|
if (state == ExpandableFabStates.Collapsed) {
|
||||||
|
0f
|
||||||
|
} else {
|
||||||
|
1f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val fabWidthFactor by transition.animateFloat(
|
||||||
|
transitionSpec = {
|
||||||
|
if (targetState == ExpandableFabStates.Collapsed) {
|
||||||
|
tween(
|
||||||
|
easing = FastOutSlowInEasing,
|
||||||
|
durationMillis = transitionDuration,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tween(
|
||||||
|
easing = FastOutSlowInEasing,
|
||||||
|
durationMillis = transitionDuration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = "fab_width_factor",
|
||||||
|
) { state ->
|
||||||
|
if (state == ExpandableFabStates.Collapsed) {
|
||||||
|
0f
|
||||||
|
} else {
|
||||||
|
1f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deferring reads using lambdas instead of Floats here can improve performance,
|
||||||
|
// preventing recompositions.
|
||||||
|
IconAndTextRow(
|
||||||
|
icon,
|
||||||
|
text,
|
||||||
|
{ textOpacity },
|
||||||
|
{ fabWidthFactor },
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IconAndTextRow(
|
||||||
|
icon: @Composable () -> Unit,
|
||||||
|
text: @Composable () -> Unit,
|
||||||
|
opacityProgress: () -> Float, // Lambdas instead of Floats, to defer read
|
||||||
|
widthProgress: () -> Float,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
Layout(
|
||||||
|
modifier = modifier,
|
||||||
|
content = {
|
||||||
|
icon()
|
||||||
|
Box(modifier = Modifier.graphicsLayer { alpha = opacityProgress() }) {
|
||||||
|
text()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { measurables, constraints ->
|
||||||
|
|
||||||
|
val iconPlaceable = measurables[0].measure(constraints)
|
||||||
|
val textPlaceable = measurables[1].measure(constraints)
|
||||||
|
|
||||||
|
val height = constraints.maxHeight
|
||||||
|
|
||||||
|
// FAB has an aspect ratio of 1 so the initial width is the height
|
||||||
|
val initialWidth = height.toFloat()
|
||||||
|
|
||||||
|
// Use it to get the padding
|
||||||
|
val iconPadding = (initialWidth - iconPlaceable.width) / 2f
|
||||||
|
|
||||||
|
// The full width will be : padding + icon + padding + text + padding
|
||||||
|
val expandedWidth = iconPlaceable.width + textPlaceable.width + iconPadding * 3
|
||||||
|
|
||||||
|
// Apply the animation factor to go from initialWidth to fullWidth
|
||||||
|
val width = lerp(initialWidth, expandedWidth, widthProgress())
|
||||||
|
|
||||||
|
layout(width.roundToInt(), height) {
|
||||||
|
iconPlaceable.place(
|
||||||
|
iconPadding.roundToInt(),
|
||||||
|
constraints.maxHeight / 2 - iconPlaceable.height / 2,
|
||||||
|
)
|
||||||
|
textPlaceable.place(
|
||||||
|
(iconPlaceable.width + iconPadding * 2).roundToInt(),
|
||||||
|
constraints.maxHeight / 2 - textPlaceable.height / 2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class ExpandableFabStates { Collapsed, Extended }
|
||||||
|
|
||||||
|
private const val transitionDuration = 200
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.components
|
||||||
|
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.FirstBaseline
|
||||||
|
import androidx.compose.ui.layout.LastBaseline
|
||||||
|
import androidx.compose.ui.layout.LayoutModifier
|
||||||
|
import androidx.compose.ui.layout.Measurable
|
||||||
|
import androidx.compose.ui.layout.MeasureResult
|
||||||
|
import androidx.compose.ui.layout.MeasureScope
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applied to a Text, it sets the distance between the top and the first baseline. It
|
||||||
|
* also makes the bottom of the element coincide with the last baseline of the text.
|
||||||
|
*
|
||||||
|
* _______________
|
||||||
|
* | | ↑
|
||||||
|
* | | | heightFromBaseline
|
||||||
|
* |Hello, World!| ↓
|
||||||
|
* ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
|
||||||
|
*
|
||||||
|
* This modifier can be used to distribute multiple text elements using a certain distance between
|
||||||
|
* baselines.
|
||||||
|
*/
|
||||||
|
data class BaselineHeightModifier(
|
||||||
|
val heightFromBaseline: Dp,
|
||||||
|
) : LayoutModifier {
|
||||||
|
override fun MeasureScope.measure(
|
||||||
|
measurable: Measurable,
|
||||||
|
constraints: Constraints,
|
||||||
|
): MeasureResult {
|
||||||
|
val textPlaceable = measurable.measure(constraints)
|
||||||
|
val firstBaseline = textPlaceable[FirstBaseline]
|
||||||
|
val lastBaseline = textPlaceable[LastBaseline]
|
||||||
|
|
||||||
|
val height = heightFromBaseline.roundToPx() + lastBaseline - firstBaseline
|
||||||
|
return layout(constraints.maxWidth, height) {
|
||||||
|
val topY = heightFromBaseline.roundToPx() - firstBaseline
|
||||||
|
textPlaceable.place(0, topY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Modifier.baselineHeight(heightFromBaseline: Dp): Modifier = this.then(BaselineHeightModifier(heightFromBaseline))
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun JetchatAppBar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
|
onNavIconPressed: () -> Unit = { },
|
||||||
|
title: @Composable () -> Unit,
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
) {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
modifier = modifier,
|
||||||
|
actions = actions,
|
||||||
|
title = title,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
navigationIcon = {
|
||||||
|
JetchatIcon(
|
||||||
|
contentDescription = stringResource(id = R.string.navigation_drawer_open),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clickable(onClick = onNavIconPressed)
|
||||||
|
.padding(16.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun JetchatAppBarPreview() {
|
||||||
|
JetchatTheme {
|
||||||
|
JetchatAppBar(title = { Text("Preview!") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun JetchatAppBarPreviewDark() {
|
||||||
|
JetchatTheme(isDarkTheme = true) {
|
||||||
|
JetchatAppBar(title = { Text("Preview!") })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.components
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.ChecksSdkIntAtLeast
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsTopHeight
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment.Companion.CenterStart
|
||||||
|
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
import xyz.magicalbits.smsremote.network.NetworkClient
|
||||||
|
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||||
|
import xyz.magicalbits.smsremote.widget.WidgetReceiver
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun JetchatDrawerContent(onChatClicked: (NetworkClient.DeviceDto) -> Unit, selectedMenu: String = "", deviceDtoList: List<NetworkClient.DeviceDto> = listOf()) {
|
||||||
|
// Use windowInsetsTopHeight() to add a spacer which pushes the drawer content
|
||||||
|
// below the status bar (y-axis)
|
||||||
|
Column {
|
||||||
|
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.statusBars))
|
||||||
|
DrawerHeader()
|
||||||
|
DividerItem()
|
||||||
|
DrawerItemHeader("Devices")
|
||||||
|
|
||||||
|
for (deviceDto in deviceDtoList) {
|
||||||
|
DeviceItem(deviceDto.name, selectedMenu == deviceDto.access_key) {
|
||||||
|
onChatClicked(deviceDto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DividerItem(modifier = Modifier.padding(horizontal = 28.dp))
|
||||||
|
if (widgetAddingIsSupported(LocalContext.current)) {
|
||||||
|
DividerItem(modifier = Modifier.padding(horizontal = 28.dp))
|
||||||
|
DrawerItemHeader("Settings")
|
||||||
|
WidgetDiscoverability()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DrawerHeader() {
|
||||||
|
Row(modifier = Modifier.padding(16.dp), verticalAlignment = CenterVertically) {
|
||||||
|
JetchatIcon(
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.jetchat_logo),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DrawerItemHeader(text: String) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(min = 52.dp)
|
||||||
|
.padding(horizontal = 28.dp),
|
||||||
|
contentAlignment = CenterStart,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceItem(text: String, selected: Boolean, onChatClicked: () -> Unit) {
|
||||||
|
val background = if (selected) {
|
||||||
|
Modifier.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(56.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.then(background)
|
||||||
|
.clickable(onClick = onChatClicked),
|
||||||
|
verticalAlignment = CenterVertically,
|
||||||
|
) {
|
||||||
|
val iconTint = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_jetchat),
|
||||||
|
tint = iconTint,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 16.dp),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileItem(text: String, @DrawableRes profilePic: Int?, selected: Boolean = false, onProfileClicked: () -> Unit) {
|
||||||
|
val background = if (selected) {
|
||||||
|
Modifier.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(56.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.then(background)
|
||||||
|
.clickable(onClick = onProfileClicked),
|
||||||
|
verticalAlignment = CenterVertically,
|
||||||
|
) {
|
||||||
|
val paddingSizeModifier = Modifier
|
||||||
|
.padding(start = 16.dp, top = 16.dp, bottom = 16.dp)
|
||||||
|
.size(24.dp)
|
||||||
|
if (profilePic != null) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = profilePic),
|
||||||
|
modifier = paddingSizeModifier.then(Modifier.clip(CircleShape)),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = paddingSizeModifier)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DividerItem(modifier: Modifier = Modifier) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = modifier,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview
|
||||||
|
fun DrawerPreview() {
|
||||||
|
JetchatTheme {
|
||||||
|
Surface {
|
||||||
|
Column {
|
||||||
|
JetchatDrawerContent({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview
|
||||||
|
fun DrawerPreviewDark() {
|
||||||
|
JetchatTheme(isDarkTheme = true) {
|
||||||
|
Surface {
|
||||||
|
Column {
|
||||||
|
JetchatDrawerContent({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
@Composable
|
||||||
|
private fun WidgetDiscoverability() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(56.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable(onClick = {
|
||||||
|
addWidgetToHomeScreen(context)
|
||||||
|
}),
|
||||||
|
verticalAlignment = CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.add_widget_to_home_page),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
private fun addWidgetToHomeScreen(context: Context) {
|
||||||
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
|
val myProvider = ComponentName(context, WidgetReceiver::class.java)
|
||||||
|
if (widgetAddingIsSupported(context)) {
|
||||||
|
appWidgetManager.requestPinAppWidget(myProvider, null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
|
||||||
|
private fun widgetAddingIsSupported(context: Context): Boolean {
|
||||||
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||||
|
AppWidgetManager.getInstance(context).isRequestPinAppWidgetSupported
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun JetchatIcon(contentDescription: String?, modifier: Modifier = Modifier) {
|
||||||
|
val semantics = if (contentDescription != null) {
|
||||||
|
Modifier.semantics {
|
||||||
|
this.contentDescription = contentDescription
|
||||||
|
this.role = Role.Image
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
Box(modifier = modifier.then(semantics)) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_jetchat_back),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_jetchat_front),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.components
|
||||||
|
|
||||||
|
import androidx.compose.material3.DrawerState
|
||||||
|
import androidx.compose.material3.DrawerValue.Closed
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import xyz.magicalbits.smsremote.network.NetworkClient
|
||||||
|
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun JetchatDrawer(
|
||||||
|
drawerState: DrawerState = rememberDrawerState(initialValue = Closed),
|
||||||
|
selectedMenu: String,
|
||||||
|
deviceDtoList: List<NetworkClient.DeviceDto>,
|
||||||
|
onChatClicked: (NetworkClient.DeviceDto) -> Unit,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
JetchatTheme {
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
drawerContent = {
|
||||||
|
ModalDrawerSheet(
|
||||||
|
drawerState = drawerState,
|
||||||
|
drawerContainerColor = MaterialTheme.colorScheme.background,
|
||||||
|
drawerContentColor = MaterialTheme.colorScheme.onBackground,
|
||||||
|
) {
|
||||||
|
JetchatDrawerContent(
|
||||||
|
onChatClicked = onChatClicked,
|
||||||
|
selectedMenu = selectedMenu,
|
||||||
|
deviceDtoList = deviceDtoList,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.conversation
|
||||||
|
|
||||||
|
import android.content.ClipDescription
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.draganddrop.dragAndDropTarget
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.exclude
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.paddingFrom
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.ScaffoldDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropTarget
|
||||||
|
import androidx.compose.ui.draganddrop.mimeTypes
|
||||||
|
import androidx.compose.ui.draganddrop.toAndroidDragEvent
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.LastBaseline
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
import xyz.magicalbits.smsremote.components.JetchatAppBar
|
||||||
|
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import xyz.magicalbits.smsremote.data.exampleUiStateNew
|
||||||
|
import xyz.magicalbits.smsremote.network.NetworkClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point for a conversation screen.
|
||||||
|
*
|
||||||
|
* @param uiState [ConversationScreenState] that contains messages to display
|
||||||
|
* @param navigateToProfile User action when navigation to a profile is requested
|
||||||
|
* @param modifier [Modifier] to apply to this layout node
|
||||||
|
* @param onNavIconPressed Sends an event up when the user clicks on the menu
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ConversationContent(
|
||||||
|
uiState: ConversationScreenState,
|
||||||
|
navigateToProfile: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onNavIconPressed: () -> Unit = { },
|
||||||
|
) {
|
||||||
|
val authorMe = stringResource(R.string.author_me)
|
||||||
|
val timeNow = stringResource(id = R.string.now)
|
||||||
|
|
||||||
|
val scrollState = rememberLazyListState()
|
||||||
|
val topBarState = rememberTopAppBarState()
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var background by remember {
|
||||||
|
mutableStateOf(Color.Transparent)
|
||||||
|
}
|
||||||
|
|
||||||
|
var borderStroke by remember {
|
||||||
|
mutableStateOf(Color.Transparent)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dragAndDropCallback = remember {
|
||||||
|
object : DragAndDropTarget {
|
||||||
|
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||||
|
val clipData = event.toAndroidDragEvent().clipData
|
||||||
|
|
||||||
|
if (clipData.itemCount < 1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.addMessage(
|
||||||
|
Message(authorMe, clipData.getItemAt(0).text.toString(), timeNow),
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStarted(event: DragAndDropEvent) {
|
||||||
|
super.onStarted(event)
|
||||||
|
borderStroke = Color.Red
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEntered(event: DragAndDropEvent) {
|
||||||
|
super.onEntered(event)
|
||||||
|
background = Color.Red.copy(alpha = .3f)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onExited(event: DragAndDropEvent) {
|
||||||
|
super.onExited(event)
|
||||||
|
background = Color.Transparent
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEnded(event: DragAndDropEvent) {
|
||||||
|
super.onEnded(event)
|
||||||
|
background = Color.Transparent
|
||||||
|
borderStroke = Color.Transparent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
ChannelNameBar(
|
||||||
|
channelName = uiState.remotePhoneNumber,
|
||||||
|
channelMembers = 2, // TODO remove?
|
||||||
|
onNavIconPressed = onNavIconPressed,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// Exclude ime and navigation bar padding so this can be added by the UserInput composable
|
||||||
|
contentWindowInsets = ScaffoldDefaults
|
||||||
|
.contentWindowInsets
|
||||||
|
.exclude(WindowInsets.navigationBars)
|
||||||
|
.exclude(WindowInsets.ime),
|
||||||
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(paddingValues)
|
||||||
|
.background(color = background)
|
||||||
|
.border(width = 2.dp, color = borderStroke)
|
||||||
|
.dragAndDropTarget(shouldStartDragAndDrop = { event ->
|
||||||
|
event
|
||||||
|
.mimeTypes()
|
||||||
|
.contains(
|
||||||
|
ClipDescription.MIMETYPE_TEXT_PLAIN,
|
||||||
|
)
|
||||||
|
}, target = dragAndDropCallback),
|
||||||
|
) {
|
||||||
|
Messages(
|
||||||
|
messages = uiState.messages,
|
||||||
|
navigateToProfile = navigateToProfile,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
scrollState = scrollState,
|
||||||
|
)
|
||||||
|
UserInput(
|
||||||
|
onMessageSent = { content ->
|
||||||
|
uiState.addMessage(
|
||||||
|
Message(authorMe, content, timeNow),
|
||||||
|
)
|
||||||
|
scope.launch {
|
||||||
|
val networkClient = NetworkClient()
|
||||||
|
// FIXME
|
||||||
|
// networkClient.sendSmsMessage(content, uiState.localPhoneNumber, uiState.remotePhoneNumber)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetScroll = {
|
||||||
|
scope.launch {
|
||||||
|
scrollState.scrollToItem(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// let this element handle the padding so that the elevation is shown behind the
|
||||||
|
// navigation bar
|
||||||
|
modifier = Modifier.navigationBarsPadding().imePadding(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ChannelNameBar(
|
||||||
|
channelName: String,
|
||||||
|
channelMembers: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
|
onNavIconPressed: () -> Unit = { },
|
||||||
|
) {
|
||||||
|
var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) }
|
||||||
|
if (functionalityNotAvailablePopupShown) {
|
||||||
|
FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false }
|
||||||
|
}
|
||||||
|
JetchatAppBar(
|
||||||
|
modifier = modifier,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
onNavIconPressed = onNavIconPressed,
|
||||||
|
title = {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
// Channel name
|
||||||
|
Text(
|
||||||
|
text = channelName,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
// Number of members
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.members, channelMembers),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
// Search icon
|
||||||
|
Icon(
|
||||||
|
painterResource(id = R.drawable.ic_search),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = { functionalityNotAvailablePopupShown = true })
|
||||||
|
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||||
|
.height(24.dp),
|
||||||
|
contentDescription = stringResource(id = R.string.search),
|
||||||
|
)
|
||||||
|
// Info icon
|
||||||
|
Icon(
|
||||||
|
painterResource(id = R.drawable.ic_info),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = { functionalityNotAvailablePopupShown = true })
|
||||||
|
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||||
|
.height(24.dp),
|
||||||
|
contentDescription = stringResource(id = R.string.info),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const val ConversationTestTag = "ConversationTestTag"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Messages(messages: List<Message>, navigateToProfile: (String) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
|
||||||
|
val authorMe = stringResource(id = R.string.author_me)
|
||||||
|
LazyColumn(
|
||||||
|
reverseLayout = true,
|
||||||
|
state = scrollState,
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(ConversationTestTag)
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
for (index in messages.indices) {
|
||||||
|
val prevAuthor = messages.getOrNull(index - 1)?.author
|
||||||
|
val nextAuthor = messages.getOrNull(index + 1)?.author
|
||||||
|
val content = messages[index]
|
||||||
|
val isFirstMessageByAuthor = prevAuthor != content.author
|
||||||
|
val isLastMessageByAuthor = nextAuthor != content.author
|
||||||
|
|
||||||
|
// Hardcode day dividers for simplicity
|
||||||
|
if (index == messages.size - 1) {
|
||||||
|
item {
|
||||||
|
DayHeader("20 Aug")
|
||||||
|
}
|
||||||
|
} else if (index == 2) {
|
||||||
|
item {
|
||||||
|
DayHeader("Today")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Message(
|
||||||
|
onAuthorClick = { name -> navigateToProfile(name) },
|
||||||
|
msg = content,
|
||||||
|
isUserMe = content.author == authorMe,
|
||||||
|
isFirstMessageByAuthor = isFirstMessageByAuthor,
|
||||||
|
isLastMessageByAuthor = isLastMessageByAuthor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Jump to bottom button shows up when user scrolls past a threshold.
|
||||||
|
// Convert to pixels:
|
||||||
|
val jumpThreshold = with(LocalDensity.current) {
|
||||||
|
JumpToBottomThreshold.toPx()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the button if the first visible item is not the first one or if the offset is
|
||||||
|
// greater than the threshold.
|
||||||
|
val jumpToBottomButtonEnabled by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
scrollState.firstVisibleItemIndex != 0 ||
|
||||||
|
scrollState.firstVisibleItemScrollOffset > jumpThreshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JumpToBottom(
|
||||||
|
// Only show if the scroller is not at the bottom
|
||||||
|
enabled = jumpToBottomButtonEnabled,
|
||||||
|
onClicked = {
|
||||||
|
scope.launch {
|
||||||
|
scrollState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Message(
|
||||||
|
onAuthorClick: (String) -> Unit,
|
||||||
|
msg: Message,
|
||||||
|
isUserMe: Boolean,
|
||||||
|
isFirstMessageByAuthor: Boolean,
|
||||||
|
isLastMessageByAuthor: Boolean,
|
||||||
|
) {
|
||||||
|
val borderColor = if (isUserMe) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.tertiary
|
||||||
|
}
|
||||||
|
|
||||||
|
val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier
|
||||||
|
Row(modifier = spaceBetweenAuthors) {
|
||||||
|
if (isLastMessageByAuthor) {
|
||||||
|
// Avatar
|
||||||
|
Image(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = { onAuthorClick(msg.author) })
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.size(42.dp)
|
||||||
|
.border(1.5.dp, borderColor, CircleShape)
|
||||||
|
.border(3.dp, MaterialTheme.colorScheme.surface, CircleShape)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.align(Alignment.Top),
|
||||||
|
painter = painterResource(id = msg.authorImage),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Space under avatar
|
||||||
|
Spacer(modifier = Modifier.width(74.dp))
|
||||||
|
}
|
||||||
|
AuthorAndTextMessage(
|
||||||
|
msg = msg,
|
||||||
|
isUserMe = isUserMe,
|
||||||
|
isFirstMessageByAuthor = isFirstMessageByAuthor,
|
||||||
|
isLastMessageByAuthor = isLastMessageByAuthor,
|
||||||
|
authorClicked = onAuthorClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = 16.dp)
|
||||||
|
.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthorAndTextMessage(
|
||||||
|
msg: Message,
|
||||||
|
isUserMe: Boolean,
|
||||||
|
isFirstMessageByAuthor: Boolean,
|
||||||
|
isLastMessageByAuthor: Boolean,
|
||||||
|
authorClicked: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
if (isLastMessageByAuthor) {
|
||||||
|
AuthorNameTimestamp(msg)
|
||||||
|
}
|
||||||
|
ChatItemBubble(msg, isUserMe, authorClicked = authorClicked)
|
||||||
|
if (isFirstMessageByAuthor) {
|
||||||
|
// Last bubble before next author
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
} else {
|
||||||
|
// Between bubbles
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AuthorNameTimestamp(msg: Message) {
|
||||||
|
// Combine author and timestamp for a11y.
|
||||||
|
Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
|
||||||
|
Text(
|
||||||
|
text = msg.author,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.alignBy(LastBaseline)
|
||||||
|
.paddingFrom(LastBaseline, after = 8.dp), // Space to 1st bubble
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = msg.timestamp,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.alignBy(LastBaseline),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DayHeader(dayString: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||||
|
.height(16.dp),
|
||||||
|
) {
|
||||||
|
DayHeaderLine()
|
||||||
|
Text(
|
||||||
|
text = dayString,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
DayHeaderLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.DayHeaderLine() {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.align(Alignment.CenterVertically),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) {
|
||||||
|
|
||||||
|
val backgroundBubbleColor = if (isUserMe) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Surface(
|
||||||
|
color = backgroundBubbleColor,
|
||||||
|
shape = ChatBubbleShape,
|
||||||
|
) {
|
||||||
|
ClickableMessage(
|
||||||
|
message = message,
|
||||||
|
isUserMe = isUserMe,
|
||||||
|
authorClicked = authorClicked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
message.image?.let {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Surface(
|
||||||
|
color = backgroundBubbleColor,
|
||||||
|
shape = ChatBubbleShape,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(it),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.size(160.dp),
|
||||||
|
contentDescription = stringResource(id = R.string.attached_image),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
|
val styledMessage = messageFormatter(
|
||||||
|
text = message.content,
|
||||||
|
primary = isUserMe,
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickableText(
|
||||||
|
text = styledMessage,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current),
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
onClick = {
|
||||||
|
styledMessage
|
||||||
|
.getStringAnnotations(start = it, end = it)
|
||||||
|
.firstOrNull()
|
||||||
|
?.let { annotation ->
|
||||||
|
when (annotation.tag) {
|
||||||
|
SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item)
|
||||||
|
SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ConversationPreview() {
|
||||||
|
JetchatTheme {
|
||||||
|
ConversationContent(
|
||||||
|
uiState = exampleUiStateNew,
|
||||||
|
navigateToProfile = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ChannelBarPrev() {
|
||||||
|
JetchatTheme {
|
||||||
|
ChannelNameBar(channelName = "composers", channelMembers = 52)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun DayHeaderPrev() {
|
||||||
|
DayHeader("Aug 6")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val JumpToBottomThreshold = 56.dp
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.conversation
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewGroup.LayoutParams
|
||||||
|
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.navigation.findNavController
|
||||||
|
import xyz.magicalbits.smsremote.MainViewModel
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
import xyz.magicalbits.smsremote.data.exampleUiState
|
||||||
|
import xyz.magicalbits.smsremote.data.exampleUiStateNew
|
||||||
|
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||||
|
|
||||||
|
class ConversationFragment : Fragment() {
|
||||||
|
private val activityViewModel: MainViewModel by activityViewModels()
|
||||||
|
private val conversationViewModel: ConversationViewModel by activityViewModels()
|
||||||
|
|
||||||
|
var phoneNumber: String = ""
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
// Consider using safe args plugin
|
||||||
|
val phoneNumber = arguments?.getString("phoneNumber")
|
||||||
|
this.phoneNumber = phoneNumber!!
|
||||||
|
// update view model with latest messages
|
||||||
|
conversationViewModel.setConversationData(phoneNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?,
|
||||||
|
): View =
|
||||||
|
ComposeView(inflater.context).apply {
|
||||||
|
layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
val conversationData by conversationViewModel.conversationData.observeAsState()
|
||||||
|
JetchatTheme {
|
||||||
|
ConversationContent(
|
||||||
|
uiState = conversationData ?: exampleUiStateNew,
|
||||||
|
navigateToProfile = { user ->
|
||||||
|
// Click callback
|
||||||
|
val bundle = bundleOf("userId" to user)
|
||||||
|
findNavController().navigate(
|
||||||
|
R.id.nav_profile,
|
||||||
|
bundle,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onNavIconPressed = {
|
||||||
|
activityViewModel.openDrawer()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.conversation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.toMutableStateList
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
|
||||||
|
class ConversationUiState(
|
||||||
|
var channelName: String,
|
||||||
|
val channelMembers: Int,
|
||||||
|
initialMessages: List<Message>,
|
||||||
|
) {
|
||||||
|
private val _messages: MutableList<Message> = initialMessages.toMutableStateList()
|
||||||
|
val messages: List<Message> = _messages
|
||||||
|
|
||||||
|
fun addMessage(msg: Message) {
|
||||||
|
_messages.add(0, msg) // Add to the beginning of the list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class Message(
|
||||||
|
val author: String,
|
||||||
|
val content: String,
|
||||||
|
val timestamp: String,
|
||||||
|
val image: Int? = null,
|
||||||
|
val authorImage: Int = if (author == "me") R.drawable.ali else R.drawable.someone_else,
|
||||||
|
)
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package xyz.magicalbits.smsremote.conversation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.toMutableStateList
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import xyz.magicalbits.smsremote.network.NetworkClient
|
||||||
|
|
||||||
|
class ConversationViewModel : ViewModel() {
|
||||||
|
private var remotePhoneNumber: String = ""
|
||||||
|
private val _conversationData = MutableLiveData<ConversationScreenState>()
|
||||||
|
val conversationData: LiveData<ConversationScreenState> = _conversationData
|
||||||
|
|
||||||
|
fun setConversationData(phoneNumber: String?) {
|
||||||
|
if (phoneNumber != null) {
|
||||||
|
this.remotePhoneNumber = phoneNumber
|
||||||
|
|
||||||
|
var messageDtoList: List<NetworkClient.SmsMessageDto> = listOf()
|
||||||
|
viewModelScope.launch {
|
||||||
|
val networkClient = NetworkClient()
|
||||||
|
messageDtoList = networkClient.getSmsMessagesByLocalPhoneNumber(phoneNumber)
|
||||||
|
}.invokeOnCompletion {
|
||||||
|
_conversationData.value =
|
||||||
|
ConversationScreenState(
|
||||||
|
remotePhoneNumber = this.remotePhoneNumber,
|
||||||
|
initialMessages = messageDtoList.map {
|
||||||
|
Message(
|
||||||
|
if (it.msg_type == "INCOMING") {
|
||||||
|
it.remote_phone_number
|
||||||
|
} else {
|
||||||
|
it.local_phone_number
|
||||||
|
},
|
||||||
|
it.content,
|
||||||
|
// FIXME convert to HH:MM AM/PM
|
||||||
|
it.ts_sent.toString(),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
println("sims live: ${_conversationData.value!!.initialMessages}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ConversationScreenState(
|
||||||
|
val remotePhoneNumber: String,
|
||||||
|
val initialMessages: List<Message>,
|
||||||
|
) {
|
||||||
|
private val _messages: MutableList<Message> = initialMessages.toMutableStateList()
|
||||||
|
|
||||||
|
val messages: List<Message> = _messages
|
||||||
|
|
||||||
|
fun addMessage(msg: Message) {
|
||||||
|
_messages.add(0, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.conversation
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDp
|
||||||
|
import androidx.compose.animation.core.updateTransition
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
|
||||||
|
private enum class Visibility {
|
||||||
|
VISIBLE,
|
||||||
|
GONE,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a button that lets the user scroll to the bottom.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun JumpToBottom(enabled: Boolean, onClicked: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
// Show Jump to Bottom button
|
||||||
|
val transition = updateTransition(
|
||||||
|
if (enabled) Visibility.VISIBLE else Visibility.GONE,
|
||||||
|
label = "JumpToBottom visibility animation",
|
||||||
|
)
|
||||||
|
val bottomOffset by transition.animateDp(label = "JumpToBottom offset animation") {
|
||||||
|
if (it == Visibility.GONE) {
|
||||||
|
(-32).dp
|
||||||
|
} else {
|
||||||
|
32.dp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bottomOffset > 0.dp) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_arrow_downward),
|
||||||
|
modifier = Modifier.height(18.dp),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(text = stringResource(id = R.string.jumpBottom))
|
||||||
|
},
|
||||||
|
onClick = onClicked,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = modifier
|
||||||
|
.offset(x = 0.dp, y = -bottomOffset)
|
||||||
|
.height(36.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun JumpToBottomPreview() {
|
||||||
|
JumpToBottom(enabled = true, onClicked = {})
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.conversation
|
||||||
|
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.BaselineShift
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
// Regex containing the syntax tokens
|
||||||
|
val symbolPattern by lazy {
|
||||||
|
Regex("""(https?://[^\s\t\n]+)|(`[^`]+`)|(@\w+)|(\*[\w]+\*)|(_[\w]+_)|(~[\w]+~)""")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepted annotations for the ClickableTextWrapper
|
||||||
|
enum class SymbolAnnotationType {
|
||||||
|
PERSON,
|
||||||
|
LINK,
|
||||||
|
}
|
||||||
|
typealias StringAnnotation = AnnotatedString.Range<String>
|
||||||
|
|
||||||
|
// Pair returning styled content and annotation for ClickableText when matching syntax token
|
||||||
|
typealias SymbolAnnotation = Pair<AnnotatedString, StringAnnotation?>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a message following Markdown-lite syntax
|
||||||
|
* | @username -> bold, primary color and clickable element
|
||||||
|
* | http(s)://... -> clickable link, opening it into the browser
|
||||||
|
* | *bold* -> bold
|
||||||
|
* | _italic_ -> italic
|
||||||
|
* | ~strikethrough~ -> strikethrough
|
||||||
|
* | `MyClass.myMethod` -> inline code styling
|
||||||
|
*
|
||||||
|
* @param text contains message to be parsed
|
||||||
|
* @return AnnotatedString with annotations used inside the ClickableText wrapper
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun messageFormatter(
|
||||||
|
text: String,
|
||||||
|
primary: Boolean,
|
||||||
|
): AnnotatedString {
|
||||||
|
val tokens = symbolPattern.findAll(text)
|
||||||
|
|
||||||
|
return buildAnnotatedString {
|
||||||
|
var cursorPosition = 0
|
||||||
|
|
||||||
|
val codeSnippetBackground =
|
||||||
|
if (primary) {
|
||||||
|
MaterialTheme.colorScheme.secondary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
|
||||||
|
for (token in tokens) {
|
||||||
|
append(text.slice(cursorPosition until token.range.first))
|
||||||
|
|
||||||
|
val (annotatedString, stringAnnotation) =
|
||||||
|
getSymbolAnnotation(
|
||||||
|
matchResult = token,
|
||||||
|
colorScheme = MaterialTheme.colorScheme,
|
||||||
|
primary = primary,
|
||||||
|
codeSnippetBackground = codeSnippetBackground,
|
||||||
|
)
|
||||||
|
append(annotatedString)
|
||||||
|
|
||||||
|
if (stringAnnotation != null) {
|
||||||
|
val (item, start, end, tag) = stringAnnotation
|
||||||
|
addStringAnnotation(tag = tag, start = start, end = end, annotation = item)
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorPosition = token.range.last + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokens.none()) {
|
||||||
|
append(text.slice(cursorPosition..text.lastIndex))
|
||||||
|
} else {
|
||||||
|
append(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map regex matches found in a message with supported syntax symbols
|
||||||
|
*
|
||||||
|
* @param matchResult is a regex result matching our syntax symbols
|
||||||
|
* @return pair of AnnotatedString with annotation (optional) used inside the ClickableText wrapper
|
||||||
|
*/
|
||||||
|
private fun getSymbolAnnotation(
|
||||||
|
matchResult: MatchResult,
|
||||||
|
colorScheme: ColorScheme,
|
||||||
|
primary: Boolean,
|
||||||
|
codeSnippetBackground: Color,
|
||||||
|
): SymbolAnnotation =
|
||||||
|
when (matchResult.value.first()) {
|
||||||
|
'@' -> {
|
||||||
|
SymbolAnnotation(
|
||||||
|
AnnotatedString(
|
||||||
|
text = matchResult.value,
|
||||||
|
spanStyle =
|
||||||
|
SpanStyle(
|
||||||
|
color = if (primary) colorScheme.inversePrimary else colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StringAnnotation(
|
||||||
|
item = matchResult.value.substring(1),
|
||||||
|
start = matchResult.range.first,
|
||||||
|
end = matchResult.range.last,
|
||||||
|
tag = SymbolAnnotationType.PERSON.name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
'*' -> {
|
||||||
|
SymbolAnnotation(
|
||||||
|
AnnotatedString(
|
||||||
|
text = matchResult.value.trim('*'),
|
||||||
|
spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
'_' -> {
|
||||||
|
SymbolAnnotation(
|
||||||
|
AnnotatedString(
|
||||||
|
text = matchResult.value.trim('_'),
|
||||||
|
spanStyle = SpanStyle(fontStyle = FontStyle.Italic),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
'~' -> {
|
||||||
|
SymbolAnnotation(
|
||||||
|
AnnotatedString(
|
||||||
|
text = matchResult.value.trim('~'),
|
||||||
|
spanStyle = SpanStyle(textDecoration = TextDecoration.LineThrough),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
'`' -> {
|
||||||
|
SymbolAnnotation(
|
||||||
|
AnnotatedString(
|
||||||
|
text = matchResult.value.trim('`'),
|
||||||
|
spanStyle =
|
||||||
|
SpanStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
background = codeSnippetBackground,
|
||||||
|
baselineShift = BaselineShift(0.2f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
'h' -> {
|
||||||
|
SymbolAnnotation(
|
||||||
|
AnnotatedString(
|
||||||
|
text = matchResult.value,
|
||||||
|
spanStyle =
|
||||||
|
SpanStyle(
|
||||||
|
color = if (primary) colorScheme.inversePrimary else colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StringAnnotation(
|
||||||
|
item = matchResult.value,
|
||||||
|
start = matchResult.range.first,
|
||||||
|
end = matchResult.range.last,
|
||||||
|
tag = SymbolAnnotationType.LINK.name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
SymbolAnnotation(AnnotatedString(matchResult.value), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.conversation
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
|
import androidx.compose.animation.core.MutableTransitionState
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
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.layout.paddingFrom
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.contentColorFor
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusTarget
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.FirstBaseline
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.SemanticsPropertyKey
|
||||||
|
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
enum class InputSelector {
|
||||||
|
NONE,
|
||||||
|
MAP,
|
||||||
|
DM,
|
||||||
|
EMOJI,
|
||||||
|
PHONE,
|
||||||
|
PICTURE,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class EmojiStickerSelector {
|
||||||
|
EMOJI,
|
||||||
|
STICKER,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun UserInputPreview() {
|
||||||
|
UserInput(onMessageSent = {})
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun UserInput(onMessageSent: (String) -> Unit, modifier: Modifier = Modifier, resetScroll: () -> Unit = {}) {
|
||||||
|
var currentInputSelector by rememberSaveable { mutableStateOf(InputSelector.NONE) }
|
||||||
|
val dismissKeyboard = { currentInputSelector = InputSelector.NONE }
|
||||||
|
|
||||||
|
// Intercept back navigation if there's a InputSelector visible
|
||||||
|
if (currentInputSelector != InputSelector.NONE) {
|
||||||
|
BackHandler(onBack = dismissKeyboard)
|
||||||
|
}
|
||||||
|
|
||||||
|
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||||
|
mutableStateOf(TextFieldValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to decide if the keyboard should be shown
|
||||||
|
var textFieldFocusState by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Surface(tonalElevation = 2.dp, contentColor = MaterialTheme.colorScheme.secondary) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
UserInputText(
|
||||||
|
textFieldValue = textState,
|
||||||
|
onTextChanged = { textState = it },
|
||||||
|
// Only show the keyboard if there's no input selector and text field has focus
|
||||||
|
keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState,
|
||||||
|
// Close extended selector if text field receives focus
|
||||||
|
onTextFieldFocused = { focused ->
|
||||||
|
if (focused) {
|
||||||
|
currentInputSelector = InputSelector.NONE
|
||||||
|
resetScroll()
|
||||||
|
}
|
||||||
|
textFieldFocusState = focused
|
||||||
|
},
|
||||||
|
onMessageSent = {
|
||||||
|
onMessageSent(textState.text)
|
||||||
|
// Reset text field and close keyboard
|
||||||
|
textState = TextFieldValue()
|
||||||
|
// Move scroll to bottom
|
||||||
|
resetScroll()
|
||||||
|
},
|
||||||
|
focusState = textFieldFocusState,
|
||||||
|
)
|
||||||
|
UserInputSelector(
|
||||||
|
onSelectorChange = { currentInputSelector = it },
|
||||||
|
sendMessageEnabled = textState.text.isNotBlank(),
|
||||||
|
onMessageSent = {
|
||||||
|
onMessageSent(textState.text)
|
||||||
|
// Reset text field and close keyboard
|
||||||
|
textState = TextFieldValue()
|
||||||
|
// Move scroll to bottom
|
||||||
|
resetScroll()
|
||||||
|
dismissKeyboard()
|
||||||
|
},
|
||||||
|
currentInputSelector = currentInputSelector,
|
||||||
|
)
|
||||||
|
SelectorExpanded(
|
||||||
|
onCloseRequested = dismissKeyboard,
|
||||||
|
onTextAdded = { textState = textState.addText(it) },
|
||||||
|
currentSelector = currentInputSelector,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TextFieldValue.addText(newString: String): TextFieldValue {
|
||||||
|
val newText = this.text.replaceRange(
|
||||||
|
this.selection.start,
|
||||||
|
this.selection.end,
|
||||||
|
newString,
|
||||||
|
)
|
||||||
|
val newSelection = TextRange(
|
||||||
|
start = newText.length,
|
||||||
|
end = newText.length,
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.copy(text = newText, selection = newSelection)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectorExpanded(currentSelector: InputSelector, onCloseRequested: () -> Unit, onTextAdded: (String) -> Unit) {
|
||||||
|
if (currentSelector == InputSelector.NONE) return
|
||||||
|
|
||||||
|
// Request focus to force the TextField to lose it
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
// If the selector is shown, always request focus to trigger a TextField.onFocusChange.
|
||||||
|
SideEffect {
|
||||||
|
if (currentSelector == InputSelector.EMOJI) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(tonalElevation = 8.dp) {
|
||||||
|
when (currentSelector) {
|
||||||
|
InputSelector.EMOJI -> EmojiSelector(onTextAdded, focusRequester)
|
||||||
|
InputSelector.DM -> NotAvailablePopup(onCloseRequested)
|
||||||
|
InputSelector.PICTURE -> FunctionalityNotAvailablePanel()
|
||||||
|
InputSelector.MAP -> FunctionalityNotAvailablePanel()
|
||||||
|
InputSelector.PHONE -> FunctionalityNotAvailablePanel()
|
||||||
|
InputSelector.NONE -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FunctionalityNotAvailablePanel() {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visibleState = remember { MutableTransitionState(false).apply { targetState = true } },
|
||||||
|
enter = expandHorizontally() + fadeIn(),
|
||||||
|
exit = shrinkHorizontally() + fadeOut(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(320.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.not_available),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.not_available_subtitle),
|
||||||
|
modifier = Modifier.paddingFrom(FirstBaseline, before = 32.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UserInputSelector(
|
||||||
|
onSelectorChange: (InputSelector) -> Unit,
|
||||||
|
sendMessageEnabled: Boolean,
|
||||||
|
onMessageSent: () -> Unit,
|
||||||
|
currentInputSelector: InputSelector,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.height(72.dp)
|
||||||
|
.wrapContentHeight()
|
||||||
|
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
InputSelectorButton(
|
||||||
|
onClick = { onSelectorChange(InputSelector.EMOJI) },
|
||||||
|
icon = painterResource(id = R.drawable.ic_mood),
|
||||||
|
selected = currentInputSelector == InputSelector.EMOJI,
|
||||||
|
description = stringResource(id = R.string.emoji_selector_bt_desc),
|
||||||
|
)
|
||||||
|
InputSelectorButton(
|
||||||
|
onClick = { onSelectorChange(InputSelector.DM) },
|
||||||
|
icon = painterResource(id = R.drawable.ic_alternate_email),
|
||||||
|
selected = currentInputSelector == InputSelector.DM,
|
||||||
|
description = stringResource(id = R.string.dm_desc),
|
||||||
|
)
|
||||||
|
InputSelectorButton(
|
||||||
|
onClick = { onSelectorChange(InputSelector.PICTURE) },
|
||||||
|
icon = painterResource(id = R.drawable.ic_insert_photo),
|
||||||
|
selected = currentInputSelector == InputSelector.PICTURE,
|
||||||
|
description = stringResource(id = R.string.attach_photo_desc),
|
||||||
|
)
|
||||||
|
InputSelectorButton(
|
||||||
|
onClick = { onSelectorChange(InputSelector.MAP) },
|
||||||
|
icon = painterResource(id = R.drawable.ic_place),
|
||||||
|
selected = currentInputSelector == InputSelector.MAP,
|
||||||
|
description = stringResource(id = R.string.map_selector_desc),
|
||||||
|
)
|
||||||
|
InputSelectorButton(
|
||||||
|
onClick = { onSelectorChange(InputSelector.PHONE) },
|
||||||
|
icon = painterResource(id = R.drawable.ic_duo),
|
||||||
|
selected = currentInputSelector == InputSelector.PHONE,
|
||||||
|
description = stringResource(id = R.string.videochat_desc),
|
||||||
|
)
|
||||||
|
|
||||||
|
val border = if (!sendMessageEnabled) {
|
||||||
|
BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
val disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
|
||||||
|
|
||||||
|
val buttonColors = ButtonDefaults.buttonColors(
|
||||||
|
disabledContainerColor = Color.Transparent,
|
||||||
|
disabledContentColor = disabledContentColor,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send button
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.height(36.dp),
|
||||||
|
enabled = sendMessageEnabled,
|
||||||
|
onClick = onMessageSent,
|
||||||
|
colors = buttonColors,
|
||||||
|
border = border,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.send),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InputSelectorButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
icon: androidx.compose.ui.graphics.painter.Painter,
|
||||||
|
description: String,
|
||||||
|
selected: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val backgroundModifier = if (selected) {
|
||||||
|
Modifier.background(
|
||||||
|
color = LocalContentColor.current,
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier.then(backgroundModifier),
|
||||||
|
) {
|
||||||
|
val tint = if (selected) {
|
||||||
|
contentColorFor(backgroundColor = LocalContentColor.current)
|
||||||
|
} else {
|
||||||
|
LocalContentColor.current
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
tint = tint,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.size(56.dp),
|
||||||
|
contentDescription = description,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NotAvailablePopup(onDismissed: () -> Unit) {
|
||||||
|
FunctionalityNotAvailablePopup(onDismissed)
|
||||||
|
}
|
||||||
|
|
||||||
|
val KeyboardShownKey = SemanticsPropertyKey<Boolean>("KeyboardShownKey")
|
||||||
|
var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAnimationApi::class)
|
||||||
|
@ExperimentalFoundationApi
|
||||||
|
@Composable
|
||||||
|
private fun UserInputText(
|
||||||
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
onTextChanged: (TextFieldValue) -> Unit,
|
||||||
|
textFieldValue: TextFieldValue,
|
||||||
|
keyboardShown: Boolean,
|
||||||
|
onTextFieldFocused: (Boolean) -> Unit,
|
||||||
|
onMessageSent: (String) -> Unit,
|
||||||
|
focusState: Boolean,
|
||||||
|
) {
|
||||||
|
val swipeOffset = remember { mutableStateOf(0f) }
|
||||||
|
var isRecordingMessage by remember { mutableStateOf(false) }
|
||||||
|
val a11ylabel = stringResource(id = R.string.textfield_desc)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(64.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = isRecordingMessage,
|
||||||
|
label = "text-field",
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
) { recording ->
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
if (recording) {
|
||||||
|
RecordingIndicator { swipeOffset.value }
|
||||||
|
} else {
|
||||||
|
UserInputTextField(
|
||||||
|
textFieldValue,
|
||||||
|
onTextChanged,
|
||||||
|
onTextFieldFocused,
|
||||||
|
keyboardType,
|
||||||
|
focusState,
|
||||||
|
onMessageSent,
|
||||||
|
Modifier.fillMaxWidth().semantics {
|
||||||
|
contentDescription = a11ylabel
|
||||||
|
keyboardShownProperty = keyboardShown
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BoxScope.UserInputTextField(
|
||||||
|
textFieldValue: TextFieldValue,
|
||||||
|
onTextChanged: (TextFieldValue) -> Unit,
|
||||||
|
onTextFieldFocused: (Boolean) -> Unit,
|
||||||
|
keyboardType: KeyboardType,
|
||||||
|
focusState: Boolean,
|
||||||
|
onMessageSent: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var lastFocusState by remember { mutableStateOf(false) }
|
||||||
|
BasicTextField(
|
||||||
|
value = textFieldValue,
|
||||||
|
onValueChange = { onTextChanged(it) },
|
||||||
|
modifier = modifier
|
||||||
|
.padding(start = 32.dp)
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.onFocusChanged { state ->
|
||||||
|
if (lastFocusState != state.isFocused) {
|
||||||
|
onTextFieldFocused(state.isFocused)
|
||||||
|
}
|
||||||
|
lastFocusState = state.isFocused
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = keyboardType,
|
||||||
|
imeAction = ImeAction.Send,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions {
|
||||||
|
if (textFieldValue.text.isNotBlank()) onMessageSent(textFieldValue.text)
|
||||||
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
cursorBrush = SolidColor(LocalContentColor.current),
|
||||||
|
textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current),
|
||||||
|
)
|
||||||
|
|
||||||
|
val disableContentColor =
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
if (textFieldValue.text.isEmpty() && !focusState) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.padding(start = 32.dp),
|
||||||
|
text = stringResource(R.string.textfield_hint),
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(color = disableContentColor),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordingIndicator(swipeOffset: () -> Float) {
|
||||||
|
var duration by remember { mutableStateOf(Duration.ZERO) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
delay(1000)
|
||||||
|
duration += 1.seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||||
|
|
||||||
|
val animatedPulse = infiniteTransition.animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = 0.2f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
tween(2000),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "pulse",
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.padding(24.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = animatedPulse.value
|
||||||
|
scaleY = animatedPulse.value
|
||||||
|
}
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Red),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
duration.toComponents { minutes, seconds, _ ->
|
||||||
|
val min = minutes.toString().padStart(2, '0')
|
||||||
|
val sec = seconds.toString().padStart(2, '0')
|
||||||
|
"$min:$sec"
|
||||||
|
},
|
||||||
|
Modifier.alignByBaseline(),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.alignByBaseline()
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
val swipeThreshold = with(LocalDensity.current) { 200.dp.toPx() }
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.graphicsLayer {
|
||||||
|
translationX = swipeOffset() / 2
|
||||||
|
alpha = 1 - (swipeOffset().absoluteValue / swipeThreshold)
|
||||||
|
},
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
text = stringResource(R.string.swipe_to_cancel_recording),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmojiSelector(onTextAdded: (String) -> Unit, focusRequester: FocusRequester) {
|
||||||
|
var selected by remember { mutableStateOf(EmojiStickerSelector.EMOJI) }
|
||||||
|
|
||||||
|
val a11yLabel = stringResource(id = R.string.emoji_selector_desc)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRequester(focusRequester) // Requests focus when the Emoji selector is displayed
|
||||||
|
// Make the emoji selector focusable so it can steal focus from TextField
|
||||||
|
.focusTarget()
|
||||||
|
.semantics { contentDescription = a11yLabel },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
) {
|
||||||
|
ExtendedSelectorInnerButton(
|
||||||
|
text = stringResource(id = R.string.emojis_label),
|
||||||
|
onClick = { selected = EmojiStickerSelector.EMOJI },
|
||||||
|
selected = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
ExtendedSelectorInnerButton(
|
||||||
|
text = stringResource(id = R.string.stickers_label),
|
||||||
|
onClick = { selected = EmojiStickerSelector.STICKER },
|
||||||
|
selected = false,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
EmojiTable(onTextAdded, modifier = Modifier.padding(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selected == EmojiStickerSelector.STICKER) {
|
||||||
|
NotAvailablePopup(onDismissed = { selected = EmojiStickerSelector.EMOJI })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExtendedSelectorInnerButton(text: String, onClick: () -> Unit, selected: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
val colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (selected) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
|
||||||
|
else Color.Transparent,
|
||||||
|
disabledContainerColor = Color.Transparent,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.74f),
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.height(36.dp),
|
||||||
|
colors = colors,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmojiTable(onTextAdded: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier.fillMaxWidth()) {
|
||||||
|
repeat(4) { x ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
repeat(EMOJI_COLUMNS) { y ->
|
||||||
|
val emoji = emojis[x * EMOJI_COLUMNS + y]
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = { onTextAdded(emoji) })
|
||||||
|
.sizeIn(minWidth = 42.dp, minHeight = 42.dp)
|
||||||
|
.padding(8.dp),
|
||||||
|
text = emoji,
|
||||||
|
style = LocalTextStyle.current.copy(
|
||||||
|
fontSize = 18.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val EMOJI_COLUMNS = 10
|
||||||
|
|
||||||
|
private val emojis = listOf(
|
||||||
|
"\ud83d\ude00", // Grinning Face
|
||||||
|
"\ud83d\ude01", // Grinning Face With Smiling Eyes
|
||||||
|
"\ud83d\ude02", // Face With Tears of Joy
|
||||||
|
"\ud83d\ude03", // Smiling Face With Open Mouth
|
||||||
|
"\ud83d\ude04", // Smiling Face With Open Mouth and Smiling Eyes
|
||||||
|
"\ud83d\ude05", // Smiling Face With Open Mouth and Cold Sweat
|
||||||
|
"\ud83d\ude06", // Smiling Face With Open Mouth and Tightly-Closed Eyes
|
||||||
|
"\ud83d\ude09", // Winking Face
|
||||||
|
"\ud83d\ude0a", // Smiling Face With Smiling Eyes
|
||||||
|
"\ud83d\ude0b", // Face Savouring Delicious Food
|
||||||
|
"\ud83d\ude0e", // Smiling Face With Sunglasses
|
||||||
|
"\ud83d\ude0d", // Smiling Face With Heart-Shaped Eyes
|
||||||
|
"\ud83d\ude18", // Face Throwing a Kiss
|
||||||
|
"\ud83d\ude17", // Kissing Face
|
||||||
|
"\ud83d\ude19", // Kissing Face With Smiling Eyes
|
||||||
|
"\ud83d\ude1a", // Kissing Face With Closed Eyes
|
||||||
|
"\u263a", // White Smiling Face
|
||||||
|
"\ud83d\ude42", // Slightly Smiling Face
|
||||||
|
"\ud83e\udd17", // Hugging Face
|
||||||
|
"\ud83d\ude07", // Smiling Face With Halo
|
||||||
|
"\ud83e\udd13", // Nerd Face
|
||||||
|
"\ud83e\udd14", // Thinking Face
|
||||||
|
"\ud83d\ude10", // Neutral Face
|
||||||
|
"\ud83d\ude11", // Expressionless Face
|
||||||
|
"\ud83d\ude36", // Face Without Mouth
|
||||||
|
"\ud83d\ude44", // Face With Rolling Eyes
|
||||||
|
"\ud83d\ude0f", // Smirking Face
|
||||||
|
"\ud83d\ude23", // Persevering Face
|
||||||
|
"\ud83d\ude25", // Disappointed but Relieved Face
|
||||||
|
"\ud83d\ude2e", // Face With Open Mouth
|
||||||
|
"\ud83e\udd10", // Zipper-Mouth Face
|
||||||
|
"\ud83d\ude2f", // Hushed Face
|
||||||
|
"\ud83d\ude2a", // Sleepy Face
|
||||||
|
"\ud83d\ude2b", // Tired Face
|
||||||
|
"\ud83d\ude34", // Sleeping Face
|
||||||
|
"\ud83d\ude0c", // Relieved Face
|
||||||
|
"\ud83d\ude1b", // Face With Stuck-Out Tongue
|
||||||
|
"\ud83d\ude1c", // Face With Stuck-Out Tongue and Winking Eye
|
||||||
|
"\ud83d\ude1d", // Face With Stuck-Out Tongue and Tightly-Closed Eyes
|
||||||
|
"\ud83d\ude12", // Unamused Face
|
||||||
|
"\ud83d\ude13", // Face With Cold Sweat
|
||||||
|
"\ud83d\ude14", // Pensive Face
|
||||||
|
"\ud83d\ude15", // Confused Face
|
||||||
|
"\ud83d\ude43", // Upside-Down Face
|
||||||
|
"\ud83e\udd11", // Money-Mouth Face
|
||||||
|
"\ud83d\ude32", // Astonished Face
|
||||||
|
"\ud83d\ude37", // Face With Medical Mask
|
||||||
|
"\ud83e\udd12", // Face With Thermometer
|
||||||
|
"\ud83e\udd15", // Face With Head-Bandage
|
||||||
|
"\u2639", // White Frowning Face
|
||||||
|
"\ud83d\ude41", // Slightly Frowning Face
|
||||||
|
"\ud83d\ude16", // Confounded Face
|
||||||
|
"\ud83d\ude1e", // Disappointed Face
|
||||||
|
"\ud83d\ude1f", // Worried Face
|
||||||
|
"\ud83d\ude24", // Face With Look of Triumph
|
||||||
|
"\ud83d\ude22", // Crying Face
|
||||||
|
"\ud83d\ude2d", // Loudly Crying Face
|
||||||
|
"\ud83d\ude26", // Frowning Face With Open Mouth
|
||||||
|
"\ud83d\ude27", // Anguished Face
|
||||||
|
"\ud83d\ude28", // Fearful Face
|
||||||
|
"\ud83d\ude29", // Weary Face
|
||||||
|
"\ud83d\ude2c", // Grimacing Face
|
||||||
|
"\ud83d\ude30", // Face With Open Mouth and Cold Sweat
|
||||||
|
"\ud83d\ude31", // Face Screaming in Fear
|
||||||
|
"\ud83d\ude33", // Flushed Face
|
||||||
|
"\ud83d\ude35", // Dizzy Face
|
||||||
|
"\ud83d\ude21", // Pouting Face
|
||||||
|
"\ud83d\ude20", // Angry Face
|
||||||
|
"\ud83d\ude08", // Smiling Face With Horns
|
||||||
|
"\ud83d\udc7f", // Imp
|
||||||
|
"\ud83d\udc79", // Japanese Ogre
|
||||||
|
"\ud83d\udc7a", // Japanese Goblin
|
||||||
|
"\ud83d\udc80", // Skull
|
||||||
|
"\ud83d\udc7b", // Ghost
|
||||||
|
"\ud83d\udc7d", // Extraterrestrial Alien
|
||||||
|
"\ud83e\udd16", // Robot Face
|
||||||
|
"\ud83d\udca9", // Pile of Poo
|
||||||
|
"\ud83d\ude3a", // Smiling Cat Face With Open Mouth
|
||||||
|
"\ud83d\ude38", // Grinning Cat Face With Smiling Eyes
|
||||||
|
"\ud83d\ude39", // Cat Face With Tears of Joy
|
||||||
|
"\ud83d\ude3b", // Smiling Cat Face With Heart-Shaped Eyes
|
||||||
|
"\ud83d\ude3c", // Cat Face With Wry Smile
|
||||||
|
"\ud83d\ude3d", // Kissing Cat Face With Closed Eyes
|
||||||
|
"\ud83d\ude40", // Weary Cat Face
|
||||||
|
"\ud83d\ude3f", // Crying Cat Face
|
||||||
|
"\ud83d\ude3e", // Pouting Cat Face
|
||||||
|
"\ud83d\udc66", // Boy
|
||||||
|
"\ud83d\udc67", // Girl
|
||||||
|
"\ud83d\udc68", // Man
|
||||||
|
"\ud83d\udc69", // Woman
|
||||||
|
"\ud83d\udc74", // Older Man
|
||||||
|
"\ud83d\udc75", // Older Woman
|
||||||
|
"\ud83d\udc76", // Baby
|
||||||
|
"\ud83d\udc71", // Person With Blond Hair
|
||||||
|
"\ud83d\udc6e", // Police Officer
|
||||||
|
"\ud83d\udc72", // Man With Gua Pi Mao
|
||||||
|
"\ud83d\udc73", // Man With Turban
|
||||||
|
"\ud83d\udc77", // Construction Worker
|
||||||
|
"\u26d1", // Helmet With White Cross
|
||||||
|
"\ud83d\udc78", // Princess
|
||||||
|
"\ud83d\udc82", // Guardsman
|
||||||
|
"\ud83d\udd75", // Sleuth or Spy
|
||||||
|
"\ud83c\udf85", // Father Christmas
|
||||||
|
"\ud83d\udc70", // Bride With Veil
|
||||||
|
"\ud83d\udc7c", // Baby Angel
|
||||||
|
"\ud83d\udc86", // Face Massage
|
||||||
|
"\ud83d\udc87", // Haircut
|
||||||
|
"\ud83d\ude4d", // Person Frowning
|
||||||
|
"\ud83d\ude4e", // Person With Pouting Face
|
||||||
|
"\ud83d\ude45", // Face With No Good Gesture
|
||||||
|
"\ud83d\ude46", // Face With OK Gesture
|
||||||
|
"\ud83d\udc81", // Information Desk Person
|
||||||
|
"\ud83d\ude4b", // Happy Person Raising One Hand
|
||||||
|
"\ud83d\ude47", // Person Bowing Deeply
|
||||||
|
"\ud83d\ude4c", // Person Raising Both Hands in Celebration
|
||||||
|
"\ud83d\ude4f", // Person With Folded Hands
|
||||||
|
"\ud83d\udde3", // Speaking Head in Silhouette
|
||||||
|
"\ud83d\udc64", // Bust in Silhouette
|
||||||
|
"\ud83d\udc65", // Busts in Silhouette
|
||||||
|
"\ud83d\udeb6", // Pedestrian
|
||||||
|
"\ud83c\udfc3", // Runner
|
||||||
|
"\ud83d\udc6f", // Woman With Bunny Ears
|
||||||
|
"\ud83d\udc83", // Dancer
|
||||||
|
"\ud83d\udd74", // Man in Business Suit Levitating
|
||||||
|
"\ud83d\udc6b", // Man and Woman Holding Hands
|
||||||
|
"\ud83d\udc6c", // Two Men Holding Hands
|
||||||
|
"\ud83d\udc6d", // Two Women Holding Hands
|
||||||
|
"\ud83d\udc8f", // Kiss
|
||||||
|
)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.data
|
||||||
|
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
import xyz.magicalbits.smsremote.conversation.ConversationScreenState
|
||||||
|
import xyz.magicalbits.smsremote.conversation.ConversationUiState
|
||||||
|
import xyz.magicalbits.smsremote.conversation.Message
|
||||||
|
import xyz.magicalbits.smsremote.profile.ProfileScreenState
|
||||||
|
|
||||||
|
val initialMessages =
|
||||||
|
listOf(
|
||||||
|
Message(
|
||||||
|
"Taylor Brooks",
|
||||||
|
"You can use all the same stuff",
|
||||||
|
"8:05 PM",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val unreadMessages = initialMessages.filter { it.author != "me" }
|
||||||
|
|
||||||
|
val exampleUiStateNew =
|
||||||
|
ConversationScreenState(
|
||||||
|
remotePhoneNumber = "",
|
||||||
|
initialMessages = mutableListOf()
|
||||||
|
)
|
||||||
|
|
||||||
|
val exampleUiState =
|
||||||
|
ConversationUiState("name", 123, listOf())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example colleague profile
|
||||||
|
*/
|
||||||
|
val colleagueProfile =
|
||||||
|
ProfileScreenState(
|
||||||
|
userId = "12345",
|
||||||
|
photo = R.drawable.someone_else,
|
||||||
|
name = "Taylor Brooks",
|
||||||
|
status = "Away",
|
||||||
|
displayName = "taylor",
|
||||||
|
position = "Senior Android Dev at Openlane",
|
||||||
|
twitter = "twitter.com/taylorbrookscodes",
|
||||||
|
timeZone = "12:25 AM local time (Eastern Daylight Time)",
|
||||||
|
commonChannels = "2",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example "me" profile.
|
||||||
|
*/
|
||||||
|
val meProfile =
|
||||||
|
ProfileScreenState(
|
||||||
|
userId = "me",
|
||||||
|
photo = R.drawable.ali,
|
||||||
|
name = "Ali Conors",
|
||||||
|
status = "Online",
|
||||||
|
displayName = "aliconors",
|
||||||
|
position = "Senior Android Dev at Yearin\nGoogle Developer Expert",
|
||||||
|
twitter = "twitter.com/aliconors",
|
||||||
|
timeZone = "In your timezone",
|
||||||
|
commonChannels = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package xyz.magicalbits.smsremote.device
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
import xyz.magicalbits.smsremote.components.baselineHeight
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeviceScreen(
|
||||||
|
deviceData: DeviceScreenState,
|
||||||
|
nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
|
||||||
|
onPhoneNumberClicked: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.nestedScroll(nestedScrollInteropConnection),
|
||||||
|
) {
|
||||||
|
Surface {
|
||||||
|
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(horizontal = 16.dp)) {
|
||||||
|
Name(deviceData, Modifier.baselineHeight(32.dp))
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
deviceData.phoneNumbers.forEach {
|
||||||
|
PhoneNumber(
|
||||||
|
value = it,
|
||||||
|
modifier = Modifier
|
||||||
|
.baselineHeight(24.dp)
|
||||||
|
.clickable(onClick = { onPhoneNumberClicked(it) }),
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Name(
|
||||||
|
deviceData: DeviceScreenState,
|
||||||
|
modifier: Modifier
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = deviceData.name,
|
||||||
|
modifier = modifier,
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PhoneNumber(
|
||||||
|
value: String,
|
||||||
|
modifier: Modifier,
|
||||||
|
style: TextStyle
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
modifier = modifier,
|
||||||
|
style = style
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeviceError() {
|
||||||
|
Text(stringResource(R.string.device_error))
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package xyz.magicalbits.smsremote.device
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import xyz.magicalbits.smsremote.MainViewModel
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
import xyz.magicalbits.smsremote.components.JetchatAppBar
|
||||||
|
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||||
|
import kotlin.getValue
|
||||||
|
import androidx.navigation.findNavController
|
||||||
|
|
||||||
|
class DeviceFragment : Fragment() {
|
||||||
|
private val viewModel: DeviceViewModel by viewModels()
|
||||||
|
private val activityViewModel: MainViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
// Consider using safe args plugin
|
||||||
|
val deviceId = arguments?.getString("deviceId")
|
||||||
|
val name = arguments?.getString("name")
|
||||||
|
val type = arguments?.getString("type")
|
||||||
|
viewModel.setDeviceData(deviceId, name, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
val rootView: View = inflater.inflate(R.layout.fragment_profile, container, false)
|
||||||
|
|
||||||
|
rootView.findViewById<ComposeView>(R.id.toolbar_compose_view).apply {
|
||||||
|
setContent {
|
||||||
|
JetchatTheme {
|
||||||
|
JetchatAppBar(
|
||||||
|
// Reset the minimum bounds that are passed to the root of a compose tree
|
||||||
|
modifier = Modifier.wrapContentSize(),
|
||||||
|
onNavIconPressed = { activityViewModel.openDrawer() },
|
||||||
|
title = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootView.findViewById<ComposeView>(R.id.profile_compose_view).apply {
|
||||||
|
setContent {
|
||||||
|
val deviceData by viewModel.deviceData.observeAsState()
|
||||||
|
val nestedScrollInteropConnection = rememberNestedScrollInteropConnection()
|
||||||
|
|
||||||
|
JetchatTheme {
|
||||||
|
if (deviceData == null) {
|
||||||
|
println("calling device error")
|
||||||
|
DeviceError()
|
||||||
|
} else {
|
||||||
|
val navController: NavController = rootView.findNavController()
|
||||||
|
DeviceScreen(
|
||||||
|
deviceData = deviceData!!,
|
||||||
|
nestedScrollInteropConnection = nestedScrollInteropConnection,
|
||||||
|
onPhoneNumberClicked = {
|
||||||
|
val args = Bundle(1)
|
||||||
|
args.putString("phoneNumber", it)
|
||||||
|
navController.navigate(R.id.action_device_to_sim, args)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootView
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package xyz.magicalbits.smsremote.device
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import xyz.magicalbits.smsremote.network.NetworkClient
|
||||||
|
|
||||||
|
class DeviceViewModel : ViewModel() {
|
||||||
|
private var deviceId: String = ""
|
||||||
|
private val _deviceData = MutableLiveData<DeviceScreenState>()
|
||||||
|
val deviceData: LiveData<DeviceScreenState> = _deviceData
|
||||||
|
|
||||||
|
fun setDeviceData(newDeviceId: String?, name: String?, type: String?) {
|
||||||
|
if (newDeviceId != null && name != null) {
|
||||||
|
deviceId = newDeviceId
|
||||||
|
|
||||||
|
var simCardDtoList: List<NetworkClient.SimCardDto> = listOf()
|
||||||
|
viewModelScope.launch {
|
||||||
|
val networkClient = NetworkClient()
|
||||||
|
simCardDtoList = networkClient.getSimCardsByAccessKey(deviceId)
|
||||||
|
println("sims: $simCardDtoList")
|
||||||
|
}.invokeOnCompletion {
|
||||||
|
// FIXME waiting for the response causes brief moment of DeviceError() before _deviceData is updated ...
|
||||||
|
// a solution: caching SIM phone numbers of all discovered devices locally on startup and updating them
|
||||||
|
// only on startup (implicit behavior) or with a pull-down refresh action (not done yet)
|
||||||
|
|
||||||
|
// placeholder since there's no API reading logic yet
|
||||||
|
_deviceData.value =
|
||||||
|
DeviceScreenState(
|
||||||
|
deviceId = deviceId,
|
||||||
|
name = name,
|
||||||
|
phoneNumbers = simCardDtoList.map { it.phone_number },
|
||||||
|
)
|
||||||
|
println("sims live: ${_deviceData.value!!.phoneNumbers}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class DeviceScreenState(
|
||||||
|
val deviceId: String,
|
||||||
|
val name: String,
|
||||||
|
val phoneNumbers: List<String>
|
||||||
|
)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package xyz.magicalbits.smsremote.network
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.auth.Auth
|
||||||
|
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||||
|
import io.ktor.client.plugins.auth.providers.bearer
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
class NetworkClient {
|
||||||
|
// TODO make apiBaseUrl configurable with fallback to my real domain
|
||||||
|
private val apiBaseUrl = "http://192.168.1.116:5000"
|
||||||
|
|
||||||
|
// TODO ask user for creds, call login endpoint, store tokens in local encrypted storage
|
||||||
|
|
||||||
|
private val networkClient = HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json()
|
||||||
|
}
|
||||||
|
install(Auth) {
|
||||||
|
bearer {
|
||||||
|
// TODO configure refresh token so it refreshes the access token
|
||||||
|
loadTokens {
|
||||||
|
// TODO load from local encrypted storage
|
||||||
|
BearerTokens(
|
||||||
|
"test",
|
||||||
|
"test"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable()
|
||||||
|
data class DeviceDto(val access_key: String, val name: String, val type: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SimCardDto(val device_access_key: String, val phone_number: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SmsMessageDto(val content: String, val ts_received: Int, val ts_sent: Int, val msg_type: String, val local_phone_number: String, val remote_phone_number: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OutgoingMessage(val content: String, val local_phone_number: String, val remote_phone_number: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConversationPreviewDto(val remote_phone_number: String, val last_message_content: String, val message_timestamp: Int)
|
||||||
|
|
||||||
|
// GET /api/v1/devices
|
||||||
|
suspend fun getDevices(): List<DeviceDto> {
|
||||||
|
// TODO handle non-200 status codes
|
||||||
|
// TODO handle '{"msg":"Token has expired"}' messages
|
||||||
|
val data = networkClient.get("$apiBaseUrl/api/v1/devices").bodyAsText()
|
||||||
|
return Json.decodeFromString(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/sim-cards
|
||||||
|
suspend fun getSimCardsByAccessKey(accessKey: String): List<SimCardDto> {
|
||||||
|
// TODO handle non-200 status codes
|
||||||
|
// TODO handle '{"msg":"Token has expired"}' messages
|
||||||
|
val data = networkClient.get("$apiBaseUrl/api/v1/sim-cards?access_key=$accessKey").bodyAsText()
|
||||||
|
return Json.decodeFromString(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/sms-messages
|
||||||
|
suspend fun getSmsMessagesByLocalPhoneNumber(phoneNumber: String): List<SmsMessageDto> {
|
||||||
|
// TODO handle non-200 status codes
|
||||||
|
// TODO handle '{"msg":"Token has expired"}' messages
|
||||||
|
// TODO extract encoder to a function here or to a utility class
|
||||||
|
val encodedPhoneNumber = withContext(Dispatchers.IO) {
|
||||||
|
URLEncoder.encode(phoneNumber, "UTF-8")
|
||||||
|
}
|
||||||
|
val data = networkClient.get("$apiBaseUrl/api/v1/sms-messages?local_phone_number=$encodedPhoneNumber").bodyAsText()
|
||||||
|
return Json.decodeFromString(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/send-message
|
||||||
|
suspend fun sendSmsMessage(content: String, localPhoneNumber: String, remotePhoneNumber: String) {
|
||||||
|
println("sending SMS message: content=$content, lPN=$localPhoneNumber, rPN=$remotePhoneNumber")
|
||||||
|
val response = networkClient.post("$apiBaseUrl/api/v1/send-message") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(OutgoingMessage(content, localPhoneNumber, remotePhoneNumber))
|
||||||
|
}
|
||||||
|
println("sending SMS message: status code ${response.status}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/conversation-previews
|
||||||
|
suspend fun getConversationPreviews(simPhoneNumber: String): List<ConversationPreviewDto> {
|
||||||
|
// TODO extract encoder to a function here or to a utility class
|
||||||
|
val encodedPhoneNumber = withContext(Dispatchers.IO) {
|
||||||
|
URLEncoder.encode(simPhoneNumber, "UTF-8")
|
||||||
|
}
|
||||||
|
val response = networkClient.get("$apiBaseUrl/api/v1/conversation-previews?local_phone_number=$encodedPhoneNumber")
|
||||||
|
return Json.decodeFromString(response.bodyAsText())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.profile
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import xyz.magicalbits.smsremote.data.colleagueProfile
|
||||||
|
import xyz.magicalbits.smsremote.data.meProfile
|
||||||
|
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||||
|
|
||||||
|
@Preview(widthDp = 340, name = "340 width - Me")
|
||||||
|
@Composable
|
||||||
|
fun ProfilePreview340() {
|
||||||
|
JetchatTheme {
|
||||||
|
ProfileScreen(meProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(widthDp = 480, name = "480 width - Me")
|
||||||
|
@Composable
|
||||||
|
fun ProfilePreview480Me() {
|
||||||
|
JetchatTheme {
|
||||||
|
ProfileScreen(meProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(widthDp = 480, name = "480 width - Other")
|
||||||
|
@Composable
|
||||||
|
fun ProfilePreview480Other() {
|
||||||
|
JetchatTheme {
|
||||||
|
ProfileScreen(colleagueProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Preview(widthDp = 340, name = "340 width - Me - Dark")
|
||||||
|
@Composable
|
||||||
|
fun ProfilePreview340MeDark() {
|
||||||
|
JetchatTheme(isDarkTheme = true) {
|
||||||
|
ProfileScreen(meProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(widthDp = 480, name = "480 width - Me - Dark")
|
||||||
|
@Composable
|
||||||
|
fun ProfilePreview480MeDark() {
|
||||||
|
JetchatTheme(isDarkTheme = true) {
|
||||||
|
ProfileScreen(meProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(widthDp = 480, name = "480 width - Other - Dark")
|
||||||
|
@Composable
|
||||||
|
fun ProfilePreview480OtherDark() {
|
||||||
|
JetchatTheme(isDarkTheme = true) {
|
||||||
|
ProfileScreen(colleagueProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.profile
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
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.heightIn
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
import xyz.magicalbits.smsremote.components.AnimatingFabContent
|
||||||
|
import xyz.magicalbits.smsremote.components.baselineHeight
|
||||||
|
import xyz.magicalbits.smsremote.data.colleagueProfile
|
||||||
|
import xyz.magicalbits.smsremote.data.meProfile
|
||||||
|
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||||
|
|
||||||
|
//@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ProfileScreen(
|
||||||
|
userData: ProfileScreenState,
|
||||||
|
nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
|
||||||
|
) {
|
||||||
|
var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) }
|
||||||
|
if (functionalityNotAvailablePopupShown) {
|
||||||
|
FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.nestedScroll(nestedScrollInteropConnection)
|
||||||
|
.systemBarsPadding(),
|
||||||
|
) {
|
||||||
|
Surface {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
ProfileHeader(
|
||||||
|
scrollState,
|
||||||
|
userData,
|
||||||
|
this@BoxWithConstraints.maxHeight,
|
||||||
|
)
|
||||||
|
UserInfoFields(userData, this@BoxWithConstraints.maxHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fabExtended by remember { derivedStateOf { scrollState.value == 0 } }
|
||||||
|
ProfileFab(
|
||||||
|
extended = fabExtended,
|
||||||
|
userIsMe = userData.isMe(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
// Offsets the FAB to compensate for CoordinatorLayout collapsing behaviour
|
||||||
|
.offset(y = ((-100).dp)),
|
||||||
|
onFabClicked = { functionalityNotAvailablePopupShown = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UserInfoFields(userData: ProfileScreenState, containerHeight: Dp) {
|
||||||
|
Column {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
NameAndPosition(userData)
|
||||||
|
|
||||||
|
ProfileProperty(stringResource(R.string.display_name), userData.displayName)
|
||||||
|
|
||||||
|
ProfileProperty(stringResource(R.string.status), userData.status)
|
||||||
|
|
||||||
|
ProfileProperty(stringResource(R.string.twitter), userData.twitter, isLink = true)
|
||||||
|
|
||||||
|
userData.timeZone?.let {
|
||||||
|
ProfileProperty(stringResource(R.string.timezone), userData.timeZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a spacer that always shows part (320.dp) of the fields list regardless of the device,
|
||||||
|
// in order to always leave some content at the top.
|
||||||
|
Spacer(Modifier.height((containerHeight - 320.dp).coerceAtLeast(0.dp)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NameAndPosition(userData: ProfileScreenState) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
Name(
|
||||||
|
userData,
|
||||||
|
modifier = Modifier.baselineHeight(32.dp),
|
||||||
|
)
|
||||||
|
Position(
|
||||||
|
userData,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(bottom = 20.dp)
|
||||||
|
.baselineHeight(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Name(userData: ProfileScreenState, modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
text = userData.name,
|
||||||
|
modifier = modifier,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Position(userData: ProfileScreenState, modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
text = userData.position,
|
||||||
|
modifier = modifier,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileHeader(scrollState: ScrollState, data: ProfileScreenState, containerHeight: Dp) {
|
||||||
|
val offset = (scrollState.value / 2)
|
||||||
|
val offsetDp = with(LocalDensity.current) { offset.toDp() }
|
||||||
|
|
||||||
|
data.photo?.let {
|
||||||
|
Image(
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = containerHeight / 2)
|
||||||
|
.fillMaxWidth()
|
||||||
|
// TODO: Update to use offset to avoid recomposition
|
||||||
|
.padding(
|
||||||
|
start = 16.dp,
|
||||||
|
top = offsetDp,
|
||||||
|
end = 16.dp,
|
||||||
|
)
|
||||||
|
.clip(CircleShape),
|
||||||
|
painter = painterResource(id = it),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileProperty(label: String, value: String, isLink: Boolean = false) {
|
||||||
|
Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) {
|
||||||
|
HorizontalDivider()
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.baselineHeight(24.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
val style = if (isLink) {
|
||||||
|
MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.typography.bodyLarge
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
modifier = Modifier.baselineHeight(24.dp),
|
||||||
|
style = style,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileError() {
|
||||||
|
Text(stringResource(R.string.profile_error))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileFab(extended: Boolean, userIsMe: Boolean, modifier: Modifier = Modifier, onFabClicked: () -> Unit = { }) {
|
||||||
|
key(userIsMe) {
|
||||||
|
// Prevent multiple invocations to execute during composition
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = onFabClicked,
|
||||||
|
modifier = modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.height(48.dp)
|
||||||
|
.widthIn(min = 48.dp),
|
||||||
|
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
) {
|
||||||
|
AnimatingFabContent(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = if (userIsMe) R.drawable.ic_create else R.drawable.ic_chat),
|
||||||
|
contentDescription = stringResource(
|
||||||
|
if (userIsMe) R.string.edit_profile else R.string.message,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
id = if (userIsMe) R.string.edit_profile else R.string.message,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
extended = extended,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(widthDp = 640, heightDp = 360)
|
||||||
|
@Composable
|
||||||
|
fun ConvPreviewLandscapeMeDefault() {
|
||||||
|
JetchatTheme {
|
||||||
|
ProfileScreen(meProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(widthDp = 360, heightDp = 480)
|
||||||
|
@Composable
|
||||||
|
fun ConvPreviewPortraitMeDefault() {
|
||||||
|
JetchatTheme {
|
||||||
|
ProfileScreen(meProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(widthDp = 360, heightDp = 480)
|
||||||
|
@Composable
|
||||||
|
fun ConvPreviewPortraitOtherDefault() {
|
||||||
|
JetchatTheme {
|
||||||
|
ProfileScreen(colleagueProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ProfileFabPreview() {
|
||||||
|
JetchatTheme {
|
||||||
|
ProfileFab(extended = true, userIsMe = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.profile
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
|
||||||
|
import xyz.magicalbits.smsremote.MainViewModel
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
import xyz.magicalbits.smsremote.components.JetchatAppBar
|
||||||
|
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||||
|
|
||||||
|
class ProfileFragment : Fragment() {
|
||||||
|
private val viewModel: ProfileViewModel by viewModels()
|
||||||
|
private val activityViewModel: MainViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
// Consider using safe args plugin
|
||||||
|
val userId = arguments?.getString("userId")
|
||||||
|
viewModel.setUserId(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?,
|
||||||
|
): View {
|
||||||
|
val rootView: View = inflater.inflate(R.layout.fragment_profile, container, false)
|
||||||
|
|
||||||
|
rootView.findViewById<ComposeView>(R.id.toolbar_compose_view).apply {
|
||||||
|
setContent {
|
||||||
|
var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) }
|
||||||
|
if (functionalityNotAvailablePopupShown) {
|
||||||
|
FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
JetchatTheme {
|
||||||
|
JetchatAppBar(
|
||||||
|
// Reset the minimum bounds that are passed to the root of a compose tree
|
||||||
|
modifier = Modifier.wrapContentSize(),
|
||||||
|
onNavIconPressed = { activityViewModel.openDrawer() },
|
||||||
|
title = { },
|
||||||
|
actions = {
|
||||||
|
// More icon
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_more_vert),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clickable(onClick = {
|
||||||
|
functionalityNotAvailablePopupShown = true
|
||||||
|
})
|
||||||
|
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||||
|
.height(24.dp),
|
||||||
|
contentDescription = stringResource(id = R.string.more_options),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootView.findViewById<ComposeView>(R.id.profile_compose_view).apply {
|
||||||
|
setContent {
|
||||||
|
val userData by viewModel.userData.observeAsState()
|
||||||
|
val nestedScrollInteropConnection = rememberNestedScrollInteropConnection()
|
||||||
|
|
||||||
|
JetchatTheme {
|
||||||
|
if (userData == null) {
|
||||||
|
ProfileError()
|
||||||
|
} else {
|
||||||
|
ProfileScreen(
|
||||||
|
userData = userData!!,
|
||||||
|
nestedScrollInteropConnection = nestedScrollInteropConnection,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rootView
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.profile
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import xyz.magicalbits.smsremote.data.colleagueProfile
|
||||||
|
import xyz.magicalbits.smsremote.data.meProfile
|
||||||
|
|
||||||
|
class ProfileViewModel : ViewModel() {
|
||||||
|
private var userId: String = ""
|
||||||
|
|
||||||
|
fun setUserId(newUserId: String?) {
|
||||||
|
if (newUserId != userId) {
|
||||||
|
userId = newUserId ?: meProfile.userId
|
||||||
|
}
|
||||||
|
// Workaround for simplicity
|
||||||
|
_userData.value =
|
||||||
|
if (userId == meProfile.userId || userId == meProfile.displayName) {
|
||||||
|
meProfile
|
||||||
|
} else {
|
||||||
|
colleagueProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _userData = MutableLiveData<ProfileScreenState>()
|
||||||
|
val userData: LiveData<ProfileScreenState> = _userData
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ProfileScreenState(
|
||||||
|
val userId: String,
|
||||||
|
@param:DrawableRes val photo: Int?,
|
||||||
|
val name: String,
|
||||||
|
val status: String,
|
||||||
|
val displayName: String,
|
||||||
|
val position: String,
|
||||||
|
val twitter: String = "",
|
||||||
|
val timeZone: String?, // Null if me
|
||||||
|
val commonChannels: String?, // Null if me
|
||||||
|
) {
|
||||||
|
fun isMe() = userId == meProfile.userId
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package xyz.magicalbits.smsremote.sim
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
import xyz.magicalbits.smsremote.components.baselineHeight
|
||||||
|
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SimScreen(
|
||||||
|
simData: SimScreenState,
|
||||||
|
nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
|
||||||
|
onConversationClicked: (String) -> Unit = { },
|
||||||
|
) {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().nestedScroll(nestedScrollInteropConnection)
|
||||||
|
) {
|
||||||
|
Surface {
|
||||||
|
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(horizontal = 16.dp)) {
|
||||||
|
Name(simData, Modifier.baselineHeight(32.dp))
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
simData.conversations.forEach {
|
||||||
|
ConversationPreview(it, onConversationClicked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Name(
|
||||||
|
simData: SimScreenState,
|
||||||
|
modifier: Modifier
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = simData.phoneNumber,
|
||||||
|
modifier = modifier,
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConversationPreview(
|
||||||
|
conversation: ConversationRowState,
|
||||||
|
onConversationClicked: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
onConversationClicked(conversation.phoneNumber)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
HorizontalDivider()
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.wrapContentHeight()
|
||||||
|
.padding(start = 16.dp, end = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(text = conversation.phoneNumber)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text(text = conversation.messageTimestamp.toString())
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 16.dp, end = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(text = conversation.lastMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun SimScreenPreview() {
|
||||||
|
JetchatTheme {
|
||||||
|
SimScreen(
|
||||||
|
simData = SimScreenState(
|
||||||
|
phoneNumber = "+420123456789",
|
||||||
|
conversations = listOf(
|
||||||
|
ConversationRowState("+15558880000", "last msg", 12345),
|
||||||
|
ConversationRowState("+15558880000", "last msg", 12345),
|
||||||
|
ConversationRowState("+15558880000", "last msg", 12345),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SimError() {
|
||||||
|
Text(stringResource(R.string.sim_error))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package xyz.magicalbits.smsremote.sim
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.findNavController
|
||||||
|
import xyz.magicalbits.smsremote.MainViewModel
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
import xyz.magicalbits.smsremote.components.JetchatAppBar
|
||||||
|
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||||
|
|
||||||
|
class SimFragment : Fragment() {
|
||||||
|
private val viewModel: SimViewModel by viewModels()
|
||||||
|
private val activityViewModel: MainViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
// Consider using safe args plugin
|
||||||
|
val phoneNumber = arguments?.getString("phoneNumber")
|
||||||
|
viewModel.setSimData(phoneNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
val rootView: View = inflater.inflate(R.layout.fragment_profile, container, false)
|
||||||
|
|
||||||
|
rootView.findViewById<ComposeView>(R.id.toolbar_compose_view).apply {
|
||||||
|
setContent {
|
||||||
|
JetchatTheme {
|
||||||
|
JetchatAppBar(
|
||||||
|
// Reset the minimum bounds that are passed to the root of a compose tree
|
||||||
|
modifier = Modifier.wrapContentSize(),
|
||||||
|
onNavIconPressed = { activityViewModel.openDrawer() },
|
||||||
|
title = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootView.findViewById<ComposeView>(R.id.profile_compose_view).apply {
|
||||||
|
setContent {
|
||||||
|
val simData by viewModel.simData.observeAsState()
|
||||||
|
val nestedScrollInteropConnection = rememberNestedScrollInteropConnection()
|
||||||
|
|
||||||
|
JetchatTheme {
|
||||||
|
// TODO flip if condition after integrating API call
|
||||||
|
if (simData != null) {
|
||||||
|
SimError()
|
||||||
|
println("calling sim error")
|
||||||
|
} else {
|
||||||
|
val navController: NavController = rootView.findNavController()
|
||||||
|
SimScreen(
|
||||||
|
// simData = simData!!,
|
||||||
|
// TODO remove fake data after integrating API call
|
||||||
|
simData = SimScreenState(
|
||||||
|
phoneNumber = "+420123456789",
|
||||||
|
conversations = listOf(
|
||||||
|
ConversationRowState("+15558880000", "last msg", 12345),
|
||||||
|
ConversationRowState("+15558880111", "last msg", 12345),
|
||||||
|
ConversationRowState("+15558880333", "last msg", 12345),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
nestedScrollInteropConnection = nestedScrollInteropConnection,
|
||||||
|
onConversationClicked = {
|
||||||
|
val args = Bundle(1)
|
||||||
|
args.putString("phoneNumber", it)
|
||||||
|
navController.navigate(R.id.action_sim_to_conversation, args)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootView
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package xyz.magicalbits.smsremote.sim
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import xyz.magicalbits.smsremote.network.NetworkClient
|
||||||
|
|
||||||
|
class SimViewModel : ViewModel() {
|
||||||
|
private var simPhoneNumber: String = ""
|
||||||
|
private val _simConversations = MutableLiveData<SimScreenState>()
|
||||||
|
val simData: LiveData<SimScreenState> = _simConversations
|
||||||
|
|
||||||
|
fun setSimData(phoneNumber: String?) {
|
||||||
|
if (phoneNumber != null) {
|
||||||
|
simPhoneNumber = phoneNumber
|
||||||
|
|
||||||
|
var conversationDtoList: List<NetworkClient.ConversationPreviewDto> = listOf()
|
||||||
|
// TODO uncomment after adding the corresponding API endpoint
|
||||||
|
// viewModelScope.launch {
|
||||||
|
// val networkClient = NetworkClient()
|
||||||
|
// conversationDtoList = networkClient.getConversationPreviews(simPhoneNumber)
|
||||||
|
// println("conv previews: $conversationDtoList")
|
||||||
|
// }.invokeOnCompletion {
|
||||||
|
// _simConversations.value =
|
||||||
|
// SimScreenState(
|
||||||
|
// phoneNumber = simPhoneNumber,
|
||||||
|
// conversations = conversationDtoList.map { convertFromDto(it) }.toList()
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun convertFromDto(conversation: NetworkClient.ConversationPreviewDto): ConversationRowState {
|
||||||
|
return ConversationRowState(
|
||||||
|
phoneNumber = conversation.remote_phone_number,
|
||||||
|
lastMessage = conversation.last_message_content,
|
||||||
|
messageTimestamp = conversation.message_timestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class SimScreenState(
|
||||||
|
val phoneNumber: String,
|
||||||
|
val conversations: List<ConversationRowState>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ConversationRowState(
|
||||||
|
val phoneNumber: String,
|
||||||
|
val lastMessage: String,
|
||||||
|
val messageTimestamp: Int,
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Blue10 = Color(0xFF000F5E)
|
||||||
|
val Blue20 = Color(0xFF001E92)
|
||||||
|
val Blue30 = Color(0xFF002ECC)
|
||||||
|
val Blue40 = Color(0xFF1546F6)
|
||||||
|
val Blue80 = Color(0xFFB8C3FF)
|
||||||
|
val Blue90 = Color(0xFFDDE1FF)
|
||||||
|
|
||||||
|
val DarkBlue10 = Color(0xFF00036B)
|
||||||
|
val DarkBlue20 = Color(0xFF000BA6)
|
||||||
|
val DarkBlue30 = Color(0xFF1026D3)
|
||||||
|
val DarkBlue40 = Color(0xFF3648EA)
|
||||||
|
val DarkBlue80 = Color(0xFFBBC2FF)
|
||||||
|
val DarkBlue90 = Color(0xFFDEE0FF)
|
||||||
|
|
||||||
|
val Yellow10 = Color(0xFF261900)
|
||||||
|
val Yellow20 = Color(0xFF402D00)
|
||||||
|
val Yellow30 = Color(0xFF5C4200)
|
||||||
|
val Yellow40 = Color(0xFF7A5900)
|
||||||
|
val Yellow80 = Color(0xFFFABD1B)
|
||||||
|
val Yellow90 = Color(0xFFFFDE9C)
|
||||||
|
|
||||||
|
val Red10 = Color(0xFF410001)
|
||||||
|
val Red20 = Color(0xFF680003)
|
||||||
|
val Red30 = Color(0xFF930006)
|
||||||
|
val Red40 = Color(0xFFBA1B1B)
|
||||||
|
val Red80 = Color(0xFFFFB4A9)
|
||||||
|
val Red90 = Color(0xFFFFDAD4)
|
||||||
|
|
||||||
|
val Grey10 = Color(0xFF191C1D)
|
||||||
|
val Grey20 = Color(0xFF2D3132)
|
||||||
|
val Grey80 = Color(0xFFC4C7C7)
|
||||||
|
val Grey90 = Color(0xFFE0E3E3)
|
||||||
|
val Grey95 = Color(0xFFEFF1F1)
|
||||||
|
val Grey99 = Color(0xFFFBFDFD)
|
||||||
|
|
||||||
|
val BlueGrey30 = Color(0xFF45464F)
|
||||||
|
val BlueGrey50 = Color(0xFF767680)
|
||||||
|
val BlueGrey60 = Color(0xFF90909A)
|
||||||
|
val BlueGrey80 = Color(0xFFC6C5D0)
|
||||||
|
val BlueGrey90 = Color(0xFFE2E1EC)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.theme
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
val JetchatDarkColorScheme = darkColorScheme(
|
||||||
|
primary = Blue80,
|
||||||
|
onPrimary = Blue20,
|
||||||
|
primaryContainer = Blue30,
|
||||||
|
onPrimaryContainer = Blue90,
|
||||||
|
inversePrimary = Blue40,
|
||||||
|
secondary = DarkBlue80,
|
||||||
|
onSecondary = DarkBlue20,
|
||||||
|
secondaryContainer = DarkBlue30,
|
||||||
|
onSecondaryContainer = DarkBlue90,
|
||||||
|
tertiary = Yellow80,
|
||||||
|
onTertiary = Yellow20,
|
||||||
|
tertiaryContainer = Yellow30,
|
||||||
|
onTertiaryContainer = Yellow90,
|
||||||
|
error = Red80,
|
||||||
|
onError = Red20,
|
||||||
|
errorContainer = Red30,
|
||||||
|
onErrorContainer = Red90,
|
||||||
|
background = Grey10,
|
||||||
|
onBackground = Grey90,
|
||||||
|
surface = Grey10,
|
||||||
|
onSurface = Grey80,
|
||||||
|
inverseSurface = Grey90,
|
||||||
|
inverseOnSurface = Grey20,
|
||||||
|
surfaceVariant = BlueGrey30,
|
||||||
|
onSurfaceVariant = BlueGrey80,
|
||||||
|
outline = BlueGrey60,
|
||||||
|
)
|
||||||
|
|
||||||
|
val JetchatLightColorScheme = lightColorScheme(
|
||||||
|
primary = Blue40,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = Blue90,
|
||||||
|
onPrimaryContainer = Blue10,
|
||||||
|
inversePrimary = Blue80,
|
||||||
|
secondary = DarkBlue40,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
secondaryContainer = DarkBlue90,
|
||||||
|
onSecondaryContainer = DarkBlue10,
|
||||||
|
tertiary = Yellow40,
|
||||||
|
onTertiary = Color.White,
|
||||||
|
tertiaryContainer = Yellow90,
|
||||||
|
onTertiaryContainer = Yellow10,
|
||||||
|
error = Red40,
|
||||||
|
onError = Color.White,
|
||||||
|
errorContainer = Red90,
|
||||||
|
onErrorContainer = Red10,
|
||||||
|
background = Grey99,
|
||||||
|
onBackground = Grey10,
|
||||||
|
surface = Grey99,
|
||||||
|
onSurface = Grey10,
|
||||||
|
inverseSurface = Grey20,
|
||||||
|
inverseOnSurface = Grey95,
|
||||||
|
surfaceVariant = BlueGrey90,
|
||||||
|
onSurfaceVariant = BlueGrey30,
|
||||||
|
outline = BlueGrey50,
|
||||||
|
)
|
||||||
|
|
||||||
|
@SuppressLint("NewApi")
|
||||||
|
@Composable
|
||||||
|
fun JetchatTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), isDynamicColor: Boolean = true, content: @Composable () -> Unit) {
|
||||||
|
val dynamicColor = isDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
val myColorScheme = when {
|
||||||
|
dynamicColor && isDarkTheme -> {
|
||||||
|
dynamicDarkColorScheme(LocalContext.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamicColor && !isDarkTheme -> {
|
||||||
|
dynamicLightColorScheme(LocalContext.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
isDarkTheme -> JetchatDarkColorScheme
|
||||||
|
|
||||||
|
else -> JetchatLightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = myColorScheme,
|
||||||
|
typography = JetchatTypography,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.googlefonts.Font
|
||||||
|
import androidx.compose.ui.text.googlefonts.GoogleFont
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
|
||||||
|
val provider =
|
||||||
|
GoogleFont.Provider(
|
||||||
|
providerAuthority = "com.google.android.gms.fonts",
|
||||||
|
providerPackage = "com.google.android.gms",
|
||||||
|
certificates = R.array.com_google_android_gms_fonts_certs,
|
||||||
|
)
|
||||||
|
|
||||||
|
val MontserratFont = GoogleFont(name = "Montserrat")
|
||||||
|
|
||||||
|
val KarlaFont = GoogleFont(name = "Karla")
|
||||||
|
|
||||||
|
val MontserratFontFamily =
|
||||||
|
FontFamily(
|
||||||
|
Font(googleFont = MontserratFont, fontProvider = provider),
|
||||||
|
Font(resId = R.font.montserrat_regular),
|
||||||
|
Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.Light),
|
||||||
|
Font(resId = R.font.montserrat_light, weight = FontWeight.Light),
|
||||||
|
Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.Medium),
|
||||||
|
Font(resId = R.font.montserrat_medium, weight = FontWeight.Medium),
|
||||||
|
Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.SemiBold),
|
||||||
|
Font(resId = R.font.montserrat_semibold, weight = FontWeight.SemiBold),
|
||||||
|
)
|
||||||
|
|
||||||
|
val KarlaFontFamily =
|
||||||
|
FontFamily(
|
||||||
|
Font(googleFont = KarlaFont, fontProvider = provider),
|
||||||
|
Font(resId = R.font.karla_regular),
|
||||||
|
Font(googleFont = KarlaFont, fontProvider = provider, weight = FontWeight.Bold),
|
||||||
|
Font(resId = R.font.karla_bold, weight = FontWeight.Bold),
|
||||||
|
)
|
||||||
|
|
||||||
|
val JetchatTypography =
|
||||||
|
Typography(
|
||||||
|
displayLarge =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = MontserratFontFamily,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
fontSize = 57.sp,
|
||||||
|
lineHeight = 64.sp,
|
||||||
|
letterSpacing = 0.sp,
|
||||||
|
),
|
||||||
|
displayMedium =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = MontserratFontFamily,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
fontSize = 45.sp,
|
||||||
|
lineHeight = 52.sp,
|
||||||
|
letterSpacing = 0.sp,
|
||||||
|
),
|
||||||
|
displaySmall =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = MontserratFontFamily,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 36.sp,
|
||||||
|
lineHeight = 44.sp,
|
||||||
|
letterSpacing = 0.sp,
|
||||||
|
),
|
||||||
|
headlineLarge =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = MontserratFontFamily,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
lineHeight = 40.sp,
|
||||||
|
letterSpacing = 0.sp,
|
||||||
|
),
|
||||||
|
headlineMedium =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = MontserratFontFamily,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
lineHeight = 36.sp,
|
||||||
|
letterSpacing = 0.sp,
|
||||||
|
),
|
||||||
|
headlineSmall =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = MontserratFontFamily,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
lineHeight = 32.sp,
|
||||||
|
letterSpacing = 0.sp,
|
||||||
|
),
|
||||||
|
titleLarge =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = MontserratFontFamily,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
letterSpacing = 0.sp,
|
||||||
|
),
|
||||||
|
titleMedium =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = MontserratFontFamily,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.15.sp,
|
||||||
|
),
|
||||||
|
titleSmall =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = KarlaFontFamily,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
bodyLarge =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = KarlaFontFamily,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.15.sp,
|
||||||
|
),
|
||||||
|
bodyMedium =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = MontserratFontFamily,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.25.sp,
|
||||||
|
),
|
||||||
|
bodySmall =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = KarlaFontFamily,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.4.sp,
|
||||||
|
),
|
||||||
|
labelLarge =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = MontserratFontFamily,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
labelMedium =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = MontserratFontFamily,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
),
|
||||||
|
labelSmall =
|
||||||
|
TextStyle(
|
||||||
|
fontFamily = MontserratFontFamily,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import xyz.magicalbits.smsremote.data.unreadMessages
|
||||||
|
import xyz.magicalbits.smsremote.widget.composables.MessagesWidget
|
||||||
|
|
||||||
|
class JetChatWidget : GlanceAppWidget() {
|
||||||
|
override suspend fun provideGlance(
|
||||||
|
context: Context,
|
||||||
|
id: GlanceId,
|
||||||
|
) {
|
||||||
|
provideContent {
|
||||||
|
GlanceTheme {
|
||||||
|
MessagesWidget(unreadMessages.toList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.widget
|
||||||
|
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
|
||||||
|
class WidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget
|
||||||
|
get() = JetChatWidget()
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.widget.composables
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.LocalContext
|
||||||
|
import androidx.glance.action.actionStartActivity
|
||||||
|
import androidx.glance.action.clickable
|
||||||
|
import androidx.glance.appwidget.components.Scaffold
|
||||||
|
import androidx.glance.appwidget.components.TitleBar
|
||||||
|
import androidx.glance.appwidget.lazy.LazyColumn
|
||||||
|
import androidx.glance.layout.Column
|
||||||
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxWidth
|
||||||
|
import androidx.glance.layout.height
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import xyz.magicalbits.smsremote.NavActivity
|
||||||
|
import xyz.magicalbits.smsremote.R
|
||||||
|
import xyz.magicalbits.smsremote.conversation.Message
|
||||||
|
import xyz.magicalbits.smsremote.widget.theme.JetChatGlanceTextStyles
|
||||||
|
import xyz.magicalbits.smsremote.widget.theme.JetchatGlanceColorScheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessagesWidget(messages: List<Message>) {
|
||||||
|
Scaffold(titleBar = {
|
||||||
|
TitleBar(
|
||||||
|
startIcon = ImageProvider(R.drawable.ic_jetchat),
|
||||||
|
iconColor = null,
|
||||||
|
title = LocalContext.current.getString(R.string.messages_widget_title),
|
||||||
|
)
|
||||||
|
}, backgroundColor = JetchatGlanceColorScheme.colors.background) {
|
||||||
|
LazyColumn(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
messages.forEach {
|
||||||
|
item {
|
||||||
|
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
MessageItem(it)
|
||||||
|
Spacer(modifier = GlanceModifier.height(10.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageItem(message: Message) {
|
||||||
|
Column(modifier = GlanceModifier.clickable(actionStartActivity<NavActivity>()).fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = message.author,
|
||||||
|
style = JetChatGlanceTextStyles.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = message.content,
|
||||||
|
style = JetChatGlanceTextStyles.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun MessageItemPreview() {
|
||||||
|
MessageItem(Message("John", "This is a preview of the message Item", "8:02PM"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun WidgetPreview() {
|
||||||
|
MessagesWidget(listOf(Message("John", "This is a preview of the message Item", "8:02PM")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.widget.theme
|
||||||
|
|
||||||
|
import androidx.glance.material3.ColorProviders
|
||||||
|
import xyz.magicalbits.smsremote.theme.JetchatDarkColorScheme
|
||||||
|
import xyz.magicalbits.smsremote.theme.JetchatLightColorScheme
|
||||||
|
|
||||||
|
object JetchatGlanceColorScheme {
|
||||||
|
val colors = ColorProviders(
|
||||||
|
light = JetchatLightColorScheme,
|
||||||
|
dark = JetchatDarkColorScheme,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.magicalbits.smsremote.widget.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.glance.text.FontWeight
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
|
||||||
|
object JetChatGlanceTextStyles {
|
||||||
|
|
||||||
|
val titleMedium = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = JetchatGlanceColorScheme.colors.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
val bodyMedium = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = JetchatGlanceColorScheme.colors.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
)
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 277 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,60 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<group android:scaleX="0.9818182"
|
||||||
|
android:scaleY="0.9818182">
|
||||||
|
<path
|
||||||
|
android:pathData="M65,43.524L71.5,43.524L74.859,46.859L85.281,57.281L106.125,78.125L77.776,106.473L36.089,64.786L43.176,57.699L48.5,55.999L41,47.5L45.5,43.524L49.5,39.499L56,45.999L58,43.999L65,43.524Z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startY="60.7846"
|
||||||
|
android:startX="72.4963"
|
||||||
|
android:endY="125.024"
|
||||||
|
android:endX="68.6386"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FF0037E6"/>
|
||||||
|
<item android:offset="0.5" android:color="#FF0540F2"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:pathData="M54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H69.105C72.361,43.371 75,46.01 75,49.266V49.266C75,52.521 72.361,55.161 69.105,55.161H60.516C57.26,55.161 54.621,52.521 54.621,49.266V49.266Z"
|
||||||
|
android:fillColor="#F3B711"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M66.916,43.371C66.916,43.371 66.916,43.371 66.916,43.371C66.916,46.627 64.277,49.266 61.021,49.266H54.621C54.621,49.266 54.621,49.266 54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H66.916Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M35,61.055C35,57.799 37.639,55.16 40.895,55.16H49.653C52.908,55.16 55.547,57.799 55.547,61.055V61.055C55.547,64.311 52.908,66.95 49.653,66.95H40.895C37.639,66.95 35,64.311 35,61.055V61.055Z"
|
||||||
|
android:fillColor="#ffffff"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M55.547,61.055C55.547,61.055 55.547,61.055 55.547,61.055C55.547,64.311 52.908,66.95 49.653,66.95H42.832C42.832,66.95 42.832,66.95 42.832,66.949C42.832,63.694 45.471,61.055 48.726,61.055H55.547Z"
|
||||||
|
android:fillColor="#F3B711"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M59,66.949C59,63.694 61.639,61.055 64.895,61.055V61.055C68.15,61.055 70.789,63.694 70.789,66.949V66.949C70.789,70.205 68.15,72.844 64.895,72.844V72.844C61.639,72.844 59,70.205 59,66.949V66.949Z"
|
||||||
|
android:fillColor="#F3B711"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M39.379,43.371C39.379,40.116 42.018,37.477 45.274,37.477V37.477C48.529,37.477 51.168,40.116 51.168,43.371V43.371C51.168,46.627 48.529,49.266 45.274,49.266V49.266C42.018,49.266 39.379,46.627 39.379,43.371V43.371Z"
|
||||||
|
android:fillColor="#ffffff"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480L880,538Q880,597 839.5,638.5Q799,680 740,680Q705,680 674,665Q643,650 622,622Q593,651 556.5,665.5Q520,680 480,680Q397,680 338.5,621.5Q280,563 280,480Q280,397 338.5,338.5Q397,280 480,280Q563,280 621.5,338.5Q680,397 680,480L680,538Q680,564 697,582Q714,600 740,600Q766,600 783,582Q800,564 800,538L800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800L680,800L680,880L480,880ZM480,600Q530,600 565,565Q600,530 600,480Q600,430 565,395Q530,360 480,360Q430,360 395,395Q360,430 360,480Q360,530 395,565Q430,600 480,600Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M440,160L440,647L216,423L160,480L480,800L800,480L744,423L520,647L520,160L440,160Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/blue500"
|
||||||
|
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M200,760L257,760L648,369L591,312L200,703L200,760ZM120,840L120,670L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L290,840L120,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480L800,160Q800,160 800,160Q800,160 800,160L480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM280,600L560,600L560,520L680,600L680,360L560,440L560,360L280,360L280,600ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM480,360Q497,360 508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM200,760L760,760Q760,760 760,760Q760,760 760,760L760,200Q760,200 760,200Q760,200 760,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760ZM240,680L720,680L570,480L450,640L360,520L240,680ZM200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M15.6968,11.4122C15.6968,8.8077 17.8081,6.6964 20.4126,6.6964H27.2841C29.8886,6.6964 31.9999,8.8077 31.9999,11.4122C31.9999,14.0167 29.8886,16.128 27.2841,16.128H20.4126C17.8081,16.128 15.6968,14.0167 15.6968,11.4122Z"
|
||||||
|
android:fillColor="@color/yellow_logo"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M25.5326,6.6964C25.5326,6.6965 25.5326,6.6965 25.5326,6.6966C25.5326,9.3011 23.4212,11.4124 20.8168,11.4124H15.6968C15.6968,11.4123 15.6968,11.4123 15.6968,11.4122C15.6968,8.8077 17.8081,6.6964 20.4126,6.6964H25.5326Z"
|
||||||
|
android:fillColor="@color/blue_logo"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M0,20.8438C0,18.2393 2.1113,16.128 4.7158,16.128H11.7221C14.3266,16.128 16.4379,18.2393 16.4379,20.8438C16.4379,23.4482 14.3266,25.5596 11.7221,25.5596H4.7158C2.1113,25.5596 0,23.4482 0,20.8438Z"
|
||||||
|
android:fillColor="@color/blue_logo"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M16.4379,20.8438C16.4379,20.8439 16.4379,20.8439 16.4379,20.844C16.4379,23.4485 14.3266,25.5598 11.7221,25.5598H6.2653C6.2653,25.5597 6.2653,25.5597 6.2653,25.5596C6.2653,22.9551 8.3766,20.8438 10.981,20.8438H16.4379Z"
|
||||||
|
android:fillColor="@color/yellow_logo"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M19.2,25.5596C19.2,22.9551 21.3113,20.8438 23.9157,20.8438C26.5202,20.8438 28.6315,22.9551 28.6315,25.5596C28.6315,28.1641 26.5202,30.2754 23.9157,30.2754C21.3113,30.2754 19.2,28.1641 19.2,25.5596Z"
|
||||||
|
android:fillColor="@color/yellow_logo"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M3.5032,6.6964C3.5032,4.092 5.6145,1.9806 8.219,1.9806C10.8234,1.9806 12.9348,4.092 12.9348,6.6964C12.9348,9.3009 10.8234,11.4122 8.219,11.4122C5.6145,11.4122 3.5032,9.3009 3.5032,6.6964Z"
|
||||||
|
android:fillColor="@color/blue_logo"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M15.6968,11.4122C15.6968,8.8077 17.8081,6.6964 20.4126,6.6964H27.2841C29.8886,6.6964 31.9999,8.8077 31.9999,11.4122C31.9999,14.0167 29.8886,16.128 27.2841,16.128H20.4126C17.8081,16.128 15.6968,14.0167 15.6968,11.4122Z"
|
||||||
|
android:fillColor="@color/yellow_logo"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M16.4379,20.8438C16.4379,20.8439 16.4379,20.8439 16.4379,20.844C16.4379,23.4485 14.3266,25.5598 11.7221,25.5598H6.2653C6.2653,25.5597 6.2653,25.5597 6.2653,25.5596C6.2653,22.9551 8.3766,20.8438 10.981,20.8438H16.4379Z"
|
||||||
|
android:fillColor="@color/yellow_logo"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M19.2,25.5596C19.2,22.9551 21.3113,20.8438 23.9157,20.8438C26.5202,20.8438 28.6315,22.9551 28.6315,25.5596C28.6315,28.1641 26.5202,30.2754 23.9157,30.2754C21.3113,30.2754 19.2,28.1641 19.2,25.5596Z"
|
||||||
|
android:fillColor="@color/yellow_logo"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M25.5326,6.6964C25.5326,6.6965 25.5326,6.6965 25.5326,6.6966C25.5326,9.3011 23.4212,11.4124 20.8168,11.4124H15.6968C15.6968,11.4123 15.6968,11.4123 15.6968,11.4122C15.6968,8.8077 17.8081,6.6964 20.4126,6.6964H25.5326Z"
|
||||||
|
android:fillColor="@color/blue_logo"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M0,20.8438C0,18.2393 2.1113,16.128 4.7158,16.128H11.7221C14.3266,16.128 16.4379,18.2393 16.4379,20.8438C16.4379,23.4482 14.3266,25.5596 11.7221,25.5596H4.7158C2.1113,25.5596 0,23.4482 0,20.8438Z"
|
||||||
|
android:fillColor="@color/blue_logo"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M3.5032,6.6964C3.5032,4.092 5.6145,1.9806 8.219,1.9806C10.8234,1.9806 12.9348,4.092 12.9348,6.6964C12.9348,9.3009 10.8234,11.4122 8.219,11.4122C5.6145,11.4122 3.5032,9.3009 3.5032,6.6964Z"
|
||||||
|
android:fillColor="@color/blue_logo"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:pathData="M65,43.524L71.5,43.524L74.859,46.859L85.281,57.281L106.125,78.125L77.776,106.473L36.089,64.786L43.176,57.699L48.5,55.999L41,47.5L45.5,43.524L49.5,39.499L56,45.999L58,43.999L65,43.524Z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startY="60.7846"
|
||||||
|
android:startX="72.4963"
|
||||||
|
android:endY="125.024"
|
||||||
|
android:endX="68.6386"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FF0037E6"/>
|
||||||
|
<item android:offset="0.5" android:color="#FF0540F2"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:pathData="M54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H69.105C72.361,43.371 75,46.01 75,49.266V49.266C75,52.521 72.361,55.161 69.105,55.161H60.516C57.26,55.161 54.621,52.521 54.621,49.266V49.266Z"
|
||||||
|
android:fillColor="#F3B711"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M66.916,43.371C66.916,43.371 66.916,43.371 66.916,43.371C66.916,46.627 64.277,49.266 61.021,49.266H54.621C54.621,49.266 54.621,49.266 54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H66.916Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M35,61.055C35,57.799 37.639,55.16 40.895,55.16H49.653C52.908,55.16 55.547,57.799 55.547,61.055V61.055C55.547,64.311 52.908,66.95 49.653,66.95H40.895C37.639,66.95 35,64.311 35,61.055V61.055Z"
|
||||||
|
android:fillColor="#ffffff"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M55.547,61.055C55.547,61.055 55.547,61.055 55.547,61.055C55.547,64.311 52.908,66.95 49.653,66.95H42.832C42.832,66.95 42.832,66.95 42.832,66.949C42.832,63.694 45.471,61.055 48.726,61.055H55.547Z"
|
||||||
|
android:fillColor="#F3B711"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M59,66.949C59,63.694 61.639,61.055 64.895,61.055V61.055C68.15,61.055 70.789,63.694 70.789,66.949V66.949C70.789,70.205 68.15,72.844 64.895,72.844V72.844C61.639,72.844 59,70.205 59,66.949V66.949Z"
|
||||||
|
android:fillColor="#F3B711"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M39.379,43.371C39.379,40.116 42.018,37.477 45.274,37.477V37.477C48.529,37.477 51.168,40.116 51.168,43.371V43.371C51.168,46.627 48.529,49.266 45.274,49.266V49.266C42.018,49.266 39.379,46.627 39.379,43.371V43.371Z"
|
||||||
|
android:fillColor="#ffffff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:pathData="M54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H69.105C72.361,43.371 75,46.01 75,49.266V49.266C75,52.521 72.361,55.161 69.105,55.161H60.516C57.26,55.161 54.621,52.521 54.621,49.266V49.266Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M66.916,43.371C66.916,43.371 66.916,43.371 66.916,43.371C66.916,46.627 64.277,49.266 61.021,49.266H54.621C54.621,49.266 54.621,49.266 54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H66.916Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:pathData="M35,61.055C35,57.799 37.639,55.16 40.895,55.16H49.653C52.908,55.16 55.547,57.799 55.547,61.055V61.055C55.547,64.311 52.908,66.95 49.653,66.95H40.895C37.639,66.95 35,64.311 35,61.055V61.055Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M55.547,61.055C55.547,61.055 55.547,61.055 55.547,61.055C55.547,64.311 52.908,66.95 49.653,66.95H42.832C42.832,66.95 42.832,66.95 42.832,66.949C42.832,63.694 45.471,61.055 48.726,61.055H55.547Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:pathData="M59,66.949C59,63.694 61.639,61.055 64.895,61.055V61.055C68.15,61.055 70.789,63.694 70.789,66.949V66.949C70.789,70.205 68.15,72.844 64.895,72.844V72.844C61.639,72.844 59,70.205 59,66.949V66.949Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:pathData="M39.379,43.371C39.379,40.116 42.018,37.477 45.274,37.477V37.477C48.529,37.477 51.168,40.116 51.168,43.371V43.371C51.168,46.627 48.529,49.266 45.274,49.266V49.266C42.018,49.266 39.379,46.627 39.379,43.371V43.371Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.2-3c0 2.75-2.25 5-5 5s-5-2.25-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-0.49 6-3.39 6-6.92h-2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M620,440Q645,440 662.5,422.5Q680,405 680,380Q680,355 662.5,337.5Q645,320 620,320Q595,320 577.5,337.5Q560,355 560,380Q560,405 577.5,422.5Q595,440 620,440ZM340,440Q365,440 382.5,422.5Q400,405 400,380Q400,355 382.5,337.5Q365,320 340,320Q315,320 297.5,337.5Q280,355 280,380Q280,405 297.5,422.5Q315,440 340,440ZM480,700Q548,700 603.5,661.5Q659,623 684,560L276,560Q301,623 356.5,661.5Q412,700 480,700ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M480,480Q513,480 536.5,456.5Q560,433 560,400Q560,367 536.5,343.5Q513,320 480,320Q447,320 423.5,343.5Q400,367 400,400Q400,433 423.5,456.5Q447,480 480,480ZM480,774Q602,662 661,570.5Q720,479 720,408Q720,299 650.5,229.5Q581,160 480,160Q379,160 309.5,229.5Q240,299 240,408Q240,479 299,570.5Q358,662 480,774ZM480,880Q319,743 239.5,625.5Q160,508 160,408Q160,258 256.5,169Q353,80 480,80Q607,80 703.5,169Q800,258 800,408Q800,508 720.5,625.5Q641,743 480,880ZM480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z"/>
|
||||||
|
</vector>
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.fragment.app.FragmentContainerView
|
||||||
|
android:id="@+id/nav_host_fragment"
|
||||||
|
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
app:defaultNavHost="true"
|
||||||
|
app:navGraph="@navigation/mobile_navigation"
|
||||||
|
tools:ignore="FragmentTagUsage" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
~ Copyright 2022 Google LLC
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:id="@+id/coordinator_layout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:id="@+id/app_bar_layout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<androidx.compose.ui.platform.ComposeView
|
||||||
|
android:id="@+id/toolbar_compose_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_scrollFlags="scroll|exitUntilCollapsed"
|
||||||
|
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<androidx.compose.ui.platform.ComposeView
|
||||||
|
android:id="@+id/profile_compose_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
tools:showIn="navigation_view">
|
||||||
|
|
||||||
|
<group android:checkableBehavior="single">
|
||||||
|
<item
|
||||||
|
android:id="@+id/nav_conversation"
|
||||||
|
android:icon="@drawable/ic_jetchat"
|
||||||
|
android:title="@string/conversations" />
|
||||||
|
</group>
|
||||||
|
</menu>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:id="@+id/mobile_navigation"
|
||||||
|
app:startDestination="@+id/nav_device">
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/nav_conversation"
|
||||||
|
android:name="xyz.magicalbits.smsremote.conversation.ConversationFragment"
|
||||||
|
android:label="Conversation">
|
||||||
|
</fragment>
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/nav_profile"
|
||||||
|
android:name="xyz.magicalbits.smsremote.profile.ProfileFragment"
|
||||||
|
android:label="Profile">
|
||||||
|
<argument
|
||||||
|
android:name="userId"
|
||||||
|
app:argType="string" />
|
||||||
|
</fragment>
|
||||||
|
|
||||||
|
<!-- TODO add a new fragment class for a blank home screen (to be eventually turned into a device selection screen) -->
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/nav_device"
|
||||||
|
android:name="xyz.magicalbits.smsremote.device.DeviceFragment"
|
||||||
|
android:label="Device">
|
||||||
|
<argument
|
||||||
|
android:name="deviceId"
|
||||||
|
app:argType="string" />
|
||||||
|
<argument
|
||||||
|
android:name="name"
|
||||||
|
app:argType="string" />
|
||||||
|
<argument
|
||||||
|
android:name="type"
|
||||||
|
app:argType="string" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_device_to_sim"
|
||||||
|
app:destination="@id/nav_sim"
|
||||||
|
/>
|
||||||
|
</fragment>
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/nav_sim"
|
||||||
|
android:name="xyz.magicalbits.smsremote.sim.SimFragment"
|
||||||
|
android:label="Sim">
|
||||||
|
<argument
|
||||||
|
android:name="phoneNumber"
|
||||||
|
app:argType="string" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_sim_to_conversation"
|
||||||
|
app:destination="@id/nav_conversation"
|
||||||
|
/>
|
||||||
|
</fragment>
|
||||||
|
|
||||||
|
<!-- TODO add a new fragment class (based on ConversationFragment) serving as a device screen opened from the home screen -->
|
||||||
|
|
||||||
|
</navigation>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<resources>
|
||||||
|
<!-- Logo -->
|
||||||
|
<color name="yellow_logo">@color/yellow400</color>
|
||||||
|
<color name="blue_logo">@color/blue300</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Our dark theme -->
|
||||||
|
<style name="Theme.Jetchat" parent="Platform.Theme.Jetchat" >
|
||||||
|
<item name="colorPrimary">@color/blue300</item>
|
||||||
|
<item name="colorPrimaryDark">@color/blue400</item>
|
||||||
|
<item name="colorAccent">@color/yellow400</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 MagicalBits
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<array name="com_google_android_gms_fonts_certs">
|
||||||
|
<item>@array/com_google_android_gms_fonts_certs_dev</item>
|
||||||
|
<item>@array/com_google_android_gms_fonts_certs_prod</item>
|
||||||
|
</array>
|
||||||
|
<string-array name="com_google_android_gms_fonts_certs_dev">
|
||||||
|
<item>
|
||||||
|
MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=
|
||||||
|
</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="com_google_android_gms_fonts_certs_prod">
|
||||||
|
<item>
|
||||||
|
MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK
|
||||||
|
</item>
|
||||||
|
</string-array>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Platform.Theme.Jetchat" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Platform.Theme.Jetchat" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLightNavigationBar">?attr/isLightTheme</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<resources>
|
||||||
|
<!-- Light -->
|
||||||
|
<color name="blue500">#0540F2</color>
|
||||||
|
<color name="blue800">#001CCF</color>
|
||||||
|
<color name="yellow700">#F3B711</color>
|
||||||
|
<!-- Dark -->
|
||||||
|
<color name="blue300">#6F7EF9</color>
|
||||||
|
<color name="blue400">#4860F7</color>
|
||||||
|
<color name="yellow400">#F6E547</color>
|
||||||
|
|
||||||
|
<!-- Logo -->
|
||||||
|
<color name="yellow_logo">@color/yellow700</color>
|
||||||
|
<color name="blue_logo">@color/blue500</color>
|
||||||
|
|
||||||
|
<!-- Status bar -->
|
||||||
|
<color name="black30">#4D000000</color>
|
||||||
|
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<resources>
|
||||||
|
<dimen name="activity_horizontal_margin">16dp</dimen>
|
||||||
|
<dimen name="activity_vertical_margin">16dp</dimen>
|
||||||
|
<dimen name="nav_header_spacing">16dp</dimen>
|
||||||
|
<dimen name="nav_header_logo_spacing">8dp</dimen>
|
||||||
|
<dimen name="nav_header_logo_size">24dp</dimen>
|
||||||
|
<dimen name="fab_margin">16dp</dimen>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#0540F2</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<resources>
|
||||||
|
<item name="conversation_fragment" type="id" />
|
||||||
|
<item name="profile_fragment" type="id" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">SMS Remote</string>
|
||||||
|
|
||||||
|
<string name="navigation_drawer_open">Open navigation drawer</string>
|
||||||
|
<string name="navigation_drawer_close">Close navigation drawer</string>
|
||||||
|
<string name="nav_header_title">Composer</string>
|
||||||
|
<string name="nav_header_subtitle">android.studio@android.com</string>
|
||||||
|
<string name="nav_header_desc">Navigation header</string>
|
||||||
|
<string name="action_settings">Settings</string>
|
||||||
|
|
||||||
|
<string name="menu_home">Home</string>
|
||||||
|
<string name="menu_gallery">Gallery</string>
|
||||||
|
<string name="menu_slideshow">Slideshow</string>
|
||||||
|
<string name="conversations">Conversations</string>
|
||||||
|
<string name="profile">Profile</string>
|
||||||
|
<string name="jumpBottom">Jump to bottom</string>
|
||||||
|
<string name="send">Send</string>
|
||||||
|
<string name="author_me">me</string>
|
||||||
|
<string name="now">8:30 PM</string>
|
||||||
|
<string name="members">%d members</string>
|
||||||
|
<string name="textfield_hint">Message #composers</string>
|
||||||
|
<string name="swipe_to_cancel_recording">◀ Swipe to cancel</string>
|
||||||
|
<string name="emojis_label">Emojis</string>
|
||||||
|
<string name="stickers_label">Stickers</string>
|
||||||
|
|
||||||
|
<string name="message">Message</string>
|
||||||
|
<string name="edit_profile">Edit Profile</string>
|
||||||
|
<string name="profile_error">There was an error loading the profile</string>
|
||||||
|
<string name="bio">Bio</string>
|
||||||
|
<string name="display_name">Display name</string>
|
||||||
|
<string name="status">Status</string>
|
||||||
|
<string name="timezone">Timezone</string>
|
||||||
|
<string name="twitter">Twitter</string>
|
||||||
|
<string name="common_channels">Channels in common</string>
|
||||||
|
<string name="lorem">Lorem or Ipsum</string>
|
||||||
|
|
||||||
|
<!-- Device -->
|
||||||
|
<string name="device_error">There was an error loading the device</string>
|
||||||
|
|
||||||
|
<!-- Sim -->
|
||||||
|
<string name="sim_error">There was an error loading the SIM</string>
|
||||||
|
|
||||||
|
<!-- Accessibility descriptions -->
|
||||||
|
|
||||||
|
<string name="emoji_selector_desc">Emoji selector</string>
|
||||||
|
<string name="emoji_selector_bt_desc">Show Emoji selector</string>
|
||||||
|
<string name="dm_desc">Direct Message</string>
|
||||||
|
<string name="attach_photo_desc">Attach Photo</string>
|
||||||
|
<string name="map_selector_desc">Location selector</string>
|
||||||
|
<string name="videochat_desc">Start videochat</string>
|
||||||
|
<string name="textfield_desc">Text input</string>
|
||||||
|
<string name="not_available">Functionality currently not available</string>
|
||||||
|
<string name="not_available_subtitle">Grab a beverage and check back later!</string>
|
||||||
|
<string name="attached_image">Attached image</string>
|
||||||
|
<string name="search">Search</string>
|
||||||
|
<string name="info">Information</string>
|
||||||
|
<string name="more_options">More options</string>
|
||||||
|
<string name="touch_and_hold_to_record">Touch and hold to record</string>
|
||||||
|
<string name="record_message">Record voice message</string>
|
||||||
|
<string name="messages_widget_title">JetChat unread messages</string>
|
||||||
|
<string name="add_widget_to_home_page">Add Widget to Home Page</string>
|
||||||
|
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright 2026 MagicalBits
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Allows us to override platform level specific attributes in their
|
||||||
|
respective values-vXX folder. -->
|
||||||
|
<style name="Platform.Theme.Jetchat" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@color/black30</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- The actual theme we use. This varies for light theme (here),
|
||||||
|
and values-night for dark theme. -->
|
||||||
|
<style name="Theme.Jetchat" parent="Platform.Theme.Jetchat">
|
||||||
|
<item name="colorPrimary">@color/blue500</item>
|
||||||
|
<item name="colorPrimaryDark">@color/blue800</item>
|
||||||
|
<item name="colorAccent">@color/yellow700</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Theme.Jetchat.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Theme.Jetchat.AppBarOverlay" parent="ThemeOverlay.MaterialComponents.Dark.ActionBar" />
|
||||||
|
|
||||||
|
<style name="Theme.Jetchat.PopupOverlay" parent="ThemeOverlay.MaterialComponents.Light" />
|
||||||
|
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout"
|
||||||
|
android:minWidth="276dp"
|
||||||
|
android:minHeight="102dp"
|
||||||
|
android:previewImage="@drawable/widget_icon"
|
||||||
|
android:resizeMode="none"
|
||||||
|
android:targetCellWidth="4"
|
||||||
|
android:targetCellHeight="3" />
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from flask import Flask, abort, jsonify, make_response, request
|
||||||
|
from flask_bcrypt import Bcrypt
|
||||||
|
from flask_jwt_extended import JWTManager, create_access_token, create_refresh_token, get_jwt, get_jwt_identity, jwt_required
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['JWT_SECRET_KEY'] = 'secret' # TODO change and load from a secrets store (should be over 32 bytes long)
|
||||||
|
app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(hours=1)
|
||||||
|
app.config['JWT_REFRESH_TOKEN_EXPIRES'] = timedelta(days=30)
|
||||||
|
flask_bcrypt = Bcrypt(app)
|
||||||
|
jwt = JWTManager(app)
|
||||||
|
|
||||||
|
cur = sqlite3.connect("sms.db")
|
||||||
|
|
||||||
|
file_handler = logging.FileHandler('log/out.log')
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'))
|
||||||
|
app.logger.addHandler(file_handler)
|
||||||
|
app.logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
msg_403_not_primary = "Only a PRIMARY device can perform this action"
|
||||||
|
msg_403_not_secondary = "Only a SECONDARY device can perform this action"
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/login", methods=["POST"])
|
||||||
|
def login():
|
||||||
|
access_key = request.json.get("access_key", None)
|
||||||
|
if access_key is None:
|
||||||
|
return make_response(jsonify({"msg": "Missing access key"}), 400)
|
||||||
|
|
||||||
|
secret_key = request.json.get("secret_key", None)
|
||||||
|
if secret_key is None:
|
||||||
|
return make_response(jsonify({"msg": "Missing secret key"}), 400)
|
||||||
|
|
||||||
|
res_tuple = cur.execute("SELECT secret_key_hash, type FROM devices WHERE access_key = ?", (access_key,)).fetchone()
|
||||||
|
if res_tuple is None:
|
||||||
|
return make_response(jsonify({"msg": "Invalid access key or secret key"}), 401)
|
||||||
|
|
||||||
|
(secret_key_hash, type_claim) = res_tuple
|
||||||
|
if flask_bcrypt.check_password_hash(secret_key_hash, secret_key):
|
||||||
|
# "typ" claim means device type
|
||||||
|
extra_claims = {"typ": type_claim}
|
||||||
|
|
||||||
|
# generate and return JWT
|
||||||
|
return make_response(
|
||||||
|
jsonify({"access_token": create_access_token(identity=access_key, additional_claims=extra_claims),
|
||||||
|
"refresh_token": create_refresh_token(identity=access_key, additional_claims=extra_claims)}),
|
||||||
|
200)
|
||||||
|
else:
|
||||||
|
return make_response(jsonify({"msg": "Invalid access key or secret key"}), 401)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/verify-token", methods=["GET"])
|
||||||
|
@jwt_required()
|
||||||
|
def verify_token():
|
||||||
|
return make_response(jsonify(logged_in_as=get_jwt_identity()), 200)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/refresh-token", methods=["POST"])
|
||||||
|
@jwt_required(refresh=True)
|
||||||
|
def refresh_token():
|
||||||
|
return make_response(jsonify({"access_token": create_access_token(identity=get_jwt_identity(), additional_claims={"typ": get_jwt()["typ"]})}), 200)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/devices", methods=["GET"])
|
||||||
|
@jwt_required()
|
||||||
|
def get_all_devices():
|
||||||
|
if get_jwt()["typ"] != "PRIMARY":
|
||||||
|
return make_response(jsonify(msg=msg_403_not_primary), 403)
|
||||||
|
return make_response(jsonify([d.to_dict() for d in db.get_all_devices(cur)]), 200)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/sim-cards", methods=["GET"])
|
||||||
|
@jwt_required()
|
||||||
|
def get_sim_cards_by_device():
|
||||||
|
if not is_primary(get_jwt()):
|
||||||
|
return make_response(jsonify(msg=msg_403_not_primary), 403)
|
||||||
|
|
||||||
|
access_key = request.args.get('access_key', None)
|
||||||
|
return make_response(jsonify([s.to_dict() for s in db.get_sim_cards_by_device(cur, access_key)]), 200)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/sms-messages", methods=["GET"])
|
||||||
|
@jwt_required()
|
||||||
|
def get_sms_messages_by_local_phone_number():
|
||||||
|
if not is_primary(get_jwt()):
|
||||||
|
return make_response(jsonify(msg=msg_403_not_primary), 403)
|
||||||
|
|
||||||
|
local_phone_number = request.args.get("local_phone_number", None)
|
||||||
|
# TODO set up access logging
|
||||||
|
#print(f"/api/v1/sms-messages - local_phone_number='{local_phone_number}'")
|
||||||
|
return make_response(jsonify([n.to_dict() for n in db.get_sms_messages_by_local_phone_number(cur, local_phone_number)]), 200)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/send-message", methods=["POST"])
|
||||||
|
@jwt_required()
|
||||||
|
def send_sms_message():
|
||||||
|
if not is_primary(get_jwt()):
|
||||||
|
return make_response(jsonify(msg=msg_403_not_primary), 403)
|
||||||
|
|
||||||
|
content = request.json.get("content", None)
|
||||||
|
access_key = get_jwt_identity()
|
||||||
|
local_phone_number = request.json.get("local_phone_number", None)
|
||||||
|
remote_phone_number = request.json.get("remote_phone_number", None)
|
||||||
|
|
||||||
|
app.logger.debug(f"sending msg: content='{content}', access_key='{access_key}', local_num='{local_phone_number}', remote_num='{remote_phone_number}'")
|
||||||
|
|
||||||
|
if db.send_sms_message(cur, content, access_key, local_phone_number, remote_phone_number):
|
||||||
|
return make_response(jsonify(msg="Message successfully sent"), 200)
|
||||||
|
else:
|
||||||
|
return make_response(jsonify(msg="Failed to send message"), 400)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/get-queued-sms-messages", methods=["GET"])
|
||||||
|
@jwt_required()
|
||||||
|
def get_queued_sms_messages():
|
||||||
|
if not is_secondary(get_jwt()):
|
||||||
|
return make_response(jsonify(msg=msg_403_not_secondary), 403)
|
||||||
|
|
||||||
|
access_key = get_jwt_identity()
|
||||||
|
local_phone_number = request.json.get("local_phone_number", None)
|
||||||
|
|
||||||
|
return make_response(jsonify([m.to_dict() for m in db.get_queued_sms_messages(cur, local_phone_number)]), 200)
|
||||||
|
|
||||||
|
|
||||||
|
def is_primary(jwt):
|
||||||
|
return jwt["typ"] == "PRIMARY"
|
||||||
|
|
||||||
|
|
||||||
|
def is_secondary(jwt):
|
||||||
|
return jwt["typ"] == "SECONDARY"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from dto import Device, SimCard, SmsMessage, QueuedSmsMessage
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_devices(cur) -> list[Device]:
|
||||||
|
res = cur.execute("SELECT access_key, type, name FROM devices")
|
||||||
|
devices_from_db = res.fetchall()
|
||||||
|
|
||||||
|
devices = []
|
||||||
|
for item in devices_from_db:
|
||||||
|
devices.append(Device.convert(item))
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def get_sim_cards_by_device(cur, access_key: str) -> list[SimCard]:
|
||||||
|
if access_key is None or not access_key:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sim_cards_from_db = cur.execute("SELECT phone_number, device_access_key FROM sim_cards WHERE device_access_key = ?", (access_key,)).fetchall()
|
||||||
|
sim_cards = []
|
||||||
|
for item in sim_cards_from_db:
|
||||||
|
sim_cards.append(SimCard.convert(item))
|
||||||
|
return sim_cards
|
||||||
|
|
||||||
|
|
||||||
|
def get_sms_messages_by_local_phone_number(cur, local_phone_number: str) -> list[SmsMessage]:
|
||||||
|
if local_phone_number is None or not local_phone_number:
|
||||||
|
return []
|
||||||
|
|
||||||
|
msgs_from_db = cur.execute("SELECT * FROM messages WHERE local_phone_number = ?", (local_phone_number,)).fetchall()
|
||||||
|
msgs = []
|
||||||
|
for item in msgs_from_db:
|
||||||
|
msgs.append(SmsMessage.convert(item))
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
def send_sms_message(cur, content: str, sender_access_key: str, local_phone_number: str, remote_phone_number: str) -> bool:
|
||||||
|
if content is None or not content \
|
||||||
|
or sender_access_key is None or not sender_access_key \
|
||||||
|
or local_phone_number is None or not local_phone_number \
|
||||||
|
or remote_phone_number is None or not remote_phone_number:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cur.execute("INSERT INTO message_queue VALUES (?, ?, ?, ?)", \
|
||||||
|
(content, sender_access_key, local_phone_number, remote_phone_number))
|
||||||
|
cur.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_queued_sms_messages(cur, local_phone_number) -> list[QueuedSmsMessage]:
|
||||||
|
if local_phone_number is None or not local_phone_number:
|
||||||
|
return []
|
||||||
|
|
||||||
|
msgs_from_db = cur.execute("DELETE FROM message_queue WHERE local_phone_number = ? RETURNING *", (local_phone_number,)).fetchall()
|
||||||
|
cur.commit()
|
||||||
|
msgs = []
|
||||||
|
for item in msgs_from_db:
|
||||||
|
msgs.append(QueuedSmsMessage.convert(item))
|
||||||
|
return msgs
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Device:
|
||||||
|
access_key: str
|
||||||
|
device_type: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'access_key': self.access_key,
|
||||||
|
'type': self.device_type,
|
||||||
|
'name': self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def convert(device_from_db) -> Device:
|
||||||
|
return Device(*device_from_db)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SimCard:
|
||||||
|
phone_number: str
|
||||||
|
device_access_key: str
|
||||||
|
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'phone_number': self.phone_number,
|
||||||
|
'device_access_key': self.device_access_key
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def convert(sim_from_db) -> SimCard:
|
||||||
|
return SimCard(*sim_from_db)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SmsMessage:
|
||||||
|
content: str
|
||||||
|
ts_received: int
|
||||||
|
ts_sent: int
|
||||||
|
msg_type: str
|
||||||
|
local_phone_number: str
|
||||||
|
remote_phone_number: str
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'content': self.content,
|
||||||
|
'ts_received': self.ts_received,
|
||||||
|
'ts_sent': self.ts_sent,
|
||||||
|
'msg_type': self.msg_type,
|
||||||
|
'local_phone_number': self.local_phone_number,
|
||||||
|
'remote_phone_number': self.remote_phone_number
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def convert(sms_from_db) -> SmsMessage:
|
||||||
|
return SmsMessage(*sms_from_db)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QueuedSmsMessage:
|
||||||
|
content: str
|
||||||
|
sender_access_key: str
|
||||||
|
local_phone_number: str
|
||||||
|
remote_phone_number: str
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'content': self.content,
|
||||||
|
'sender_access_key': self.sender_access_key,
|
||||||
|
'local_phone_number': self.local_phone_number,
|
||||||
|
'remote_phone_number': self.remote_phone_number
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def convert(sms_from_db) -> QueuedSmsMessage:
|
||||||
|
return QueuedSmsMessage(*sms_from_db)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from flask_bcrypt import Bcrypt
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
if os.path.isfile("sms.db"):
|
||||||
|
os.unlink("sms.db")
|
||||||
|
|
||||||
|
flask_bcrypt = Bcrypt()
|
||||||
|
|
||||||
|
con = sqlite3.connect("sms.db")
|
||||||
|
cur = con.cursor()
|
||||||
|
|
||||||
|
# ID columns are not necessary - SQLite by default sets ROWID as INTEGER PRIMARY KEY which auto increments
|
||||||
|
cur.execute("CREATE TABLE devices(access_key, secret_key_hash, type, name)")
|
||||||
|
|
||||||
|
# type : INCOMING, OUTGOING
|
||||||
|
# local_phone_number : a SECONDARY device SIM's phone number
|
||||||
|
# remote_phone_number : a phone number or shortcode of the other party
|
||||||
|
# TODO add column for sender's access key
|
||||||
|
cur.execute("CREATE TABLE messages(content, ts_received, ts_sent, type, local_phone_number, remote_phone_number)")
|
||||||
|
cur.execute("CREATE TABLE sim_events(sim_id, ts, note, cost, currency)")
|
||||||
|
cur.execute("CREATE TABLE sim_cards(phone_number, device_access_key)")
|
||||||
|
cur.execute("CREATE TABLE message_queue(content, sender_access_key, local_phone_number, remote_phone_number)")
|
||||||
|
|
||||||
|
pw1_hash = flask_bcrypt.generate_password_hash('pw1').decode('utf-8')
|
||||||
|
pw2_hash = flask_bcrypt.generate_password_hash('pw2').decode('utf-8')
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO devices VALUES
|
||||||
|
('test_access_key', ?, 'PRIMARY', 'test-primary'),
|
||||||
|
('test_access_key2', ?, 'SECONDARY', 'test-secondary')
|
||||||
|
""", (pw1_hash, pw2_hash))
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO sim_cards VALUES
|
||||||
|
('+420123456789', 'test_access_key'),
|
||||||
|
('+421000111222', 'test_access_key'),
|
||||||
|
('+422999888777', 'test_access_key2')
|
||||||
|
""")
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO messages VALUES
|
||||||
|
('how are you?', 1000, 999, 'INCOMING', '+420123456789', '+10005558888'),
|
||||||
|
('i am fine', 2000, 1999, 'OUTGOING', '+420123456789', '+10005558888')
|
||||||
|
""")
|
||||||
|
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
uv run gunicorn -b 0.0.0.0:5000 app:app \
|
||||||
|
--reload
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.gradle.versions)
|
||||||
|
alias(libs.plugins.version.catalog.update)
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.parcelize) apply false
|
||||||
|
alias(libs.plugins.compose) apply false
|
||||||
|
alias(libs.plugins.spotless) apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
apply("${project.rootDir}/buildscripts/toml-updater-config.gradle")
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
apply(plugin = "com.diffplug.spotless")
|
||||||
|
configure<com.diffplug.gradle.spotless.SpotlessExtension> {
|
||||||
|
// kotlin {
|
||||||
|
// target("**/*.kt")
|
||||||
|
// targetExclude("${layout.buildDirectory}/**/*.kt")
|
||||||
|
// ktlint()
|
||||||
|
// licenseHeaderFile(rootProject.file("spotless/copyright.kt"))
|
||||||
|
// }
|
||||||
|
// kotlinGradle {
|
||||||
|
// target("*.gradle.kts")
|
||||||
|
// targetExclude("${layout.buildDirectory}/**/*.kt")
|
||||||
|
// ktlint()
|
||||||
|
// // Look for the first line that doesn't have a block comment (assumed to be the license)
|
||||||
|
// licenseHeaderFile(rootProject.file("spotless/copyright.kt"), "(^(?![\\/ ]\\*).*$)")
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 MagicalBits
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
versionCatalogUpdate {
|
||||||
|
sortByKey.set(true)
|
||||||
|
|
||||||
|
keep {
|
||||||
|
// keep versions without any library or plugin reference
|
||||||
|
keepUnusedVersions.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def isNonStable = { String version ->
|
||||||
|
def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) }
|
||||||
|
def regex = /^[0-9,.v-]+(-r)?$/
|
||||||
|
return !stableKeyword && !(version ==~ regex)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("dependencyUpdates").configure {
|
||||||
|
resolutionStrategy {
|
||||||
|
componentSelection {
|
||||||
|
all {
|
||||||
|
if (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)) {
|
||||||
|
reject('Release candidate')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2026 MagicalBits
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m
|
||||||
|
|
||||||
|
# Turn on parallel compilation, caching and on-demand configuration
|
||||||
|
org.gradle.configureondemand=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#This file is generated by updateDaemonJvm
|
||||||
|
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac68b3e883c7c91c760/redirect
|
||||||
|
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect
|
||||||
|
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac68b3e883c7c91c760/redirect
|
||||||
|
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect
|
||||||
|
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/f257be9f04bfdf169051808541767806/redirect
|
||||||
|
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/1dcbacacca32618bd21ec5465779ade1/redirect
|
||||||
|
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac68b3e883c7c91c760/redirect
|
||||||
|
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect
|
||||||
|
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/1e91f45234d88a64dafb961c93ddc75a/redirect
|
||||||
|
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect
|
||||||
|
toolchainVendor=ADOPTIUM
|
||||||
|
toolchainVersion=21
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
#####
|
||||||
|
# This file is duplicated to individual samples from the global scripts/libs.versions.toml
|
||||||
|
# Do not add a dependency to an individual sample, edit the global version instead.
|
||||||
|
#####
|
||||||
|
[versions]
|
||||||
|
accompanist = "0.37.3"
|
||||||
|
android-material3 = "1.14.0-rc01"
|
||||||
|
androidGradlePlugin = "9.2.0"
|
||||||
|
androidx-activity-compose = "1.13.0"
|
||||||
|
androidx-appcompat = "1.7.1"
|
||||||
|
androidx-compose-bom = "2026.04.01"
|
||||||
|
androidx-constraintlayout = "1.1.1"
|
||||||
|
androidx-core-splashscreen = "1.2.0"
|
||||||
|
androidx-corektx = "1.18.0"
|
||||||
|
androidx-glance = "1.1.1"
|
||||||
|
androidx-lifecycle = "2.8.2"
|
||||||
|
androidx-lifecycle-compose = "2.10.0"
|
||||||
|
androidx-lifecycle-runtime-compose = "2.10.0"
|
||||||
|
androidx-navigation = "2.9.8"
|
||||||
|
androidx-palette = "1.0.0"
|
||||||
|
androidx-test = "1.7.0"
|
||||||
|
androidx-test-espresso = "3.7.0"
|
||||||
|
androidx-test-ext-junit = "1.3.0"
|
||||||
|
androidx-test-ext-truth = "1.7.0"
|
||||||
|
androidx-tv-foundation = "1.0.0-rc01"
|
||||||
|
androidx-tv-material = "1.0.1"
|
||||||
|
androidx-wear-compose = "1.6.1"
|
||||||
|
androidx-window = "1.5.1"
|
||||||
|
androidxHiltNavigationCompose = "1.3.0"
|
||||||
|
androix-test-uiautomator = "2.3.0"
|
||||||
|
coil = "2.7.0"
|
||||||
|
# @keep
|
||||||
|
compileSdk = "36"
|
||||||
|
coroutines = "1.10.2"
|
||||||
|
google-maps = "20.0.0"
|
||||||
|
gradle-versions = "0.54.0"
|
||||||
|
hilt = "2.59.2"
|
||||||
|
hiltExt = "1.3.0"
|
||||||
|
horologist = "0.7.15"
|
||||||
|
ktor-client = "3.5.0"
|
||||||
|
jdkDesugar = "2.1.5"
|
||||||
|
junit = "4.13.2"
|
||||||
|
kotlin = "2.3.21"
|
||||||
|
kotlinx-serialization-json = "1.11.0"
|
||||||
|
kotlinx_immutable = "0.4.0"
|
||||||
|
ksp = "2.3.7"
|
||||||
|
maps-compose = "8.3.0"
|
||||||
|
# @keep
|
||||||
|
minSdk = "23"
|
||||||
|
okhttp = "5.3.2"
|
||||||
|
play-services-wearable = "20.0.1"
|
||||||
|
robolectric = "4.16.1"
|
||||||
|
roborazzi = "1.60.0"
|
||||||
|
rome = "2.1.0"
|
||||||
|
room = "2.8.4"
|
||||||
|
secrets = "2.0.1"
|
||||||
|
spotless = "8.4.0"
|
||||||
|
# @keep
|
||||||
|
targetSdk = "33"
|
||||||
|
version-catalog-update = "1.1.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" }
|
||||||
|
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
|
||||||
|
android-material3 = { module = "com.google.android.material:material", version.ref = "android-material3" }
|
||||||
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
|
||||||
|
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||||
|
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
|
||||||
|
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" }
|
||||||
|
androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" }
|
||||||
|
androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" }
|
||||||
|
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||||
|
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" }
|
||||||
|
androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" }
|
||||||
|
androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" }
|
||||||
|
androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" }
|
||||||
|
androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" }
|
||||||
|
androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" }
|
||||||
|
androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
|
||||||
|
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
|
androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" }
|
||||||
|
androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
|
||||||
|
androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" }
|
||||||
|
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
|
||||||
|
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||||
|
androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" }
|
||||||
|
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||||
|
androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" }
|
||||||
|
androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" }
|
||||||
|
androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout" }
|
||||||
|
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-corektx" }
|
||||||
|
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" }
|
||||||
|
androidx-glance = { module = "androidx.glance:glance", version.ref = "androidx-glance" }
|
||||||
|
androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" }
|
||||||
|
androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" }
|
||||||
|
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
|
||||||
|
androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" }
|
||||||
|
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-compose" }
|
||||||
|
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" }
|
||||||
|
androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" }
|
||||||
|
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" }
|
||||||
|
androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" }
|
||||||
|
androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" }
|
||||||
|
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
|
||||||
|
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" }
|
||||||
|
androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" }
|
||||||
|
androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" }
|
||||||
|
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||||
|
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||||
|
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||||
|
androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" }
|
||||||
|
androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" }
|
||||||
|
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" }
|
||||||
|
androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-ext-truth" }
|
||||||
|
androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" }
|
||||||
|
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" }
|
||||||
|
androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androix-test-uiautomator" }
|
||||||
|
androidx-tv-foundation = { module = "androidx.tv:tv-foundation", version.ref = "androidx-tv-foundation" }
|
||||||
|
androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "androidx-tv-material" }
|
||||||
|
androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "androidx-wear-compose" }
|
||||||
|
androidx-wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "androidx-wear-compose" }
|
||||||
|
androidx-wear-compose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "androidx-wear-compose" }
|
||||||
|
androidx-wear-compose-ui-tooling = { module = "androidx.wear.compose:compose-ui-tooling", version.ref = "androidx-wear-compose" }
|
||||||
|
androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" }
|
||||||
|
androidx-window-core = { module = "androidx.window:window-core", version.ref = "androidx-window" }
|
||||||
|
coil-kt-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
|
||||||
|
core-jdk-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "jdkDesugar" }
|
||||||
|
dagger-hiltandroidplugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" }
|
||||||
|
googlemaps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" }
|
||||||
|
googlemaps-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "google-maps" }
|
||||||
|
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||||
|
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
|
||||||
|
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
||||||
|
hilt-ext-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltExt" }
|
||||||
|
horologist-audio-ui = { module = "com.google.android.horologist:horologist-audio-ui", version.ref = "horologist" }
|
||||||
|
horologist-composables = { module = "com.google.android.horologist:horologist-composables", version.ref = "horologist" }
|
||||||
|
horologist-compose-layout = { module = "com.google.android.horologist:horologist-compose-layout", version.ref = "horologist" }
|
||||||
|
horologist-compose-material = { module = "com.google.android.horologist:horologist-compose-material", version.ref = "horologist" }
|
||||||
|
horologist-compose-tools = { module = "com.google.android.horologist:horologist-compose-tools", version.ref = "horologist" }
|
||||||
|
horologist-images-coil = { module = "com.google.android.horologist:horologist-images-coil", version.ref = "horologist" }
|
||||||
|
horologist-media-data = { module = "com.google.android.horologist:horologist-media-data", version.ref = "horologist" }
|
||||||
|
horologist-media-ui = { module = "com.google.android.horologist:horologist-media-ui", version.ref = "horologist" }
|
||||||
|
horologist-roboscreenshots = { module = "com.google.android.horologist:horologist-roboscreenshots", version.ref = "horologist" }
|
||||||
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
|
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" }
|
||||||
|
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx_immutable" }
|
||||||
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||||
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
|
||||||
|
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor-client"}
|
||||||
|
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor-client"}
|
||||||
|
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor-client"}
|
||||||
|
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor-client"}
|
||||||
|
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor-client"}
|
||||||
|
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||||
|
okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||||
|
play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" }
|
||||||
|
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
|
||||||
|
roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
|
||||||
|
roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" }
|
||||||
|
roborazzi-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" }
|
||||||
|
rometools-modules = { module = "com.rometools:rome-modules", version.ref = "rome" }
|
||||||
|
rometools-rome = { module = "com.rometools:rome", version.ref = "rome" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
|
||||||
|
android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
|
||||||
|
android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" }
|
||||||
|
compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" }
|
||||||
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
|
kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
|
||||||
|
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
|
||||||
|
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
|
||||||
|
version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" }
|
||||||
Vendored
BIN
Binary file not shown.
+9
@@ -0,0 +1,9 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user