feat(ui): complete ABox app experience redesign (#10)

## Summary

- rebuild the authenticated shell, device dashboard, device detail pages, scenes, activity timeline, account sheet, and settings experience
- redesign infrared remote management and controls, including custom remotes, learn-state handling, slot isolation, and safe default content
- add persisted theme controls with internal accent, color-style, and color-standard resolution while keeping dynamic color authoritative
- add ABox profile defaults for the three exact gateway device names, editable starter scenes, and custom infrared remotes
- persist real activity events for device commands, infrared actions, and scene execution
- stabilize navigation and back behavior, including duplicate top-back protection and removal of the unfinished predictive-back option
- update launcher icons and bilingual application name

## Verification

- `:android:app:assembleDebug`
- installed the debug APK on `emulator-5554`
- manually exercised device detail navigation, settings/theme navigation, infrared management, activity records, and repeated back actions

## Notes

- no automated test suite was run during the visual iteration cycle
- `docs/superpowers/**` and local reference repositories are excluded from this PR

Reviewed-on: #10
Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #10.
This commit is contained in:
2026-08-27 23:25:07 +08:00
committed by KremeCN
parent 1570a95f30
commit 86b7e5849b
194 changed files with 11198 additions and 1444 deletions
@@ -18,6 +18,7 @@ import java.util.concurrent.CopyOnWriteArrayList
class FakeSocketGateway : SocketGateway {
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
private val errors = ConcurrentHashMap<DeviceKey, DomainError>()
private val commandErrors = ConcurrentHashMap<DeviceKey, DomainError>()
override suspend fun getStatus(
profileId: ProviderProfileId,
@@ -35,11 +36,27 @@ class FakeSocketGateway : SocketGateway {
return DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
}
override suspend fun commandPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<Unit> {
val key = DeviceKey(profileId, deviceName)
commandErrors[key]?.let { return DomainResult.Failure(it) }
states[key] = powerState
return DomainResult.Success(Unit)
}
fun setError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) {
val key = DeviceKey(profileId, deviceName)
if (error == null) errors.remove(key) else errors[key] = error
}
fun setCommandError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) {
val key = DeviceKey(profileId, deviceName)
if (error == null) commandErrors.remove(key) else commandErrors[key] = error
}
private fun resultFor(
profileId: ProviderProfileId,
deviceName: DeviceName,
@@ -12,6 +12,7 @@ import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import java.util.concurrent.ConcurrentHashMap
class FakeVirtualRemoteRepository : VirtualRemoteRepository {
@@ -80,13 +81,13 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository {
): DomainResult<VirtualRemoteId> {
remotes[remote.id] = remote
bindings[remote.id] = initialBindings.toMutableList()
changeTrigger.value = System.currentTimeMillis()
changeTrigger.update { it + 1L }
return DomainResult.Success(remote.id)
}
override suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit> {
remotes[remote.id] = remote
changeTrigger.value = System.currentTimeMillis()
changeTrigger.update { it + 1L }
return DomainResult.Success(Unit)
}
@@ -96,7 +97,7 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository {
): DomainResult<Unit> {
remotes.remove(remoteId)
bindings.remove(remoteId)
changeTrigger.value = System.currentTimeMillis()
changeTrigger.update { it + 1L }
return DomainResult.Success(Unit)
}
@@ -109,7 +110,7 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository {
list.add(binding)
}
bindings[binding.remoteId] = list
changeTrigger.value = System.currentTimeMillis()
changeTrigger.update { it + 1L }
return DomainResult.Success(Unit)
}
@@ -120,7 +121,7 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository {
val list = bindings[remoteId] ?: return DomainResult.Success(Unit)
list.removeAll { it.buttonId == buttonId }
bindings[remoteId] = list
changeTrigger.value = System.currentTimeMillis()
changeTrigger.update { it + 1L }
return DomainResult.Success(Unit)
}
@@ -131,7 +132,7 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository {
): DomainResult<Unit> {
val existing = remotes[remoteId] ?: return DomainResult.Success(Unit)
remotes[remoteId] = existing.copy(acState = state)
changeTrigger.value = System.currentTimeMillis()
changeTrigger.update { it + 1L }
return DomainResult.Success(Unit)
}
}
@@ -12,6 +12,7 @@ import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionValidity
import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
@@ -133,6 +134,101 @@ class SessionCoordinatorTest {
)
}
@Test
fun endActiveSessionKeepsAboxCredentials() = runTest {
val fixture = Fixture()
fixture.coordinator.selectProvider(remoteProfile)
fixture.coordinator.login(
remoteProfile.id,
credentials,
rememberUsername = true,
autoLogin = true,
)
val result = fixture.coordinator.endActiveSession()
assertTrue(result is DomainResult.Success)
assertEquals(remoteProfile, fixture.profiles.activeProfile())
assertNull(fixture.remote.observeSession(remoteProfile.id).first())
assertEquals(
credentials,
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
)
assertTrue(fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled)
}
@Test
fun forgetSavedCredentialsDisablesAutoLoginAndKeepsSessionStateExplicit() = runTest {
val fixture = Fixture()
fixture.coordinator.selectProvider(remoteProfile)
fixture.coordinator.login(
remoteProfile.id,
credentials,
rememberUsername = true,
autoLogin = true,
)
val result = fixture.coordinator.forgetSavedCredentials()
assertTrue(result is DomainResult.Success)
assertEquals(remoteProfile, fixture.profiles.activeProfile())
assertEquals(
remoteProfile.id,
fixture.remote.observeSession(remoteProfile.id).first()?.profileId,
)
assertNull(
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
)
val preferences = fixture.profiles.loginPreferences(remoteProfile.id)
assertEquals(credentials.username, preferences.rememberedUsername)
assertTrue(!preferences.autoLoginEnabled)
}
@Test
fun switchProviderClearsActiveProfileAfterGatewayLogout() = runTest {
val profiles = FakeProviderProfileStore()
val remote = RecordingSessionGateway(profiles)
val coordinator = SessionCoordinator(
profileStore = profiles,
credentialStore = FakeCredentialStore(),
remoteGateway = remote,
localGateway = FakeLocalSessionGateway(),
)
coordinator.selectProvider(remoteProfile)
remote.login(remoteProfile.id, credentials)
val result = coordinator.switchProvider()
assertTrue(result is DomainResult.Success)
assertEquals(remoteProfile.id, remote.activeProfileAtLogout)
assertNull(profiles.activeProfile())
}
@Test
fun validateActiveSessionDoesNotRelogin() = runTest {
val remote = CountingSessionGateway()
val credentialsStore = CountingCredentialStore()
val profiles = FakeProviderProfileStore()
val coordinator = SessionCoordinator(
profileStore = profiles,
credentialStore = credentialsStore,
remoteGateway = remote,
localGateway = FakeLocalSessionGateway(),
)
profiles.saveProfile(remoteProfile)
profiles.setActiveProfile(remoteProfile.id)
remote.setValidationResult(
remoteProfile.id,
DomainResult.Success(SessionValidity.INVALID),
)
val result = coordinator.validateActiveSession()
assertEquals(DomainResult.Success(SessionValidity.INVALID), result)
assertEquals(0, remote.loginCalls)
assertEquals(0, credentialsStore.loadCalls)
}
@Test
fun authenticationFailureDuringRecoveryClearsSavedCredential() = runTest {
val fixture = Fixture()
@@ -189,6 +285,18 @@ class SessionCoordinatorTest {
assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled)
}
@Test
fun endActiveSessionClearsLocalActiveProfile() = runTest {
val fixture = Fixture()
fixture.coordinator.selectProvider(localProfile)
val result = fixture.coordinator.endActiveSession()
assertTrue(result is DomainResult.Success)
assertNull(fixture.profiles.activeProfile())
assertNull(fixture.local.observeSession(localProfile.id).first())
}
private class Fixture {
val profiles = FakeProviderProfileStore()
val credentials = FakeCredentialStore()
@@ -197,6 +305,19 @@ class SessionCoordinatorTest {
val coordinator = SessionCoordinator(profiles, credentials, remote, local)
}
private class RecordingSessionGateway(
private val profileStore: FakeProviderProfileStore,
private val delegate: FakeSessionGateway = FakeSessionGateway(),
) : SessionGateway by delegate {
var activeProfileAtLogout: ProviderProfileId? = null
private set
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> {
activeProfileAtLogout = profileStore.activeProfile()?.id
return delegate.logout(profileId)
}
}
private class CountingSessionGateway(
private val delegate: FakeSessionGateway = FakeSessionGateway(),
) : SessionGateway by delegate {
@@ -214,6 +335,13 @@ class SessionCoordinatorTest {
fun failLogin(profileId: ProviderProfileId, error: DomainError) {
delegate.failLogin(profileId, error)
}
fun setValidationResult(
profileId: ProviderProfileId,
result: DomainResult<SessionValidity>,
) {
delegate.setValidationResult(profileId, result)
}
}
private class CountingCredentialStore(