SilverDROID - Dark Side Admin with CI/CD pipeline

- Android PWA/WASM launcher with glassmorphism UI
- Loads https://admin.dark.side directly on launch
- Complete GitLab CI/CD pipeline configuration
- Automated builds for Debug, Release, and AAB
- Full WASM support with optimized WebView
- Material Design 3 theme
- Comprehensive documentation

Features:
- Auto-load target URL on app launch
- Glassmorphism components (frosted glass effects)
- Full PWA/WASM support
- Room database for future extensions
- Jetpack Compose UI
- CI/CD with artifact storage

Built for SilverLABS
This commit is contained in:
2025-09-30 17:13:14 +01:00
commit c667765488
41 changed files with 4857 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
package uk.silverlabs.silverdroid
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 uk.silverlabs.silverdroid.ui.theme.SilverDROIDTheme
import uk.silverlabs.silverdroid.ui.webview.WasmWebView
/**
* SilverDROID - Direct Load Version
*
* This version loads https://admin.dark.side directly on launch,
* bypassing the launcher screen.
*/
class MainActivity : ComponentActivity() {
// Direct load configuration
private val targetUrl = "https://admin.dark.side"
private val appName = "Dark Side Admin"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Load admin.dark.side directly
setContent {
SilverDROIDTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
WasmWebView(
url = targetUrl,
appName = appName,
onBackPressed = {
// Exit app on back press
finish()
}
)
}
}
}
}
}

View File

@@ -0,0 +1,35 @@
package uk.silverlabs.silverdroid.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import uk.silverlabs.silverdroid.data.model.PwaApp
@Dao
interface PwaAppDao {
@Query("SELECT * FROM pwa_apps ORDER BY sortOrder ASC, lastAccessed DESC")
fun getAllApps(): Flow<List<PwaApp>>
@Query("SELECT * FROM pwa_apps WHERE id = :id")
suspend fun getAppById(id: Long): PwaApp?
@Query("SELECT * FROM pwa_apps WHERE url = :url LIMIT 1")
suspend fun getAppByUrl(url: String): PwaApp?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertApp(app: PwaApp): Long
@Update
suspend fun updateApp(app: PwaApp)
@Delete
suspend fun deleteApp(app: PwaApp)
@Query("UPDATE pwa_apps SET lastAccessed = :timestamp WHERE id = :id")
suspend fun updateLastAccessed(id: Long, timestamp: Long = System.currentTimeMillis())
@Query("DELETE FROM pwa_apps WHERE id = :id")
suspend fun deleteAppById(id: Long)
@Query("SELECT COUNT(*) FROM pwa_apps")
suspend fun getAppCount(): Int
}

View File

