Service list item

This commit is contained in:
Rafał Kobyłko 2023-02-11 18:13:04 +01:00
parent 3488c3bc3d
commit 53c0456663
37 changed files with 629 additions and 439 deletions

View File

@ -10,8 +10,8 @@ repositories {
}
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
dependencies {

View File

@ -27,8 +27,8 @@ internal fun Project.applyKotlinAndroid(
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
@ -37,7 +37,7 @@ internal fun Project.applyKotlinAndroid(
}
kotlinOptions {
jvmTarget = "11"
jvmTarget = "17"
freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn",

View File

@ -1,82 +1,69 @@
package com.twofasapp.designsystem.service
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.twofasapp.designsystem.TwIcons
import com.twofasapp.designsystem.TwTheme
import com.twofasapp.designsystem.common.TwIconButton
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Service(
state: ServiceState,
style: ServiceStyle,
isInEditMode: Boolean = false,
containerColor: Color = TwTheme.color.background,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
containerColor: Color = TwTheme.color.background,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
if (isInEditMode) {
Box(
modifier = modifier
.fillMaxWidth()
.clip(TwTheme.shape.roundedDefault)
.background(containerColor)
.combinedClickable(
onClick = { onClick() },
onLongClick = { onLongClick() },
)
.border(1.dp, TwTheme.color.surfaceVariant, TwTheme.shape.roundedDefault)
.padding(start = 21.dp)
) {
ServiceNoCode(
name = state.name,
info = state.info,
imageType = state.imageType,
iconLight = state.iconLight,
iconDark = state.iconDark,
labelText = state.labelText,
labelColor = state.labelColor,
imageSize = 36.dp,
when (style) {
ServiceStyle.Default -> {
ServiceDefault(
state = state,
containerColor = containerColor,
modifier = Modifier,
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
)
}
ServiceStyle.Modal -> {
ServiceModal(
state = state,
containerColor = containerColor,
modifier = modifier,
)
}
ServiceStyle.Edit -> {
ServiceWithoutCode(
state = state,
imageSize = 36.dp,
showBadge = true,
containerColor = containerColor,
modifier = modifier,
) {
TwIconButton(TwIcons.DragHandle, enabled = false)
}
}
} else {
when (style) {
ServiceStyle.Normal -> {
ServiceNormal(
state = state,
containerColor = containerColor,
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
)
}
ServiceStyle.Modal -> {
ServiceModal(
state = state,
containerColor = containerColor,
modifier = modifier,
)
}
ServiceStyle.Compact -> {}
}
ServiceStyle.Compact -> {}
}
}
}
internal val ServicePreview = ServiceState(
name = "Service Name",
info = "Additional Info",
code = "123456",
nextCode = "789987",
timer = 10,
progress = .33f,
imageType = ServiceImageType.Label,
iconLight = "",
iconDark = "",
labelText = "2F",
labelColor = Color.Red,
badgeColor = Color.Red,
)

View File

@ -0,0 +1,82 @@
package com.twofasapp.designsystem.service
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.twofasapp.designsystem.TwTheme
import com.twofasapp.designsystem.service.component.ServiceBadge
import com.twofasapp.designsystem.service.component.ServiceCode
import com.twofasapp.designsystem.service.component.ServiceImage
import com.twofasapp.designsystem.service.component.ServiceInfo
import com.twofasapp.designsystem.service.component.ServiceName
import com.twofasapp.designsystem.service.component.ServiceTimer
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun ServiceDefault(
state: ServiceState,
modifier: Modifier = Modifier,
containerColor: Color = TwTheme.color.background,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(128.dp)
.background(containerColor)
.combinedClickable(
enabled = onClick != null,
onClick = { onClick?.invoke() },
onLongClick = { onLongClick?.invoke() },
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
ServiceBadge(
color = state.badgeColor,
)
ServiceImage(
type = state.imageType,
iconLight = state.iconLight,
iconDark = state.iconDark,
labelText = state.labelText,
labelColor = state.labelColor,
)
Column(modifier = Modifier.weight(1f)) {
ServiceName(state.name)
ServiceInfo(state.info)
ServiceCode(
code = state.code,
nextCode = state.nextCode,
)
}
ServiceTimer(
timer = state.timer,
progress = state.progress,
modifier = Modifier.padding(end = 12.dp),
)
}
}
@Preview
@Composable
private fun Preview() {
ServiceDefault(state = ServicePreview)
}

View File

@ -1,13 +1,11 @@
package com.twofasapp.designsystem.service
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -24,8 +22,8 @@ import com.twofasapp.designsystem.service.component.ServiceTimer
@Composable
internal fun ServiceModal(
state: ServiceState,
containerColor: Color = TwTheme.color.background,
modifier: Modifier = Modifier,
containerColor: Color = TwTheme.color.background,
) {
Column(
modifier = modifier
@ -46,13 +44,11 @@ internal fun ServiceModal(
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically
modifier = Modifier.padding(top = 16.dp, start = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(Modifier.width(16.dp))
ServiceImage(
type = state.imageType,
iconLight = state.iconLight,
@ -61,47 +57,23 @@ internal fun ServiceModal(
labelColor = state.labelColor
)
Spacer(Modifier.width(16.dp))
ServiceCode(
code = state.code,
nextCode = state.nextCode,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
ServiceTimer(
timer = state.timer,
progress = state.progress
progress = state.progress,
modifier = Modifier.padding(end = 12.dp),
)
Spacer(Modifier.width(12.dp))
}
}
}
@Preview
@Composable
private fun Preview() {
Service(
state = ServiceState(
name = "Service Name",
info = "Additional Info",
code = "123456",
nextCode = "456789",
timer = 15,
progress = .5f,
imageType = ServiceImageType.Label,
iconLight = "Hollie",
iconDark = "Louisa",
labelText = "2F",
labelColor = Color.Red,
badgeColor = Color.Red,
),
style = ServiceStyle.Modal,
modifier = Modifier.fillMaxWidth()
)
ServiceModal(state = ServicePreview)
}

View File

@ -1,77 +0,0 @@
package com.twofasapp.designsystem.service
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.twofasapp.designsystem.TwTheme
import com.twofasapp.designsystem.service.component.ServiceData
import com.twofasapp.designsystem.service.component.ServiceImage
@Composable
fun ServiceNoCode(
name: String,
info: String? = null,
imageType: ServiceImageType,
iconLight: String,
iconDark: String,
labelText: String?,
labelColor: Color,
imageSize: Dp = 36.dp,
containerColor: Color = TwTheme.color.background,
modifier: Modifier = Modifier,
endContent: @Composable () -> Unit = {},
) {
Row(
modifier = modifier
.height(64.dp)
.background(containerColor),
verticalAlignment = Alignment.CenterVertically
) {
ServiceImage(
type = imageType,
iconLight = iconLight,
iconDark = iconDark,
labelText = labelText,
labelColor = labelColor,
size = imageSize,
)
Spacer(Modifier.width(16.dp))
ServiceData(
name = name,
info = info,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(16.dp))
endContent()
}
}
@Preview
@Composable
private fun Preview() {
ServiceNoCode(
name = "Service Name",
info = "Additional Info",
imageType = ServiceImageType.Label,
iconLight = "Hollie",
iconDark = "Louisa",
labelText = "2F",
labelColor = Color.Red,
modifier = Modifier.fillMaxWidth(),
endContent = {}
)
}

View File

@ -1,104 +0,0 @@
package com.twofasapp.designsystem.service
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.twofasapp.designsystem.TwTheme
import com.twofasapp.designsystem.service.component.ServiceBadge
import com.twofasapp.designsystem.service.component.ServiceData
import com.twofasapp.designsystem.service.component.ServiceImage
import com.twofasapp.designsystem.service.component.ServiceTimer
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun ServiceNormal(
state: ServiceState,
containerColor: Color = TwTheme.color.background,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(128.dp)
.clip(TwTheme.shape.roundedDefault)
.background(containerColor)
.combinedClickable(
onClick = { onClick() },
onLongClick = { onLongClick() },
)
.border(1.dp, TwTheme.color.surfaceVariant, TwTheme.shape.roundedDefault),
verticalAlignment = Alignment.CenterVertically
) {
ServiceBadge(color = state.badgeColor)
Spacer(Modifier.width(16.dp))
ServiceImage(
type = state.imageType,
iconLight = state.iconLight,
iconDark = state.iconDark,
labelText = state.labelText,
labelColor = state.labelColor
)
Spacer(Modifier.width(16.dp))
ServiceData(
name = state.name,
info = state.info,
code = state.code,
nextCode = state.code,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(16.dp))
ServiceTimer(
timer = state.timer,
progress = state.progress
)
Spacer(Modifier.width(12.dp))
}
}
@Preview
@Composable
private fun Preview() {
Service(
state = ServiceState(
name = "Service Name",
info = "Additional Info",
code = "123456",
nextCode = "456789",
timer = 15,
progress = .5f,
imageType = ServiceImageType.Label,
iconLight = "Hollie",
iconDark = "Louisa",
labelText = "2F",
labelColor = Color.Red,
badgeColor = Color.Red,
),
style = ServiceStyle.Normal,
modifier = Modifier.fillMaxWidth()
)
}

View File

@ -1,7 +1,8 @@
package com.twofasapp.designsystem.service
enum class ServiceStyle {
Normal,
Modal,
Default,
Compact,
Edit,
Modal,
}

View File

@ -0,0 +1,70 @@
package com.twofasapp.designsystem.service
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.twofasapp.designsystem.TwTheme
import com.twofasapp.designsystem.service.component.ServiceBadge
import com.twofasapp.designsystem.service.component.ServiceImage
import com.twofasapp.designsystem.service.component.ServiceInfo
import com.twofasapp.designsystem.service.component.ServiceName
@Composable
fun ServiceWithoutCode(
state: ServiceState,
modifier: Modifier = Modifier,
imageSize: Dp = 36.dp,
showBadge: Boolean = false,
containerColor: Color = TwTheme.color.background,
content: @Composable () -> Unit = {},
) {
Row(
modifier = modifier
.height(64.dp)
.background(containerColor),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
if (showBadge) {
ServiceBadge(
color = state.badgeColor,
)
}
ServiceImage(
type = state.imageType,
iconLight = state.iconLight,
iconDark = state.iconDark,
labelText = state.labelText,
labelColor = state.labelColor,
size = imageSize,
)
Column(Modifier.weight(1f)) {
ServiceName(text = state.name)
ServiceInfo(text = state.info)
}
content()
}
}
@Preview
@Composable
private fun Preview() {
ServiceWithoutCode(
state = ServicePreview,
modifier = Modifier.fillMaxWidth(),
content = {}
)
}

View File

@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.twofasapp.designsystem.TwTheme
@Composable
fun ServiceBadge(color: Color) {
@ -17,4 +19,10 @@ fun ServiceBadge(color: Color) {
.width(5.dp)
.background(color)
)
}
@Preview
@Composable
private fun Preview() {
ServiceBadge(color = TwTheme.color.primary)
}

View File

@ -0,0 +1,41 @@
package com.twofasapp.designsystem.service.component
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.twofasapp.designsystem.TwTheme
@Composable
fun ServiceCode(
code: String,
nextCode: String,
modifier: Modifier = Modifier,
) {
Text(
text = code.formatCode(),
style = TwTheme.typo.h1,
color = TwTheme.color.onSurfacePrimary,
maxLines = 1,
overflow = TextOverflow.Visible,
modifier = modifier,
)
}
private fun String.formatCode(): String {
if (isEmpty()) return ""
return when (this.length) {
6 -> "${take(3)} ${takeLast(3)}"
7 -> "${take(4)} ${takeLast(3)}"
8 -> "${take(4)} ${takeLast(4)}"
else -> this
}
}
@Preview
@Composable
private fun Preview() {
ServiceCode(code = "123456", nextCode = "789987")
}

View File

@ -31,68 +31,9 @@ fun ServiceData(
}
}
@Composable
fun ServiceName(
text: String,
style: TextStyle = TwTheme.typo.body3,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = style,
color = TwTheme.color.onSurfacePrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
}
@Composable
fun ServiceInfo(
text: String?,
style: TextStyle = TwTheme.typo.body3,
modifier: Modifier = Modifier
) {
if (text.isNullOrEmpty().not()) {
Text(
text = text!!,
style = style,
color = TwTheme.color.onSurfaceSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
} else {
Spacer(Modifier.width(8.dp))
}
}
@Composable
fun ServiceCode(
code: String,
nextCode: String,
modifier: Modifier = Modifier,
) {
Text(
text = code.formatCode(),
style = TwTheme.typo.h1,
color = TwTheme.color.onSurfacePrimary,
maxLines = 1,
overflow = TextOverflow.Visible,
modifier = modifier,
)
}
private fun String.formatCode(): String {
if (isEmpty()) return ""
return when (this.length) {
6 -> "${take(3)} ${takeLast(3)}"
7 -> "${take(4)} ${takeLast(3)}"
8 -> "${take(4)} ${takeLast(4)}"
else -> this
}
}
@Preview
@Composable

View File

@ -26,8 +26,8 @@ fun ServiceImage(
iconDark: String,
labelText: String?,
labelColor: Color,
size: Dp = 36.dp,
modifier: Modifier = Modifier,
size: Dp = 36.dp,
) {
Box(modifier = modifier) {
when (type) {

View File

@ -0,0 +1,38 @@
package com.twofasapp.designsystem.service.component
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.twofasapp.designsystem.TwTheme
@Composable
fun ServiceInfo(
text: String?,
modifier: Modifier = Modifier,
style: TextStyle = TwTheme.typo.body3
) {
if (text.isNullOrEmpty().not()) {
Text(
text = text!!,
style = style,
color = TwTheme.color.onSurfaceSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
} else {
Spacer(Modifier.width(8.dp))
}
}
@Preview
@Composable
private fun Preview() {
ServiceInfo(text = "Info")
}

View File

@ -0,0 +1,31 @@
package com.twofasapp.designsystem.service.component
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.twofasapp.designsystem.TwTheme
@Composable
fun ServiceName(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = TwTheme.typo.body3
) {
Text(
text = text,
style = style,
color = TwTheme.color.onSurfacePrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
}
@Preview
@Composable
private fun Preview() {
ServiceName(text = "Name")
}

View File

@ -1,12 +1,16 @@
package com.twofasapp.designsystem.service.component
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.twofasapp.designsystem.TwTheme
@ -16,14 +20,33 @@ fun ServiceTimer(
progress: Float,
modifier: Modifier = Modifier,
) {
Box(modifier, contentAlignment = Alignment.Center) {
val progressFraction by animateFloatAsState(
targetValue = progress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec
)
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
progress = progress,
progress = progressFraction,
color = TwTheme.color.onSurfacePrimary,
strokeWidth = 2.dp,
modifier = Modifier.size(32.dp),
)
Text(text = timer.toString(), style = TwTheme.typo.caption, color = TwTheme.color.onSurfacePrimary)
Text(
text = timer.toString(),
style = TwTheme.typo.caption,
color = TwTheme.color.onSurfacePrimary
)
}
}
@Preview
@Composable
private fun Preview() {
ServiceTimer(timer = 10, progress = 0.33f)
}

View File

@ -1,31 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<plurals name="past_duration_seconds" tools:ignore="MissingDefaultResource">
<item quantity="other">moments ago</item>
</plurals>
<plurals name="past_duration_minutes" tools:ignore="MissingDefaultResource">
<item quantity="one">%d minute ago</item>
<item quantity="other">%d minutes ago</item>
</plurals>
<plurals name="past_duration_hours" tools:ignore="MissingDefaultResource">
<item quantity="one">%d hour ago</item>
<item quantity="other">%d hours ago</item>
</plurals>
<plurals name="past_duration_days" tools:ignore="MissingDefaultResource">
<item quantity="one">%d day ago</item>
<item quantity="other">%d days ago</item>
</plurals>
<plurals name="past_duration_weeks" tools:ignore="MissingDefaultResource">
<item quantity="one">%d week ago</item>
<item quantity="other">%d weeks ago</item>
</plurals>
<plurals name="past_duration_months" tools:ignore="MissingDefaultResource">
<item quantity="one">%d month ago</item>
<item quantity="other">%d months ago</item>
</plurals>
</resources>

View File

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Loco xml export: Android string resources
Project: 2FAS iOS
Project: 2FAS App
Release: Working copy
Locale: en, English
Exported by: rafakob
Exported at: Wed, 28 Dec 2022 11:53:36 +0100
Exported at: Sat, 11 Feb 2023 08:20:15 -0800
-->
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<!-- InfoPlist.strings
@ -160,7 +160,7 @@
<string name="tokens__choose_method">Choose method</string>
<string name="tokens__duplicated_private_key">Duplicated Service Key</string>
<!-- tokens__incorrect_private_key -->
<string name="tokens__incorrect_service_key">Incorrect service key (only numbers 2 to 7, letters)</string>
<string name="tokens__incorrect_service_key">Incorrect service key (only numbers 2 to 7, letters), max. 512 chars long</string>
<string name="tokens__try_again">OK, let\'s try again</string>
<!-- Original: To capture the QR code
tokens__point_right_2fa_code -->
@ -646,4 +646,57 @@
<string name="settings__recommendation">Check out this awesome two-factor authentication app from 2FAS: https://2fas.com</string>
<string name="settings__acknowledgements">Acknowledgements</string>
<string name="introduction__import_external_app">Import from external app</string>
<plurals name="past_duration_seconds">
<item quantity="one">moments ago</item>
<item quantity="other">moments ago</item>
</plurals>
<plurals name="past_duration_minutes">
<item quantity="one">%d minute ago</item>
<item quantity="other">%d minutes ago</item>
</plurals>
<plurals name="past_duration_hours">
<item quantity="one">%d hour ago</item>
<item quantity="other">%d hours ago</item>
</plurals>
<plurals name="past_duration_days">
<item quantity="one">%d day ago</item>
<item quantity="other">%d days ago</item>
</plurals>
<plurals name="past_duration_weeks">
<item quantity="one">%d week ago</item>
<item quantity="other">%d weeks ago</item>
</plurals>
<plurals name="past_duration_months">
<item quantity="one">%d month ago</item>
<item quantity="other">%d months ago</item>
</plurals>
<string name="commons__unknown_error">Unknown error occurred! Try again!</string>
<string name="backup__export_result_success">File successfully saved!</string>
<string name="backup__share_result_failure">Could not share file!</string>
<string name="backup__enter_password_dialog_title">Type in password</string>
<string name="backup__remove_password_msg">Enter the backup password to proceed with remove.</string>
<string name="backup__revoke_google_access_msg">Enter the backup password to proceed with revoking access to Google.</string>
<string name="backup__synchronization_settings">Synchronization settings</string>
<string name="backup__local_file_title">Local file</string>
<string name="backup__drive_title">Google Drive sync</string>
<string name="backup__delete_file_title">Delete your backup file from Google Drive?</string>
<string name="backup__delete_file_msg">Google Sync will be disabled. Your tokens will remain locally, but the 2FAS app will be logged out from your Google Account on this and your synced other devices.</string>
<string name="backup__sync_status_waiting">Waiting for sync…</string>
<string name="backup__sync_status_progress">Syncing…</string>
<string name="import_backup_msg1_encrypted">You are going to import an encrypted backup file.</string>
<string name="externalimport__choose_json_cta">Choose JSON file</string>
<string name="externalimport__aegis_msg">Export your accounts from Aegis to an unencrypted JSON file and upload it using the \"Choose JSON file\" button. Remember to remove the file after a successful import.</string>
<string name="externalimport__raivo_msg">Use the \"Export OTPs to ZIP archive\" option in Raivo\'s Settings, save a ZIP file, extract it and import the JSON file using the \"Choose JSON file\" button.</string>
<string name="externalimport__no_tokens_msg">However, there are no services that could be imported.</string>
<string name="commons__try_again">Try again</string>
<string name="commons__proceed">Proceed</string>
<string name="externalimport__ga_title">Importing 2FA tokens from Google Authenticator app</string>
<string name="externalimport__aegis_title">Importing 2FA tokens from Aegis app</string>
<string name="externalimport__raivo_title">Importing 2FA tokens from Raivo app</string>
<string name="externalimport__ga_success_msg">This QR code allows importing tokens from Google Authenticator.</string>
<string name="externalimport__aegis_success_msg">This JSON file allows importing tokens from Aegis.</string>
<string name="externalimport__raivo_success_msg">This JSON file allows importing tokens from Raivo.</string>
<string name="externalimport__read_error">Could not read any tokens. Try to select a different file.</string>
<string name="settings__gd_sync_info">Google Drive sync info</string>
<string name="settings__gd_sync_disable_confirm">Are you sure? Without Google Drive sync, you won\'t be able to restore your tokens if you lose or reset your phone!</string>
</resources>

View File

@ -15,7 +15,7 @@ interface Preferences {
fun putInt(key: String, value: Int)
fun putFloat(key: String, value: Float)
fun <T> observe(key: String, default: T): Flow<T>
fun <T> observe(key: String, default: T?): Flow<T?>
fun delete(key: String)
}

View File

@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import timber.log.Timber
internal class PreferencesDelegate(
@ -27,8 +28,9 @@ internal class PreferencesDelegate(
}
}
override fun <T> observe(key: String, default: T): Flow<T> {
override fun <T> observe(key: String, default: T?): Flow<T?> {
return flow
.onStart { emit(key) }
.filter { it == key || it == null }
.map {
@Suppress("UNCHECKED_CAST")
@ -38,7 +40,7 @@ internal class PreferencesDelegate(
is Long -> getLong(key) as T?
is Boolean -> getBoolean(key) as T?
is Float -> getFloat(key) as T?
else -> throw IllegalArgumentException("Unsupported preference flow type")
else -> null
} ?: default
}
.conflate()

View File

@ -1,11 +1,16 @@
package com.twofasapp.data.services
import com.twofasapp.data.services.domain.Group
import com.twofasapp.data.services.local.GroupsLocalSource
import com.twofasapp.data.services.mapper.asDomain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
internal class GroupsRepositoryImpl(
private val local: GroupsLocalSource,
) : GroupsRepository {
internal class GroupsRepositoryImpl : GroupsRepository {
override fun observeGroups(): Flow<List<Group>> {
return flow { }
return local.observeGroups().map { it.list.map { it.asDomain() } }
}
}

View File

@ -4,6 +4,7 @@ import com.twofasapp.data.services.GroupsRepository
import com.twofasapp.data.services.GroupsRepositoryImpl
import com.twofasapp.data.services.ServicesRepository
import com.twofasapp.data.services.ServicesRepositoryImpl
import com.twofasapp.data.services.local.GroupsLocalSource
import com.twofasapp.data.services.local.ServicesLocalSource
import com.twofasapp.data.services.otp.ServiceCodeGenerator
import com.twofasapp.di.KoinModule
@ -17,6 +18,7 @@ class DataServicesModule : KoinModule {
singleOf(::ServicesLocalSource)
singleOf(::ServicesRepositoryImpl) { bind<ServicesRepository>() }
singleOf(::GroupsLocalSource)
singleOf(::GroupsRepositoryImpl) { bind<GroupsRepository>() }
}
}

View File

@ -1,7 +1,7 @@
package com.twofasapp.data.services.domain
data class Group(
val id: String,
val id: String?,
val name: String,
val isExpanded: Boolean = true,
val updatedAt: Long = 0,

View File

@ -10,7 +10,7 @@ data class Service(
val period: Int?,
val digits: Int?,
val algorithm: Algorithm?,
val groupId: String? = null,
val imageType: ImageType = ImageType.IconCollection,
val iconCollectionId: String,
val iconLight: String,
@ -25,7 +25,6 @@ data class Service(
// val otp: Otp = Otp(),
////
// val groupId: String? = null,
// val assignedDomains: List<String> = emptyList(),
// val isDeleted: Boolean = false,
//// val backupSyncStatus: BackupSyncStatus = BackupSyncStatus.NOT_SYNCED,
@ -89,7 +88,4 @@ data class Service(
// )
}

View File

@ -1,11 +1,45 @@
package com.twofasapp.data.services.local
import com.twofasapp.data.services.local.model.GroupEntity
import com.twofasapp.data.services.local.model.GroupsEntity
import com.twofasapp.storage.PlainPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
internal class GroupsLocalSource(
private val json: Json,
private val preferences: PlainPreferences,
) {
companion object {
private const val KeyGroups = "groups"
}
fun observeGroups(): Flow<GroupsEntity> {
return preferences.observe<String>(KeyGroups, null).map { value ->
value?.let {
json.decodeFromString(value)
} ?: GroupsEntity()
}
}
fun insertGroup(groupsEntity: GroupEntity) {
val local = getGroups()
preferences.putString(
KeyGroups, json.encodeToString(
local.copy(
list = local.list.plus(groupsEntity)
)
)
)
}
private fun getGroups(): GroupsEntity {
return preferences.getString(KeyGroups)?.let {
json.decodeFromString(it)
} ?: GroupsEntity()
}
}

View File

@ -0,0 +1,32 @@
package com.twofasapp.data.services.local.model
import kotlinx.serialization.Serializable
@Serializable
internal data class GroupEntity(
val id: String?,
val name: String,
val isExpanded: Boolean = true,
val updatedAt: Long = 0,
val backupSyncStatus: String,
) {
// companion object {
// fun generateNew(name: String) = GroupEntity(
// id = UUID.randomUUID().toString(),
// name = name,
// )
// }
//
// fun isContentEqualTo(group: GroupEntity) =
// name == group.name &&
// id == group.id &&
// isExpanded == group.isExpanded
//
// fun toRemote() = RemoteGroup(
// id = id!!,
// name = name,
// isExpanded = isExpanded,
// updatedAt = updatedAt
// )
}

View File

@ -0,0 +1,9 @@
package com.twofasapp.data.services.local.model
import kotlinx.serialization.Serializable
@Serializable
internal data class GroupsEntity(
val list: List<GroupEntity> = emptyList(),
val isDefaultGroupExpanded: Boolean = true,
)

View File

@ -0,0 +1,9 @@
package com.twofasapp.data.services.mapper
import com.twofasapp.data.services.domain.Group
import com.twofasapp.data.services.local.model.GroupEntity
internal fun GroupEntity.asDomain() = Group(
id = id,
name = name,
)

View File

@ -18,6 +18,7 @@ internal fun ServiceEntity.asDomain(): Service {
period = otpPeriod,
digits = otpDigits,
algorithm = otpAlgorithm?.let { Service.Algorithm.valueOf(it) },
groupId = groupId,
imageType = selectedImageType?.let {
when (it) {
"Brand" -> Service.ImageType.IconCollection

View File

@ -17,7 +17,7 @@ internal fun AegisRoute(
ImportFileScaffold(
title = stringResource(id = R.string.externalimport_aegis),
image = painterResource(id = R.drawable.ic_import_aegis),
description = { ImportDescription(text = "Export your accounts from Aegis to unencrypted JSON file and upload it using \"Choose JSON file\" button. Remember to remove the file after successful import.") }
description = { ImportDescription(text = stringResource(id = R.string.externalimport__aegis_msg)) }
) {
ImportFilePickerButton(
text = "Choose JSON file",

View File

@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.Divider
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
@ -133,20 +134,21 @@ private fun ServicesScreen(
is ModalType.FocusService -> {
val id = (modalType as ModalType.FocusService).id
FocusServiceModal(
serviceState = uiState.getService(id).asState(),
onEditClick = {
listener.openService(activity, (modalType as ModalType.FocusService).id)
scope.launch { modalState.hide() }
},
onCopyClick = {
activity.copyToClipboard(
uiState.getService(id).code?.current.toString()
)
scope.launch { modalState.hide() }
}
)
uiState.getService(id)?.asState()?.let {
FocusServiceModal(
serviceState = it,
onEditClick = {
scope.launch { modalState.hide() }
listener.openService(activity, (modalType as ModalType.FocusService).id)
},
onCopyClick = {
scope.launch { modalState.hide() }
activity.copyToClipboard(
uiState.getService(id)?.code?.current.toString()
)
}
)
}
}
}
}
@ -206,6 +208,8 @@ private fun ServicesScreen(
items = uiState.services,
type = { ServicesListItem.Service(it.id) }
) { service ->
Divider(color = TwTheme.color.divider)
ReorderableItem(
state = reorderableState,
key = service.id,
@ -215,18 +219,18 @@ private fun ServicesScreen(
) { isDragging ->
Service(
state = service.asState(),
style = ServiceStyle.Normal,
isInEditMode = uiState.isInEditMode,
style = if (uiState.isInEditMode) ServiceStyle.Edit else ServiceStyle.Default,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.shadow(if (isDragging) 8.dp else 0.dp)
.run {
if (uiState.isInEditMode) {
detectReorderAfterLongPress(reorderableState)
} else {
this
}
},
.detectReorderAfterLongPress(reorderableState),
// .run {
// if (uiState.isInEditMode) {
// detectReorderAfterLongPress(reorderableState)
// } else {
// this
// }
// },
onClick = {
modalType = ModalType.FocusService(service.id)
scope.launch { modalState.show() }
@ -239,6 +243,8 @@ private fun ServicesScreen(
)
}
}
item { Divider(color = TwTheme.color.divider) }
}
}
}

View File

@ -9,8 +9,8 @@ data class ServicesUiState(
val isInEditMode: Boolean = false,
val events: List<ServicesStateEvent> = listOf(),
) {
fun getService(id: Long): Service {
return services.first { it.id == id }
fun getService(id: Long): Service? {
return services.firstOrNull() { it.id == id }
}
}

View File

@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.twofasapp.data.services.GroupsRepository
import com.twofasapp.data.services.ServicesRepository
import com.twofasapp.data.services.domain.Group
import com.twofasapp.data.services.domain.Service
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@ -24,8 +25,9 @@ internal class ServicesViewModel(
viewModelScope.launch {
combine(
servicesRepository.observeServicesTicker(),
groupsRepository.observeGroups(),
isInEditMode,
) { services, isInEditMode -> CombinedResult(services, isInEditMode) }.collect { result ->
) { services, groups, isInEditMode -> CombinedResult(services, groups, isInEditMode) }.collect { result ->
uiState.update {
it.copy(
@ -52,6 +54,7 @@ internal class ServicesViewModel(
data class CombinedResult(
val services: List<Service>,
val groups: List<Group>,
val isInEditMode: Boolean,
)
}

View File

@ -28,7 +28,7 @@ internal fun FocusServiceModal(
ModalList {
SettingsLink(title = "Edit", icon = TwIcons.Edit) { onEditClick() }
SettingsLink(title = "Copy code", icon = TwIcons.Copy) { onCopyClick() }
SettingsLink(title = "Copy token", icon = TwIcons.Copy) { onCopyClick() }
}
}
}

View File

@ -25,7 +25,8 @@ import com.twofasapp.designsystem.common.TwDropdownMenuItem
import com.twofasapp.designsystem.common.TwIconButton
import com.twofasapp.designsystem.common.TwTopAppBar
import com.twofasapp.designsystem.service.ServiceImageType
import com.twofasapp.designsystem.service.ServiceNoCode
import com.twofasapp.designsystem.service.ServiceState
import com.twofasapp.designsystem.service.ServiceWithoutCode
import com.twofasapp.locale.TwLocale
import org.koin.androidx.compose.koinViewModel
@ -74,22 +75,24 @@ private fun TrashScreen(
}
items(services, key = { it.id }) {
ServiceNoCode(
name = it.name,
info = it.info,
imageType = when (it.imageType) {
Service.ImageType.IconCollection -> ServiceImageType.Icon
Service.ImageType.Label -> ServiceImageType.Label
},
iconLight = it.iconLight,
iconDark = it.iconDark,
labelText = it.labelText,
labelColor = it.badgeColor.asState(),
ServiceWithoutCode(
state = ServiceState(
name = it.name,
info = it.info,
imageType = when (it.imageType) {
Service.ImageType.IconCollection -> ServiceImageType.Icon
Service.ImageType.Label -> ServiceImageType.Label
},
iconLight = it.iconLight,
iconDark = it.iconDark,
labelText = it.labelText,
labelColor = it.badgeColor.asState(),
),
imageSize = 32.dp,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 0.dp),
endContent = {
content = {
var expanded by rememberSaveable { mutableStateOf(false) }
TwDropdownMenu(

View File

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-rc-2-bin.zip

View File

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Loco xml export: Android string resources
Project: 2FAS iOS
Project: 2FAS App
Release: Working copy
Locale: en, English
Exported by: rafakob
Exported at: Wed, 28 Dec 2022 11:53:36 +0100
Exported at: Sat, 11 Feb 2023 08:20:15 -0800
-->
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<!-- InfoPlist.strings
@ -160,7 +160,7 @@
<string name="tokens__choose_method">Choose method</string>
<string name="tokens__duplicated_private_key">Duplicated Service Key</string>
<!-- tokens__incorrect_private_key -->
<string name="tokens__incorrect_service_key">Incorrect service key (only numbers 2 to 7, letters)</string>
<string name="tokens__incorrect_service_key">Incorrect service key (only numbers 2 to 7, letters), max. 512 chars long</string>
<string name="tokens__try_again">OK, let\'s try again</string>
<!-- Original: To capture the QR code
tokens__point_right_2fa_code -->
@ -646,4 +646,57 @@
<string name="settings__recommendation">Check out this awesome two-factor authentication app from 2FAS: https://2fas.com</string>
<string name="settings__acknowledgements">Acknowledgements</string>
<string name="introduction__import_external_app">Import from external app</string>
<plurals name="past_duration_seconds">
<item quantity="one">moments ago</item>
<item quantity="other">moments ago</item>
</plurals>
<plurals name="past_duration_minutes">
<item quantity="one">%d minute ago</item>
<item quantity="other">%d minutes ago</item>
</plurals>
<plurals name="past_duration_hours">
<item quantity="one">%d hour ago</item>
<item quantity="other">%d hours ago</item>
</plurals>
<plurals name="past_duration_days">
<item quantity="one">%d day ago</item>
<item quantity="other">%d days ago</item>
</plurals>
<plurals name="past_duration_weeks">
<item quantity="one">%d week ago</item>
<item quantity="other">%d weeks ago</item>
</plurals>
<plurals name="past_duration_months">
<item quantity="one">%d month ago</item>
<item quantity="other">%d months ago</item>
</plurals>
<string name="commons__unknown_error">Unknown error occurred! Try again!</string>
<string name="backup__export_result_success">File successfully saved!</string>
<string name="backup__share_result_failure">Could not share file!</string>
<string name="backup__enter_password_dialog_title">Type in password</string>
<string name="backup__remove_password_msg">Enter the backup password to proceed with remove.</string>
<string name="backup__revoke_google_access_msg">Enter the backup password to proceed with revoking access to Google.</string>
<string name="backup__synchronization_settings">Synchronization settings</string>
<string name="backup__local_file_title">Local file</string>
<string name="backup__drive_title">Google Drive sync</string>
<string name="backup__delete_file_title">Delete your backup file from Google Drive?</string>
<string name="backup__delete_file_msg">Google Sync will be disabled. Your tokens will remain locally, but the 2FAS app will be logged out from your Google Account on this and your synced other devices.</string>
<string name="backup__sync_status_waiting">Waiting for sync…</string>
<string name="backup__sync_status_progress">Syncing…</string>
<string name="import_backup_msg1_encrypted">You are going to import an encrypted backup file.</string>
<string name="externalimport__choose_json_cta">Choose JSON file</string>
<string name="externalimport__aegis_msg">Export your accounts from Aegis to an unencrypted JSON file and upload it using the \"Choose JSON file\" button. Remember to remove the file after a successful import.</string>
<string name="externalimport__raivo_msg">Use the \"Export OTPs to ZIP archive\" option in Raivo\'s Settings, save a ZIP file, extract it and import the JSON file using the \"Choose JSON file\" button.</string>
<string name="externalimport__no_tokens_msg">However, there are no services that could be imported.</string>
<string name="commons__try_again">Try again</string>
<string name="commons__proceed">Proceed</string>
<string name="externalimport__ga_title">Importing 2FA tokens from Google Authenticator app</string>
<string name="externalimport__aegis_title">Importing 2FA tokens from Aegis app</string>
<string name="externalimport__raivo_title">Importing 2FA tokens from Raivo app</string>
<string name="externalimport__ga_success_msg">This QR code allows importing tokens from Google Authenticator.</string>
<string name="externalimport__aegis_success_msg">This JSON file allows importing tokens from Aegis.</string>
<string name="externalimport__raivo_success_msg">This JSON file allows importing tokens from Raivo.</string>
<string name="externalimport__read_error">Could not read any tokens. Try to select a different file.</string>
<string name="settings__gd_sync_info">Google Drive sync info</string>
<string name="settings__gd_sync_disable_confirm">Are you sure? Without Google Drive sync, you won\'t be able to restore your tokens if you lose or reset your phone!</string>
</resources>