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:
+17
@@ -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,
|
||||
|
||||
+7
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+128
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user