androidify

This commit is contained in:
Minecon724 2024-07-25 13:17:45 +02:00
parent 99d16d56c8
commit c785b73c3c
Signed by: Minecon724
GPG key ID: 3CCC4D267742C8E8
21 changed files with 515 additions and 217 deletions

View file

@ -20,7 +20,9 @@ android {
useSupportLibrary = true useSupportLibrary = true
} }
// don't forget to add another "s this is counter intuitive I know but not my fault
buildConfigField("String", "VASTAI_KEY", "null") buildConfigField("String", "VASTAI_KEY", "null")
buildConfigField("String", "VASIAI_API_ENDPOINT", "\"https://console.vast.ai/api/v0\"")
} }
buildTypes { buildTypes {
@ -67,6 +69,7 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.runtime.livedata)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View file

@ -14,12 +14,12 @@
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
tools:targetApi="34"> tools:targetApi="34">
<activity <activity
android:name=".DashboardActivity" android:name=".activity.dashboard.DashboardActivity"
android:exported="false" android:exported="false"
android:label="@string/title_activity_dashboard" android:label="@string/title_activity_dashboard"
android:theme="@style/Theme.Vastapp" /> android:theme="@style/Theme.Vastapp" />
<activity <activity
android:name=".LoginActivity" android:name=".activity.login.LoginActivity"
android:exported="true" android:exported="true"
android:label="@string/title_activity_login" android:label="@string/title_activity_login"
android:theme="@style/Theme.Vastapp"> android:theme="@style/Theme.Vastapp">

View file

@ -1,128 +0,0 @@
package eu.m724.vastapp
import android.os.Bundle
import androidx.activity.BackEventCompat
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material3.BottomAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavOptions
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import eu.m724.vastapp.dashboard.DashboardScreen
import eu.m724.vastapp.dashboard.InstancesScreen
import eu.m724.vastapp.ui.theme.VastappTheme
import eu.m724.vastapp.vastai.data.User
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlin.coroutines.cancellation.CancellationException
class DashboardActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val user = intent.getParcelableExtra<User>("user")!! // TODO null check
enableEdgeToEdge()
setContent {
VastappTheme {
val items = listOf(
Screen.Dashboard,
Screen.Instances,
Screen.Billing
)
val navController = rememberNavController()
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
NavigationBar {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
NavigationBarItem(
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
},
icon = { Icon(screen.icon, contentDescription = stringResource(screen.resourceId)) },
label = { Text(text = stringResource(screen.resourceId)) }
)
}
}
}
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
NavHost(navController = navController, startDestination = "dashboard") {
composable("dashboard") { DashboardScreen(user = user) }
composable("instances") { InstancesScreen() }
composable("billing") { BillingScreen(user = user) }
}
}
}
}
}
}
}
@Composable
fun BillingScreen(user: User) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center) {
Text("You have $%.2f".format(user.credit), fontSize = 28.sp)
}
Column(
modifier = Modifier.fillMaxSize()
) {
}
}

View file

@ -0,0 +1,113 @@
package eu.m724.vastapp.activity.dashboard
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
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.stringResource
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import eu.m724.vastapp.activity.dashboard.screen.Screen
import eu.m724.vastapp.activity.dashboard.screen.BillingScreen
import eu.m724.vastapp.activity.dashboard.screen.DashboardScreen
import eu.m724.vastapp.activity.dashboard.screen.InstancesScreen
import eu.m724.vastapp.ui.theme.VastappTheme
import eu.m724.vastapp.vastai.VastApi
import eu.m724.vastapp.vastai.data.User
import org.chromium.net.CronetEngine
import java.util.concurrent.Executors
class DashboardActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val user = intent.getParcelableExtra<User>("user")!! // TODO null check
val executor = Executors.newSingleThreadExecutor()
val cronetEngine = CronetEngine.Builder(baseContext).build()
val vastApi = VastApi(user.apiKey, cronetEngine, executor) // TODO use that from login activity
val dashboardViewModel = DashboardViewModel(user, vastApi)
enableEdgeToEdge()
setContent {
VastappTheme {
val items = listOf(
Screen.Dashboard,
Screen.Instances,
Screen.Billing
)
val navController = rememberNavController()
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
MyNavigationBar(items, navController = navController)
}
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
NavHost(navController = navController, startDestination = "dashboard") {
composable("dashboard") { DashboardScreen(dashboardViewModel) }
composable("instances") { InstancesScreen(dashboardViewModel) }
composable("billing") { BillingScreen(dashboardViewModel) }
}
}
}
}
}
}
}
@Composable
fun MyNavigationBar(items: List<Screen>, navController: NavHostController) {
NavigationBar {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
NavigationBarItem(
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
},
icon = {
Icon(
screen.icon,
contentDescription = stringResource(screen.resourceId)
)
},
label = { Text(text = stringResource(screen.resourceId)) }
)
}
}
}

