mirror of
https://github.com/vide/matedroid.git
synced 2026-01-20 00:03:17 +08:00
feat: initial project structure with Settings screen
- Set up Kotlin + Jetpack Compose Android project - Configure Gradle with version catalog and all dependencies - Implement Settings screen for TeslamateApi server configuration - Add Material Design 3 theming with Tesla-inspired colors - Set up Hilt dependency injection - Add DataStore for settings persistence - Include navigation component with Compose integration - Add GPLv3 license 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
31
app/src/main/java/com/matedroid/MainActivity.kt
Normal file
31
app/src/main/java/com/matedroid/MainActivity.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.matedroid
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.matedroid.ui.navigation.NavGraph
|
||||
import com.matedroid.ui.theme.MateDroidTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MateDroidTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
NavGraph()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
app/src/main/java/com/matedroid/MateDroidApp.kt
Normal file
7
app/src/main/java/com/matedroid/MateDroidApp.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.matedroid
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class MateDroidApp : Application()
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.matedroid.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "matedroid_settings")
|
||||
|
||||
data class AppSettings(
|
||||
val serverUrl: String = "",
|
||||
val apiToken: String = ""
|
||||
) {
|
||||
val isConfigured: Boolean
|
||||
get() = serverUrl.isNotBlank()
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class SettingsDataStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val serverUrlKey = stringPreferencesKey("server_url")
|
||||
private val apiTokenKey = stringPreferencesKey("api_token")
|
||||
|
||||
val settings: Flow<AppSettings> = context.dataStore.data.map { preferences ->
|
||||
AppSettings(
|
||||
serverUrl = preferences[serverUrlKey] ?: "",
|
||||
apiToken = preferences[apiTokenKey] ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun saveSettings(serverUrl: String, apiToken: String) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[serverUrlKey] = serverUrl
|
||||
preferences[apiTokenKey] = apiToken
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearSettings() {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
23
app/src/main/java/com/matedroid/di/AppModule.kt
Normal file
23
app/src/main/java/com/matedroid/di/AppModule.kt
Normal file
@@ -0,0 +1,23 @@
|
||||
package com.matedroid.di
|
||||
|
||||
import android.content.Context
|
||||
import com.matedroid.data.local.SettingsDataStore
|
||||
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)
|
||||
object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSettingsDataStore(
|
||||
@ApplicationContext context: Context
|
||||
): SettingsDataStore {
|
||||
return SettingsDataStore(context)
|
||||
}
|
||||
}
|
||||
44
app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt
Normal file
44
app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt
Normal file
@@ -0,0 +1,44 @@
|
||||
package com.matedroid.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.matedroid.ui.screens.settings.SettingsScreen
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
data object Settings : Screen("settings")
|
||||
data object Dashboard : Screen("dashboard")
|
||||
data object Charges : Screen("charges")
|
||||
data object ChargeDetail : Screen("charges/{chargeId}") {
|
||||
fun createRoute(chargeId: Int) = "charges/$chargeId"
|
||||
}
|
||||
data object Drives : Screen("drives")
|
||||
data object DriveDetail : Screen("drives/{driveId}") {
|
||||
fun createRoute(driveId: Int) = "drives/$driveId"
|
||||
}
|
||||
data object Battery : Screen("battery")
|
||||
data object Updates : Screen("updates")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavGraph() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Settings.route
|
||||
) {
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
onNavigateToDashboard = {
|
||||
navController.navigate(Screen.Dashboard.route) {
|
||||
popUpTo(Screen.Settings.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Dashboard and other screens will be added in subsequent phases
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package com.matedroid.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.matedroid.ui.theme.MateDroidTheme
|
||||
import com.matedroid.ui.theme.StatusError
|
||||
import com.matedroid.ui.theme.StatusSuccess
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigateToDashboard: () -> Unit,
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(uiState.error) {
|
||||
uiState.error?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("MateDroid Settings") }
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { paddingValues ->
|
||||
if (uiState.isLoading) {
|
||||
LoadingContent(modifier = Modifier.padding(paddingValues))
|
||||
} else {
|
||||
SettingsContent(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
uiState = uiState,
|
||||
onServerUrlChange = viewModel::updateServerUrl,
|
||||
onApiTokenChange = viewModel::updateApiToken,
|
||||
onTestConnection = viewModel::testConnection,
|
||||
onSave = { viewModel.saveSettings(onNavigateToDashboard) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingContent(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text("Loading settings...")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsContent(
|
||||
modifier: Modifier = Modifier,
|
||||
uiState: SettingsUiState,
|
||||
onServerUrlChange: (String) -> Unit,
|
||||
onApiTokenChange: (String) -> Unit,
|
||||
onTestConnection: () -> Unit,
|
||||
onSave: () -> Unit
|
||||
) {
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Connect to TeslamateApi",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter your TeslamateApi server URL and optional API token to connect.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.serverUrl,
|
||||
onValueChange = onServerUrlChange,
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("https://teslamate.example.com") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("urlInput"),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
enabled = !uiState.isTesting && !uiState.isSaving
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.apiToken,
|
||||
onValueChange = onApiTokenChange,
|
||||
label = { Text("API Token (optional)") },
|
||||
placeholder = { Text("Your API token") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("tokenInput"),
|
||||
singleLine = true,
|
||||
visualTransformation = if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) {
|
||||
Icons.Filled.VisibilityOff
|
||||
} else {
|
||||
Icons.Filled.Visibility
|
||||
},
|
||||
contentDescription = if (passwordVisible) {
|
||||
"Hide token"
|
||||
} else {
|
||||
"Show token"
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = !uiState.isTesting && !uiState.isSaving
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Leave empty if your TeslamateApi doesn't require authentication.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Test result card
|
||||
uiState.testResult?.let { result ->
|
||||
TestResultCard(result = result)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onTestConnection,
|
||||
enabled = uiState.serverUrl.isNotBlank() && !uiState.isTesting && !uiState.isSaving,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
if (uiState.isTesting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text("Test Connection")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onSave,
|
||||
enabled = uiState.serverUrl.isNotBlank() && !uiState.isTesting && !uiState.isSaving,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTag("saveButton")
|
||||
) {
|
||||
if (uiState.isSaving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text("Save & Continue")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TestResultCard(result: TestResult) {
|
||||
val (icon, color, text) = when (result) {
|
||||
is TestResult.Success -> Triple(
|
||||
Icons.Filled.CheckCircle,
|
||||
StatusSuccess,
|
||||
"Connection successful!"
|
||||
)
|
||||
is TestResult.Failure -> Triple(
|
||||
Icons.Filled.Error,
|
||||
StatusError,
|
||||
"Connection failed: ${result.message}"
|
||||
)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = color.copy(alpha = 0.1f)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = color
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = text,
|
||||
color = color,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun SettingsScreenPreview() {
|
||||
MateDroidTheme {
|
||||
SettingsContent(
|
||||
uiState = SettingsUiState(isLoading = false),
|
||||
onServerUrlChange = {},
|
||||
onApiTokenChange = {},
|
||||
onTestConnection = {},
|
||||
onSave = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun SettingsScreenWithResultPreview() {
|
||||
MateDroidTheme {
|
||||
SettingsContent(
|
||||
uiState = SettingsUiState(
|
||||
isLoading = false,
|
||||
serverUrl = "https://teslamate.example.com",
|
||||
testResult = TestResult.Success
|
||||
),
|
||||
onServerUrlChange = {},
|
||||
onApiTokenChange = {},
|
||||
onTestConnection = {},
|
||||
onSave = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.matedroid.ui.screens.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.matedroid.data.local.AppSettings
|
||||
import com.matedroid.data.local.SettingsDataStore
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SettingsUiState(
|
||||
val serverUrl: String = "",
|
||||
val apiToken: String = "",
|
||||
val isLoading: Boolean = true,
|
||||
val isTesting: Boolean = false,
|
||||
val isSaving: Boolean = false,
|
||||
val testResult: TestResult? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
sealed class TestResult {
|
||||
data object Success : TestResult()
|
||||
data class Failure(val message: String) : TestResult()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val settingsDataStore: SettingsDataStore
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadSettings()
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
viewModelScope.launch {
|
||||
val settings = settingsDataStore.settings.first()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
serverUrl = settings.serverUrl,
|
||||
apiToken = settings.apiToken,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateServerUrl(url: String) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
serverUrl = url,
|
||||
testResult = null,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
|
||||
fun updateApiToken(token: String) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
apiToken = token,
|
||||
testResult = null,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
|
||||
fun testConnection() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isTesting = true, testResult = null, error = null)
|
||||
|
||||
try {
|
||||
val url = _uiState.value.serverUrl.trimEnd('/')
|
||||
if (url.isBlank()) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isTesting = false,
|
||||
testResult = TestResult.Failure("Server URL is required")
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// TODO: Implement actual API ping test in Phase 2
|
||||
// For now, just validate URL format
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isTesting = false,
|
||||
testResult = TestResult.Failure("URL must start with http:// or https://")
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Simulate successful test for now
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isTesting = false,
|
||||
testResult = TestResult.Success
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isTesting = false,
|
||||
testResult = TestResult.Failure(e.message ?: "Unknown error")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSettings(onSuccess: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isSaving = true, error = null)
|
||||
|
||||
try {
|
||||
val url = _uiState.value.serverUrl.trimEnd('/')
|
||||
if (url.isBlank()) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isSaving = false,
|
||||
error = "Server URL is required"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
settingsDataStore.saveSettings(
|
||||
serverUrl = url,
|
||||
apiToken = _uiState.value.apiToken
|
||||
)
|
||||
|
||||
_uiState.value = _uiState.value.copy(isSaving = false)
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isSaving = false,
|
||||
error = e.message ?: "Failed to save settings"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTestResult() {
|
||||
_uiState.value = _uiState.value.copy(testResult = null)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
}
|
||||
53
app/src/main/java/com/matedroid/ui/theme/Color.kt
Normal file
53
app/src/main/java/com/matedroid/ui/theme/Color.kt
Normal file
@@ -0,0 +1,53 @@
|
||||
package com.matedroid.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Tesla-inspired colors
|
||||
val TeslaRed = Color(0xFFE31937)
|
||||
val TeslaDark = Color(0xFF171A20)
|
||||
val TeslaBlue = Color(0xFF3E6AE1)
|
||||
|
||||
// Status colors
|
||||
val StatusSuccess = Color(0xFF4CAF50)
|
||||
val StatusWarning = Color(0xFFFF9800)
|
||||
val StatusError = Color(0xFFF44336)
|
||||
|
||||
// Light theme colors
|
||||
val PrimaryLight = TeslaRed
|
||||
val OnPrimaryLight = Color.White
|
||||
val PrimaryContainerLight = Color(0xFFFFDAD6)
|
||||
val OnPrimaryContainerLight = Color(0xFF410002)
|
||||
val SecondaryLight = TeslaDark
|
||||
val OnSecondaryLight = Color.White
|
||||
val SecondaryContainerLight = Color(0xFFE2E2E2)
|
||||
val OnSecondaryContainerLight = Color(0xFF1B1B1B)
|
||||
val TertiaryLight = TeslaBlue
|
||||
val OnTertiaryLight = Color.White
|
||||
val BackgroundLight = Color(0xFFFFFBFF)
|
||||
val OnBackgroundLight = Color(0xFF1C1B1F)
|
||||
val SurfaceLight = Color(0xFFFFFBFF)
|
||||
val OnSurfaceLight = Color(0xFF1C1B1F)
|
||||
val SurfaceVariantLight = Color(0xFFF5DDDA)
|
||||
val OnSurfaceVariantLight = Color(0xFF534341)
|
||||
val ErrorLight = StatusError
|
||||
val OnErrorLight = Color.White
|
||||
|
||||
// Dark theme colors
|
||||
val PrimaryDark = Color(0xFFFFB4AB)
|
||||
val OnPrimaryDark = Color(0xFF690005)
|
||||
val PrimaryContainerDark = TeslaRed
|
||||
val OnPrimaryContainerDark = Color.White
|
||||
val SecondaryDark = Color(0xFFC6C6C6)
|
||||
val OnSecondaryDark = Color(0xFF303030)
|
||||
val SecondaryContainerDark = Color(0xFF474747)
|
||||
val OnSecondaryContainerDark = Color(0xFFE2E2E2)
|
||||
val TertiaryDark = Color(0xFFADC6FF)
|
||||
val OnTertiaryDark = Color(0xFF002E69)
|
||||
val BackgroundDark = Color(0xFF1C1B1F)
|
||||
val OnBackgroundDark = Color(0xFFE6E1E5)
|
||||
val SurfaceDark = Color(0xFF1C1B1F)
|
||||
val OnSurfaceDark = Color(0xFFE6E1E5)
|
||||
val SurfaceVariantDark = Color(0xFF534341)
|
||||
val OnSurfaceVariantDark = Color(0xFFD8C2BF)
|
||||
val ErrorDark = Color(0xFFFFB4AB)
|
||||
val OnErrorDark = Color(0xFF690005)
|
||||
75
app/src/main/java/com/matedroid/ui/theme/Theme.kt
Normal file
75
app/src/main/java/com/matedroid/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,75 @@
|
||||
package com.matedroid.ui.theme
|
||||
|
||||
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.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = PrimaryDark,
|
||||
onPrimary = OnPrimaryDark,
|
||||
primaryContainer = PrimaryContainerDark,
|
||||
onPrimaryContainer = OnPrimaryContainerDark,
|
||||
secondary = SecondaryDark,
|
||||
onSecondary = OnSecondaryDark,
|
||||
secondaryContainer = SecondaryContainerDark,
|
||||
onSecondaryContainer = OnSecondaryContainerDark,
|
||||
tertiary = TertiaryDark,
|
||||
onTertiary = OnTertiaryDark,
|
||||
background = BackgroundDark,
|
||||
onBackground = OnBackgroundDark,
|
||||
surface = SurfaceDark,
|
||||
onSurface = OnSurfaceDark,
|
||||
surfaceVariant = SurfaceVariantDark,
|
||||
onSurfaceVariant = OnSurfaceVariantDark,
|
||||
error = ErrorDark,
|
||||
onError = OnErrorDark
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = PrimaryLight,
|
||||
onPrimary = OnPrimaryLight,
|
||||
primaryContainer = PrimaryContainerLight,
|
||||
onPrimaryContainer = OnPrimaryContainerLight,
|
||||
secondary = SecondaryLight,
|
||||
onSecondary = OnSecondaryLight,
|
||||
secondaryContainer = SecondaryContainerLight,
|
||||
onSecondaryContainer = OnSecondaryContainerLight,
|
||||
tertiary = TertiaryLight,
|
||||
onTertiary = OnTertiaryLight,
|
||||
background = BackgroundLight,
|
||||
onBackground = OnBackgroundLight,
|
||||
surface = SurfaceLight,
|
||||
onSurface = OnSurfaceLight,
|
||||
surfaceVariant = SurfaceVariantLight,
|
||||
onSurfaceVariant = OnSurfaceVariantLight,
|
||||
error = ErrorLight,
|
||||
onError = OnErrorLight
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MateDroidTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
115
app/src/main/java/com/matedroid/ui/theme/Type.kt
Normal file
115
app/src/main/java/com/matedroid/ui/theme/Type.kt
Normal file
@@ -0,0 +1,115 @@
|
||||
package com.matedroid.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
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.sp
|
||||
|
||||
val Typography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = (-0.25).sp
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 45.sp,
|
||||
lineHeight = 52.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
displaySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 44.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user