@@ -0,0 +1,35 @@
package uk.silverlabs.silverdroid.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import uk.silverlabs.silverdroid.data.model.PwaApp
@Database(
entities = [PwaApp::class],
version = 1,
exportSchema = true
)
abstract class PwaDatabase : RoomDatabase() {
abstract fun pwaAppDao(): PwaAppDao
companion object {
@Volatile
private var INSTANCE: PwaDatabase? = null
fun getInstance(context: Context): PwaDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
PwaDatabase::class.java,
"pwa_database"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -0,0 +1,55 @@
package uk.silverlabs.silverdroid.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Represents a Progressive Web App or WASM app installed in the launcher
*/
@Entity(tableName = "pwa_apps")
data class PwaApp(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
/** Display name of the app */
val name: String,
/** URL of the PWA/WASM app */
val url: String,
/** Icon URL or local path */
val iconUrl: String? = null,
/** Short description */
val description: String? = null,
/** Theme color from manifest */
val themeColor: String? = null,
/** Background color from manifest */
val backgroundColor: String? = null,
/** Whether the app is installed (from manifest) */
val isInstalled: Boolean = true,
/** Last accessed timestamp */
val lastAccessed: Long = System.currentTimeMillis(),
/** Installation timestamp */
val installedAt: Long = System.currentTimeMillis(),
/** Display mode: standalone, fullscreen, minimal-ui, browser */
val displayMode: String = "standalone",
/** Manifest JSON (serialized) */
val manifestJson: String? = null,
/** Is this a WASM-specific app */
val isWasmApp: Boolean = false,
/** Custom tags for categorization */
val tags: String? = null,
/** Sort order */
val sortOrder: Int = 0
)

View File

@@ -0,0 +1,88 @@
package uk.silverlabs.silverdroid.data.repository
import kotlinx.coroutines.flow.Flow
import uk.silverlabs.silverdroid.data.PwaAppDao
import uk.silverlabs.silverdroid.data.model.PwaApp
class PwaRepository(private val pwaAppDao: PwaAppDao) {
val allApps: Flow<List<PwaApp>> = pwaAppDao.getAllApps()
suspend fun getAppById(id: Long): PwaApp? {
return pwaAppDao.getAppById(id)
}
suspend fun getAppByUrl(url: String): PwaApp? {
return pwaAppDao.getAppByUrl(url)
}
suspend fun insertApp(app: PwaApp): Long {
return pwaAppDao.insertApp(app)
}
suspend fun updateApp(app: PwaApp) {
pwaAppDao.updateApp(app)
}
suspend fun deleteApp(app: PwaApp) {
pwaAppDao.deleteApp(app)
}
suspend fun deleteAppById(id: Long) {
pwaAppDao.deleteAppById(id)
}
suspend fun updateLastAccessed(id: Long) {
pwaAppDao.updateLastAccessed(id)
}
suspend fun getAppCount(): Int {
return pwaAppDao.getAppCount()
}
/**
* Install a PWA from URL by fetching its manifest
*/
suspend fun installPwaFromUrl(
url: String,
name: String? = null,
iconUrl: String? = null
): Result<Long> {
return try {
// Check if already installed
val existing = getAppByUrl(url)
if (existing != null) {
return Result.failure(Exception("App already installed"))
}
val app = PwaApp(
name = name ?: extractDomainName(url),
url = url,
iconUrl = iconUrl,
isInstalled = true,
installedAt = System.currentTimeMillis(),
lastAccessed = System.currentTimeMillis()
)
val id = insertApp(app)
Result.success(id)
} catch (e: Exception) {
Result.failure(e)
}
}
private fun extractDomainName(url: String): String {
return try {
val domain = url.substringAfter("://")
.substringBefore("/")
.substringBefore(":")
domain.substringAfter("www.")
.split(".")
.firstOrNull()
?.replaceFirstChar { it.uppercase() }
?: "App"
} catch (e: Exception) {
"App"
}
}
}

View File

@@ -0,0 +1,204 @@
package uk.silverlabs.silverdroid.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import uk.silverlabs.silverdroid.ui.theme.GlassColors
/**
* Glassmorphism card component with blur and transparency effects
*/
@Composable
fun GlassCard(
modifier: Modifier = Modifier,
cornerRadius: Dp = 16.dp,
borderWidth: Dp = 1.dp,
blurRadius: Dp = 10.dp,
content: @Composable BoxScope.() -> Unit
) {
val isDark = isSystemInDarkTheme()
val glassSurface = if (isDark) GlassColors.GlassSurfaceDark else GlassColors.GlassSurfaceLight
val glassBorder = if (isDark) GlassColors.GlassBorderDark else GlassColors.GlassBorderLight
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier = modifier
.clip(shape)
.background(glassSurface)
.border(
width = borderWidth,
color = glassBorder,
shape = shape
)
) {
content()
}
}
/**
* Animated gradient glass background
*/
@Composable
fun GlassBackground(
modifier: Modifier = Modifier,
darkTheme: Boolean = isSystemInDarkTheme()
) {
val gradientColors = if (darkTheme) {
listOf(
Color(0xFF0F0C29),
Color(0xFF302B63),
Color(0xFF24243E)
)
} else {
listOf(
Color(0xFFE0E7FF),
Color(0xFFF5F3FF),
Color(0xFFFFE4E6)
)
}
Box(
modifier = modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(
colors = gradientColors
)
)
)
}
/**
* Frosted glass panel with elevated blur effect
*/
@Composable
fun FrostedGlassPanel(
modifier: Modifier = Modifier,
cornerRadius: Dp = 20.dp,
content: @Composable BoxScope.() -> Unit
) {
val isDark = isSystemInDarkTheme()
val glassSurface = if (isDark) {
Color(0xBB1C1B1F)
} else {
Color(0xDDFFFFFF)
}
val glassBorder = if (isDark) {
Color(0x80FFFFFF)
} else {
Color(0x40000000)
}
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier = modifier
.clip(shape)
.background(glassSurface)
.border(
width = 1.5.dp,
brush = Brush.verticalGradient(
colors = listOf(
glassBorder.copy(alpha = 0.8f),
glassBorder.copy(alpha = 0.2f)
)
),
shape = shape
)
) {
content()
}
}
/**
* Glass surface with subtle shimmer effect
*/
@Composable
fun ShimmerGlass(
modifier: Modifier = Modifier,
cornerRadius: Dp = 12.dp,
content: @Composable BoxScope.() -> Unit
) {
val isDark = isSystemInDarkTheme()
val shimmerColors = if (isDark) {
listOf(
GlassColors.GlassSurfaceDark,
GlassColors.GlassAccentBlue.copy(alpha = 0.15f),
GlassColors.GlassSurfaceDark
)
} else {
listOf(
GlassColors.GlassSurfaceLight,
Color(0x20007ACC),
GlassColors.GlassSurfaceLight
)
}
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier = modifier
.clip(shape)
.background(
brush = Brush.horizontalGradient(shimmerColors)
)
.border(
width = 1.dp,
color = if (isDark) GlassColors.GlassBorderDark else GlassColors.GlassBorderLight,
shape = shape
)
) {
content()
}
}
/**
* Floating glass button with elevation
*/
@Composable
fun GlassButton(
modifier: Modifier = Modifier,
cornerRadius: Dp = 16.dp,
content: @Composable BoxScope.() -> Unit
) {
val isDark = isSystemInDarkTheme()
val buttonSurface = if (isDark) {
Color(0xDD2C2C2E)
} else {
Color(0xEEFFFFFF)
}
val shape = RoundedCornerShape(cornerRadius)
Box(
modifier = modifier
.clip(shape)
.background(buttonSurface)
.border(
width = 1.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
shape = shape
)
) {
content()
}
}

View File

@@ -0,0 +1,225 @@
package uk.silverlabs.silverdroid.ui.launcher
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import uk.silverlabs.silverdroid.data.model.PwaApp
import uk.silverlabs.silverdroid.ui.components.GlassBackground
import uk.silverlabs.silverdroid.ui.components.GlassCard
import uk.silverlabs.silverdroid.ui.components.FrostedGlassPanel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LauncherScreen(
apps: List<PwaApp>,
onAppClick: (PwaApp) -> Unit,
onAddAppClick: () -> Unit,
onSettingsClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.fillMaxSize()) {
// Glassmorphism gradient background
GlassBackground()
Scaffold(
containerColor = androidx.compose.ui.graphics.Color.Transparent,
topBar = {
LauncherTopBar(
onSettingsClick = onSettingsClick
)
},
floatingActionButton = {
FloatingActionButton(
onClick = onAddAppClick,
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Icon(Icons.Default.Add, contentDescription = "Add App")
}
}
) { paddingValues ->
if (apps.isEmpty()) {
EmptyState(
onAddAppClick = onAddAppClick,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
)
} else {
AppGrid(
apps = apps,
onAppClick = onAppClick,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LauncherTopBar(
onSettingsClick: () -> Unit
) {
FrostedGlassPanel(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
cornerRadius = 24.dp
) {
TopAppBar(
title = {
Text(
"SilverDROID",
style = MaterialTheme.typography.headlineSmall
)
},
actions = {
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = androidx.compose.ui.graphics.Color.Transparent
)
)
}
}
@Composable
private fun AppGrid(
apps: List<PwaApp>,
onAppClick: (PwaApp) -> Unit,
modifier: Modifier = Modifier
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
) {
items(apps, key = { it.id }) { app ->
AppCard(
app = app,
onClick = { onAppClick(app) }
)
}
}
}
@Composable
private fun AppCard(
app: PwaApp,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
GlassCard(
modifier = modifier
.aspectRatio(1f)
.clickable(onClick = onClick),
cornerRadius = 20.dp
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// App icon placeholder
Surface(
modifier = Modifier
.size(64.dp)
.clip(CircleShape),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = app.name.take(2).uppercase(),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// App name
Text(
text = app.name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
@Composable
private fun EmptyState(
onAddAppClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
FrostedGlassPanel(
modifier = Modifier.padding(24.dp),
cornerRadius = 28.dp
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No Apps Yet",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Add your first PWA or WASM app to get started",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onAddAppClick,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f)
)
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Add App")
}
}
}
}
}

View File

@@ -0,0 +1,94 @@
package uk.silverlabs.silverdroid.ui.launcher
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import uk.silverlabs.silverdroid.data.PwaDatabase
import uk.silverlabs.silverdroid.data.model.PwaApp
import uk.silverlabs.silverdroid.data.repository.PwaRepository
class LauncherViewModel(application: Application) : AndroidViewModel(application) {
private val repository: PwaRepository
private val _apps = MutableStateFlow<List<PwaApp>>(emptyList())
val apps: StateFlow<List<PwaApp>> = _apps.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
init {
val database = PwaDatabase.getInstance(application)
repository = PwaRepository(database.pwaAppDao())
loadApps()
}
private fun loadApps() {
viewModelScope.launch {
repository.allApps.collect { appList ->
_apps.value = appList
}
}
}
fun addApp(url: String, name: String? = null) {
viewModelScope.launch {
_isLoading.value = true
try {
repository.installPwaFromUrl(url, name)
} finally {
_isLoading.value = false
}
}
}
fun deleteApp(app: PwaApp) {
viewModelScope.launch {
repository.deleteApp(app)
}
}
fun updateLastAccessed(appId: Long) {
viewModelScope.launch {
repository.updateLastAccessed(appId)
}
}
fun addSampleApps() {
viewModelScope.launch {
// Add some sample PWAs for demonstration
val samples = listOf(
PwaApp(
name = "Twitter",
url = "https://mobile.twitter.com",
description = "Social media platform"
),
PwaApp(
name = "Spotify",
url = "https://open.spotify.com",
description = "Music streaming"
),
PwaApp(
name = "YouTube",
url = "https://m.youtube.com",
description = "Video platform"
),
PwaApp(
name = "WebAssembly Demo",
url = "https://webassembly.org/demo/",
description = "WASM showcase",
isWasmApp = true
)
)
samples.forEach { app ->
repository.insertApp(app)
}
}
}
}

View File

@@ -0,0 +1,78 @@
package uk.silverlabs.silverdroid.ui.theme
import androidx.compose.ui.graphics.Color
// Glassmorphism Colors
object GlassColors {
// Light theme glass
val GlassSurfaceLight = Color(0xE6FFFFFF)
val GlassTintLight = Color(0x40FFFFFF)
val GlassBorderLight = Color(0x60FFFFFF)
// Dark theme glass
val GlassSurfaceDark = Color(0xCC1C1B1F)
val GlassTintDark = Color(0x40000000)
val GlassBorderDark = Color(0x60FFFFFF)
// Accent colors for glass effects
val GlassAccentBlue = Color(0x4000B8FF)
val GlassAccentPurple = Color(0x40BB86FC)
}
// Material Design 3 Light Theme
val md_theme_light_primary = Color(0xFF0061A4)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFD1E4FF)
val md_theme_light_onPrimaryContainer = Color(0xFF001D36)
val md_theme_light_secondary = Color(0xFF535E70)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFD7E2F7)
val md_theme_light_onSecondaryContainer = Color(0xFF10192B)
val md_theme_light_tertiary = Color(0xFF6C5677)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFF5D9FF)
val md_theme_light_onTertiaryContainer = Color(0xFF261431)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFDFCFF)
val md_theme_light_onBackground = Color(0xFF1A1C1E)
val md_theme_light_surface = Color(0xFFFDFCFF)
val md_theme_light_onSurface = Color(0xFF1A1C1E)
val md_theme_light_surfaceVariant = Color(0xFFDEE3EB)
val md_theme_light_onSurfaceVariant = Color(0xFF42474E)
val md_theme_light_outline = Color(0xFF72777F)
// Material Design 3 Dark Theme
val md_theme_dark_primary = Color(0xFF9ECAFF)
val md_theme_dark_onPrimary = Color(0xFF003258)
val md_theme_dark_primaryContainer = Color(0xFF00497D)
val md_theme_dark_onPrimaryContainer = Color(0xFFD1E4FF)
val md_theme_dark_secondary = Color(0xFFBBC6DB)
val md_theme_dark_onSecondary = Color(0xFF253140)
val md_theme_dark_secondaryContainer = Color(0xFF3C4758)
val md_theme_dark_onSecondaryContainer = Color(0xFFD7E2F7)
val md_theme_dark_tertiary = Color(0xFFD8BDE4)
val md_theme_dark_onTertiary = Color(0xFF3C2947)
val md_theme_dark_tertiaryContainer = Color(0xFF543F5F)
val md_theme_dark_onTertiaryContainer = Color(0xFFF5D9FF)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF1A1C1E)
val md_theme_dark_onBackground = Color(0xFFE2E2E6)
val md_theme_dark_surface = Color(0xFF1A1C1E)
val md_theme_dark_onSurface = Color(0xFFE2E2E6)
val md_theme_dark_surfaceVariant = Color(0xFF42474E)
val md_theme_dark_onSurfaceVariant = Color(0xFFC2C7CF)
val md_theme_dark_outline = Color(0xFF8C9199)

View File

@@ -0,0 +1,101 @@
package uk.silverlabs.silverdroid.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.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
)
private val DarkColorScheme = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
)
@Composable
fun SilverDROIDTheme(
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
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as android.app.Activity).window
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,115 @@
package uk.silverlabs.silverdroid.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.Bold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
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,
)
)

View File

@@ -0,0 +1,204 @@
package uk.silverlabs.silverdroid.ui.webview
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.webkit.*
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.unit.dp
@SuppressLint("SetJavaScriptEnabled")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WasmWebView(
url: String,
appName: String,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier
) {
var webView by remember { mutableStateOf<WebView?>(null) }
var isLoading by remember { mutableStateOf(true) }
var loadProgress by remember { mutableIntStateOf(0) }
var pageTitle by remember { mutableStateOf(appName) }
var canGoBack by remember { mutableStateOf(false) }
BackHandler(enabled = canGoBack) {
webView?.goBack()
}
Column(modifier = modifier.fillMaxSize()) {
// Top bar with navigation
Surface(
tonalElevation = 3.dp,
shadowElevation = 3.dp
) {
TopAppBar(
title = {
Column {
Text(
text = pageTitle,
style = MaterialTheme.typography.titleMedium
)
if (isLoading) {
LinearProgressIndicator(
progress = { loadProgress / 100f },
modifier = Modifier
.fillMaxWidth()
.height(2.dp),
)
}
}
},
navigationIcon = {
IconButton(onClick = {
if (canGoBack) {
webView?.goBack()
} else {
onBackPressed()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = { webView?.reload() }) {
Icon(Icons.Default.Refresh, "Refresh")
}
}
)
}
// WebView
Box(modifier = Modifier.weight(1f)) {
AndroidView(
factory = { context ->
WebView(context).apply {
webView = this
// Enable JavaScript
settings.javaScriptEnabled = true
// Enable WASM support
settings.allowFileAccess = true
settings.allowContentAccess = true
// Enable DOM storage (required for PWAs)
settings.domStorageEnabled = true
// Enable database storage
settings.databaseEnabled = true
// Enable caching for offline support
settings.cacheMode = WebSettings.LOAD_DEFAULT
settings.setAppCacheEnabled(true)
// Enable mixed content (for development)
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
// Performance optimizations
settings.setRenderPriority(WebSettings.RenderPriority.HIGH)
settings.cacheMode = WebSettings.LOAD_DEFAULT
// Enable zooming
settings.setSupportZoom(true)
settings.builtInZoomControls = true
settings.displayZoomControls = false
// User agent (modern)
settings.userAgentString = settings.userAgentString +
" SilverDROID/1.0 (PWA/WASM Launcher)"
// Enable wide viewport
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
// WebView client
webViewClient = object : WebViewClient() {
override fun onPageStarted(
view: WebView?,
url: String?,
favicon: Bitmap?
) {
isLoading = true
loadProgress = 0
}
override fun onPageFinished(view: WebView?, url: String?) {
isLoading = false
loadProgress = 100
canGoBack = view?.canGoBack() ?: false
pageTitle = view?.title ?: appName
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
// Allow all navigation within WebView
return false
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
isLoading = false
}
}
// Chrome client for progress
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
loadProgress = newProgress
if (newProgress == 100) {
isLoading = false
}
}
override fun onReceivedTitle(view: WebView?, title: String?) {
pageTitle = title ?: appName
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
// Log console messages for debugging
consoleMessage?.let {
android.util.Log.d(
"WebView",
"${it.message()} -- From line ${it.lineNumber()} of ${it.sourceId()}"
)
}
return true
}
}
// Load the URL
loadUrl(url)
}
},
update = { view ->
canGoBack = view.canGoBack()
},
modifier = Modifier.fillMaxSize()
)
// Loading indicator
if (isLoading && loadProgress < 20) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
}

View File

@@ -0,0 +1,48 @@
package uk.silverlabs.silverdroid.ui.webview
import android.annotation.SuppressLint
import android.os.Bundle
import android.webkit.WebView
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 uk.silverlabs.silverdroid.ui.theme.SilverDROIDTheme
class WebViewActivity : ComponentActivity() {
companion object {
const val EXTRA_APP_ID = "app_id"
const val EXTRA_APP_URL = "app_url"
const val EXTRA_APP_NAME = "app_name"
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val appUrl = intent.getStringExtra(EXTRA_APP_URL) ?: ""
val appName = intent.getStringExtra(EXTRA_APP_NAME) ?: "App"
// Enable WebView debugging in debug builds
WebView.setWebContentsDebuggingEnabled(true)
setContent {
SilverDROIDTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
WasmWebView(
url = appUrl,
appName = appName,
onBackPressed = { finish() }
)
}
}
}
}
}