View file

@ -0,0 +1,9 @@
package eu.m724.vastapp.activity.dashboard
import eu.m724.vastapp.vastai.data.User
data class DashboardUiState(
val isRefreshing: Boolean,
val user: User,
val error: String?
) { }

View file

@ -0,0 +1,45 @@
package eu.m724.vastapp.activity.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.m724.vastapp.activity.login.LoginUiState
import eu.m724.vastapp.vastai.ApiRoute
import eu.m724.vastapp.vastai.VastApi
import eu.m724.vastapp.vastai.api.UserUrlRequestCallback
import eu.m724.vastapp.vastai.data.User
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class DashboardViewModel(private val _user: User, val vastApi: VastApi) : ViewModel() {
private val _uiState: MutableStateFlow<DashboardUiState> =
MutableStateFlow(DashboardUiState(false, _user, null))
val uiState: StateFlow<DashboardUiState> =
_uiState.asStateFlow()
private val _navigationEvent = MutableSharedFlow<String>()
val navigationEvent: SharedFlow<String> = _navigationEvent.asSharedFlow()
fun navigateTo(route: String) {
viewModelScope.launch {
_navigationEvent.emit(route)
}
}
fun refreshData() {
val request = vastApi.buildRequest(
ApiRoute.SHOW_USER,
UserUrlRequestCallback({ user ->
_uiState.value = _uiState.value.copy(isRefreshing = false, user = user)
}, { apiFailure ->
_uiState.value = _uiState.value.copy(isRefreshing = false, error = apiFailure.errorMessage)
})
)
}
}

View file

@ -0,0 +1,12 @@
package eu.m724.vastapp.activity.dashboard.screen
import androidx.compose.runtime.Composable
import eu.m724.vastapp.activity.dashboard.DashboardViewModel
class Billing {
}
@Composable
fun BillingScreen(dashboardViewModel: DashboardViewModel) {
}

View file

@ -1,20 +1,14 @@
package eu.m724.vastapp.dashboard package eu.m724.vastapp.activity.dashboard.screen
import android.widget.Space
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -28,20 +22,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.modifier.modifierLocalMapOf
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.FontScaling
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import eu.m724.vastapp.R import eu.m724.vastapp.R
import eu.m724.vastapp.activity.dashboard.DashboardViewModel
import eu.m724.vastapp.vastai.data.User import eu.m724.vastapp.vastai.data.User
import org.json.JSONObject
class Dashboard { class Dashboard {
} }
@Composable @Composable
fun DashboardScreen(user: User) { fun DashboardScreen(dashboardViewModel: DashboardViewModel) {
var balance by rememberSaveable { mutableDoubleStateOf(user.credit) } var balance by rememberSaveable { mutableDoubleStateOf(user.credit) }
var remainingTime by rememberSaveable { mutableIntStateOf(0) } var remainingTime by rememberSaveable { mutableIntStateOf(0) }
@ -114,6 +108,70 @@ fun DashboardScreen(user: User) {
} }
} }
} }
Row(
modifier = Modifier.width(340.dp)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.weight(1f),
colors = CardDefaults.cardColors(
containerColor = balanceCardColor(balance)
)
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.baseline_account_balance_wallet_24),
contentDescription = "Balance"
)
Spacer(modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
Text(
text = "$%.2f".format(balance),
fontSize = 22.sp,
color = balanceColor(balance, user.balanceThreshold)
)
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.weight(1f),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.baseline_access_time_filled_24),
contentDescription = "Remaining time"
)
Spacer(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
Text(
text = formatTime(remainingTime),
fontSize = 22.sp
)
}
}
}
Column(
modifier = Modifier.width(340.dp)
) {
}
} }
} }

View file

@ -0,0 +1,43 @@
package eu.m724.vastapp.activity.dashboard.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import eu.m724.vastapp.activity.dashboard.DashboardViewModel
import org.json.JSONObject
class Instances {
}
@Composable
fun InstancesScreen(dashboardViewModel: DashboardViewModel) {
Column {
}
}
@Composable
fun InstanceCard(instance: JSONObject, modifier: Modifier = Modifier) {
Card(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth()
) {
Text(text = instance.getString("id"))
}
}
}
@Preview
@Composable
fun PreviewInstanceCard() {
val instance = JSONObject()
instance.put("id", 3423941)
InstanceCard(instance = instance, modifier = Modifier.size(300.dp))
}

