Implement Android Home activity and HTTPS synchronization (#97)
This commit is contained in:
1 parent
8c9341f214
commit
5c933a99a3
29 files changed
+2008
-110
No files matched your search
@@ -0,0 +1,281 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.HomeAction
|
||||
import de.rfc1437.ironstorage.core.HomeActionKind
|
||||
import de.rfc1437.ironstorage.core.HomeFreshness
|
||||
import de.rfc1437.ironstorage.core.HomeRepository
|
||||
import de.rfc1437.ironstorage.core.ShellIcon
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(repository: HomeRepository, modifier: Modifier = Modifier) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
var committing by remember { mutableStateOf(false) }
|
||||
var message by remember { mutableStateOf("") }
|
||||
DisposableEffect(repository) {
|
||||
repository.enter()
|
||||
onDispose { repository.leave() }
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
committing = false
|
||||
message = ""
|
||||
repository.leave()
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { repository.enter() }
|
||||
fun perform(action: HomeActionKind) {
|
||||
if (action == HomeActionKind.Commit) {
|
||||
committing = true
|
||||
} else {
|
||||
repository.perform(action)
|
||||
}
|
||||
}
|
||||
Column(modifier) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(enabled = !state.busy, onClick = repository::refresh) {
|
||||
Text(stringResource(R.string.home_refresh))
|
||||
}
|
||||
if (state.busy) {
|
||||
TextButton(onClick = repository::cancel) {
|
||||
Text(stringResource(R.string.home_cancel))
|
||||
}
|
||||
Text(
|
||||
state.progress?.title ?: stringResource(R.string.home_loading),
|
||||
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }
|
||||
)
|
||||
}
|
||||
}
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.busy,
|
||||
onRefresh = { if (!state.busy) repository.refresh() },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.home_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.semantics { heading() }
|
||||
)
|
||||
}
|
||||
if (state.busy) {
|
||||
item {
|
||||
Column(Modifier.semantics { liveRegion = LiveRegionMode.Polite }) {
|
||||
CircularProgressIndicator()
|
||||
state.progress?.let {
|
||||
Text(it.detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.failure?.let { failure ->
|
||||
item {
|
||||
Text(failure.title, color = MaterialTheme.colorScheme.error)
|
||||
Text(failure.detail)
|
||||
TextButton(enabled = !state.busy, onClick = repository::reload) {
|
||||
Text(stringResource(R.string.home_reload))
|
||||
}
|
||||
}
|
||||
}
|
||||
val page = state.page
|
||||
if (page != null) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(
|
||||
when (page.freshness) {
|
||||
HomeFreshness.NeverRefreshed -> R.string.home_never
|
||||
HomeFreshness.Cached -> R.string.home_cached
|
||||
HomeFreshness.Current -> R.string.home_current
|
||||
}
|
||||
)
|
||||
)
|
||||
page.refreshedAt?.let { timestamp ->
|
||||
val formatted = DateUtils.formatDateTime(
|
||||
LocalContext.current,
|
||||
timestamp * 1000,
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME
|
||||
)
|
||||
Text(stringResource(R.string.home_checked, formatted))
|
||||
}
|
||||
page.notice?.let {
|
||||
Text(it.title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(it.detail)
|
||||
}
|
||||
}
|
||||
items(page.summaries, key = { it.id }) { row ->
|
||||
Column {
|
||||
ListItem(
|
||||
headlineContent = { Text(row.title) },
|
||||
supportingContent = { Text(row.detail) },
|
||||
leadingContent = { HomeIcon(row.icon) }
|
||||
)
|
||||
HomeActions(row.actions, state.busy, ::perform)
|
||||
}
|
||||
}
|
||||
if (page.activityKnown) {
|
||||
listOf(
|
||||
Triple(R.string.home_incoming, page.incomingTotal, page.incoming),
|
||||
Triple(R.string.home_outgoing, page.outgoingTotal, page.outgoing)
|
||||
).forEach { (label, total, commits) ->
|
||||
item(key = "activity:$label") {
|
||||
Text(
|
||||
stringResource(label, total.toString()),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.semantics { heading() }
|
||||
)
|
||||
if (total == 0u) Text(stringResource(R.string.home_no_activity))
|
||||
}
|
||||
commits.forEach { commit ->
|
||||
item(key = "$label:${commit.id}") {
|
||||
Text(commit.title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(commit.detail)
|
||||
val time = DateUtils.formatDateTime(
|
||||
LocalContext.current,
|
||||
commit.timestamp * 1000,
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME
|
||||
)
|
||||
Text(time)
|
||||
HomeActions(commit.actions, state.busy, ::perform)
|
||||
}
|
||||
items(
|
||||
commit.changes,
|
||||
key = { "$label:${commit.id}:${it.id}" }
|
||||
) { change ->
|
||||
ListItem(
|
||||
headlineContent = { Text(change.title) },
|
||||
supportingContent = { Text(change.detail) },
|
||||
leadingContent = { HomeIcon(change.icon) }
|
||||
)
|
||||
}
|
||||
}
|
||||
if (total > commits.size.toUInt()) {
|
||||
item {
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.home_bounded,
|
||||
commits.size,
|
||||
commits.size
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (committing) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
committing = false
|
||||
message = ""
|
||||
},
|
||||
title = { Text(stringResource(R.string.home_commit_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(state.name)
|
||||
Text(state.email)
|
||||
OutlinedTextField(
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
label = { Text(stringResource(R.string.home_commit_message)) }
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = !state.busy, onClick = {
|
||||
val submitted = message
|
||||
message = ""
|
||||
committing = false
|
||||
repository.perform(HomeActionKind.Commit, submitted)
|
||||
}) { Text(stringResource(R.string.home_commit_submit)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
committing = false
|
||||
message = ""
|
||||
}) {
|
||||
Text(stringResource(R.string.home_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeActions(
|
||||
actions: List<HomeAction>,
|
||||
busy: Boolean,
|
||||
perform: (HomeActionKind) -> Unit
|
||||
) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
actions.forEach { action ->
|
||||
TextButton(enabled = !busy, onClick = { perform(action.kind) }) {
|
||||
HomeIcon(action.icon)
|
||||
Text(action.title, Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeIcon(icon: ShellIcon) {
|
||||
Icon(
|
||||
painterResource(
|
||||
when (icon) {
|
||||
ShellIcon.Key -> R.drawable.ic_key
|
||||
ShellIcon.Warning -> R.drawable.ic_warning
|
||||
ShellIcon.Incoming -> R.drawable.ic_incoming
|
||||
ShellIcon.Outgoing -> R.drawable.ic_outgoing
|
||||
ShellIcon.Success -> R.drawable.ic_success
|
||||
ShellIcon.Refresh, ShellIcon.Sync -> R.drawable.ic_refresh
|
||||
ShellIcon.Branch, ShellIcon.Merge -> R.drawable.ic_branch
|
||||
ShellIcon.Protected -> R.drawable.ic_protected
|
||||
ShellIcon.History -> R.drawable.ic_history
|
||||
ShellIcon.Repository -> R.drawable.ic_folder
|
||||
ShellIcon.Recipients -> R.drawable.ic_settings
|
||||
ShellIcon.Signature, ShellIcon.File -> R.drawable.ic_file
|
||||
else -> error("Invalid Rust Home icon")
|
||||
}
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
@@ -53,10 +53,13 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
val browsing = !snapshot.fixture && snapshot.nativeFailure == null &&
|
||||
snapshot.storageRecovery == null && snapshot.state != ShellState.Empty &&
|
||||
val ready = !snapshot.fixture && snapshot.nativeFailure == null &&
|
||||
snapshot.storageRecovery == null &&
|
||||
(snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked)
|
||||
val browsing = ready &&
|
||||
snapshot.selectedTab == ShellTab.Passwords
|
||||
if (!browsing) {
|
||||
val home = ready && snapshot.selectedTab == ShellTab.Home
|
||||
if (!browsing && !home) {
|
||||
Text(
|
||||
stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
@@ -92,6 +95,8 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
} else if (browsing) {
|
||||
LaunchedEffect(viewModel) { viewModel.openPasswords() }
|
||||
PasswordScreen(viewModel.passwords, Modifier.weight(1f))
|
||||
} else if (home) {
|
||||
HomeScreen(viewModel.home, Modifier.weight(1f))
|
||||
} else {
|
||||
Text(
|
||||
title,
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.rfc1437.ironstorage.core.HomeRepository
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.ShellRepository
|
||||
@@ -16,6 +17,7 @@ class ShellViewModel @Inject constructor(
|
||||
private val repository: ShellRepository,
|
||||
val onboarding: OnboardingRepository,
|
||||
val passwords: PasswordRepository,
|
||||
val home: HomeRepository,
|
||||
private val saved: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
val snapshot = repository.snapshot
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M6,2a3,3 0,1 0,0 6v8a3,3 0,1 0,2 0V12h8a4,4 0,0 0,4 -4V7a3,3 0,1 0,-2 0v1a2,2 0,0 1,-2 2H8V8a3,3 0,0 0,-2 -6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M6,2h9l5,5v15H4V2z M14,4v5h5z M7,12v2h10v-2z M7,16v2h10v-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M13,3a9,9 0,1 1,-8.6 12h2.2A7,7 0,1 0,6.4 8H10v2H2V2h2v4A9,9 0,0 1,13 3z M12,7h2v5l3,2 -1,1.6 -4,-2.6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M11,3h2v12l4,-4 1.4,1.4L12,18 5.6,12.4 7,11l4,4z M5,20h14v2H5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M11,21h2V9l4,4 1.4,-1.4L12,6 5.6,11.6 7,13l4,-4z M5,2h14v2H5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M12,2L3,6v6c0,5 4,9 9,10 5,-1 9,-5 9,-10V6z M10,16l-4,-4 1.4,-1.4 2.6,2.6 6.6,-6.6L18,8z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -8,3.58 -8,8s3.58,8 8,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,.69 4.22,1.78L13,11h7V4z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M9,16.2L4.8,12 3.4,13.4 9,19 21,7 19.6,5.6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M1,21h22L12,2 1,21z M13,18h-2v-2h2v2z M13,14h-2v-4h2v4z" />
|
||||
</vector>
|
||||
@@ -1,4 +1,23 @@
|
||||
<resources>
|
||||
<string name="home_title">Remote activity</string>
|
||||
<string name="home_loading">Loading Home</string>
|
||||
<string name="home_cancel">Cancel</string>
|
||||
<string name="home_refresh">Refresh and sync</string>
|
||||
<string name="home_reload">Reload status</string>
|
||||
<string name="home_never">Remote status has not been refreshed</string>
|
||||
<string name="home_cached">Cached activity</string>
|
||||
<string name="home_current">Current activity</string>
|
||||
<string name="home_checked">Last checked: %1$s</string>
|
||||
<string name="home_incoming">Incoming · %1$s</string>
|
||||
<string name="home_outgoing">Outgoing · %1$s</string>
|
||||
<string name="home_no_activity">No commits in this direction.</string>
|
||||
<plurals name="home_bounded">
|
||||
<item quantity="one">Showing the latest %1$d commit.</item>
|
||||
<item quantity="other">Showing the latest %1$d commits.</item>
|
||||
</plurals>
|
||||
<string name="home_commit_title">Commit password-store changes</string>
|
||||
<string name="home_commit_message">Commit message</string>
|
||||
<string name="home_commit_submit">Create commit</string>
|
||||
<string name="app_name">IronStorage</string>
|
||||
<string name="scaffold_title">No Password Store</string>
|
||||
<string name="scaffold_detail">Your password store has not been configured.</string>
|
||||
|
||||
Reference in new issue
Block a user