Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,23 @@ with a packet capture.

## TV settings screen

### First run

The first launch after installing opens a three-step setup instead of the settings screen:

1. **Welcome** explains what the app does and shows the address this TV listens on.
2. **Permission** walks through the overlay permission. When the TV offers a system page for it, a
"Try system settings" button opens it; otherwise the adb command is shown on screen. The step
re-checks every few seconds and moves on by itself once the permission is granted. It can be
skipped, but every following screen then states that TibeePost is unusable until the permission
is granted, and the settings screen keeps saying so on every launch.
3. **Test** sends a real notification through the local server so you can see a card appear before
any remote client is involved.

The setup runs once. A "Run setup again" row at the bottom of the settings screen brings it back.

### Settings

Open TibeePost from the launcher. Everything is reachable with the D-pad; there are no text
fields.

Expand All @@ -342,6 +359,7 @@ fields.
- **Default sound** None or chime, with a Preview button.
- **Default card width, duration, dim, position, background, text color, accent** Press left
and right on a row to step the value. These become the defaults for fields a `POST` omits.
- **Run setup again** Replays the first-run flow.

## Web client

Expand Down
18 changes: 17 additions & 1 deletion tv/app/src/main/kotlin/com/zorenkonte/tibeepost/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,32 @@ package com.zorenkonte.tibeepost
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.zorenkonte.tibeepost.service.ServiceStarter
import com.zorenkonte.tibeepost.settings.Settings
import com.zorenkonte.tibeepost.ui.SettingsScreen
import com.zorenkonte.tibeepost.ui.TibeeTheme
import com.zorenkonte.tibeepost.ui.onboarding.OnboardingScreen
import com.zorenkonte.tibeepost.ui.rememberSettingsState

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ServiceStarter.start(this)
val setupPending = !Settings(this).onboardingDone
setContent {
TibeeTheme { SettingsScreen() }
TibeeTheme {
val state = rememberSettingsState()
var showSetup by rememberSaveable { mutableStateOf(setupPending) }
if (showSetup) {
OnboardingScreen(state, onFinished = { showSetup = false })
} else {
SettingsScreen(state, onRerunSetup = { showSetup = true })
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ class Settings(context: Context) {
get() = prefs.getString(SettingsKeys.TOKEN, "") ?: ""
set(value) = prefs.edit().putString(SettingsKeys.TOKEN, value).apply()

var onboardingDone: Boolean
get() = prefs.getBoolean(SettingsKeys.ONBOARDING_DONE, false)
set(value) = prefs.edit().putBoolean(SettingsKeys.ONBOARDING_DONE, value).apply()

var autostart: Boolean
get() = prefs.getBoolean(SettingsKeys.AUTOSTART, true)
set(value) = prefs.edit().putBoolean(SettingsKeys.AUTOSTART, value).apply()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ object SettingsKeys {
const val ACCENT = "default_accent"
const val POSITION = "default_position"
const val SOUND = "default_sound"
const val ONBOARDING_DONE = "onboarding_done"
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import com.zorenkonte.tibeepost.sound.ChimePlayer
import kotlinx.coroutines.launch

@Composable
fun SettingsScreen(state: SettingsState = rememberSettingsState()) {
fun SettingsScreen(state: SettingsState = rememberSettingsState(), onRerunSetup: () -> Unit = {}) {
val context = LocalContext.current
val chime = remember { ChimePlayer(context) }
DisposableEffect(chime) { onDispose { chime.release() } }
Expand Down Expand Up @@ -139,6 +139,17 @@ fun SettingsScreen(state: SettingsState = rememberSettingsState()) {
decoration = { ColorSwatch(state.accent) },
)
}
item {
ListItem(
selected = false,
onClick = {
state.resetOnboarding()
onRerunSetup()
},
headlineContent = { Text(stringResource(R.string.row_rerun_setup), style = MaterialTheme.typography.titleLarge) },
supportingContent = { Text(stringResource(R.string.row_rerun_setup_body), style = MaterialTheme.typography.bodyMedium) },
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ class SettingsState(context: Context) {

fun stopObservingPreferences() = settings.stopObserving(preferenceListener)

fun completeOnboarding() {
settings.onboardingDone = true
}

fun resetOnboarding() {
settings.onboardingDone = false
}

fun generateToken() {
settings.token = TokenGenerator.generate()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.zorenkonte.tibeepost.ui.onboarding

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.zorenkonte.tibeepost.R
import com.zorenkonte.tibeepost.ui.SettingsState

@Composable
fun DoneStep(state: SettingsState, primaryFocus: FocusRequester, onFinish: () -> Unit) {
val usable = state.overlayPermitted
val address = "http://${state.ipAddress ?: stringResource(R.string.status_no_network)}:${state.port}"

Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = stringResource(if (usable) R.string.setup_done_title else R.string.setup_done_unusable_title),
style = MaterialTheme.typography.displayMedium,
)
if (!usable) UnusableWarning(packageName = state.packageName)
Text(stringResource(R.string.setup_done_address), style = MaterialTheme.typography.titleMedium, color = Color(0xFF9CA3AF))
Text(address, style = MaterialTheme.typography.displayMedium.copy(fontSize = 52.sp, lineHeight = 60.sp))
Text(stringResource(R.string.setup_done_body), style = MaterialTheme.typography.bodyLarge)
Button(onClick = onFinish, modifier = Modifier.focusRequester(primaryFocus)) {
Text(stringResource(if (usable) R.string.setup_finish else R.string.setup_finish_anyway))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.zorenkonte.tibeepost.ui.onboarding

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.zorenkonte.tibeepost.R
import com.zorenkonte.tibeepost.ui.SettingsState

@Composable
fun OnboardingScreen(state: SettingsState, onFinished: () -> Unit) {
var step by rememberSaveable { mutableStateOf(OnboardingStep.WELCOME) }
var permissionSkipped by rememberSaveable { mutableStateOf(false) }
val primaryFocus = remember { FocusRequester() }

LaunchedEffect(step) { primaryFocus.requestFocus() }

val permittedOnEntry = remember(step) { state.overlayPermitted }
LaunchedEffect(step, state.overlayPermitted) {
if (step == OnboardingStep.PERMISSION && !permittedOnEntry && state.overlayPermitted) {
permissionSkipped = false
step = OnboardingStep.TEST
}
}

Box(
Modifier
.fillMaxSize()
.background(Color(0xFF0B1020))
.padding(horizontal = 96.dp, vertical = 56.dp),
contentAlignment = Alignment.CenterStart,
) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(20.dp)) {
if (step != OnboardingStep.DONE) {
Text(
text = stringResource(R.string.setup_step, step.number),
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF9CA3AF),
)
}
when (step) {
OnboardingStep.WELCOME -> WelcomeStep(
state = state,
primaryFocus = primaryFocus,
onContinue = { step = OnboardingStep.PERMISSION },
)
OnboardingStep.PERMISSION -> PermissionStep(
state = state,
primaryFocus = primaryFocus,
onContinue = {
permissionSkipped = false
step = OnboardingStep.TEST
},
onSkip = {
permissionSkipped = true
step = OnboardingStep.TEST
},
)
OnboardingStep.TEST -> TestStep(
state = state,
permissionMissing = permissionSkipped && !state.overlayPermitted,
primaryFocus = primaryFocus,
onBackToPermission = { step = OnboardingStep.PERMISSION },
onContinue = { step = OnboardingStep.DONE },
)
OnboardingStep.DONE -> DoneStep(
state = state,
primaryFocus = primaryFocus,
onFinish = {
state.completeOnboarding()
onFinished()
},
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.zorenkonte.tibeepost.ui.onboarding

enum class OnboardingStep(val number: Int) {
WELCOME(1),
PERMISSION(2),
TEST(3),
DONE(3),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.zorenkonte.tibeepost.ui.onboarding

import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.zorenkonte.tibeepost.R
import com.zorenkonte.tibeepost.ui.SettingsState

@Composable
fun PermissionStep(
state: SettingsState,
primaryFocus: FocusRequester,
onContinue: () -> Unit,
onSkip: () -> Unit,
) {
if (state.overlayPermitted) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(stringResource(R.string.setup_permission_granted_title), style = MaterialTheme.typography.displayMedium)
Text(stringResource(R.string.setup_permission_granted_body), style = MaterialTheme.typography.bodyLarge)
Button(onClick = onContinue, modifier = Modifier.focusRequester(primaryFocus)) {
Text(stringResource(R.string.setup_continue))
}
}
return
}

val context = LocalContext.current
val systemSettings = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${state.packageName}"))
val canOpenSystemSettings = systemSettings.resolveActivity(context.packageManager) != null

Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(stringResource(R.string.setup_permission_title), style = MaterialTheme.typography.displayMedium)
Text(stringResource(R.string.setup_permission_body), style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.setup_permission_system), style = MaterialTheme.typography.bodyLarge)
Text(
text = "adb shell appops set ${state.packageName} SYSTEM_ALERT_WINDOW allow",
style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace),
color = Color(0xFFFDE68A),
)
Text(
text = stringResource(R.string.setup_permission_recheck),
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF9CA3AF),
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (canOpenSystemSettings) {
Button(
onClick = { context.startActivity(systemSettings) },
modifier = Modifier.focusRequester(primaryFocus),
) {
Text(stringResource(R.string.overlay_open_settings))
}
Button(onClick = onSkip) { Text(stringResource(R.string.setup_skip)) }
} else {
Button(onClick = onSkip, modifier = Modifier.focusRequester(primaryFocus)) {
Text(stringResource(R.string.setup_skip))
}
}
}
}
}
Loading
Loading