View file

@ -1,14 +1,12 @@
package eu.m724.vastapp package eu.m724.vastapp.activity.dashboard.screen
import android.graphics.drawable.Icon
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Menu import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.ShoppingCart import androidx.compose.material.icons.outlined.ShoppingCart
import androidx.compose.material3.Icon
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import eu.m724.vastapp.R
sealed class Screen(val route: String, @StringRes val resourceId: Int, val icon: ImageVector) { sealed class Screen(val route: String, @StringRes val resourceId: Int, val icon: ImageVector) {
object Dashboard : Screen("dashboard", R.string.nav_dashboard, Icons.Outlined.Home) object Dashboard : Screen("dashboard", R.string.nav_dashboard, Icons.Outlined.Home)

View file

@ -1,6 +1,5 @@
package eu.m724.vastapp package eu.m724.vastapp.activity.login
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
@ -37,9 +36,13 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -50,20 +53,17 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import eu.m724.vastapp.BuildConfig
import eu.m724.vastapp.activity.dashboard.DashboardActivity
import eu.m724.vastapp.ui.theme.VastappTheme import eu.m724.vastapp.ui.theme.VastappTheme
import eu.m724.vastapp.vastai.api.ApiFailure
import eu.m724.vastapp.vastai.api.UserUrlRequestCallback
import eu.m724.vastapp.vastai.data.User import eu.m724.vastapp.vastai.data.User
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.chromium.net.CronetEngine import org.chromium.net.CronetEngine
import org.chromium.net.UrlRequest
import java.util.concurrent.Executor
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.random.Random import kotlin.random.Random
class LoginActivity : ComponentActivity() { class LoginActivity : ComponentActivity() {
private val executor: Executor = Executors.newSingleThreadExecutor()
private lateinit var cronetEngine: CronetEngine
private lateinit var dashboardLauncher: ActivityResultLauncher<Intent> private lateinit var dashboardLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -73,9 +73,23 @@ class LoginActivity : ComponentActivity() {
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { result -> finish() } // TODO re-login here ) { result -> finish() } // TODO re-login here
cronetEngine = CronetEngine.Builder(applicationContext).build() val executor = Executors.newSingleThreadExecutor()
val tryLogin: (String, (ApiFailure) -> Unit) -> Unit = val cronetEngine = CronetEngine.Builder(baseContext).build()
{ apiKey, onFailure -> tryLogin(apiKey, { user -> loadApp(user) }, onFailure) } val loginViewModel = LoginViewModel(cronetEngine, executor)
loginViewModel.error.observe(this) { errorMessage ->
if (errorMessage != null) {
Toast.makeText(baseContext, errorMessage, Toast.LENGTH_SHORT).show()
}
}
lifecycleScope.launch { // TODO I was suggested not to launch an activity from a lifecyclescope
loginViewModel.uiState.collect { state ->
if (state is LoginUiState.Success) {
loadApp(state.user)
}
}
}
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
@ -89,7 +103,7 @@ class LoginActivity : ComponentActivity() {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
LoginApp(applicationContext, tryLogin) LoginApp(loginViewModel)
} }
} }
} }
@ -105,25 +119,16 @@ class LoginActivity : ComponentActivity() {
finish() finish()
} }
private fun tryLogin(apiKey: String, onSuccess: (User) -> Unit, onFailure: (ApiFailure) -> Unit) {
val requestBuilder = cronetEngine.newUrlRequestBuilder(
"https://console.vast.ai/api/v0/users/current",
UserUrlRequestCallback(onSuccess, onFailure),
executor
).addHeader("Authorization", "Bearer $apiKey")
val request: UrlRequest = requestBuilder.build()
request.start()
}
} }
@Composable @Composable
fun LoginApp(context: Context, tryLogin: (String, (ApiFailure) -> Unit) -> Unit) { fun LoginApp(loginViewModel: LoginViewModel) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val uiState by loginViewModel.uiState.collectAsState()
val loginErrorMessage by loginViewModel.error.observeAsState() // TODO put this in uistate
val isIdle by remember(uiState) { derivedStateOf { uiState !is LoginUiState.Loading } }
var apiKey by rememberSaveable { mutableStateOf(BuildConfig.VASTAI_KEY ?: "") } var apiKey by rememberSaveable { mutableStateOf(BuildConfig.VASTAI_KEY ?: "") }
var loading by rememberSaveable { mutableStateOf(false) }
var advancedOpen by rememberSaveable { mutableStateOf(false) } var advancedOpen by rememberSaveable { mutableStateOf(false) }
val transition = updateTransition(targetState = advancedOpen, label = "Advanced Menu Transition") val transition = updateTransition(targetState = advancedOpen, label = "Advanced Menu Transition")
@ -131,15 +136,6 @@ fun LoginApp(context: Context, tryLogin: (String, (ApiFailure) -> Unit) -> Unit)
if (state) 180f else 0f if (state) 180f else 0f
} }
val login: () -> Unit = {
tryLogin(apiKey) { apiFailure ->
loading = false
coroutineScope.launch {
Toast.makeText(context, apiFailure.errorMessage, Toast.LENGTH_SHORT).show()
}
}
}
Column( Column(
modifier = Modifier.width(300.dp), modifier = Modifier.width(300.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@ -147,18 +143,19 @@ fun LoginApp(context: Context, tryLogin: (String, (ApiFailure) -> Unit) -> Unit)
) { ) {
TextField( TextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = !loading, enabled = isIdle,
value = apiKey, value = apiKey,
onValueChange = { apiKey = it }, onValueChange = { apiKey = it },
label = { Text(text = "API key") }, label = { Text(text = "API key") },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true singleLine = true,
isError = loginErrorMessage != null
) )
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
Row { Row {
TextButton( TextButton(
enabled = !loading, enabled = isIdle,
onClick = { onClick = {
advancedOpen = !advancedOpen advancedOpen = !advancedOpen
} }
@ -172,13 +169,12 @@ fun LoginApp(context: Context, tryLogin: (String, (ApiFailure) -> Unit) -> Unit)
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Button( Button(
enabled = !loading, enabled = isIdle,
onClick = { onClick = {
loading = true loginViewModel.tryLogin(apiKey)
login()
} }
) { ) {
if (loading) { if (uiState is LoginUiState.Loading) {
CircularProgressIndicator() CircularProgressIndicator()
} else { } else {
Text("Log in") Text("Log in")

View file

@ -0,0 +1,22 @@
package eu.m724.vastapp.activity.login
import eu.m724.vastapp.vastai.data.User
sealed interface LoginUiState {
/**
* when nothing is being done
* you should get the error
*/
data object Idle : LoginUiState
/**
* when we're currently logging in
*/
data object Loading : LoginUiState
/**
* called when logged in
* @param user the logged in user
*/
data class Success(val user: User) : LoginUiState
}

View file

@ -0,0 +1,43 @@
package eu.m724.vastapp.activity.login
import android.widget.Toast
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import eu.m724.vastapp.vastai.ApiFailure
import eu.m724.vastapp.vastai.ApiRoute
import eu.m724.vastapp.vastai.VastApi
import eu.m724.vastapp.vastai.api.UserUrlRequestCallback
import eu.m724.vastapp.vastai.data.User
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.chromium.net.CronetEngine
import java.util.concurrent.Executor
class LoginViewModel(val cronetEngine: CronetEngine, val executor: Executor) : ViewModel() {
private val _uiState: MutableStateFlow<LoginUiState> =
MutableStateFlow(LoginUiState.Idle)
val uiState: StateFlow<LoginUiState> =
_uiState.asStateFlow()
private val _error = MutableLiveData<String?>(null)
val error: LiveData<String?> = _error // TODO put this in uistate
fun tryLogin(apiKey: String) {
val vastApi = VastApi(apiKey, cronetEngine, executor)
val request = vastApi.buildRequest(
ApiRoute.SHOW_USER,
UserUrlRequestCallback({ user ->
_uiState.value = LoginUiState.Success(user)
}, { apiFailure ->
_uiState.value = LoginUiState.Idle
_error.value = apiFailure.errorMessage
})
)
_uiState.value = LoginUiState.Loading
request.start()
}
}

View file

@ -1,4 +0,0 @@
package eu.m724.vastapp.dashboard
class Billing {
}

View file

@ -1,20 +0,0 @@
package eu.m724.vastapp.dashboard
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import eu.m724.vastapp.vastai.data.User
class Instances {
}
@Composable
fun InstancesScreen() {
Column {
}
}
@Composable
fun Instance() {
}

View file

@ -1,7 +1,4 @@
package eu.m724.vastapp.vastai.api package eu.m724.vastapp.vastai
import android.os.Parcel
import android.os.Parcelable
data class ApiFailure( data class ApiFailure(
/** user friendly error message */ /** user friendly error message */

View file

@ -0,0 +1,6 @@
package eu.m724.vastapp.vastai
enum class ApiRoute(val path: String, val method: String) {
SHOW_USER("/users/current", "GET"),
GET_INSTANCES("/instances", "GET")
}

View file

@ -0,0 +1,44 @@
package eu.m724.vastapp.vastai
import android.os.Parcel
import android.os.Parcelable
import eu.m724.vastapp.BuildConfig
import org.chromium.net.CronetEngine
import org.chromium.net.UrlRequest
import java.util.concurrent.Executor
class VastApi(
val apiKey: String,
val cronetEngine: CronetEngine,
val executor: Executor
) {
/**
* build an api request
* don't forget to call .start() on the returned [UrlRequest]
*
* @param endpoint the endpoint path starting with a slash like /users/current
* @param callback any callback for example [UserUrlRequestCallback]
* @return an [UrlRequest] you must .start() yourself
*/
fun buildRequest(endpoint: String, callback: UrlRequest.Callback): UrlRequest {
val requestBuilder = cronetEngine.newUrlRequestBuilder(
BuildConfig.VASIAI_API_ENDPOINT + endpoint,
callback,
executor
).addHeader("Authorization", "Bearer $apiKey")
return requestBuilder.build()
}
/**
* build an api request
* don't forget to call .start() on the returned [UrlRequest]
*
* @param apiRoute the api route
* @param callback any callback for example [UserUrlRequestCallback]
* @return an [UrlRequest] you must .start() yourself
*/
fun buildRequest(apiRoute: ApiRoute, callback: UrlRequest.Callback): UrlRequest {
return buildRequest(apiRoute.path, callback)
}
}

View file

@ -0,0 +1,62 @@
package eu.m724.vastapp.vastai.api
import eu.m724.vastapp.vastai.ApiFailure
import eu.m724.vastapp.vastai.data.User
import org.chromium.net.CronetException
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
import org.json.JSONObject
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
class InstancesUrlRequestCallback(
val onSuccess: (List<JSONObject>) -> Unit,
val onFailure: (ApiFailure) -> Unit
) : UrlRequest.Callback() {
private val stringResponse = StringBuilder()
override fun onRedirectReceived(
request: UrlRequest?,
info: UrlResponseInfo?,
newLocationUrl: String?
) {
request?.followRedirect()
}
override fun onResponseStarted(request: UrlRequest?, info: UrlResponseInfo?) {
request?.read(ByteBuffer.allocateDirect(102400))
}
override fun onReadCompleted(
request: UrlRequest?,
info: UrlResponseInfo?,
byteBuffer: ByteBuffer?
) {
byteBuffer?.clear()
request?.read(byteBuffer)
stringResponse.append(Charsets.UTF_8.newDecoder().onUnmappableCharacter(CodingErrorAction.IGNORE).decode(byteBuffer))
}
override fun onSucceeded(request: UrlRequest?, info: UrlResponseInfo?) {
if (info?.httpStatusCode == 200) {
val jsonResponse = JSONObject(stringResponse.toString())
val instances = ArrayList<JSONObject>()
val instancesJson = jsonResponse.getJSONArray("instances")
for (i in 0..<instancesJson.length()) {
instances.add(instancesJson.getJSONObject(i))
}
onSuccess(instances) // TODO make it better
} else {
onFailure(ApiFailure("${info?.httpStatusCode} ${info?.httpStatusText}"))
println("API error: $stringResponse")
}
}
override fun onFailed(request: UrlRequest?, info: UrlResponseInfo?, error: CronetException?) {
onFailure(ApiFailure("Network error: ${error?.message ?: "Unknown"}"))
}
}

View file

@ -1,15 +1,12 @@
package eu.m724.vastapp.vastai.api package eu.m724.vastapp.vastai.api
import eu.m724.vastapp.vastai.ApiFailure
import eu.m724.vastapp.vastai.data.User import eu.m724.vastapp.vastai.data.User
import org.chromium.net.CronetException import org.chromium.net.CronetException
import org.chromium.net.UrlRequest import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo import org.chromium.net.UrlResponseInfo
import org.json.JSONObject import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.channels.Channels
import java.nio.channels.WritableByteChannel
import java.nio.charset.Charset
import java.nio.charset.CodingErrorAction import java.nio.charset.CodingErrorAction

View file

@ -13,6 +13,7 @@ composeBom = "2024.06.00"
playServicesCronet = "18.1.0" playServicesCronet = "18.1.0"
navigationCompose = "2.7.7" navigationCompose = "2.7.7"
secretsGradlePlugin = "2.0.1" secretsGradlePlugin = "2.0.1"
runtimeLivedata = "1.6.8"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -33,6 +34,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
play-services-cronet = { module = "com.google.android.gms:play-services-cronet", version.ref = "playServicesCronet" } play-services-cronet = { module = "com.google.android.gms:play-services-cronet", version.ref = "playServicesCronet" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }