feat(kiosk): add setup wizard on first launch

- 3-step wizard: Welcome → Server URL → Scale Setup → Launch
- Connection test with live feedback
- BLE scale status during wizard
- Dark theme with modern UI (slate/purple palette)
- Settings page with URL edit, connection test, scale info, wizard reset
- Skip scale option for users without BLE scales
- Error page with retry button when server unreachable
- All UI in English
This commit is contained in:
dadaloop82
2026-04-16 16:23:13 +00:00
parent d931b471f0
commit 95b6258ad8
9 changed files with 1098 additions and 258 deletions
@@ -2,9 +2,13 @@ package it.dadaloop.evershelf.kiosk
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.content.*
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -13,100 +17,408 @@ import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.view.WindowManager
import android.webkit.*
import android.webkit.ConsoleMessage
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import it.dadaloop.evershelf.kiosk.databinding.ActivityKioskBinding
private const val PREFS_NAME = "evershelf_kiosk"
private const val PREF_URL = "evershelf_url"
private const val DEFAULT_URL = "http://evershelf.local"
import com.google.android.material.button.MaterialButton
import java.net.HttpURLConnection
import java.net.URL
class KioskActivity : AppCompatActivity() {
private lateinit var binding: ActivityKioskBinding
private var gatewayService: ScaleGatewayService? = null
private var bound = false
private lateinit var prefs: SharedPreferences
private var currentStep = 1
// Views
private lateinit var wizardContainer: ScrollView
private lateinit var webView: WebView
private lateinit var btnSettings: ImageButton
private lateinit var step1: LinearLayout
private lateinit var step2: LinearLayout
private lateinit var step3: LinearLayout
private lateinit var stepIndicator: LinearLayout
private lateinit var wizardUrl: EditText
private lateinit var urlStatus: TextView
private lateinit var scaleStatusIcon: TextView
private lateinit var scaleStatusText: TextView
private lateinit var scaleStatusDetail: TextView
// Scale service
private var scaleService: ScaleGatewayService? = null
private var serviceBound = false
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
val binder = service as ScaleGatewayService.LocalBinder
gatewayService = binder.getService()
bound = true
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
val localBinder = binder as ScaleGatewayService.LocalBinder
scaleService = localBinder.getService()
serviceBound = true
scaleService?.statusCallback = { status ->
runOnUiThread { updateScaleStatusUI(status) }
}
}
override fun onServiceDisconnected(name: ComponentName) {
gatewayService = null
bound = false
override fun onServiceDisconnected(name: ComponentName?) {
scaleService = null
serviceBound = false
}
}
// Permission request launcher
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { results ->
val allGranted = results.all { it.value }
if (allGranted) {
startGatewayService()
} else {
Toast.makeText(this, "BLE permissions required for scale gateway", Toast.LENGTH_LONG).show()
// Start anyway without BLE
startGatewayService()
}
// File chooser
private var fileChooserCallback: ValueCallback<Array<Uri>>? = null
companion object {
private const val BLE_PERMISSION_REQUEST = 1001
private const val FILE_CHOOSER_REQUEST = 1002
private const val PREFS_NAME = "evershelf_kiosk"
private const val KEY_URL = "evershelf_url"
private const val KEY_SETUP_COMPLETE = "setup_complete"
private const val KEY_LAST_DEVICE = "last_device_address"
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityKioskBinding.inflate(layoutInflater)
setContentView(binding.root)
setContentView(R.layout.activity_kiosk)
// Full-screen immersive mode
enterKioskMode()
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
bindViews()
enterImmersiveMode()
// Keep screen on
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (prefs.getBoolean(KEY_SETUP_COMPLETE, false)) {
launchWebView()
} else {
showWizard()
}
}
// Setup WebView
setupWebView()
private fun bindViews() {
wizardContainer = findViewById(R.id.wizardContainer)
webView = findViewById(R.id.webView)
btnSettings = findViewById(R.id.btnSettings)
step1 = findViewById(R.id.step1)
step2 = findViewById(R.id.step2)
step3 = findViewById(R.id.step3)
stepIndicator = findViewById(R.id.stepIndicator)
wizardUrl = findViewById(R.id.wizardUrl)
urlStatus = findViewById(R.id.urlStatus)
scaleStatusIcon = findViewById(R.id.scaleStatusIcon)
scaleStatusText = findViewById(R.id.scaleStatusText)
scaleStatusDetail = findViewById(R.id.scaleStatusDetail)
// Settings button (long press corner area)
binding.btnSettings.setOnClickListener {
// Step 1 buttons
findViewById<MaterialButton>(R.id.btnGetStarted).setOnClickListener {
goToStep(2)
}
// Step 2 buttons
findViewById<MaterialButton>(R.id.btnTestUrl).setOnClickListener {
testConnection()
}
findViewById<MaterialButton>(R.id.btnStep2Back).setOnClickListener {
goToStep(1)
}
findViewById<MaterialButton>(R.id.btnStep2Next).setOnClickListener {
val url = wizardUrl.text.toString().trim()
if (url.isEmpty()) {
showUrlStatus("Please enter a URL", false)
return@setOnClickListener
}
prefs.edit().putString(KEY_URL, url).apply()
requestBlePermissions()
goToStep(3)
}
// Step 3 buttons
findViewById<MaterialButton>(R.id.btnStep3Back).setOnClickListener {
goToStep(2)
}
findViewById<MaterialButton>(R.id.btnFinish).setOnClickListener {
finishWizard()
}
findViewById<MaterialButton>(R.id.btnSkipScale).setOnClickListener {
finishWizard()
}
// Settings button
btnSettings.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
// Request permissions and start gateway
requestPermissionsAndStart()
// Load the EverShelf URL
loadEverShelfUrl()
}
override fun onResume() {
super.onResume()
enterKioskMode()
// Reload URL in case it was changed in settings
val currentUrl = binding.webView.url ?: ""
val savedUrl = getSavedUrl()
if (currentUrl.isNotEmpty() && !currentUrl.startsWith(savedUrl)) {
loadEverShelfUrl()
// Pre-fill URL if saved
val savedUrl = prefs.getString(KEY_URL, "") ?: ""
if (savedUrl.isNotEmpty()) {
wizardUrl.setText(savedUrl)
}
}
override fun onDestroy() {
if (bound) {
unbindService(serviceConnection)
bound = false
}
super.onDestroy()
// ── Wizard Flow ───────────────────────────────────────────────────────
private fun showWizard() {
wizardContainer.visibility = View.VISIBLE
webView.visibility = View.GONE
btnSettings.visibility = View.GONE
goToStep(1)
}
private fun enterKioskMode() {
private fun goToStep(step: Int) {
currentStep = step
step1.visibility = if (step == 1) View.VISIBLE else View.GONE
step2.visibility = if (step == 2) View.VISIBLE else View.GONE
step3.visibility = if (step == 3) View.VISIBLE else View.GONE
updateStepIndicator()
if (step == 3) {
startScaleGateway()
}
}
private fun updateStepIndicator() {
stepIndicator.removeAllViews()
for (i in 1..3) {
val dot = View(this)
val size = if (i == currentStep) 10 else 8
val dp = (size * resources.displayMetrics.density).toInt()
val params = LinearLayout.LayoutParams(dp, dp)
params.marginStart = (4 * resources.displayMetrics.density).toInt()
params.marginEnd = (4 * resources.displayMetrics.density).toInt()
dot.layoutParams = params
val bg = GradientDrawable()
bg.shape = GradientDrawable.OVAL
if (i == currentStep) {
bg.setColor(0xFF7c3aed.toInt())
} else if (i < currentStep) {
bg.setColor(0xFF34d399.toInt())
} else {
bg.setColor(0xFF334155.toInt())
}
dot.background = bg
stepIndicator.addView(dot)
}
}
private fun finishWizard() {
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, true).apply()
wizardContainer.visibility = View.GONE
launchWebView()
}
fun resetWizard() {
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, false).apply()
wizardContainer.visibility = View.VISIBLE
webView.visibility = View.GONE
btnSettings.visibility = View.GONE
goToStep(1)
}
// ── Connection Test ───────────────────────────────────────────────────
private fun testConnection() {
val url = wizardUrl.text.toString().trim()
if (url.isEmpty()) {
showUrlStatus("Please enter a URL first", false)
return
}
showUrlStatus("Testing connection...", null)
Thread {
try {
val testUrl = if (url.endsWith("/")) "${url}api/" else "${url}/api/"
val conn = URL(testUrl).openConnection() as HttpURLConnection
conn.connectTimeout = 5000
conn.readTimeout = 5000
conn.requestMethod = "GET"
val code = conn.responseCode
conn.disconnect()
runOnUiThread {
if (code in 200..299) {
showUrlStatus("✓ Connected successfully!", true)
} else {
showUrlStatus("⚠ Server responded with code $code", false)
}
}
} catch (e: Exception) {
runOnUiThread {
showUrlStatus("✗ Cannot reach server: ${e.message}", false)
}
}
}.start()
}
private fun showUrlStatus(text: String, success: Boolean?) {
urlStatus.visibility = View.VISIBLE
urlStatus.text = text
urlStatus.setTextColor(
when (success) {
true -> 0xFF34d399.toInt()
false -> 0xFFf87171.toInt()
null -> 0xFF94a3b8.toInt()
}
)
}
// ── Scale Gateway ─────────────────────────────────────────────────────
private fun startScaleGateway() {
val intent = Intent(this, ScaleGatewayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
private fun updateScaleStatusUI(status: String) {
when {
status.contains("Connected", ignoreCase = true) -> {
scaleStatusIcon.text = ""
scaleStatusText.text = "Scale connected!"
scaleStatusDetail.text = status
scaleStatusDetail.setTextColor(0xFF34d399.toInt())
}
status.contains("Scanning", ignoreCase = true) ||
status.contains("search", ignoreCase = true) -> {
scaleStatusIcon.text = "🔍"
scaleStatusText.text = "Scanning for scales..."
scaleStatusDetail.text = "Turn on your scale and place it nearby"
scaleStatusDetail.setTextColor(0xFF64748b.toInt())
}
status.contains("Ready", ignoreCase = true) ||
status.contains("running", ignoreCase = true) -> {
scaleStatusIcon.text = "📡"
scaleStatusText.text = "Gateway is running"
scaleStatusDetail.text = status
scaleStatusDetail.setTextColor(0xFF94a3b8.toInt())
}
else -> {
scaleStatusIcon.text = "📡"
scaleStatusText.text = "Gateway active"
scaleStatusDetail.text = status
scaleStatusDetail.setTextColor(0xFF94a3b8.toInt())
}
}
}
// ── BLE Permissions ───────────────────────────────────────────────────
private fun requestBlePermissions() {
val perms = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED)
perms.add(Manifest.permission.BLUETOOTH_SCAN)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED)
perms.add(Manifest.permission.BLUETOOTH_CONNECT)
} else {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED)
perms.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
perms.add(Manifest.permission.POST_NOTIFICATIONS)
}
if (perms.isNotEmpty()) {
ActivityCompat.requestPermissions(this, perms.toTypedArray(), BLE_PERMISSION_REQUEST)
}
}
// ── WebView ───────────────────────────────────────────────────────────
@SuppressLint("SetJavaScriptEnabled")
private fun launchWebView() {
webView.visibility = View.VISIBLE
btnSettings.visibility = View.VISIBLE
val settings = webView.settings
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
settings.allowFileAccess = true
webView.webViewClient = object : WebViewClient() {
override fun onReceivedError(
view: WebView?, request: WebResourceRequest?,
error: WebResourceError?
) {
if (request?.isForMainFrame == true) {
view?.loadData(errorPageHtml(), "text/html", "UTF-8")
}
}
}
webView.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest?) {
runOnUiThread { request?.grant(request.resources) }
}
override fun onConsoleMessage(msg: ConsoleMessage?): Boolean = true
override fun onShowFileChooser(
wv: WebView?,
callback: ValueCallback<Array<Uri>>?,
params: FileChooserParams?
): Boolean {
fileChooserCallback?.onReceiveValue(null)
fileChooserCallback = callback
val intent = params?.createIntent()
if (intent != null) {
startActivityForResult(intent, FILE_CHOOSER_REQUEST)
}
return true
}
}
val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local"
webView.loadUrl(url)
// Start scale gateway
startScaleGateway()
// Keep screen on
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
private fun errorPageHtml(): String {
val url = prefs.getString(KEY_URL, "") ?: ""
return """
<html>
<head><meta name='viewport' content='width=device-width,initial-scale=1'></head>
<body style='background:#0f172a;color:#f1f5f9;font-family:sans-serif;
display:flex;flex-direction:column;align-items:center;
justify-content:center;height:100vh;margin:0;padding:24px;
text-align:center;'>
<div style='font-size:48px;margin-bottom:16px;'>⚠️</div>
<h2 style='margin:0 0 8px 0;'>Cannot reach EverShelf</h2>
<p style='color:#94a3b8;margin:0 0 8px 0;'>$url</p>
<p style='color:#64748b;font-size:14px;margin:0 0 32px 0;'>
Check that the server is running and the URL is correct.
</p>
<button onclick='location.reload()'
style='background:#7c3aed;color:#fff;border:none;padding:14px 32px;
border-radius:12px;font-size:16px;cursor:pointer;'>
Retry
</button>
</body>
</html>
""".trimIndent()
}
// ── Immersive Mode ────────────────────────────────────────────────────
private fun enterImmersiveMode() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.let {
it.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
it.hide(WindowInsets.Type.systemBars())
it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
@@ -122,120 +434,47 @@ class KioskActivity : AppCompatActivity() {
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
binding.webView.apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.databaseEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
settings.allowFileAccess = false
settings.allowContentAccess = false
settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
settings.cacheMode = WebSettings.LOAD_DEFAULT
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
settings.setSupportZoom(false)
settings.builtInZoomControls = false
// ── Lifecycle ─────────────────────────────────────────────────────────
// Allow camera access for barcode scanning
webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
runOnUiThread {
request.grant(request.resources)
}
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
return true
}
}
webViewClient = object : WebViewClient() {
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
// Show retry page on load error
if (request?.isForMainFrame == true) {
view?.loadData(
"""
<html><body style="font-family:sans-serif;text-align:center;padding:40px;background:#1a1a2e;color:#fff">
<h2>⚠️ Connection Error</h2>
<p>Cannot reach EverShelf server</p>
<p style="color:#888;font-size:14px">${getSavedUrl()}</p>
<button onclick="location.reload()" style="padding:12px 24px;font-size:16px;border:none;border-radius:8px;background:#7C3AED;color:#fff;cursor:pointer;margin-top:20px">Retry</button>
<br><br>
<button onclick="window.location='evershelf://settings'" style="padding:8px 16px;font-size:14px;border:1px solid #666;border-radius:8px;background:transparent;color:#aaa;cursor:pointer">Settings</button>
</body></html>
""".trimIndent(),
"text/html", "utf-8"
)
}
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url.toString()
if (url.startsWith("evershelf://settings")) {
startActivity(Intent(this@KioskActivity, SettingsActivity::class.java))
return true
}
// Keep navigation within the WebView for same-origin
return false
}
override fun onResume() {
super.onResume()
enterImmersiveMode()
// Reload WebView if setup is complete (in case URL changed in settings)
if (prefs.getBoolean(KEY_SETUP_COMPLETE, false) && webView.visibility == View.VISIBLE) {
val url = prefs.getString(KEY_URL, "") ?: ""
if (url.isNotEmpty() && webView.url != url) {
webView.loadUrl(url)
}
}
}
private fun loadEverShelfUrl() {
val url = getSavedUrl()
binding.webView.loadUrl(url)
}
private fun getSavedUrl(): String {
return getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
.getString(PREF_URL, DEFAULT_URL) ?: DEFAULT_URL
}
private fun requestPermissionsAndStart() {
val needed = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN)
!= PackageManager.PERMISSION_GRANTED) needed.add(Manifest.permission.BLUETOOTH_SCAN)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT)
!= PackageManager.PERMISSION_GRANTED) needed.add(Manifest.permission.BLUETOOTH_CONNECT)
} else {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) needed.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) needed.add(Manifest.permission.POST_NOTIFICATIONS)
}
if (needed.isNotEmpty()) {
permissionLauncher.launch(needed.toTypedArray())
} else {
startGatewayService()
// Check if wizard reset was requested
if (!prefs.getBoolean(KEY_SETUP_COMPLETE, false) && wizardContainer.visibility != View.VISIBLE) {
showWizard()
}
}
private fun startGatewayService() {
val intent = Intent(this, ScaleGatewayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == FILE_CHOOSER_REQUEST) {
val result = if (resultCode == RESULT_OK && data != null) {
WebChromeClient.FileChooserParams.parseResult(resultCode, data)
} else null
fileChooserCallback?.onReceiveValue(result)
fileChooserCallback = null
}
}
override fun onDestroy() {
super.onDestroy()
if (serviceBound) {
unbindService(serviceConnection)
serviceBound = false
}
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (binding.webView.canGoBack()) {
binding.webView.goBack()
if (webView.visibility == View.VISIBLE && webView.canGoBack()) {
webView.goBack()
}
// Don't call super — prevent exiting kiosk mode
// Block back button in kiosk mode
}
}
@@ -1,39 +1,101 @@
package it.dadaloop.evershelf.kiosk
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import it.dadaloop.evershelf.kiosk.databinding.ActivitySettingsBinding
private const val PREFS_NAME = "evershelf_kiosk"
private const val PREF_URL = "evershelf_url"
private const val DEFAULT_URL = "http://evershelf.local"
import com.google.android.material.button.MaterialButton
import java.net.HttpURLConnection
import java.net.URL
class SettingsActivity : AppCompatActivity() {
private lateinit var binding: ActivitySettingsBinding
private lateinit var prefs: SharedPreferences
private lateinit var urlEdit: EditText
companion object {
private const val PREFS_NAME = "evershelf_kiosk"
private const val KEY_URL = "evershelf_url"
private const val KEY_SETUP_COMPLETE = "setup_complete"
private const val KEY_LAST_DEVICE = "last_device_address"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
setContentView(R.layout.activity_settings)
val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
binding.editUrl.setText(prefs.getString(PREF_URL, DEFAULT_URL))
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
urlEdit = findViewById(R.id.urlEdit)
binding.btnSave.setOnClickListener {
val url = binding.editUrl.text.toString().trim()
// Load saved URL
urlEdit.setText(prefs.getString(KEY_URL, "") ?: "")
// Scale status
val scaleDevice = prefs.getString(KEY_LAST_DEVICE, null)
findViewById<TextView>(R.id.scaleDeviceInfo).text =
if (scaleDevice != null) "Last connected: $scaleDevice" else "No scale connected yet"
// Back button
findViewById<android.widget.ImageButton>(R.id.btnBack).setOnClickListener {
finish()
}
// Test connection
findViewById<MaterialButton>(R.id.btnTestConnection).setOnClickListener {
testConnection()
}
// Run wizard again
findViewById<MaterialButton>(R.id.btnRunWizard).setOnClickListener {
prefs.edit().putBoolean(KEY_SETUP_COMPLETE, false).apply()
Toast.makeText(this, "Wizard will run on next launch", Toast.LENGTH_SHORT).show()
finish()
}
// Save
findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
val url = urlEdit.text.toString().trim()
if (url.isEmpty()) {
Toast.makeText(this, "URL cannot be empty", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
prefs.edit().putString(PREF_URL, url).apply()
Toast.makeText(this, "Saved! Returning to kiosk...", Toast.LENGTH_SHORT).show()
finish()
}
binding.btnBack.setOnClickListener {
prefs.edit().putString(KEY_URL, url).apply()
Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show()
finish()
}
}
private fun testConnection() {
val url = urlEdit.text.toString().trim()
if (url.isEmpty()) {
Toast.makeText(this, "Enter a URL first", Toast.LENGTH_SHORT).show()
return
}
Thread {
try {
val testUrl = if (url.endsWith("/")) "${url}api/" else "${url}/api/"
val conn = URL(testUrl).openConnection() as HttpURLConnection
conn.connectTimeout = 5000
conn.readTimeout = 5000
conn.requestMethod = "GET"
val code = conn.responseCode
conn.disconnect()
runOnUiThread {
if (code in 200..299) {
Toast.makeText(this, "✓ Connection successful!", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "⚠ Server responded: $code", Toast.LENGTH_SHORT).show()
}
}
} catch (e: Exception) {
runOnUiThread {
Toast.makeText(this, "✗ Cannot reach server", Toast.LENGTH_SHORT).show()
}
}
}.start()
}
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1e293b" />
<corners android:radius="16dp" />
<stroke android:width="1dp" android:color="#334155" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1e293b" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="#334155" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#0f1729" />
<corners android:radius="10dp" />
<stroke android:width="1dp" android:color="#1e293b" />
</shape>
@@ -2,25 +2,412 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000">
android:background="#0f172a">
<!-- Setup wizard container (shown on first launch) -->
<ScrollView
android:id="@+id/wizardContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:paddingStart="32dp"
android:paddingEnd="32dp"
android:paddingTop="48dp"
android:paddingBottom="48dp">
<!-- Step indicator -->
<LinearLayout
android:id="@+id/stepIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="40dp"
android:gravity="center" />
<!-- Step 1: Welcome -->
<LinearLayout
android:id="@+id/step1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:visibility="visible">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🏠"
android:textSize="64sp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Welcome to\nEverShelf Kiosk"
android:textColor="#f1f5f9"
android:textSize="28sp"
android:textStyle="bold"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Turn this tablet into a dedicated kitchen panel for your smart pantry. Always-on display with built-in smart scale support."
android:textColor="#94a3b8"
android:textSize="16sp"
android:gravity="center"
android:lineSpacingExtra="6dp"
android:layout_marginBottom="48dp" />
<!-- Feature highlights -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="48dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📺 Full-screen kiosk mode"
android:textColor="#cbd5e1"
android:textSize="15sp"
android:paddingTop="8dp"
android:paddingBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⚖️ Built-in BLE scale gateway"
android:textColor="#cbd5e1"
android:textSize="15sp"
android:paddingTop="8dp"
android:paddingBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔋 Always-on, screen never sleeps"
android:textColor="#cbd5e1"
android:textSize="15sp"
android:paddingTop="8dp"
android:paddingBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📷 Barcode scanning from WebView"
android:textColor="#cbd5e1"
android:textSize="15sp"
android:paddingTop="8dp"
android:paddingBottom="8dp" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnGetStarted"
android:layout_width="match_parent"
android:layout_height="56dp"
android:text="Get Started"
android:textSize="17sp"
android:textAllCaps="false"
android:backgroundTint="#7c3aed" />
</LinearLayout>
<!-- Step 2: Server URL -->
<LinearLayout
android:id="@+id/step2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🌐"
android:textSize="48sp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Connect to EverShelf"
android:textColor="#f1f5f9"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Enter the URL of your EverShelf server. This is the address you use to access EverShelf from a browser."
android:textColor="#94a3b8"
android:textSize="15sp"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="32dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Server URL"
android:textColor="#cbd5e1"
android:textSize="13sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/wizardUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="https://192.168.1.100/dispensa"
android:inputType="textUri"
android:textColor="#f1f5f9"
android:textColorHint="#475569"
android:background="@drawable/input_background"
android:padding="16dp"
android:textSize="16sp"
android:singleLine="true"
android:layout_marginBottom="12dp" />
<TextView
android:id="@+id/urlStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="13sp"
android:visibility="gone"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnTestUrl"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="🔗 Test Connection"
android:textSize="15sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#334155"
android:textColor="#94a3b8"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="💡 Tip: You can find this URL in your browser's address bar when EverShelf is open."
android:textColor="#64748b"
android:textSize="13sp"
android:lineSpacingExtra="2dp"
android:background="@drawable/tip_background"
android:padding="14dp"
android:layout_marginBottom="32dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnStep2Back"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:text="Back"
android:textSize="15sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#334155"
android:textColor="#94a3b8"
android:layout_marginEnd="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnStep2Next"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="2"
android:text="Next →"
android:textSize="15sp"
android:textAllCaps="false"
android:backgroundTint="#7c3aed" />
</LinearLayout>
</LinearLayout>
<!-- Step 3: Scale Setup -->
<LinearLayout
android:id="@+id/step3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="⚖️"
android:textSize="48sp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Smart Scale (Optional)"
android:textColor="#f1f5f9"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="This app includes a built-in BLE scale gateway. If you have a Bluetooth kitchen scale, it will be detected automatically."
android:textColor="#94a3b8"
android:textSize="15sp"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="24dp" />
<!-- Scale status card -->
<LinearLayout
android:id="@+id/scaleStatusCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_background"
android:padding="20dp"
android:layout_marginBottom="16dp">
<TextView
android:id="@+id/scaleStatusIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🔍"
android:textSize="32sp"
android:layout_gravity="center"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/scaleStatusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Searching for scales..."
android:textColor="#cbd5e1"
android:textSize="16sp"
android:gravity="center"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/scaleStatusDetail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Turn on your scale and place it nearby"
android:textColor="#64748b"
android:textSize="13sp"
android:gravity="center" />
</LinearLayout>
<!-- Gateway info -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/tip_background"
android:padding="14dp"
android:layout_marginBottom="32dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="️ Gateway runs on ws://localhost:8765"
android:textColor="#94a3b8"
android:textSize="13sp"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="EverShelf will auto-configure the scale connection when running in kiosk mode."
android:textColor="#64748b"
android:textSize="12sp"
android:lineSpacingExtra="2dp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnStep3Back"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:text="Back"
android:textSize="15sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#334155"
android:textColor="#94a3b8"
android:layout_marginEnd="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnFinish"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="2"
android:text="🚀 Launch EverShelf"
android:textSize="16sp"
android:textAllCaps="false"
android:backgroundTint="#059669" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSkipScale"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="Skip — I don't have a scale"
android:textSize="13sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:textColor="#64748b"
android:layout_marginTop="12dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<!-- WebView (shown after setup) -->
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent"
android:visibility="gone" />
<!-- Settings button — small transparent gear icon in top-right corner -->
<!-- Settings gear (shown after setup, over WebView) -->
<ImageButton
android:id="@+id/btnSettings"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="top|end"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:background="@android:color/transparent"
android:src="@android:drawable/ic_menu_manage"
android:alpha="0.15"
android:alpha="0.12"
android:contentDescription="Settings"
android:scaleType="centerInside" />
android:scaleType="centerInside"
android:visibility="gone" />
</FrameLayout>
@@ -1,69 +1,195 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#1a1a2e"
android:padding="32dp"
android:gravity="center">
android:background="#0f172a"
android:fillViewport="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="⚙️ EverShelf Kiosk Settings"
android:textColor="#FFFFFF"
android:textSize="22sp"
android:textStyle="bold"
android:layout_marginBottom="32dp" />
<TextView
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="EverShelf Server URL"
android:textColor="#AAAAAA"
android:textSize="14sp"
android:layout_marginBottom="8dp" />
android:orientation="vertical"
android:padding="24dp">
<EditText
android:id="@+id/editUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="http://192.168.1.100"
android:inputType="textUri"
android:textColor="#FFFFFF"
android:textColorHint="#666666"
android:background="#2a2a3e"
android:padding="14dp"
android:textSize="16sp"
android:singleLine="true"
android:layout_marginBottom="24dp" />
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="32dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="The app will display this URL in full-screen kiosk mode.\nThe Scale Gateway runs on port 8765 (WebSocket).\nSet the gateway URL in EverShelf settings to:\nws://localhost:8765"
android:textColor="#888888"
android:textSize="13sp"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="32dp" />
<ImageButton
android:id="@+id/btnBack"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/card_background"
android:src="@android:drawable/ic_menu_revert"
android:scaleType="centerInside"
android:contentDescription="Back"
android:layout_marginEnd="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Save &amp; Return"
android:textSize="16sp"
android:backgroundTint="#7C3AED"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Settings"
android:textColor="#f1f5f9"
android:textSize="22sp"
android:textStyle="bold" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Cancel"
android:textSize="14sp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#666666"
android:textColor="#AAAAAA" />
<!-- Server URL Section -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="SERVER CONNECTION"
android:textColor="#7c3aed"
android:textSize="12sp"
android:textStyle="bold"
android:letterSpacing="0.1"
android:layout_marginBottom="12dp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_background"
android:padding="16dp"
android:layout_marginBottom="24dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="EverShelf URL"
android:textColor="#cbd5e1"
android:textSize="14sp"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/urlEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:textColor="#f1f5f9"
android:textColorHint="#475569"
android:hint="https://192.168.1.100/dispensa"
android:background="@drawable/input_background"
android:padding="14dp"
android:textSize="15sp"
android:singleLine="true"
android:layout_marginBottom="10dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnTestConnection"
android:layout_width="match_parent"
android:layout_height="40dp"
android:text="Test Connection"
android:textSize="13sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#334155"
android:textColor="#94a3b8" />
</LinearLayout>
<!-- Scale Section -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="SMART SCALE"
android:textColor="#7c3aed"
android:textSize="12sp"
android:textStyle="bold"
android:letterSpacing="0.1"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_background"
android:padding="16dp"
android:layout_marginBottom="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Scale Gateway"
android:textColor="#cbd5e1"
android:textSize="14sp" />
<TextView
android:id="@+id/scaleGatewayStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Active"
android:textColor="#34d399"
android:textSize="13sp" />
</LinearLayout>
<TextView
android:id="@+id/scaleDeviceInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="No scale connected"
android:textColor="#64748b"
android:textSize="13sp" />
</LinearLayout>
<!-- Danger Zone -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="RESET"
android:textColor="#ef4444"
android:textSize="12sp"
android:textStyle="bold"
android:letterSpacing="0.1"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_background"
android:padding="16dp"
android:layout_marginBottom="24dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnRunWizard"
android:layout_width="match_parent"
android:layout_height="44dp"
android:text="Run Setup Wizard Again"
android:textSize="14sp"
android:textAllCaps="false"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:strokeColor="#334155"
android:textColor="#94a3b8" />
</LinearLayout>
<!-- Spacer -->
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- Buttons -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="52dp"
android:text="Save Changes"
android:textSize="16sp"
android:textAllCaps="false"
android:backgroundTint="#7c3aed"
android:layout_marginBottom="12dp" />
</LinearLayout>
</ScrollView>
@@ -1,8 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="accent">#7C3AED</color>
<color name="green">#059669</color>
<color name="red">#EF4444</color>
<color name="blue">#1D4ED8</color>
<color name="ic_launcher_background">#10B981</color>
<color name="bg_dark">#0f172a</color>
<color name="bg_card">#1e293b</color>
<color name="text_primary">#f1f5f9</color>
<color name="text_secondary">#94a3b8</color>
<color name="text_muted">#64748b</color>
<color name="accent_purple">#7c3aed</color>
<color name="accent_green">#059669</color>
<color name="border">#334155</color>
<color name="success">#34d399</color>
<color name="error">#f87171</color>
</resources>
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EverShelf Kiosk</string>
</resources>