Merge pull request #1 from Assasinnys/compose_refactor

Compose refactor
This commit is contained in:
Dzmitry Zianevich 2022-12-06 21:14:12 +03:00 committed by GitHub
commit 3f49e3c4be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 359 additions and 146 deletions

View File

@ -68,4 +68,6 @@ dependencies {
implementation "com.squareup.moshi:moshi:1.14.0"
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.14.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1"
}

View File

@ -23,6 +23,7 @@ class SocketRepositoryImpl @Inject constructor(
true
} catch (e: Exception) {
Log.e( javaClass.simpleName, "Socket connection error; address: $address, port: $port", e)
socketSource.close()
false
}
}

View File

@ -1,6 +1,10 @@
package com.dmitryzenevich.remoteaudiocontrol.data
import android.util.Log
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.Closeable
import java.net.InetSocketAddress
import java.net.Socket
import javax.inject.Inject
import javax.inject.Singleton
@ -9,6 +13,7 @@ import javax.inject.Singleton
class SocketSource @Inject constructor() : Closeable {
private var socket: Socket? = null
private val mutex = Mutex()
val writer
get() = socket().getOutputStream().writer()
@ -18,12 +23,16 @@ class SocketSource @Inject constructor() : Closeable {
private fun socket(): Socket = socket ?: throw IllegalStateException("Socket is not created")
fun openSocketConnection(address: String, port: Int) {
socket = Socket(address, port)
suspend fun openSocketConnection(address: String, port: Int) {
mutex.withLock {
close()
socket = Socket().apply { connect(InetSocketAddress(address, port), 5000) }
}
}
override fun close() {
Log.i(javaClass.simpleName, "Socket $socket is now closing")
socket?.close()
socket = null
}
}

View File

@ -1,13 +1,22 @@
package com.dmitryzenevich.remoteaudiocontrol.di
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class AppModule {
class AppModule {
@Singleton
@Provides
fun getSharedPreferences(@ApplicationContext appContext: Context): SharedPreferences {
return appContext.getSharedPreferences("AppPrefs", MODE_PRIVATE)
}
}

View File

@ -1,5 +0,0 @@
package com.dmitryzenevich.remoteaudiocontrol.presentation
import kotlin.math.roundToInt
fun Float.classicRound(): Float = times(100).roundToInt().div(100f)

View File

@ -1,45 +1,57 @@
package com.dmitryzenevich.remoteaudiocontrol.presentation
import android.annotation.SuppressLint
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import com.dmitryzenevich.remoteaudiocontrol.presentation.theme.Dimens.TabHeight
import com.dmitryzenevich.remoteaudiocontrol.presentation.theme.RemoteAudioControlTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlin.math.roundToInt
import androidx.lifecycle.viewmodel.compose.viewModel
import com.dmitryzenevich.remoteaudiocontrol.R
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
RemoteAudioControlTheme {
val uiState by viewModel.uiState.collectAsState()
LazyRow {
items(uiState.volumes) { itemState ->
VolumeItem(itemState, viewModel)
}
RemoteAudioControlApp()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RemoteAudioControlApp(viewModel: MainViewModel = viewModel()) {
val uiState = viewModel.uiState.collectAsState()
val showDialog = viewModel.showAddressDialog.collectAsState()
val isLoading = viewModel.isLoading.collectAsState()
RemoteAudioControlTheme {
Scaffold(
topBar = { TopBar(viewModel::onAddressClick) }
) { padding ->
Box(Modifier.padding(padding)) {
MainScreen(
uiState = uiState.value,
onVolumeChanged = viewModel::onVolumeChanged,
onMuteClick = viewModel::onMuteClick
)
if (showDialog.value) {
IpAddressDialog(initIp = viewModel.getCurrentIp(), onConfirm = viewModel::onConfirmAddress)
}
if (isLoading.value) {
ConnectionProgressBar()
}
}
}
@ -47,59 +59,60 @@ class MainActivity : ComponentActivity() {
}
@Composable
fun VolumeItem(volumeItemState: VolumeItemState, viewModel: MainViewModel) {
var volume by remember { mutableStateOf(volumeItemState.volume) }
volume = volumeItemState.volume
val onValueChanged = { newValue: Float ->
Log.i("debug", "new value: $newValue")
viewModel.onVolumeChanged(volumeItemState, newValue.toInt())
volume = newValue.toInt()
}
Column(
modifier = Modifier
.padding(8.dp)
.wrapContentSize(),
horizontalAlignment = Alignment.CenterHorizontally
fun ConnectionProgressBar() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "${volumeItemState.name}\nVolume: ${volumeItemState.volume}"
)
VerticalSlider(
value = /*volumeItemState.volume.toFloat()*/volume.toFloat(),
onValueChanged = onValueChanged
)
CircularProgressIndicator()
}
}
@Composable
fun VerticalSlider(
value: Float,
onValueChanged: (Float) -> Unit
) {
Slider(
value = value,
valueRange = 0f..100f,
steps = 99,
onValueChange = onValueChanged,
fun TopBar(onAddressClick: () -> Unit) {
Surface(
modifier = Modifier
.graphicsLayer {
rotationZ = 270f
transformOrigin = TransformOrigin(0f, 0f)
}
.layout { measurable, constraints ->
val placeable = measurable.measure(
Constraints(
minWidth = constraints.minHeight,
maxWidth = constraints.maxHeight,
minHeight = constraints.minWidth,
maxHeight = constraints.maxHeight,
)
.height(TabHeight)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = stringResource(R.string.app_name))
IconButton(onClick = onAddressClick) {
Icon(
imageVector = Icons.Filled.Build,
contentDescription = stringResource(R.string.accessibility_ip_address_button)
)
layout(placeable.height, placeable.width) {
placeable.place(-placeable.width, 0)
}
}
.padding(horizontal = 24.dp)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IpAddressDialog(initIp: String, onConfirm: (String) -> Unit) {
var ipText by remember { mutableStateOf(initIp) }
AlertDialog(
onDismissRequest = {},
title = {
Text(text = stringResource(R.string.ip_dialog_title))
},
text = {
TextField(value = ipText, onValueChange = { ipText = it })
},
confirmButton = {
Button(
onClick = { onConfirm(ipText) }
) {
Text(text = "Ok")
}
}
)
}

View File

@ -0,0 +1,31 @@
package com.dmitryzenevich.remoteaudiocontrol.presentation
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun MainScreen(
uiState: MainScreenUiState,
onVolumeChanged: (VolumeItemState, Int) -> Unit,
onMuteClick: (VolumeItemState) -> Unit
) {
LazyRow(
modifier = Modifier.fillMaxSize()
) {
items(
items = uiState.volumes,
key = { item -> item.pid }
) { itemState ->
VolumeItem(
volumeItemState = itemState,
onValueChanged = { newValue: Float ->
onVolumeChanged(itemState, newValue.toInt())
},
onMuteClick = { onMuteClick(itemState) }
)
}
}
}

View File

@ -0,0 +1,19 @@
package com.dmitryzenevich.remoteaudiocontrol.presentation
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.SnapshotStateList
data class MainScreenUiState(
val volumes: SnapshotStateList<VolumeItemState> = mutableStateListOf(),
val isError: Boolean = false
)
data class VolumeItemState(
val pid: Long,
val name: String = "",
val volume: MutableState<Int> = mutableStateOf(0),
val isMuted: MutableState<Boolean> = mutableStateOf(false),
val isActive: Boolean = false
)

View File

@ -5,57 +5,64 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.dmitryzenevich.remoteaudiocontrol.data.SocketRepositoryImpl
import com.dmitryzenevich.remoteaudiocontrol.data.model.*
import com.dmitryzenevich.remoteaudiocontrol.presentation.helper.PrefHelper
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import javax.inject.Inject
import kotlin.math.absoluteValue
const val NOT_BLOCKED = -1L
@HiltViewModel
class MainViewModel @Inject constructor(
private val socketRepositoryImpl: SocketRepositoryImpl
private val socketRepositoryImpl: SocketRepositoryImpl,
private val prefHelper: PrefHelper
) : ViewModel() {
private val _uiState = MutableStateFlow(MainScreenUiState())
val uiState = _uiState.asStateFlow()
private var commandJob: Job? = null
private val _showAddressDialog = MutableStateFlow(false)
val showAddressDialog = _showAddressDialog.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
@Volatile
private var blockingPid: Long = NOT_BLOCKED
private var blockingJob: Job? = null
init {
connectToSocket()
}
private fun connectToSocket() {
viewModelScope.launch {
val isReady = socketRepositoryImpl.openSocketConnection("10.0.2.2", 54683)
_isLoading.value = true
val isReady = socketRepositoryImpl.openSocketConnection(prefHelper.getIpAddress(), 54683)
if (isReady) {
socketRepositoryImpl.bindSocketInput()
.filterNotNull()
.onEach {
Log.i(javaClass.simpleName, "received: $it")
// TODO: test it
Log.i(javaClass.simpleName, "Received: $it")
withContext(Dispatchers.Main) { proceedEvent(it) }
}
.catch { Log.e(javaClass.simpleName, "Fetch event error", it) }
.launchIn(CoroutineScope(Dispatchers.IO))
}
_isLoading.value = false
}
}
private fun proceedEvent(event: Event) {
val volumes = _uiState.value.volumes.toMutableList()
val volumes = _uiState.value.volumes
when(event) {
is NewSessionEvent -> {
// if (_uiState.value.volumes.find { it.pid == event.PID } == null) {
// _uiState.value = MainScreenUiState(volumes = _uiState.value.volumes + event.toVolumeItemState())
// }
}
is NewSessionEvent -> addIfNotExist(event)
is MuteStateChangedEvent -> {
volumes.find { event.PID == it.pid }?.let { item ->
val newItem = item.copy(isMuted = event.isMuted)
volumes.set(
index = volumes.indexOfFirst { it.pid == newItem.pid },
element = newItem
)
item.isMuted.value = event.isMuted
}
_uiState.value = _uiState.value.copy(volumes = volumes)
}
is SetNameEvent -> {
volumes.find { event.PID == it.pid }?.let { item ->
@ -65,7 +72,6 @@ class MainViewModel @Inject constructor(
element = newItem
)
}
_uiState.value = _uiState.value.copy(volumes = volumes)
}
is StateChangedEvent -> {
volumes.find { event.PID == it.pid }?.let { item ->
@ -75,69 +81,70 @@ class MainViewModel @Inject constructor(
element = newItem
)
}
_uiState.value = _uiState.value.copy(volumes = volumes)
}
is VolumeChangedEvent -> {
addIfNotExist(event)
val v = _uiState.value.volumes.toMutableList()
if (blockingPid == event.PID) return
v.find { event.PID == it.pid }?.let { item ->
val newItem = item.copy(volume = event.volume)
v.set(
index = v.indexOfFirst { it.pid == newItem.pid },
element = newItem
)
volumes.find { event.PID == it.pid }?.let { item ->
item.volume.value = event.volume
blockVolumeEvent(event.PID)
}
_uiState.value = _uiState.value.copy(volumes = v)
}
UnknownEvent -> _uiState.value = _uiState.value.copy(isError = true)
UnknownEvent -> _uiState.value.isError // TODO: implement error state
}
}
private fun blockVolumeEvent(eventPid: Long) {
blockingJob?.cancel()
blockingJob = viewModelScope.launch {
blockingPid = eventPid
delay(200)
blockingPid = NOT_BLOCKED
}
}
fun onVolumeChanged(volumeItemState: VolumeItemState, newVolume: Int) {
Log.i(javaClass.simpleName, "old volume: ${volumeItemState.volume}, newVolume: $newVolume")
val increment = newVolume.minus(volumeItemState.volume)
Log.i(javaClass.simpleName, "increment: $increment")
// val volumes = _uiState.value.volumes.toMutableList()
// volumes.find { volumeItemState.pid == it.pid }?.let { item ->
// val newItem = item.copy(volume = newVolume)
// volumes.set(
// index = volumes.indexOfFirst { it.pid == newItem.pid },
// element = newItem
// )
// }
// _uiState.value = _uiState.value.copy(volumes = volumes)
volumeItemState.volume.value = newVolume
sendCommand(SetVolumeCommand(volumeItemState.pid, newVolume))
}
private fun sendCommand(command: Command) {
commandJob?.cancel()
commandJob = viewModelScope.launch { socketRepositoryImpl.sendCommand(command) }
fun onMuteClick(volumeItemState: VolumeItemState) {
Log.i(javaClass.simpleName, "new checked: ${!volumeItemState.isMuted.value}")
volumeItemState.isMuted.value = !volumeItemState.isMuted.value
sendCommand(MuteToggleCommand(volumeItemState.pid))
}
private fun addIfNotExist(event: VolumeChangedEvent) {
private fun sendCommand(command: Command) {
viewModelScope.launch { socketRepositoryImpl.sendCommand(command) }
}
private fun addIfNotExist(event: NewSessionEvent) {
if (_uiState.value.volumes.find { it.pid == event.PID } == null) {
_uiState.value = MainScreenUiState(volumes = _uiState.value.volumes + event.toVolumeItemState())
_uiState.value.volumes.add(event.toVolumeItemState())
}
}
fun onAddressClick() {
_showAddressDialog.value = true
Log.i(javaClass.simpleName, "address clicked")
}
fun onConfirmAddress(ip: String) {
if (isValidIp(ip)) {
prefHelper.setIpAddress(ip)
_showAddressDialog.value = false
connectToSocket()
Log.i(javaClass.simpleName, "new ip: $ip")
}
}
fun getCurrentIp() = prefHelper.getIpAddress().also { Log.i(javaClass.simpleName, "Current ip: $it") }
private fun isValidIp(ip: String): Boolean = ip.matches(ipCheckRegex.toRegex())
.also { Log.i(javaClass.simpleName, "Regex matches: $it") }
}
data class MainScreenUiState(
val volumes: List<VolumeItemState> = emptyList(),
val isError: Boolean = false
)
data class VolumeItemState(
val pid: Long,
val name: String = "",
val volume: Int = 0,
val isMuted: Boolean = false,
val isActive: Boolean = false
)
const val ipCheckRegex = "^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}\$"
fun NewSessionEvent.toVolumeItemState() = VolumeItemState(pid = PID)
fun VolumeChangedEvent.toVolumeItemState() = VolumeItemState(pid = PID, volume = volume)

View File

@ -0,0 +1,84 @@
package com.dmitryzenevich.remoteaudiocontrol.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.dmitryzenevich.remoteaudiocontrol.R
@Composable
fun VolumeItem(
volumeItemState: VolumeItemState,
onValueChanged: (Float) -> Unit,
onMuteClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxHeight()
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Text(text = volumeItemState.name)
Text(text = volumeItemState.volume.value.toString())
VerticalSlider(
value = volumeItemState.volume.value.toFloat(),
onValueChanged = onValueChanged,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
IconButton(onClick = onMuteClick) {
if (volumeItemState.isMuted.value)
Icon(
painter = painterResource(R.drawable.ic_volume_off),
contentDescription = stringResource(R.string.accessibility_muted)
)
else
Icon(
painter = painterResource(R.drawable.ic_volume_up),
contentDescription = stringResource(R.string.accessibility_resumed)
)
}
}
}
@Composable
fun VerticalSlider(
value: Float,
onValueChanged: (Float) -> Unit,
modifier: Modifier = Modifier
) {
Slider(
value = value,
valueRange = 0f..100f,
onValueChange = onValueChanged,
modifier = modifier
.graphicsLayer {
rotationZ = 270f
transformOrigin = TransformOrigin(0f, 0f)
}
.layout { measurable, constraints ->
val placeable = measurable.measure(
Constraints(
minWidth = constraints.minHeight,
maxWidth = constraints.maxHeight,
minHeight = constraints.minWidth,
maxHeight = constraints.maxHeight,
)
)
layout(placeable.height, placeable.width) {
placeable.place(-placeable.width, 0)
}
}
.padding(horizontal = 8.dp)
)
}

View File

@ -0,0 +1,20 @@
package com.dmitryzenevich.remoteaudiocontrol.presentation.helper
import android.content.SharedPreferences
import androidx.core.content.edit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PrefHelper @Inject constructor(private val sPref: SharedPreferences) {
fun getIpAddress(): String {
return sPref.getString(KEY_IP, "192.168.100.9")!!
}
fun setIpAddress(ip: String) = sPref.edit { putString(KEY_IP, ip) }
private companion object {
const val KEY_IP = "key_ip"
}
}

View File

@ -2,10 +2,10 @@ package com.dmitryzenevich.remoteaudiocontrol.presentation.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple80 = Color(0xFF80CBC4)
val PurpleGrey80 = Color(0xFF4DB6AC)
val Pink80 = Color(0xFF81C784)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val Purple40 = Color(0xFF009688)
val PurpleGrey40 = Color(0xFF00897B)
val Pink40 = Color(0xFF80CBC4)

View File

@ -0,0 +1,7 @@
package com.dmitryzenevich.remoteaudiocontrol.presentation.theme
import androidx.compose.ui.unit.dp
object Dimens {
val TabHeight = 56.dp
}

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v2.21l2.45,2.45c0.03,-0.2 0.05,-0.41 0.05,-0.63zM19,12c0,0.94 -0.2,1.82 -0.54,2.64l1.51,1.51C20.63,14.91 21,13.5 21,12c0,-4.28 -2.99,-7.86 -7,-8.77v2.06c2.89,0.86 5,3.54 5,6.71zM4.27,3L3,4.27 7.73,9L3,9v6h4l5,5v-6.73l4.25,4.25c-0.67,0.52 -1.42,0.93 -2.25,1.18v2.06c1.38,-0.31 2.63,-0.95 3.69,-1.81L19.73,21 21,19.73l-9,-9L4.27,3zM12,4L9.91,6.09 12,8.18L12,4z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M3,9v6h4l5,5L12,4L7,9L3,9zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"/>
</vector>

View File

@ -1,3 +1,9 @@
<resources>
<string name="app_name">Remote Audio Control</string>
<string name="ip_dialog_title">Enter a valid ip address</string>
<string name="accessibility_ip_address_button">Choose server ip address</string>
<string name="accessibility_muted">Muted</string>
<string name="accessibility_resumed">Resumed</string>
</resources>