From b0bf55b3848ff85adeb1eaebcd827d92b22a57cc Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Fri, 20 Sep 2024 23:49:06 +0530 Subject: [PATCH 01/23] UI: add material theme for compose --- .../fr/free/nrw/commons/ui/theme/Color.kt | 219 ++++++++++++++++++ .../fr/free/nrw/commons/ui/theme/Theme.kt | 111 +++++++++ .../java/fr/free/nrw/commons/ui/theme/Type.kt | 33 +++ 3 files changed, 363 insertions(+) create mode 100644 app/src/main/java/fr/free/nrw/commons/ui/theme/Color.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/ui/theme/Theme.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/ui/theme/Type.kt diff --git a/app/src/main/java/fr/free/nrw/commons/ui/theme/Color.kt b/app/src/main/java/fr/free/nrw/commons/ui/theme/Color.kt new file mode 100644 index 0000000000..f4d19b9880 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/ui/theme/Color.kt @@ -0,0 +1,219 @@ +package fr.free.nrw.commons.ui.theme + +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF004B7D) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFF2970AD) +val onPrimaryContainerLight = Color(0xFFFFFFFF) +val secondaryLight = Color(0xFF4A6079) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFD3E6FF) +val onSecondaryContainerLight = Color(0xFF354B63) +val tertiaryLight = Color(0xFF643377) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFF8B579E) +val onTertiaryContainerLight = Color(0xFFFFFFFF) +val errorLight = Color(0xFFBA1A1A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD6) +val onErrorContainerLight = Color(0xFF410002) +val backgroundLight = Color(0xFFF8F9FF) +val onBackgroundLight = Color(0xFF191C20) +val surfaceLight = Color(0xFFF8F9FF) +val onSurfaceLight = Color(0xFF191C20) +val surfaceVariantLight = Color(0xFFDDE3EE) +val onSurfaceVariantLight = Color(0xFF414750) +val outlineLight = Color(0xFF717781) +val outlineVariantLight = Color(0xFFC1C7D1) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF2E3135) +val inverseOnSurfaceLight = Color(0xFFEFF0F6) +val inversePrimaryLight = Color(0xFF9CCAFF) +val surfaceDimLight = Color(0xFFD8DADF) +val surfaceBrightLight = Color(0xFFF8F9FF) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFF2F3F9) +val surfaceContainerLight = Color(0xFFECEEF3) +val surfaceContainerHighLight = Color(0xFFE7E8EE) +val surfaceContainerHighestLight = Color(0xFFE1E2E8) + +val primaryLightMediumContrast = Color(0xFF004574) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFF2970AD) +val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val secondaryLightMediumContrast = Color(0xFF2F445C) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF617690) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF5F2E72) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFF8B579E) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF8C0009) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFDA342E) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFF8F9FF) +val onBackgroundLightMediumContrast = Color(0xFF191C20) +val surfaceLightMediumContrast = Color(0xFFF8F9FF) +val onSurfaceLightMediumContrast = Color(0xFF191C20) +val surfaceVariantLightMediumContrast = Color(0xFFDDE3EE) +val onSurfaceVariantLightMediumContrast = Color(0xFF3D434C) +val outlineLightMediumContrast = Color(0xFF596069) +val outlineVariantLightMediumContrast = Color(0xFF757B85) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF2E3135) +val inverseOnSurfaceLightMediumContrast = Color(0xFFEFF0F6) +val inversePrimaryLightMediumContrast = Color(0xFF9CCAFF) +val surfaceDimLightMediumContrast = Color(0xFFD8DADF) +val surfaceBrightLightMediumContrast = Color(0xFFF8F9FF) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFF2F3F9) +val surfaceContainerLightMediumContrast = Color(0xFFECEEF3) +val surfaceContainerHighLightMediumContrast = Color(0xFFE7E8EE) +val surfaceContainerHighestLightMediumContrast = Color(0xFFE1E2E8) + +val primaryLightHighContrast = Color(0xFF002440) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF004574) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF0B243A) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF2F445C) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF3A064F) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF5F2E72) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF4E0002) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF8C0009) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFF8F9FF) +val onBackgroundLightHighContrast = Color(0xFF191C20) +val surfaceLightHighContrast = Color(0xFFF8F9FF) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFDDE3EE) +val onSurfaceVariantLightHighContrast = Color(0xFF1E242C) +val outlineLightHighContrast = Color(0xFF3D434C) +val outlineVariantLightHighContrast = Color(0xFF3D434C) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF2E3135) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFE1EDFF) +val surfaceDimLightHighContrast = Color(0xFFD8DADF) +val surfaceBrightLightHighContrast = Color(0xFFF8F9FF) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFF2F3F9) +val surfaceContainerLightHighContrast = Color(0xFFECEEF3) +val surfaceContainerHighLightHighContrast = Color(0xFFE7E8EE) +val surfaceContainerHighestLightHighContrast = Color(0xFFE1E2E8) + +val primaryDark = Color(0xFF9CCAFF) +val onPrimaryDark = Color(0xFF003257) +val primaryContainerDark = Color(0xFF00568E) +val onPrimaryContainerDark = Color(0xFFF1F5FF) +val secondaryDark = Color(0xFFB2C9E5) +val onSecondaryDark = Color(0xFF1B3249) +val secondaryContainerDark = Color(0xFF2B4159) +val onSecondaryContainerDark = Color(0xFFC0D7F4) +val tertiaryDark = Color(0xFFECB1FF) +val onTertiaryDark = Color(0xFF4A195E) +val tertiaryContainerDark = Color(0xFF713F84) +val onTertiaryContainerDark = Color(0xFFFFF2FD) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFF93000A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF111417) +val onBackgroundDark = Color(0xFFE1E2E8) +val surfaceDark = Color(0xFF111417) +val onSurfaceDark = Color(0xFFE1E2E8) +val surfaceVariantDark = Color(0xFF414750) +val onSurfaceVariantDark = Color(0xFFC1C7D1) +val outlineDark = Color(0xFF8B919B) +val outlineVariantDark = Color(0xFF414750) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFE1E2E8) +val inverseOnSurfaceDark = Color(0xFF2E3135) +val inversePrimaryDark = Color(0xFF10629E) +val surfaceDimDark = Color(0xFF111417) +val surfaceBrightDark = Color(0xFF37393E) +val surfaceContainerLowestDark = Color(0xFF0B0E12) +val surfaceContainerLowDark = Color(0xFF191C20) +val surfaceContainerDark = Color(0xFF1D2024) +val surfaceContainerHighDark = Color(0xFF272A2E) +val surfaceContainerHighestDark = Color(0xFF323539) + +val primaryDarkMediumContrast = Color(0xFFA4CEFF) +val onPrimaryDarkMediumContrast = Color(0xFF00172C) +val primaryContainerDarkMediumContrast = Color(0xFF5595D4) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFB6CDEA) +val onSecondaryDarkMediumContrast = Color(0xFF00172C) +val secondaryContainerDarkMediumContrast = Color(0xFF7D93AE) +val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) +val tertiaryDarkMediumContrast = Color(0xFFEEB7FF) +val onTertiaryDarkMediumContrast = Color(0xFF2A003B) +val tertiaryContainerDarkMediumContrast = Color(0xFFB37CC6) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFBAB1) +val onErrorDarkMediumContrast = Color(0xFF370001) +val errorContainerDarkMediumContrast = Color(0xFFFF5449) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF111417) +val onBackgroundDarkMediumContrast = Color(0xFFE1E2E8) +val surfaceDarkMediumContrast = Color(0xFF111417) +val onSurfaceDarkMediumContrast = Color(0xFFFAFAFF) +val surfaceVariantDarkMediumContrast = Color(0xFF414750) +val onSurfaceVariantDarkMediumContrast = Color(0xFFC5CBD6) +val outlineDarkMediumContrast = Color(0xFF9DA3AD) +val outlineVariantDarkMediumContrast = Color(0xFF7D848D) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFE1E2E8) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF272A2E) +val inversePrimaryDarkMediumContrast = Color(0xFF004B7D) +val surfaceDimDarkMediumContrast = Color(0xFF111417) +val surfaceBrightDarkMediumContrast = Color(0xFF37393E) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF0B0E12) +val surfaceContainerLowDarkMediumContrast = Color(0xFF191C20) +val surfaceContainerDarkMediumContrast = Color(0xFF1D2024) +val surfaceContainerHighDarkMediumContrast = Color(0xFF272A2E) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF323539) + +val primaryDarkHighContrast = Color(0xFFFAFAFF) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFFA4CEFF) +val onPrimaryContainerDarkHighContrast = Color(0xFF000000) +val secondaryDarkHighContrast = Color(0xFFFAFAFF) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFFB6CDEA) +val onSecondaryContainerDarkHighContrast = Color(0xFF000000) +val tertiaryDarkHighContrast = Color(0xFFFFF9FA) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFFEEB7FF) +val onTertiaryContainerDarkHighContrast = Color(0xFF000000) +val errorDarkHighContrast = Color(0xFFFFF9F9) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFBAB1) +val onErrorContainerDarkHighContrast = Color(0xFF000000) +val backgroundDarkHighContrast = Color(0xFF111417) +val onBackgroundDarkHighContrast = Color(0xFFE1E2E8) +val surfaceDarkHighContrast = Color(0xFF111417) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF414750) +val onSurfaceVariantDarkHighContrast = Color(0xFFFAFAFF) +val outlineDarkHighContrast = Color(0xFFC5CBD6) +val outlineVariantDarkHighContrast = Color(0xFFC5CBD6) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFE1E2E8) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF002C4C) +val surfaceDimDarkHighContrast = Color(0xFF111417) +val surfaceBrightDarkHighContrast = Color(0xFF37393E) +val surfaceContainerLowestDarkHighContrast = Color(0xFF0B0E12) +val surfaceContainerLowDarkHighContrast = Color(0xFF191C20) +val surfaceContainerDarkHighContrast = Color(0xFF1D2024) +val surfaceContainerHighDarkHighContrast = Color(0xFF272A2E) +val surfaceContainerHighestDarkHighContrast = Color(0xFF323539) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/ui/theme/Theme.kt b/app/src/main/java/fr/free/nrw/commons/ui/theme/Theme.kt new file mode 100644 index 0000000000..d77f3864f2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/ui/theme/Theme.kt @@ -0,0 +1,111 @@ +package fr.free.nrw.commons.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +private val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +@Composable +fun CommonsTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, /* TODO("Enable this when app is ready for dynamic colors") */ + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> darkScheme + else -> lightScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/ui/theme/Type.kt b/app/src/main/java/fr/free/nrw/commons/ui/theme/Type.kt new file mode 100644 index 0000000000..a3046ea97a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/ui/theme/Type.kt @@ -0,0 +1,33 @@ +package fr.free.nrw.commons.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file From 3f3df7d014274eec707d8329f142eaed9b90a827 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Fri, 20 Sep 2024 23:50:37 +0530 Subject: [PATCH 02/23] add ui components for custom selector --- .../customselector/ui/components/Buttons.kt | 87 ++++++++++++++++++ .../ui/components/CustomSelectorBottomBar.kt | 48 ++++++++++ .../ui/components/CustomSelectorTopBar.kt | 90 +++++++++++++++++++ .../components/PartialStorageAccessDialog.kt | 71 +++++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/components/PartialStorageAccessDialog.kt diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt new file mode 100644 index 0000000000..3b7f44a882 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt @@ -0,0 +1,87 @@ +package fr.free.nrw.commons.customselector.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import fr.free.nrw.commons.ui.theme.CommonsTheme + +@Composable +fun PrimaryButton( + text: String, + onClick: ()-> Unit, + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(12.dp), +) { + Button( + onClick = onClick, + modifier = modifier, + shape = shape, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 4.dp) + ) { + Text( + text = text, + textAlign = TextAlign.Center + ) + } +} + +@Composable +fun SecondaryButton( + text: String, + onClick: ()-> Unit, + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(12.dp), +) { + OutlinedButton( + onClick = onClick, + modifier = modifier, + border = BorderStroke(1.dp, color = MaterialTheme.colorScheme.primary), + shape = shape, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 4.dp) + ) { + Text( + text = text, + textAlign = TextAlign.Center + ) + } +} + +@PreviewLightDark +@Composable +private fun PrimaryButtonPreview() { + CommonsTheme { + Surface { + PrimaryButton( + text = "Primary Button", + onClick = { }, + modifier = Modifier.padding(16.dp) + ) + } + } +} + +@PreviewLightDark +@Composable +private fun SecondaryButtonPreview() { + CommonsTheme { + Surface { + SecondaryButton( + text = "Secondary Button", + onClick = { }, + modifier = Modifier.padding(16.dp) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt new file mode 100644 index 0000000000..0a673d01d7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt @@ -0,0 +1,48 @@ +package fr.free.nrw.commons.customselector.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.IntrinsicSize +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.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import fr.free.nrw.commons.ui.theme.CommonsTheme + +@Composable +fun CustomSelectorBottomBar(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + SecondaryButton( + text = "Mark as\nnot for upload".uppercase(), + onClick = { /*TODO*/ }, + modifier = Modifier.weight(1f) + ) + + PrimaryButton( + text = "Upload".uppercase(), + onClick = { /*TODO*/ }, + modifier = Modifier.weight(1f) + .height(IntrinsicSize.Max) + ) + } +} + +@PreviewLightDark +@Composable +private fun CustomSelectorBottomBarPreview() { + CommonsTheme { + Surface(tonalElevation = 3.dp) { + CustomSelectorBottomBar( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt new file mode 100644 index 0000000000..098a2fffa1 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt @@ -0,0 +1,90 @@ +package fr.free.nrw.commons.customselector.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import fr.free.nrw.commons.R +import fr.free.nrw.commons.ui.theme.CommonsTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomSelectorTopBar( + primaryText: String, + onNavigateBack: ()-> Unit, + modifier: Modifier = Modifier, + secondaryText: String? = null, + showAlertIcon: Boolean = false, + onAlertAction: ()-> Unit = { }, +) { + TopAppBar( + title = { + Column { + Text( + text = primaryText, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 20.sp), + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + secondaryText?.let { + Text( + text = it, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + modifier = modifier, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, + contentDescription = "Navigate Back", + modifier = Modifier.fillMaxSize() + ) + } + }, + actions = { + if(showAlertIcon) { + IconButton(onClick = onAlertAction) { + Icon( + painter = painterResource(R.drawable.ic_error_red_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + } + } + } + ) +} + +@PreviewLightDark +@Composable +private fun CustomSelectorTopBarPreview() { + CommonsTheme { + Surface(tonalElevation = 1.dp) { + CustomSelectorTopBar( + primaryText = "My Folder", + secondaryText = "10 images", + onNavigateBack = { }, + showAlertIcon = true + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/PartialStorageAccessDialog.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/PartialStorageAccessDialog.kt new file mode 100644 index 0000000000..1fdec49a85 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/PartialStorageAccessDialog.kt @@ -0,0 +1,71 @@ +package fr.free.nrw.commons.customselector.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import fr.free.nrw.commons.ui.theme.CommonsTheme + +@Composable +fun PartialStorageAccessDialog( + isVisible: Boolean, + onManage: ()-> Unit, + modifier: Modifier = Modifier +) { + if(isVisible) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "You've given access to a selected number of photos", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Button( + onClick = onManage, + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + Text(text = "Manage", style = MaterialTheme.typography.labelMedium) + } + } + } + } +} + +@PreviewLightDark +@Composable +fun PartialStorageAccessIndicatorPreview() { + CommonsTheme { + Surface { + PartialStorageAccessDialog(isVisible = true, onManage = {}, modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) + } + } +} \ No newline at end of file From 8558594ef668df33144c028f0128334dca885262 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Thu, 3 Oct 2024 23:33:46 +0530 Subject: [PATCH 03/23] add adaptive layout, image loading, and compose navigation dependencies --- app/build.gradle | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c949707c25..e0cf16e8e3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -52,10 +52,10 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' // Jetpack Compose - def composeBom = platform('androidx.compose:compose-bom:2024.08.00') + def composeBom = platform('androidx.compose:compose-bom:2024.09.02') - implementation "androidx.activity:activity-compose:1.9.1" - implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4" + implementation "androidx.activity:activity-compose:1.9.2" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.6" implementation (composeBom) implementation "androidx.compose.runtime:runtime" implementation "androidx.compose.ui:ui" @@ -65,6 +65,13 @@ dependencies { implementation "androidx.compose.foundation:foundation-layout" implementation "androidx.compose.material3:material3" androidTestImplementation(composeBom) + // Adaptive Layout APIs + implementation "androidx.compose.material3.adaptive:adaptive:1.0.0" + implementation "androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0" + implementation "androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0" + + implementation "io.coil-kt:coil-compose:2.2.0" + implementation "androidx.navigation:navigation-compose:2.7.0" implementation "com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:$ADAPTER_DELEGATES_VERSION" implementation "com.hannesdorfmann:adapterdelegates4-pagination:$ADAPTER_DELEGATES_VERSION" From 74cf035663eaa39d77e069a4b73e1b74bd763db4 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Thu, 3 Oct 2024 23:37:24 +0530 Subject: [PATCH 04/23] add logic to fetch images from storage and manage in viewmodel --- .../customselector/data/MediaReader.kt | 70 +++++++++++++++++++ .../ui/screens/CustomSelectorViewModel.kt | 60 ++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/data/MediaReader.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/data/MediaReader.kt b/app/src/main/java/fr/free/nrw/commons/customselector/data/MediaReader.kt new file mode 100644 index 0000000000..f75400712e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/data/MediaReader.kt @@ -0,0 +1,70 @@ +package fr.free.nrw.commons.customselector.data + +import android.content.ContentUris +import android.content.Context +import android.provider.MediaStore +import android.text.format.DateFormat +import fr.free.nrw.commons.customselector.model.Image +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import java.util.Calendar +import java.util.Date + +class MediaReader(private val context: Context) { + fun getImages() = flow { + val projection = arrayOf( + MediaStore.Images.Media._ID, + MediaStore.Images.Media.DISPLAY_NAME, + MediaStore.Images.Media.DATA, + MediaStore.Images.Media.BUCKET_ID, + MediaStore.Images.Media.BUCKET_DISPLAY_NAME, + MediaStore.Images.Media.DATE_ADDED, + MediaStore.Images.Media.MIME_TYPE + ) + val cursor = context.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, + null, null, MediaStore.Images.Media.DATE_ADDED + " DESC" + ) + + cursor?.use { + val idColumn = cursor.getColumnIndex(MediaStore.Images.Media._ID) + val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME) + val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA) + val bucketIdColumn = cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_ID) + val bucketNameColumn = cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_DISPLAY_NAME) + val dateColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATE_ADDED) + val mimeTypeColumn = cursor.getColumnIndex(MediaStore.Images.Media.MIME_TYPE) + + while(cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + val name = cursor.getString(nameColumn) + val path = cursor.getString(dataColumn) + val bucketId = cursor.getLong(bucketIdColumn) + val bucketName = cursor.getString(bucketNameColumn) + val date = cursor.getLong(dateColumn) + val mimeType = cursor.getString(mimeTypeColumn) + + val validMimeTypes = arrayOf( + "image/jpeg", "image/png", "image/svg+xml", "image/gif", + "image/tiff", "image/webp", "image/x-xcf" + ) + // Skip the media items with unsupported MIME types + if(mimeType.lowercase() !in validMimeTypes) continue + + // URI to access the image + val uri = ContentUris.withAppendedId( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id + ) + + val calendar = Calendar.getInstance() + calendar.timeInMillis = date * 1000L + val calendarDate: Date = calendar.time + val dateFormat = DateFormat.getMediumDateFormat(context) + val formattedDate = dateFormat.format(calendarDate) + + emit(Image(id, name, uri, path, bucketId, bucketName, date = formattedDate)) + } + } + }.flowOn(Dispatchers.IO) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt new file mode 100644 index 0000000000..d21ecb6275 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt @@ -0,0 +1,60 @@ +package fr.free.nrw.commons.customselector.ui.screens + +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import fr.free.nrw.commons.customselector.data.MediaReader +import fr.free.nrw.commons.customselector.model.Image +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class CustomSelectorViewModel(private val mediaReader: MediaReader): ViewModel() { + + private val _uiState = MutableStateFlow(CustomSelectorState()) + val uiState = _uiState.asStateFlow() + + private val foldersMap = mutableMapOf>() + + private var _selectedImageIds = mutableStateListOf() + val selectedImageIds = _selectedImageIds + + init { + _uiState.update { it.copy(isLoading = true) } + viewModelScope.launch { + mediaReader.getImages().collect { image-> + val bucketId = image.bucketId + foldersMap.getOrPut(bucketId) { mutableListOf() }.add(image) + } + val foldersList = foldersMap.map { (bucketId, images)-> + val firstImage = images.first() + Folder( + bucketId = bucketId, bucketName = firstImage.bucketName, + preview = firstImage.uri, itemsCount = images.size + ) + } + _uiState.update { it.copy(isLoading = false, folders = foldersList) } + } + } + + fun onEvent(e: CustomSelectorEvent) { + when(e) { + is CustomSelectorEvent.OnFolderClick-> { + _uiState.update { + it.copy(filteredImages = foldersMap[e.bucketId]?.toList() ?: emptyList()) + } + } + + is CustomSelectorEvent.OnImageSelect -> { + if(_selectedImageIds.contains(e.imageId)) { + _selectedImageIds.remove(e.imageId) + } else { + _selectedImageIds.add(e.imageId) + } + } + + else -> {} + } + } +} \ No newline at end of file From 9f7ae759a254603a7a1cc6d389f61478fd98dfd5 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Thu, 3 Oct 2024 23:38:32 +0530 Subject: [PATCH 05/23] create custom selector main screen with UI events and folder data class --- .../ui/screens/CustomSelectorEvent.kt | 6 + .../ui/screens/CustomSelectorScreen.kt | 242 ++++++++++++++++++ .../ui/screens/CustomSelectorState.kt | 9 + .../customselector/ui/screens/Folder.kt | 13 + 4 files changed, 270 insertions(+) create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/Folder.kt diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt new file mode 100644 index 0000000000..523f09188f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt @@ -0,0 +1,6 @@ +package fr.free.nrw.commons.customselector.ui.screens + +interface CustomSelectorEvent { + data class OnFolderClick(val bucketId: Long): CustomSelectorEvent + data class OnImageSelect(val imageId: Long): CustomSelectorEvent +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt new file mode 100644 index 0000000000..8d48dfa12e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt @@ -0,0 +1,242 @@ +package fr.free.nrw.commons.customselector.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.WindowAdaptiveInfo +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.window.core.layout.WindowWidthSizeClass +import coil.compose.rememberAsyncImagePainter +import fr.free.nrw.commons.R +import fr.free.nrw.commons.customselector.ui.components.CustomSelectorBottomBar +import fr.free.nrw.commons.customselector.ui.components.CustomSelectorTopBar +import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog +import fr.free.nrw.commons.ui.theme.CommonsTheme + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun CustomSelectorScreen( + uiState: CustomSelectorState, + onEvent: (CustomSelectorEvent)-> Unit, + selectedImageIds: List +) { + val adaptiveInfo = currentWindowAdaptiveInfo() + val navigator = rememberListDetailPaneScaffoldNavigator() + + BackHandler(navigator.canNavigateBack()) { + navigator.navigateBack() + } + LaunchedEffect(key1 = navigator.currentDestination, key2 = navigator.scaffoldValue) { + println("Current Dest:- ${navigator.currentDestination} | Scaffold Value:- ${navigator.scaffoldValue}") + } + + ListDetailPaneScaffold( + directive = navigator.scaffoldDirective.copy(horizontalPartitionSpacerSize = 0.dp), + value = navigator.scaffoldValue, + listPane = { + AnimatedPane { + FoldersPane(uiState = uiState, + onFolderClick = { + onEvent(CustomSelectorEvent.OnFolderClick(it.bucketId)) + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it) + }, + adaptiveInfo = adaptiveInfo + ) + } + }, + detailPane = { + AnimatedPane(modifier = Modifier) { + navigator.currentDestination?.content?.let { folder-> + ImagesPane( + selectedFolder = folder, + selectedImages = selectedImageIds, + imageList = uiState.filteredImages, + onNavigateBack = { navigator.navigateBack() }, + onToggleImageSelection = { onEvent(CustomSelectorEvent.OnImageSelect(it)) }, + adaptiveInfo = adaptiveInfo + ) + } + } + }, + ) +} + +@Composable +fun FoldersPane( + uiState: CustomSelectorState, + onFolderClick: (Folder)-> Unit, + adaptiveInfo: WindowAdaptiveInfo +) { + Scaffold( + topBar = { + Surface(tonalElevation = 1.dp) { + CustomSelectorTopBar( + primaryText = stringResource(R.string.custom_selector_title), + onNavigateBack = { /*TODO*/ }, + showAlertIcon = true + ) + } + }, + bottomBar = { + Surface(tonalElevation = 1.dp) { + CustomSelectorBottomBar( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + ) { innerPadding-> + Surface(tonalElevation = 0.dp) { + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) { + if(adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT) { + PartialStorageAccessDialog( + isVisible = true, + onManage = { /*TODO*/ }, + modifier = Modifier.padding(8.dp) + ) + } + + if(uiState.isLoading) { + CircularProgressIndicator() + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(164.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(8.dp), + modifier = Modifier.fillMaxSize(1f) + ) { + items(uiState.folders, key = { it.bucketId }) { + FolderItem( + previewPainter = rememberAsyncImagePainter(model = it.preview), + folderName = it.bucketName, + itemsCount = it.itemsCount, + onClick = { onFolderClick(it) } + ) + } + } + } + } + } + } +} + +@Composable +fun FolderItem( + previewPainter: Painter, + folderName: String, + itemsCount: Int, + onClick: ()-> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + .clickable { onClick() } + ) { + Image( + painter = previewPainter, + contentDescription = null, + modifier = Modifier.aspectRatio(1f), + contentScale = ContentScale.Crop + ) + Text( + text = "$itemsCount", + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .widthIn(min = 32.dp) + .align(Alignment.TopEnd) + .clip(RoundedCornerShape(bottomStart = 12.dp)) + .background(color = MaterialTheme.colorScheme.secondaryContainer) + .padding(4.dp) + ) + Surface( + modifier = Modifier.align(Alignment.BottomStart), + color = MaterialTheme.colorScheme.secondaryContainer + ) { + Text( + text = folderName, + style = MaterialTheme.typography.titleSmall, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) + } + } +} + +@PreviewLightDark +@Composable +private fun FolderItemPreview() { + CommonsTheme { + Surface { + FolderItem( + previewPainter = painterResource(R.drawable.placeholder_image), + folderName = "Folder Name", + itemsCount = 12, + onClick = { }, + modifier = Modifier + .padding(16.dp) + .size(164.dp) + ) + } + } +} + +@Preview +@Composable +private fun CustomSelectorScreenPreview() { + CommonsTheme { + CustomSelectorScreen( + uiState = CustomSelectorState(), + onEvent = { }, + selectedImageIds = emptyList() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt new file mode 100644 index 0000000000..73840ae9d8 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt @@ -0,0 +1,9 @@ +package fr.free.nrw.commons.customselector.ui.screens + +import fr.free.nrw.commons.customselector.model.Image + +data class CustomSelectorState( + val isLoading: Boolean = false, + val folders: List = emptyList(), + val filteredImages: List = emptyList() +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/Folder.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/Folder.kt new file mode 100644 index 0000000000..840e9d6bf4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/Folder.kt @@ -0,0 +1,13 @@ +package fr.free.nrw.commons.customselector.ui.screens + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Folder( + val bucketId: Long, + val bucketName: String, + val preview: Uri, + val itemsCount: Int +): Parcelable From 0ff2880ba9cd78d77b5a7ded8dc7fa713a385f8b Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Thu, 3 Oct 2024 23:40:30 +0530 Subject: [PATCH 06/23] create image grid screen/pane to display images from any folder --- .../customselector/ui/screens/ImagesPane.kt | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt new file mode 100644 index 0000000000..98167fe891 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt @@ -0,0 +1,260 @@ +package fr.free.nrw.commons.customselector.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.adaptive.WindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.toIntRect +import androidx.window.core.layout.WindowWidthSizeClass +import coil.compose.rememberAsyncImagePainter +import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.ui.components.CustomSelectorTopBar +import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ImagesPane( + selectedFolder: Folder, + selectedImages: List, + imageList: List, + onNavigateBack: ()-> Unit, + onToggleImageSelection: (Long) -> Unit, + adaptiveInfo: WindowAdaptiveInfo +) { + val inSelectionMode by remember { derivedStateOf { selectedImages.isNotEmpty() } } + val lazyGridState = rememberLazyGridState() + var autoScrollSpeed by remember { mutableFloatStateOf(0f) } + + LaunchedEffect(autoScrollSpeed) { + if (autoScrollSpeed != 0f) { + while (isActive) { + lazyGridState.scrollBy(autoScrollSpeed) + delay(10) + } + } + } + + Scaffold( + topBar = { + CustomSelectorTopBar( + primaryText = selectedFolder.bucketName, + secondaryText = "${selectedFolder.itemsCount} images", + onNavigateBack = onNavigateBack, + showNavigationIcon = adaptiveInfo.windowSizeClass + .windowWidthSizeClass == WindowWidthSizeClass.COMPACT + ) + } + ) { innerPadding-> + Column(modifier = Modifier.padding(innerPadding)) { + PartialStorageAccessDialog( + isVisible = true, + onManage = { /*TODO*/ }, + modifier = Modifier.padding(8.dp) + ) + + LazyVerticalGrid( + columns = GridCells.Adaptive(116.dp), + modifier = Modifier + .fillMaxSize() + .imageGridDragHandler( + gridState = lazyGridState, + imageList = imageList, + selectedImageIds = { selectedImages }, + onImageSelect = { onToggleImageSelection(it) }, + autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }, + setAutoScrollSpeed = { autoScrollSpeed = it } + ), + state = lazyGridState, + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(8.dp) + ) { + items(imageList, key = { it.id }) { image-> + val isSelected by remember { + derivedStateOf { selectedImages.contains(image.id) } + } + + ImageItem( + imagePainter = rememberAsyncImagePainter(model = image.uri), + isSelected = isSelected, + inSelectionMode = inSelectionMode, + modifier = Modifier.combinedClickable( + onClick = { + if(inSelectionMode) { + onToggleImageSelection(image.id) + } + }, + onLongClick = { + onToggleImageSelection(image.id) + } + ) + ) + } + } + } + } +} + +@Composable +fun ImageItem( + imagePainter: Painter, + isSelected: Boolean, + modifier: Modifier = Modifier, + inSelectionMode: Boolean = false +) { + Box(modifier = modifier.clip(RoundedCornerShape(12.dp))) { + Image( + painter = imagePainter, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + contentScale = ContentScale.Crop + ) + + if(inSelectionMode) { + if(isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(bottomEnd = 12.dp)) + .background(color = MaterialTheme.colorScheme.primary) + .padding(2.dp) + ) + } else { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(bottomEnd = 12.dp)) + .background(color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)) + .padding(2.dp) + ) + } + } + } +} + +fun Modifier.imageGridDragHandler( + gridState: LazyGridState, + imageList: List, + selectedImageIds:()-> List, + autoScrollThreshold: Float, + onImageSelect: (Long) -> Unit = { }, + setAutoScrollSpeed: (Float) -> Unit = { }, +) = pointerInput(autoScrollThreshold, setAutoScrollSpeed, onImageSelect) { + + fun imageIndexAtOffset(hitPoint: Offset): Int? = + gridState.layoutInfo.visibleItemsInfo.find { itemInfo -> + itemInfo.size.toIntRect().contains(hitPoint.round() - itemInfo.offset) + }?.index + + var dragStartIndex: Int? = null + var currentDragIndex: Int? = null + var isSelecting = true + + detectDragGestures( + onDragStart = { offset-> + imageIndexAtOffset(offset)?.let { + val imageId = imageList[it].id + if(!selectedImageIds().contains(imageId)) { + dragStartIndex = it + currentDragIndex = it + onImageSelect(imageList[it].id) + } + } + }, + onDragEnd = { setAutoScrollSpeed(0f); dragStartIndex = null }, + onDragCancel = { setAutoScrollSpeed(0f); dragStartIndex = null }, + onDrag = { change, _-> + dragStartIndex?.let { startIndex-> + currentDragIndex?.let { endIndex-> + val start = minOf(startIndex, endIndex) + val end = maxOf(start, endIndex) + + (start..end).forEach { index-> + val imageId = imageList[index].id + val ifContains = selectedImageIds().contains(imageId) + if (isSelecting && !selectedImageIds().contains(imageId)) { + println("Selecting...") + println("contains: $ifContains") + onImageSelect(imageId) + } else if (!isSelecting && selectedImageIds().contains(imageId)) { + onImageSelect(imageId) + } + } + } + } + } + ) +} + +private fun Set.addUpTo( + initialKey: Int?, + pointerKey: Int? +): Set { + return if(initialKey == null || pointerKey == null) { + this + } else { + this.plus(initialKey..pointerKey) + .plus(pointerKey..initialKey) + } +} + +private fun Set.removeUpTo( + initialKey: Int?, + previousPointerKey: Int? +): Set { + return if(initialKey == null || previousPointerKey == null) { + this + } else { + this.minus(initialKey..previousPointerKey) + .minus(previousPointerKey..initialKey) + } +} \ No newline at end of file From 5da909754701204c7d7e67f70c183e78391596d3 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Thu, 3 Oct 2024 23:41:34 +0530 Subject: [PATCH 07/23] refactor: add new cs screen into custom selector activity Also, refactor the UI components --- .../customselector/ui/components/Buttons.kt | 2 +- .../ui/components/CustomSelectorTopBar.kt | 19 ++- .../ui/selector/CustomSelectorActivity.kt | 146 ++++++------------ 3 files changed, 56 insertions(+), 111 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt index 3b7f44a882..7720cfca14 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt @@ -49,7 +49,7 @@ fun SecondaryButton( modifier = modifier, border = BorderStroke(1.dp, color = MaterialTheme.colorScheme.primary), shape = shape, - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 4.dp) + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp) ) { Text( text = text, diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt index 098a2fffa1..859c4fabc7 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt @@ -28,6 +28,7 @@ fun CustomSelectorTopBar( onNavigateBack: ()-> Unit, modifier: Modifier = Modifier, secondaryText: String? = null, + showNavigationIcon: Boolean = true, showAlertIcon: Boolean = false, onAlertAction: ()-> Unit = { }, ) { @@ -36,7 +37,7 @@ fun CustomSelectorTopBar( Column { Text( text = primaryText, - style = MaterialTheme.typography.titleMedium.copy(fontSize = 20.sp), + style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp), color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -44,7 +45,7 @@ fun CustomSelectorTopBar( secondaryText?.let { Text( text = it, - style = MaterialTheme.typography.labelLarge, + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -52,12 +53,14 @@ fun CustomSelectorTopBar( }, modifier = modifier, navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, - contentDescription = "Navigate Back", - modifier = Modifier.fillMaxSize() - ) + if(showNavigationIcon) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, + contentDescription = "Navigate Back", + modifier = Modifier.fillMaxSize() + ) + } } }, actions = { diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index 959db52f3b..14a9b2fa4b 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -13,53 +13,34 @@ import android.view.Window import android.widget.Button import android.widget.ImageButton import android.widget.TextView -import androidx.activity.result.ActivityResult -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable +import androidx.activity.compose.setContent import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import fr.free.nrw.commons.R +import fr.free.nrw.commons.customselector.data.MediaReader import fr.free.nrw.commons.customselector.database.NotForUploadStatus import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.ui.screens.CustomSelectorScreen import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding import fr.free.nrw.commons.filepicker.Constants import fr.free.nrw.commons.media.ZoomableActivity import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.ui.theme.CommonsTheme import fr.free.nrw.commons.upload.FileUtilsWrapper import fr.free.nrw.commons.utils.CustomSelectorUtils -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* import java.io.File import java.lang.Integer.max import javax.inject.Inject @@ -166,25 +147,24 @@ class CustomSelectorActivity : showPartialAccessIndicator = true } - binding = ActivityCustomSelectorBinding.inflate(layoutInflater) - toolbarBinding = CustomSelectorToolbarBinding.bind(binding.root) - bottomSheetBinding = CustomSelectorBottomLayoutBinding.bind(binding.root) - binding.partialAccessIndicator.setContent { - partialStorageAccessIndicator( - isVisible = showPartialAccessIndicator, - onManage = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - requestPermissions(arrayOf(Manifest.permission.READ_MEDIA_IMAGES), 1) - } - }, - modifier = - Modifier - .padding(vertical = 8.dp, horizontal = 4.dp) - .fillMaxWidth(), - ) - } - val view = binding.root - setContentView(view) +// binding = ActivityCustomSelectorBinding.inflate(layoutInflater) +// toolbarBinding = CustomSelectorToolbarBinding.bind(binding.root) +// bottomSheetBinding = CustomSelectorBottomLayoutBinding.bind(binding.root) +// binding.partialAccessIndicator.setContent { +// PartialStorageAccessDialog( +// isVisible = showPartialAccessIndicator, +// onManage = { +// if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { +// requestPermissions(arrayOf(Manifest.permission.READ_MEDIA_IMAGES), 1) +// } +// }, +// modifier = Modifier +// .padding(vertical = 8.dp, horizontal = 4.dp) +// .fillMaxWidth() +// ) +// } +// val view = binding.root +// setContentView(view) prefs = applicationContext.getSharedPreferences("CustomSelector", MODE_PRIVATE) viewModel = @@ -192,7 +172,25 @@ class CustomSelectorActivity : CustomSelectorViewModel::class.java, ) - setupViews() + val mediaReader = MediaReader(this) + + setContent { + val csViewModel = viewModel { + fr.free.nrw.commons.customselector.ui.screens.CustomSelectorViewModel(mediaReader) + } + + val uiState by csViewModel.uiState.collectAsStateWithLifecycle() + + CommonsTheme { + CustomSelectorScreen( + uiState = uiState, + onEvent = csViewModel::onEvent, + selectedImageIds = csViewModel.selectedImageIds + ) + } + } + +// setupViews() if (prefs.getBoolean("customSelectorFirstLaunch", true)) { // show welcome dialog on first launch @@ -602,59 +600,3 @@ class CustomSelectorActivity : const val ITEM_ID: String = "ItemId" } } - -@Composable -fun partialStorageAccessIndicator( - isVisible: Boolean, - onManage: () -> Unit, - modifier: Modifier = Modifier, -) { - if (isVisible) { - OutlinedCard( - modifier = modifier, - colors = - CardDefaults.cardColors( - containerColor = colorResource(R.color.primarySuperLightColor), - ), - border = BorderStroke(0.5.dp, color = colorResource(R.color.primaryColor)), - shape = RoundedCornerShape(8.dp), - ) { - Row(modifier = Modifier.padding(16.dp).fillMaxWidth()) { - Text( - text = "You've given access to a select number of photos", - modifier = Modifier.weight(1f), - ) - TextButton( - onClick = onManage, - modifier = Modifier.align(Alignment.Bottom), - colors = - ButtonDefaults.buttonColors( - containerColor = colorResource(R.color.primaryColor), - ), - shape = RoundedCornerShape(8.dp), - ) { - Text( - text = "Manage", - style = MaterialTheme.typography.labelMedium, - color = colorResource(R.color.primaryTextColor), - ) - } - } - } - } -} - -@Preview -@Composable -fun partialStorageAccessIndicatorPreview() { - Surface { - partialStorageAccessIndicator( - isVisible = true, - onManage = {}, - modifier = - Modifier - .padding(vertical = 8.dp, horizontal = 4.dp) - .fillMaxWidth(), - ) - } -} From 1a86883ec0d17e44714959183c65e884ad5d575a Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Sun, 13 Oct 2024 14:06:06 +0530 Subject: [PATCH 08/23] add actions lambda to bottom bar and replace hard-coded strings --- .../ui/components/CustomSelectorBottomBar.kt | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt index 0a673d01d7..62129de952 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt @@ -9,26 +9,33 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import fr.free.nrw.commons.R import fr.free.nrw.commons.ui.theme.CommonsTheme @Composable -fun CustomSelectorBottomBar(modifier: Modifier = Modifier) { +fun CustomSelectorBottomBar( + onPrimaryAction: ()-> Unit, + onSecondaryAction: ()-> Unit, + modifier: Modifier = Modifier +) { Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { SecondaryButton( - text = "Mark as\nnot for upload".uppercase(), - onClick = { /*TODO*/ }, + text = stringResource(R.string.mark_as_not_for_upload).uppercase(), + onClick = onSecondaryAction, modifier = Modifier.weight(1f) ) PrimaryButton( - text = "Upload".uppercase(), - onClick = { /*TODO*/ }, - modifier = Modifier.weight(1f) + text = stringResource(R.string.upload).uppercase(), + onClick = onPrimaryAction, + modifier = Modifier + .weight(1f) .height(IntrinsicSize.Max) ) } @@ -40,7 +47,10 @@ private fun CustomSelectorBottomBarPreview() { CommonsTheme { Surface(tonalElevation = 3.dp) { CustomSelectorBottomBar( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + onPrimaryAction = { }, + onSecondaryAction = { }, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) .fillMaxWidth() ) } From ca30bf18bfdf390598a03b506df53810a57061c5 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Sun, 13 Oct 2024 14:07:02 +0530 Subject: [PATCH 09/23] Add selection count indicator in top bar and refactor --- .../ui/components/CustomSelectorTopBar.kt | 29 ++++++++- .../components/PartialStorageAccessDialog.kt | 59 +++++++++---------- 2 files changed, 56 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt index 859c4fabc7..a596d4620d 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt @@ -2,8 +2,12 @@ package fr.free.nrw.commons.customselector.ui.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -14,6 +18,8 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp @@ -28,7 +34,9 @@ fun CustomSelectorTopBar( onNavigateBack: ()-> Unit, modifier: Modifier = Modifier, secondaryText: String? = null, + selectionCount: Int = 0, showNavigationIcon: Boolean = true, + showSelectionCount: Boolean = false, showAlertIcon: Boolean = false, onAlertAction: ()-> Unit = { }, ) { @@ -38,7 +46,6 @@ fun CustomSelectorTopBar( Text( text = primaryText, style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp), - color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -73,6 +80,23 @@ fun CustomSelectorTopBar( ) } } + + if(showSelectionCount) { + ElevatedCard( + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.primary + ), + shape = CircleShape, + modifier = Modifier.semantics { contentDescription = "$selectionCount Selected" } + .padding(end = 8.dp) + ) { + Text( + text = "$selectionCount", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelMedium + ) + } + } } ) } @@ -86,7 +110,8 @@ private fun CustomSelectorTopBarPreview() { primaryText = "My Folder", secondaryText = "10 images", onNavigateBack = { }, - showAlertIcon = true + showAlertIcon = true, + selectionCount = 1 ) } } diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/PartialStorageAccessDialog.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/PartialStorageAccessDialog.kt index 1fdec49a85..9cf69530ff 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/PartialStorageAccessDialog.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/PartialStorageAccessDialog.kt @@ -21,37 +21,34 @@ import fr.free.nrw.commons.ui.theme.CommonsTheme @Composable fun PartialStorageAccessDialog( - isVisible: Boolean, - onManage: ()-> Unit, + onManageAction: () -> Unit, modifier: Modifier = Modifier ) { - if(isVisible) { - Card( - modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer - ), - shape = RoundedCornerShape(12.dp) + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .padding(vertical = 8.dp, horizontal = 16.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically + Text( + text = "You've given access to a selected number of photos", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Button( + onClick = onManageAction, + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp) ) { - Text( - text = "You've given access to a selected number of photos", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f) - ) - Button( - onClick = onManage, - shape = RoundedCornerShape(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp) - ) { - Text(text = "Manage", style = MaterialTheme.typography.labelMedium) - } + Text(text = "Manage", style = MaterialTheme.typography.labelMedium) } } } @@ -62,9 +59,11 @@ fun PartialStorageAccessDialog( fun PartialStorageAccessIndicatorPreview() { CommonsTheme { Surface { - PartialStorageAccessDialog(isVisible = true, onManage = {}, modifier = Modifier - .padding(8.dp) - .fillMaxWidth() + PartialStorageAccessDialog( + onManageAction = {}, + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() ) } } From 55c09395e44700d1eb05c4a825f703c812f89704 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Sun, 13 Oct 2024 14:09:13 +0530 Subject: [PATCH 10/23] Add drag and tap gestures to select images --- .../ui/screens/CustomSelectorEvent.kt | 3 +- .../ui/screens/CustomSelectorState.kt | 8 +- .../ui/screens/CustomSelectorViewModel.kt | 21 +- .../customselector/ui/screens/ImagesPane.kt | 222 ++++++++++++------ 4 files changed, 174 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt index 523f09188f..fe6ea6bc71 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt @@ -2,5 +2,6 @@ package fr.free.nrw.commons.customselector.ui.screens interface CustomSelectorEvent { data class OnFolderClick(val bucketId: Long): CustomSelectorEvent - data class OnImageSelect(val imageId: Long): CustomSelectorEvent + data class OnImageSelection(val imageId: Long): CustomSelectorEvent + data class OnDragImageSelection(val imageIds: Set): CustomSelectorEvent } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt index 73840ae9d8..11d88bf9bb 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt @@ -5,5 +5,9 @@ import fr.free.nrw.commons.customselector.model.Image data class CustomSelectorState( val isLoading: Boolean = false, val folders: List = emptyList(), - val filteredImages: List = emptyList() -) \ No newline at end of file + val filteredImages: List = emptyList(), + val selectedImageIds: Set = emptySet() +) { + val inSelectionMode: Boolean + get() = selectedImageIds.isNotEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt index d21ecb6275..5d06671ea9 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt @@ -1,6 +1,5 @@ package fr.free.nrw.commons.customselector.ui.screens -import androidx.compose.runtime.mutableStateListOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import fr.free.nrw.commons.customselector.data.MediaReader @@ -17,9 +16,6 @@ class CustomSelectorViewModel(private val mediaReader: MediaReader): ViewModel() private val foldersMap = mutableMapOf>() - private var _selectedImageIds = mutableStateListOf() - val selectedImageIds = _selectedImageIds - init { _uiState.update { it.copy(isLoading = true) } viewModelScope.launch { @@ -46,14 +42,21 @@ class CustomSelectorViewModel(private val mediaReader: MediaReader): ViewModel() } } - is CustomSelectorEvent.OnImageSelect -> { - if(_selectedImageIds.contains(e.imageId)) { - _selectedImageIds.remove(e.imageId) - } else { - _selectedImageIds.add(e.imageId) + is CustomSelectorEvent.OnImageSelection -> { + _uiState.update { state -> + val updatedSelectedIds = if (state.selectedImageIds.contains(e.imageId)) { + state.selectedImageIds - e.imageId // Remove if already selected + } else{ + state.selectedImageIds + e.imageId // Add if not selected + } + state.copy(selectedImageIds = updatedSelectedIds) } } + is CustomSelectorEvent.OnDragImageSelection-> { + _uiState.update { it.copy(selectedImageIds = e.imageIds) } + } + else -> {} } } diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt index 98167fe891..a984323b17 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt @@ -1,5 +1,8 @@ package fr.free.nrw.commons.customselector.ui.screens +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -26,6 +29,7 @@ import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -41,30 +45,42 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round import androidx.compose.ui.unit.toIntRect import androidx.window.core.layout.WindowWidthSizeClass import coil.compose.rememberAsyncImagePainter +import fr.free.nrw.commons.R import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.ui.components.CustomSelectorBottomBar import fr.free.nrw.commons.customselector.ui.components.CustomSelectorTopBar import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog +import fr.free.nrw.commons.ui.theme.CommonsTheme import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @OptIn(ExperimentalFoundationApi::class) @Composable fun ImagesPane( + uiState: CustomSelectorState, selectedFolder: Folder, - selectedImages: List, - imageList: List, - onNavigateBack: ()-> Unit, - onToggleImageSelection: (Long) -> Unit, - adaptiveInfo: WindowAdaptiveInfo + selectedImages: () -> Set, + onNavigateBack: () -> Unit, + onEvent: (CustomSelectorEvent) -> Unit, + adaptiveInfo: WindowAdaptiveInfo, + hasPartialAccess: Boolean = false ) { - val inSelectionMode by remember { derivedStateOf { selectedImages.isNotEmpty() } } +// val inSelectionMode by remember { derivedStateOf { selectedImages().isNotEmpty() } } val lazyGridState = rememberLazyGridState() var autoScrollSpeed by remember { mutableFloatStateOf(0f) } + val isCompatWidth by remember(adaptiveInfo.windowSizeClass) { + derivedStateOf { + adaptiveInfo.windowSizeClass + .windowWidthSizeClass == WindowWidthSizeClass.COMPACT + } + } LaunchedEffect(autoScrollSpeed) { if (autoScrollSpeed != 0f) { @@ -81,17 +97,35 @@ fun ImagesPane( primaryText = selectedFolder.bucketName, secondaryText = "${selectedFolder.itemsCount} images", onNavigateBack = onNavigateBack, - showNavigationIcon = adaptiveInfo.windowSizeClass - .windowWidthSizeClass == WindowWidthSizeClass.COMPACT + showNavigationIcon = isCompatWidth, + showAlertIcon = selectedImages().size > 20, + selectionCount = selectedImages().size, + showSelectionCount = uiState.inSelectionMode ) + }, + bottomBar = { + AnimatedVisibility( + visible = uiState.inSelectionMode && isCompatWidth, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }) + ) { + Surface(tonalElevation = 1.dp) { + CustomSelectorBottomBar( + onPrimaryAction = { /*TODO("Implement action to upload selected images")*/ }, + onSecondaryAction = { /*TODO("Implement action to mark/unmark as not for upload")*/ }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } } - ) { innerPadding-> + ) { innerPadding -> Column(modifier = Modifier.padding(innerPadding)) { - PartialStorageAccessDialog( - isVisible = true, - onManage = { /*TODO*/ }, - modifier = Modifier.padding(8.dp) - ) + if (hasPartialAccess) { + PartialStorageAccessDialog( + onManageAction = { /*TODO("Request permission[READ_MEDIA_IMAGES]")*/ }, + modifier = Modifier.padding(8.dp) + ) + } LazyVerticalGrid( columns = GridCells.Adaptive(116.dp), @@ -99,9 +133,11 @@ fun ImagesPane( .fillMaxSize() .imageGridDragHandler( gridState = lazyGridState, - imageList = imageList, - selectedImageIds = { selectedImages }, - onImageSelect = { onToggleImageSelection(it) }, + imageList = uiState.filteredImages, + selectedImageIds = selectedImages, + setSelectedImageIds = { + onEvent(CustomSelectorEvent.OnDragImageSelection(it)) + }, autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }, setAutoScrollSpeed = { autoScrollSpeed = it } ), @@ -110,23 +146,23 @@ fun ImagesPane( horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(8.dp) ) { - items(imageList, key = { it.id }) { image-> + items(uiState.filteredImages, key = { it.id }) { image -> val isSelected by remember { - derivedStateOf { selectedImages.contains(image.id) } + derivedStateOf { selectedImages().contains(image.id) } } ImageItem( imagePainter = rememberAsyncImagePainter(model = image.uri), isSelected = isSelected, - inSelectionMode = inSelectionMode, + inSelectionMode = uiState.inSelectionMode, modifier = Modifier.combinedClickable( onClick = { - if(inSelectionMode) { - onToggleImageSelection(image.id) + if (uiState.inSelectionMode) { + onEvent(CustomSelectorEvent.OnImageSelection(image.id)) } }, onLongClick = { - onToggleImageSelection(image.id) + onEvent(CustomSelectorEvent.OnImageSelection(image.id)) } ) ) @@ -153,8 +189,8 @@ fun ImageItem( contentScale = ContentScale.Crop ) - if(inSelectionMode) { - if(isSelected) { + if (inSelectionMode) { + if (isSelected) { Icon( imageVector = Icons.Rounded.Check, contentDescription = null, @@ -181,14 +217,44 @@ fun ImageItem( } } +@Preview +@Composable +private fun ImageItemPreview() { + CommonsTheme { + Surface { + ImageItem( + imagePainter = painterResource(id = R.drawable.image_placeholder_96), + isSelected = false, + inSelectionMode = true, + modifier = Modifier + .padding(16.dp) + .size(116.dp) + ) + } + } +} + +/** + * A modifier that handles drag gestures on an image grid to allow for selecting multiple images. + * + * This modifier detects drag gestures and updates the selected images based on the drag position. + * It also handles auto-scrolling when the drag reaches the edges of the grid. + * + * @param gridState The state of the lazy grid. + * @param imageList The list of images displayed in the grid. + * @param selectedImageIds A function that returns the currently selected image IDs. + * @param autoScrollThreshold The distance from the edge of the grid at which auto-scrolling should start. + * @param setSelectedImageIds A callback function that is invoked when the selected images change. + * @param setAutoScrollSpeed A callback function that is invoked to set the auto-scroll speed. + */ fun Modifier.imageGridDragHandler( gridState: LazyGridState, imageList: List, - selectedImageIds:()-> List, + selectedImageIds: () -> Set, autoScrollThreshold: Float, - onImageSelect: (Long) -> Unit = { }, - setAutoScrollSpeed: (Float) -> Unit = { }, -) = pointerInput(autoScrollThreshold, setAutoScrollSpeed, onImageSelect) { + setSelectedImageIds: (Set) -> Unit, + setAutoScrollSpeed: (Float) -> Unit, +) = pointerInput(autoScrollThreshold, setAutoScrollSpeed, setSelectedImageIds, imageList) { fun imageIndexAtOffset(hitPoint: Offset): Int? = gridState.layoutInfo.visibleItemsInfo.find { itemInfo -> @@ -200,33 +266,56 @@ fun Modifier.imageGridDragHandler( var isSelecting = true detectDragGestures( - onDragStart = { offset-> + onDragStart = { offset -> imageIndexAtOffset(offset)?.let { val imageId = imageList[it].id - if(!selectedImageIds().contains(imageId)) { - dragStartIndex = it - currentDragIndex = it - onImageSelect(imageList[it].id) + dragStartIndex = it + currentDragIndex = it + + if (!selectedImageIds().contains(imageId)) { + isSelecting = true + setSelectedImageIds(selectedImageIds().plus(imageId)) + } else { + isSelecting = false + setSelectedImageIds(selectedImageIds().minus(imageId)) } } }, onDragEnd = { setAutoScrollSpeed(0f); dragStartIndex = null }, onDragCancel = { setAutoScrollSpeed(0f); dragStartIndex = null }, - onDrag = { change, _-> - dragStartIndex?.let { startIndex-> - currentDragIndex?.let { endIndex-> - val start = minOf(startIndex, endIndex) - val end = maxOf(start, endIndex) + onDrag = { change, _ -> + dragStartIndex?.let { startIndex -> + val distFromBottom = gridState.layoutInfo.viewportSize.height - change.position.y + val distFromTop = change.position.y + setAutoScrollSpeed( + when { + distFromBottom < autoScrollThreshold -> autoScrollThreshold - distFromBottom + distFromTop < autoScrollThreshold -> -(autoScrollThreshold - distFromTop) + else -> 0f + } + ) - (start..end).forEach { index-> - val imageId = imageList[index].id - val ifContains = selectedImageIds().contains(imageId) - if (isSelecting && !selectedImageIds().contains(imageId)) { - println("Selecting...") - println("contains: $ifContains") - onImageSelect(imageId) - } else if (!isSelecting && selectedImageIds().contains(imageId)) { - onImageSelect(imageId) + currentDragIndex?.let { currentIndex -> + imageIndexAtOffset(change.position)?.let { pointerIndex -> + if (currentIndex != pointerIndex) { + if (isSelecting) { + setSelectedImageIds( + selectedImageIds().minus( + imageList.getImageIdsInRange(startIndex, currentIndex) + ).plus( + imageList.getImageIdsInRange(startIndex, pointerIndex) + ) + ) + } else { + setSelectedImageIds( + selectedImageIds().plus( + imageList.getImageIdsInRange(currentIndex, pointerIndex) + ).minus( + imageList.getImageIdsInRange(startIndex, pointerIndex) + ) + ) + } + currentDragIndex = pointerIndex } } } @@ -235,26 +324,23 @@ fun Modifier.imageGridDragHandler( ) } -private fun Set.addUpTo( - initialKey: Int?, - pointerKey: Int? -): Set { - return if(initialKey == null || pointerKey == null) { - this - } else { - this.plus(initialKey..pointerKey) - .plus(pointerKey..initialKey) - } -} - -private fun Set.removeUpTo( - initialKey: Int?, - previousPointerKey: Int? -): Set { - return if(initialKey == null || previousPointerKey == null) { - this +/** + * Calculates a set of image IDs within a given range of indices in a list of images. + * + * @param initialKey The starting index of the range. + * @param pointerKey The ending index of the range. + * @return A set of image IDs within the specified range. + */ +fun List.getImageIdsInRange(initialKey: Int, pointerKey: Int): Set { + val setOfKeys = mutableSetOf() + if (initialKey < pointerKey) { + (initialKey..pointerKey).forEach { + setOfKeys.add(this[it].id) + } } else { - this.minus(initialKey..previousPointerKey) - .minus(previousPointerKey..initialKey) + (pointerKey..initialKey).forEach { + setOfKeys.add(this[it].id) + } } + return setOfKeys } \ No newline at end of file From 31c012f953fcfb2222eea38455efaac38942100e Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Sun, 13 Oct 2024 14:14:39 +0530 Subject: [PATCH 11/23] refactor holder screen for both folder and image panes --- .../ui/screens/CustomSelectorScreen.kt | 72 +++++++++++++------ .../ui/selector/CustomSelectorActivity.kt | 3 +- 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt index 8d48dfa12e..6139919adc 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt @@ -1,6 +1,9 @@ package fr.free.nrw.commons.customselector.ui.screens import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -31,7 +34,9 @@ import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -57,7 +62,8 @@ import fr.free.nrw.commons.ui.theme.CommonsTheme fun CustomSelectorScreen( uiState: CustomSelectorState, onEvent: (CustomSelectorEvent)-> Unit, - selectedImageIds: List + selectedImageIds: ()-> Set, + hasPartialAccess: Boolean = false ) { val adaptiveInfo = currentWindowAdaptiveInfo() val navigator = rememberListDetailPaneScaffoldNavigator() @@ -65,34 +71,34 @@ fun CustomSelectorScreen( BackHandler(navigator.canNavigateBack()) { navigator.navigateBack() } - LaunchedEffect(key1 = navigator.currentDestination, key2 = navigator.scaffoldValue) { - println("Current Dest:- ${navigator.currentDestination} | Scaffold Value:- ${navigator.scaffoldValue}") - } ListDetailPaneScaffold( directive = navigator.scaffoldDirective.copy(horizontalPartitionSpacerSize = 0.dp), value = navigator.scaffoldValue, listPane = { AnimatedPane { - FoldersPane(uiState = uiState, + FoldersPane( + uiState = uiState, onFolderClick = { onEvent(CustomSelectorEvent.OnFolderClick(it.bucketId)) navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it) }, + hasPartialAccess = hasPartialAccess, adaptiveInfo = adaptiveInfo ) } }, detailPane = { - AnimatedPane(modifier = Modifier) { + AnimatedPane { navigator.currentDestination?.content?.let { folder-> ImagesPane( + uiState = uiState, selectedFolder = folder, selectedImages = selectedImageIds, - imageList = uiState.filteredImages, onNavigateBack = { navigator.navigateBack() }, - onToggleImageSelection = { onEvent(CustomSelectorEvent.OnImageSelect(it)) }, - adaptiveInfo = adaptiveInfo + onEvent = onEvent, + adaptiveInfo = adaptiveInfo, + hasPartialAccess = hasPartialAccess ) } } @@ -104,23 +110,42 @@ fun CustomSelectorScreen( fun FoldersPane( uiState: CustomSelectorState, onFolderClick: (Folder)-> Unit, - adaptiveInfo: WindowAdaptiveInfo + adaptiveInfo: WindowAdaptiveInfo, + hasPartialAccess: Boolean = false ) { + val isCompatWidth by remember(adaptiveInfo.windowSizeClass) { + derivedStateOf { adaptiveInfo.windowSizeClass + .windowWidthSizeClass == WindowWidthSizeClass.COMPACT } + } + Scaffold( topBar = { Surface(tonalElevation = 1.dp) { CustomSelectorTopBar( primaryText = stringResource(R.string.custom_selector_title), onNavigateBack = { /*TODO*/ }, - showAlertIcon = true + showAlertIcon = uiState.selectedImageIds.size > 20 && isCompatWidth, + selectionCount = uiState.selectedImageIds.size, + onAlertAction = { }, + showSelectionCount = uiState.inSelectionMode && isCompatWidth ) } }, bottomBar = { - Surface(tonalElevation = 1.dp) { - CustomSelectorBottomBar( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) + AnimatedVisibility( + visible = uiState.inSelectionMode, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }) + ) { + Surface(tonalElevation = 1.dp) { + CustomSelectorBottomBar( + onPrimaryAction = { /*TODO("Implement action to upload selected images")*/}, + onSecondaryAction = { + /*TODO("Implement action to mark/unmark images as not for upload")*/ + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } } } ) { innerPadding-> @@ -130,16 +155,20 @@ fun FoldersPane( .padding(innerPadding) .fillMaxSize() ) { - if(adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT) { + if(hasPartialAccess && isCompatWidth) { PartialStorageAccessDialog( - isVisible = true, - onManage = { /*TODO*/ }, + onManageAction = { /*TODO("Request permission[READ_MEDIA_IMAGES]")*/ }, modifier = Modifier.padding(8.dp) ) } if(uiState.isLoading) { - CircularProgressIndicator() + Box( + modifier = Modifier.fillMaxSize(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } } else { LazyVerticalGrid( columns = GridCells.Adaptive(164.dp), @@ -236,7 +265,8 @@ private fun CustomSelectorScreenPreview() { CustomSelectorScreen( uiState = CustomSelectorState(), onEvent = { }, - selectedImageIds = emptyList() + selectedImageIds = { emptySet() }, + hasPartialAccess = true ) } } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index 14a9b2fa4b..47e139147b 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -185,7 +185,8 @@ class CustomSelectorActivity : CustomSelectorScreen( uiState = uiState, onEvent = csViewModel::onEvent, - selectedImageIds = csViewModel.selectedImageIds + selectedImageIds = { uiState.selectedImageIds }, + hasPartialAccess = showPartialAccessIndicator ) } } From 071bffbfa858499fa1fbbe37608aff546a925754 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Sun, 13 Oct 2024 14:20:02 +0530 Subject: [PATCH 12/23] refactor preview for folder item --- .../commons/customselector/ui/screens/CustomSelectorScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt index 6139919adc..26724fe655 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt @@ -246,7 +246,7 @@ private fun FolderItemPreview() { CommonsTheme { Surface { FolderItem( - previewPainter = painterResource(R.drawable.placeholder_image), + previewPainter = painterResource(R.drawable.image_placeholder_96), folderName = "Folder Name", itemsCount = 12, onClick = { }, From a930d8eca66593828d765de6d74d6b6b2cd422c2 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Mon, 25 Nov 2024 23:28:11 +0530 Subject: [PATCH 13/23] add view image screen and enable edge to edge for custom selector --- app/src/main/AndroidManifest.xml | 1 + .../ui/screens/CustomSelectorScreen.kt | 3 + .../customselector/ui/screens/ImagesPane.kt | 5 +- .../ui/screens/ViewImageScreen.kt | 179 ++++++++++++++++++ .../ui/selector/CustomSelectorActivity.kt | 42 +++- 5 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 29f280c9ec..5227cca3e8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -144,6 +144,7 @@ android:label="@string/result" /> diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt index 26724fe655..4fa3bbdca9 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt @@ -63,6 +63,7 @@ fun CustomSelectorScreen( uiState: CustomSelectorState, onEvent: (CustomSelectorEvent)-> Unit, selectedImageIds: ()-> Set, + onViewImage: (id: Long)-> Unit, hasPartialAccess: Boolean = false ) { val adaptiveInfo = currentWindowAdaptiveInfo() @@ -96,6 +97,7 @@ fun CustomSelectorScreen( selectedFolder = folder, selectedImages = selectedImageIds, onNavigateBack = { navigator.navigateBack() }, + onViewImage = onViewImage, onEvent = onEvent, adaptiveInfo = adaptiveInfo, hasPartialAccess = hasPartialAccess @@ -264,6 +266,7 @@ private fun CustomSelectorScreenPreview() { CommonsTheme { CustomSelectorScreen( uiState = CustomSelectorState(), + onViewImage = { }, onEvent = { }, selectedImageIds = { emptySet() }, hasPartialAccess = true diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt index a984323b17..77c32a24c3 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt @@ -68,6 +68,7 @@ fun ImagesPane( selectedFolder: Folder, selectedImages: () -> Set, onNavigateBack: () -> Unit, + onViewImage: (id: Long)-> Unit, onEvent: (CustomSelectorEvent) -> Unit, adaptiveInfo: WindowAdaptiveInfo, hasPartialAccess: Boolean = false @@ -128,7 +129,7 @@ fun ImagesPane( } LazyVerticalGrid( - columns = GridCells.Adaptive(116.dp), + columns = GridCells.Adaptive(96.dp), modifier = Modifier .fillMaxSize() .imageGridDragHandler( @@ -159,6 +160,8 @@ fun ImagesPane( onClick = { if (uiState.inSelectionMode) { onEvent(CustomSelectorEvent.OnImageSelection(image.id)) + } else { + onViewImage(image.id) } }, onLongClick = { diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt new file mode 100644 index 0000000000..90b9b641c3 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt @@ -0,0 +1,179 @@ +package fr.free.nrw.commons.customselector.ui.screens + +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import fr.free.nrw.commons.customselector.model.Image +import kotlin.math.abs + +@Composable +fun ViewImageScreen( + currentImageIndex: Int, + imageList: List, +) { + var imageScale by remember { mutableFloatStateOf(1f) } + var imageOffset by remember { mutableStateOf(Offset.Zero) } + var imageSize by remember { mutableStateOf(IntSize.Zero) } + var containerSize by remember { mutableStateOf(IntSize.Zero) } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(imageSize) { + println("Image Size : $imageSize") + } + + val pagerState = rememberPagerState(initialPage = currentImageIndex) { imageList.size } + + val scrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + return if (imageScale > 1f) { + // If zoomed in, consume the scroll for panning + println("Consuming for panning...") + available + } else if ( + source == NestedScrollSource.UserInput && abs(pagerState.currentPageOffsetFraction) > 1e-6 + ) { + println("Handling swipe gestures...") + // Handle page swipes only if the image isn't zoomed + val delta = available.x + val consumed = -pagerState.dispatchRawDelta(-delta) + Offset(consumed, 0f) + } else { + println("Just passing the as it is...") + Offset.Zero + } + } + } + + HorizontalPager( + state = pagerState, + key = { imageList[it].id }, + pageSpacing = 16.dp + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .onSizeChanged { + containerSize = it + }, + contentAlignment = Alignment.Center + ) { +// val state = rememberTransformableState { zoomChange, panChange, _ -> +// imageScale = (imageScale * zoomChange).coerceIn(1f, 7f) +// +// val imageWidth = imageSize.width * imageScale +// val imageHeight = imageSize.height * imageScale +// +// val extraWidth = (imageWidth - constraints.maxWidth).coerceAtLeast(0f) +// val extraHeight = (imageHeight - constraints.maxHeight).coerceAtLeast(0f) +// +// val maxX = extraWidth / 2 +// val maxY = extraHeight / 2 +// +// imageOffset = Offset( +// x = (imageOffset.x + imageScale * panChange.x).coerceIn(-maxX, maxX), +// y = (imageOffset.y + imageScale * panChange.y).coerceIn(-maxY, maxY) +// ) +// } + + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageList[it].uri) + .build(), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .onSizeChanged { imageSize = it } +// .pointerInput(Unit) { +// detectTransformGestures { centroid, pan, zoom, _ -> +// imageScale = (imageScale * zoom).coerceIn(1f, 7f) +// +// val imageWidth = imageSize.width * imageScale +// val imageHeight = imageSize.height * imageScale +// +// val extraWidth = (imageWidth-constraints.maxWidth).coerceAtLeast(0f) +// val extraHeight = (imageHeight-constraints.maxHeight).coerceAtLeast(0f) +// +// val maxX = extraWidth / 2 +// val maxY = extraHeight / 2 +// +// imageOffset = Offset( +// x = (imageOffset.x + imageScale * pan.x).coerceIn( +// -maxX, +// maxX +// ), +// y = (imageOffset.y + imageScale * pan.y).coerceIn(-maxY, maxY) +// ) +// } +// } + .graphicsLayer { + scaleX = imageScale + scaleY = imageScale + translationX = imageOffset.x + translationY = imageOffset.y + } + ) + } + } +} + +suspend fun PointerInputScope.detectDragAndZoomGestures( + onZoom: (Float) -> Unit, + onDrag: (Offset) -> Unit +) { + detectTransformGestures { _, pan, zoom, _ -> + onZoom(zoom) + onDrag(pan) + } +} + +fun Offset.calculateNewOffset( + centroid: Offset, + pan: Offset, + zoom: Float, + gestureZoom: Float, + size: IntSize +): Offset { + val newScale = maxOf(1f, zoom * gestureZoom) + val newOffset = (this + centroid / zoom) - + (centroid / newScale + pan / zoom) + return Offset( + newOffset.x.coerceIn(0f, (size.width / zoom) * (zoom - 1f)), + newOffset.y.coerceIn(0f, (size.height / zoom) * (zoom - 1f)) + ) +} + +fun calculateDoubleTapOffset( + zoom: Float, + size: IntSize, + tapOffset: Offset +): Offset { + val newOffset = Offset(tapOffset.x, tapOffset.y) + return Offset( + newOffset.x.coerceIn(0f, (size.width / zoom) * (zoom - 1f)), + newOffset.y.coerceIn(0f, (size.height / zoom) * (zoom - 1f)) + ) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index 47e139147b..1d9fcd1187 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -6,6 +6,7 @@ import android.app.Dialog import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.os.Bundle import android.view.View @@ -14,6 +15,9 @@ import android.widget.Button import android.widget.ImageButton import android.widget.TextView import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -22,6 +26,9 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import fr.free.nrw.commons.R import fr.free.nrw.commons.customselector.data.MediaReader import fr.free.nrw.commons.customselector.database.NotForUploadStatus @@ -31,10 +38,10 @@ import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.ui.screens.CustomSelectorScreen +import fr.free.nrw.commons.customselector.ui.screens.ViewImageScreen import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding -import fr.free.nrw.commons.filepicker.Constants import fr.free.nrw.commons.media.ZoomableActivity import fr.free.nrw.commons.theme.BaseActivity import fr.free.nrw.commons.ui.theme.CommonsTheme @@ -129,7 +136,7 @@ class CustomSelectorActivity : private var showPartialAccessIndicator by mutableStateOf(false) - private val startForResult = registerForActivityResult(StartActivityForResult()){ result -> + private val startForResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()){ result -> onFullScreenDataReceived(result) } @@ -137,6 +144,7 @@ class CustomSelectorActivity : * onCreate Activity, sets theme, initialises the view model, setup view. */ override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && ContextCompat.checkSelfPermission( @@ -182,12 +190,30 @@ class CustomSelectorActivity : val uiState by csViewModel.uiState.collectAsStateWithLifecycle() CommonsTheme { - CustomSelectorScreen( - uiState = uiState, - onEvent = csViewModel::onEvent, - selectedImageIds = { uiState.selectedImageIds }, - hasPartialAccess = showPartialAccessIndicator - ) + val navController = rememberNavController() + + NavHost(navController = navController, startDestination = "main") { + composable(route = "main") { + CustomSelectorScreen( + uiState = uiState, + onEvent = csViewModel::onEvent, + onViewImage = { navController.navigate("view_image/$it") }, + selectedImageIds = { uiState.selectedImageIds }, + hasPartialAccess = showPartialAccessIndicator + ) + } + + composable(route = "view_image/{imageId}") { backStackEntry-> + val imageId = backStackEntry.arguments?.getString("imageId")?.toLongOrNull() + val imageUri = uiState.filteredImages.find { it.id == imageId }?.uri ?: Uri.EMPTY + val imageIndex = uiState.filteredImages.indexOfFirst { it.id == imageId } + + ViewImageScreen( + currentImageIndex = imageIndex, + imageList = uiState.filteredImages + ) + } + } } } From dcd31f967149bc1148534867a59de23a7e2e947c Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Mon, 25 Nov 2024 23:28:33 +0530 Subject: [PATCH 14/23] update dependencies --- app/build.gradle | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index e0cf16e8e3..59308bbafb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -47,14 +47,14 @@ dependencies { implementation 'com.jakewharton.timber:timber:4.7.1' implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0' - implementation "com.google.android.material:material:1.9.0" + implementation "com.google.android.material:material:1.12.0" implementation 'com.karumi:dexter:5.0.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' // Jetpack Compose - def composeBom = platform('androidx.compose:compose-bom:2024.09.02') + def composeBom = platform('androidx.compose:compose-bom:2024.10.00') - implementation "androidx.activity:activity-compose:1.9.2" + implementation "androidx.activity:activity-compose:1.9.3" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.6" implementation (composeBom) implementation "androidx.compose.runtime:runtime" @@ -70,8 +70,8 @@ dependencies { implementation "androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0" implementation "androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0" - implementation "io.coil-kt:coil-compose:2.2.0" - implementation "androidx.navigation:navigation-compose:2.7.0" + implementation "io.coil-kt:coil-compose:2.6.0" + implementation "androidx.navigation:navigation-compose:2.8.3" implementation "com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:$ADAPTER_DELEGATES_VERSION" implementation "com.hannesdorfmann:adapterdelegates4-pagination:$ADAPTER_DELEGATES_VERSION" From 130158f510ac14d3e58ba7a3f71d7fae5dc74587 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Tue, 26 Nov 2024 01:30:08 +0530 Subject: [PATCH 15/23] remove state-changing argument causing unnecessary recompositions --- .../fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt index 77c32a24c3..eae2e9349f 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt @@ -257,7 +257,7 @@ fun Modifier.imageGridDragHandler( autoScrollThreshold: Float, setSelectedImageIds: (Set) -> Unit, setAutoScrollSpeed: (Float) -> Unit, -) = pointerInput(autoScrollThreshold, setAutoScrollSpeed, setSelectedImageIds, imageList) { +) = pointerInput(autoScrollThreshold, setAutoScrollSpeed, imageList) { fun imageIndexAtOffset(hitPoint: Offset): Int? = gridState.layoutInfo.visibleItemsInfo.find { itemInfo -> From 03713dd62b8a091d21944e7ce8a73c539d085111 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Tue, 26 Nov 2024 20:52:14 +0530 Subject: [PATCH 16/23] add functionality for unselecting all pictures at once --- .../ui/components/CustomSelectorTopBar.kt | 31 +++++++++++++++---- .../ui/screens/CustomSelectorEvent.kt | 1 + .../ui/screens/CustomSelectorScreen.kt | 6 +++- .../ui/screens/CustomSelectorViewModel.kt | 4 ++- .../customselector/ui/screens/ImagesPane.kt | 4 ++- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt index a596d4620d..0eac774ca0 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt @@ -1,11 +1,15 @@ package fr.free.nrw.commons.customselector.ui.components +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api @@ -16,6 +20,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription @@ -39,6 +44,7 @@ fun CustomSelectorTopBar( showSelectionCount: Boolean = false, showAlertIcon: Boolean = false, onAlertAction: ()-> Unit = { }, + onUnselectAllAction: ()-> Unit = { } ) { TopAppBar( title = { @@ -86,15 +92,27 @@ fun CustomSelectorTopBar( colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.primary ), + elevation = CardDefaults.elevatedCardElevation(8.dp), shape = CircleShape, modifier = Modifier.semantics { contentDescription = "$selectionCount Selected" } .padding(end = 8.dp) ) { - Text( - text = "$selectionCount", - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelMedium - ) + Row( + modifier = Modifier.padding(start = 16.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "$selectionCount", + modifier = Modifier.padding(vertical = 8.dp) + ) + + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + modifier = Modifier.clickable { onUnselectAllAction() } + ) + } } } } @@ -111,7 +129,8 @@ private fun CustomSelectorTopBarPreview() { secondaryText = "10 images", onNavigateBack = { }, showAlertIcon = true, - selectionCount = 1 + selectionCount = 2, + showSelectionCount = true ) } } diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt index fe6ea6bc71..b1af47f9a3 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt @@ -4,4 +4,5 @@ interface CustomSelectorEvent { data class OnFolderClick(val bucketId: Long): CustomSelectorEvent data class OnImageSelection(val imageId: Long): CustomSelectorEvent data class OnDragImageSelection(val imageIds: Set): CustomSelectorEvent + data object OnUnselectAll: CustomSelectorEvent } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt index 4fa3bbdca9..c3cf1c34db 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn @@ -85,7 +86,8 @@ fun CustomSelectorScreen( navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it) }, hasPartialAccess = hasPartialAccess, - adaptiveInfo = adaptiveInfo + adaptiveInfo = adaptiveInfo, + onUnselectAll = { onEvent(CustomSelectorEvent.OnUnselectAll) } ) } }, @@ -112,6 +114,7 @@ fun CustomSelectorScreen( fun FoldersPane( uiState: CustomSelectorState, onFolderClick: (Folder)-> Unit, + onUnselectAll: ()-> Unit, adaptiveInfo: WindowAdaptiveInfo, hasPartialAccess: Boolean = false ) { @@ -129,6 +132,7 @@ fun FoldersPane( showAlertIcon = uiState.selectedImageIds.size > 20 && isCompatWidth, selectionCount = uiState.selectedImageIds.size, onAlertAction = { }, + onUnselectAllAction = onUnselectAll, showSelectionCount = uiState.inSelectionMode && isCompatWidth ) } diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt index 5d06671ea9..842ee8ac68 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt @@ -57,7 +57,9 @@ class CustomSelectorViewModel(private val mediaReader: MediaReader): ViewModel() _uiState.update { it.copy(selectedImageIds = e.imageIds) } } - else -> {} + CustomSelectorEvent.OnUnselectAll-> { + _uiState.update { it.copy(selectedImageIds = emptySet()) } + } } } } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt index eae2e9349f..bb88fb3987 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells @@ -101,7 +102,8 @@ fun ImagesPane( showNavigationIcon = isCompatWidth, showAlertIcon = selectedImages().size > 20, selectionCount = selectedImages().size, - showSelectionCount = uiState.inSelectionMode + showSelectionCount = uiState.inSelectionMode, + onUnselectAllAction = { onEvent(CustomSelectorEvent.OnUnselectAll) } ) }, bottomBar = { From 178154ce0e82f03260520b2b21aa293cdb0addef Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Tue, 26 Nov 2024 20:53:14 +0530 Subject: [PATCH 17/23] fix overlapping navigation bar adding navigation bar padding --- .../commons/customselector/ui/screens/CustomSelectorScreen.kt | 1 + .../fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt index c3cf1c34db..de73ffc2ad 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt @@ -150,6 +150,7 @@ fun FoldersPane( /*TODO("Implement action to mark/unmark images as not for upload")*/ }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + .navigationBarsPadding() ) } } diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt index bb88fb3987..65540eb5af 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt @@ -117,6 +117,7 @@ fun ImagesPane( onPrimaryAction = { /*TODO("Implement action to upload selected images")*/ }, onSecondaryAction = { /*TODO("Implement action to mark/unmark as not for upload")*/ }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + .navigationBarsPadding() ) } } From c688059a43f24cc5a628030376cff2488652052f Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Wed, 27 Nov 2024 11:01:31 +0530 Subject: [PATCH 18/23] remove onBackPressed override --- .../ui/selector/CustomSelectorActivity.kt | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index 50d09361d9..975bffbb56 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -20,19 +20,6 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -700,23 +687,6 @@ class CustomSelectorActivity : finish() } - /** - * Back pressed. - * Change toolbar title. - */ - override fun onBackPressed() { - super.onBackPressed() - val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container) - if (fragment != null && fragment is FolderFragment) { - isImageFragmentOpen = false - changeTitle(getString(R.string.custom_selector_title), 0) - } - - //hide overflow menu when not in folder - showOverflowMenu = false - setUpToolbar() - } - /** * Displays a dialog explaining the upload limit warning. */ From c033003a475ff7604b0051b209bf302500c98945 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Fri, 29 Nov 2024 22:51:22 +0530 Subject: [PATCH 19/23] move models package inside domain package --- .../commons/customselector/{ => domain}/model/CallbackStatus.kt | 2 +- .../nrw/commons/customselector/{ => domain}/model/Folder.kt | 2 +- .../free/nrw/commons/customselector/{ => domain}/model/Image.kt | 2 +- .../nrw/commons/customselector/{ => domain}/model/Result.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename app/src/main/java/fr/free/nrw/commons/customselector/{ => domain}/model/CallbackStatus.kt (87%) rename app/src/main/java/fr/free/nrw/commons/customselector/{ => domain}/model/Folder.kt (93%) rename app/src/main/java/fr/free/nrw/commons/customselector/{ => domain}/model/Image.kt (98%) rename app/src/main/java/fr/free/nrw/commons/customselector/{ => domain}/model/Result.kt (81%) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/CallbackStatus.kt b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/CallbackStatus.kt similarity index 87% rename from app/src/main/java/fr/free/nrw/commons/customselector/model/CallbackStatus.kt rename to app/src/main/java/fr/free/nrw/commons/customselector/domain/model/CallbackStatus.kt index c47806f16e..9339219f7c 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/model/CallbackStatus.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/CallbackStatus.kt @@ -1,4 +1,4 @@ -package fr.free.nrw.commons.customselector.model +package fr.free.nrw.commons.customselector.domain.model /** * sealed class Callback Status. diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/Folder.kt b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Folder.kt similarity index 93% rename from app/src/main/java/fr/free/nrw/commons/customselector/model/Folder.kt rename to app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Folder.kt index ec08f6f73a..a0e0300e6b 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/model/Folder.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Folder.kt @@ -1,4 +1,4 @@ -package fr.free.nrw.commons.customselector.model +package fr.free.nrw.commons.customselector.domain.model /** * Custom selector data class Folder. diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Image.kt similarity index 98% rename from app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt rename to app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Image.kt index a2965fb5df..bd8dc25951 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Image.kt @@ -1,4 +1,4 @@ -package fr.free.nrw.commons.customselector.model +package fr.free.nrw.commons.customselector.domain.model import android.net.Uri import android.os.Parcel diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/Result.kt b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Result.kt similarity index 81% rename from app/src/main/java/fr/free/nrw/commons/customselector/model/Result.kt rename to app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Result.kt index 5cccccae6a..c402cb62e7 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/model/Result.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Result.kt @@ -1,4 +1,4 @@ -package fr.free.nrw.commons.customselector.model +package fr.free.nrw.commons.customselector.domain.model /** * Custom selector data class Result. From 1d11ab7eab50646709e5ebd0725fd0dcac82d706 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Fri, 29 Nov 2024 22:54:14 +0530 Subject: [PATCH 20/23] fix imports and refactor code --- .../free/nrw/commons/customselector/data/MediaReader.kt | 5 +++-- .../free/nrw/commons/customselector/helper/ImageHelper.kt | 4 ++-- .../customselector/listeners/ImageLoaderListener.kt | 2 +- .../customselector/listeners/ImageSelectListener.kt | 2 +- .../commons/customselector/listeners/PassDataListener.kt | 2 +- .../commons/customselector/ui/adapter/FolderAdapter.kt | 4 ++-- .../nrw/commons/customselector/ui/adapter/ImageAdapter.kt | 2 +- .../nrw/commons/customselector/ui/screens/ImagesPane.kt | 2 +- .../commons/customselector/ui/screens/ViewImageScreen.kt | 2 +- .../customselector/ui/selector/CustomSelectorActivity.kt | 2 +- .../customselector/ui/selector/CustomSelectorViewModel.kt | 6 +++--- .../commons/customselector/ui/selector/FolderFragment.kt | 6 +++--- .../commons/customselector/ui/selector/ImageFileLoader.kt | 2 +- .../commons/customselector/ui/selector/ImageFragment.kt | 8 +++----- .../nrw/commons/customselector/ui/selector/ImageLoader.kt | 3 +-- .../java/fr/free/nrw/commons/filepicker/FilePicker.java | 2 +- .../java/fr/free/nrw/commons/media/ZoomableActivity.kt | 6 +++--- .../java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt | 2 +- .../nrw/commons/customselector/helper/ImageHelperTest.kt | 4 ++-- .../customselector/ui/adapter/FolderAdapterTest.kt | 4 ++-- .../commons/customselector/ui/adapter/ImageAdapterTest.kt | 2 +- .../ui/selector/CustomSelectorActivityTest.kt | 2 +- .../customselector/ui/selector/FolderFragmentTest.kt | 4 ++-- .../customselector/ui/selector/ImageFragmentTest.kt | 6 +++--- .../commons/customselector/ui/selector/ImageLoaderTest.kt | 2 +- .../free/nrw/commons/media/ZoomableActivityUnitTests.kt | 6 +++--- 26 files changed, 45 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/data/MediaReader.kt b/app/src/main/java/fr/free/nrw/commons/customselector/data/MediaReader.kt index f75400712e..2b54f24977 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/data/MediaReader.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/data/MediaReader.kt @@ -4,14 +4,15 @@ import android.content.ContentUris import android.content.Context import android.provider.MediaStore import android.text.format.DateFormat -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import java.util.Calendar import java.util.Date +import javax.inject.Inject -class MediaReader(private val context: Context) { +class MediaReader @Inject constructor(private val context: Context) { fun getImages() = flow { val projection = arrayOf( MediaStore.Images.Media._ID, diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt index 5df123ad2c..3de523b3cd 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt @@ -1,7 +1,7 @@ package fr.free.nrw.commons.customselector.helper -import fr.free.nrw.commons.customselector.model.Folder -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Folder +import fr.free.nrw.commons.customselector.domain.model.Image /** * Image Helper object, includes all the static functions and variables required by custom selector. diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt index 78ce46c6e4..06c9b1cc6b 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt @@ -1,6 +1,6 @@ package fr.free.nrw.commons.customselector.listeners -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image /** * Custom Selector Image Loader Listener diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt index 24565963b6..10c2b5fd2e 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt @@ -1,6 +1,6 @@ package fr.free.nrw.commons.customselector.listeners -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image /** * Custom selector Image select listener diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt index da526be35e..3893a96e62 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt @@ -1,6 +1,6 @@ package fr.free.nrw.commons.customselector.listeners -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image /** * Interface to pass data between fragment and activity diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt index 87f68a3e13..ba3d36d5e0 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt @@ -10,8 +10,8 @@ import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import fr.free.nrw.commons.R import fr.free.nrw.commons.customselector.listeners.FolderClickListener -import fr.free.nrw.commons.customselector.model.Folder -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Folder +import fr.free.nrw.commons.customselector.domain.model.Image /** * Custom selector FolderAdapter. diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt index 74b937f970..a2ddfb21be 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt @@ -17,7 +17,7 @@ import fr.free.nrw.commons.customselector.helper.ImageHelper import fr.free.nrw.commons.customselector.helper.ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY import fr.free.nrw.commons.customselector.helper.ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY import fr.free.nrw.commons.customselector.listeners.ImageSelectListener -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image import fr.free.nrw.commons.customselector.ui.selector.ImageLoader import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt index 65540eb5af..3a036239e0 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt @@ -54,7 +54,7 @@ import androidx.compose.ui.unit.toIntRect import androidx.window.core.layout.WindowWidthSizeClass import coil.compose.rememberAsyncImagePainter import fr.free.nrw.commons.R -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image import fr.free.nrw.commons.customselector.ui.components.CustomSelectorBottomBar import fr.free.nrw.commons.customselector.ui.components.CustomSelectorTopBar import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt index 90b9b641c3..a9acf25908 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image import kotlin.math.abs @Composable diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index 975bffbb56..efff0a07c7 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -39,7 +39,7 @@ import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants import fr.free.nrw.commons.customselector.helper.FolderDeletionHelper import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.ImageSelectListener -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image import fr.free.nrw.commons.customselector.ui.screens.CustomSelectorScreen import fr.free.nrw.commons.customselector.ui.screens.ViewImageScreen import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorViewModel.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorViewModel.kt index f3465063a7..b8821bda2b 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorViewModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorViewModel.kt @@ -4,9 +4,9 @@ import android.content.Context import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import fr.free.nrw.commons.customselector.listeners.ImageLoaderListener -import fr.free.nrw.commons.customselector.model.CallbackStatus -import fr.free.nrw.commons.customselector.model.Image -import fr.free.nrw.commons.customselector.model.Result +import fr.free.nrw.commons.customselector.domain.model.CallbackStatus +import fr.free.nrw.commons.customselector.domain.model.Image +import fr.free.nrw.commons.customselector.domain.model.Result import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/FolderFragment.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/FolderFragment.kt index 6ca2b06e40..83fa5366c0 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/FolderFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/FolderFragment.kt @@ -10,9 +10,9 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import fr.free.nrw.commons.customselector.helper.ImageHelper import fr.free.nrw.commons.customselector.listeners.FolderClickListener -import fr.free.nrw.commons.customselector.model.CallbackStatus -import fr.free.nrw.commons.customselector.model.Folder -import fr.free.nrw.commons.customselector.model.Result +import fr.free.nrw.commons.customselector.domain.model.CallbackStatus +import fr.free.nrw.commons.customselector.domain.model.Folder +import fr.free.nrw.commons.customselector.domain.model.Result import fr.free.nrw.commons.customselector.ui.adapter.FolderAdapter import fr.free.nrw.commons.databinding.FragmentCustomSelectorBinding import fr.free.nrw.commons.di.CommonsDaggerSupportFragment diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt index f079dee507..9083c74b4e 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt @@ -5,7 +5,7 @@ import android.content.Context import android.provider.MediaStore import android.text.format.DateFormat import fr.free.nrw.commons.customselector.listeners.ImageLoaderListener -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt index 3912a4d12f..2c898259af 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt @@ -1,7 +1,6 @@ package fr.free.nrw.commons.customselector.ui.selector import android.app.Activity -import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.SharedPreferences import android.os.Bundle @@ -26,15 +25,14 @@ import fr.free.nrw.commons.customselector.helper.ImageHelper.SHOW_ALREADY_ACTION import fr.free.nrw.commons.customselector.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.listeners.PassDataListener import fr.free.nrw.commons.customselector.listeners.RefreshUIListener -import fr.free.nrw.commons.customselector.model.CallbackStatus -import fr.free.nrw.commons.customselector.model.Image -import fr.free.nrw.commons.customselector.model.Result +import fr.free.nrw.commons.customselector.domain.model.CallbackStatus +import fr.free.nrw.commons.customselector.domain.model.Image +import fr.free.nrw.commons.customselector.domain.model.Result import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter import fr.free.nrw.commons.databinding.FragmentCustomSelectorBinding import fr.free.nrw.commons.databinding.ProgressDialogBinding import fr.free.nrw.commons.di.CommonsDaggerSupportFragment import fr.free.nrw.commons.media.MediaClient -import fr.free.nrw.commons.theme.BaseActivity import fr.free.nrw.commons.upload.FileProcessor import fr.free.nrw.commons.upload.FileUtilsWrapper import io.reactivex.schedulers.Schedulers diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt index ddfcf341ea..b36e054a15 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt @@ -8,7 +8,7 @@ import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao import fr.free.nrw.commons.customselector.database.UploadedStatus import fr.free.nrw.commons.customselector.database.UploadedStatusDao import fr.free.nrw.commons.customselector.helper.ImageHelper -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter.ImageViewHolder import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.upload.FileProcessor @@ -17,7 +17,6 @@ import fr.free.nrw.commons.utils.CustomSelectorUtils import fr.free.nrw.commons.utils.CustomSelectorUtils.Companion.checkWhetherFileExistsOnCommonsUsingSHA1 import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import java.util.Calendar diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java index b64db24c5f..30f9ac2763 100644 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java @@ -17,7 +17,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.PreferenceManager; -import fr.free.nrw.commons.customselector.model.Image; +import fr.free.nrw.commons.customselector.domain.model.Image; import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity; import java.io.File; import java.io.IOException; diff --git a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt index 14b5788c24..f455668c03 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt @@ -28,9 +28,9 @@ import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants.SHOULD_REFRESH import fr.free.nrw.commons.customselector.helper.ImageHelper import fr.free.nrw.commons.customselector.helper.OnSwipeTouchListener -import fr.free.nrw.commons.customselector.model.CallbackStatus -import fr.free.nrw.commons.customselector.model.Image -import fr.free.nrw.commons.customselector.model.Result +import fr.free.nrw.commons.customselector.domain.model.CallbackStatus +import fr.free.nrw.commons.customselector.domain.model.Image +import fr.free.nrw.commons.customselector.domain.model.Result import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorViewModel import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorViewModelFactory import fr.free.nrw.commons.databinding.ActivityZoomableBinding diff --git a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt index fc80252fc9..250d81f678 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt +++ b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt @@ -4,7 +4,7 @@ import android.content.ContentResolver import android.content.Context import android.net.Uri import androidx.exifinterface.media.ExifInterface -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image import fr.free.nrw.commons.customselector.ui.selector.ImageLoader import fr.free.nrw.commons.filepicker.PickedFiles import fr.free.nrw.commons.media.MediaClient diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/helper/ImageHelperTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/helper/ImageHelperTest.kt index d8b501522d..2fc73c2ad2 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/helper/ImageHelperTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/helper/ImageHelperTest.kt @@ -1,8 +1,8 @@ package fr.free.nrw.commons.customselector.helper import android.net.Uri -import fr.free.nrw.commons.customselector.model.Folder -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Folder +import fr.free.nrw.commons.customselector.domain.model.Image import org.junit.Test import org.junit.jupiter.api.Assertions.assertEquals import org.mockito.Mockito.mock diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapterTest.kt index 08dadca25a..5ee286468e 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapterTest.kt @@ -11,8 +11,8 @@ import com.nhaarman.mockitokotlin2.whenever import fr.free.nrw.commons.R import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.customselector.listeners.FolderClickListener -import fr.free.nrw.commons.customselector.model.Folder -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Folder +import fr.free.nrw.commons.customselector.domain.model.Image import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity import org.junit.Before import org.junit.Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapterTest.kt index 2a4c8c7915..d604bd06a3 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapterTest.kt @@ -11,7 +11,7 @@ import com.nhaarman.mockitokotlin2.whenever import fr.free.nrw.commons.R import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.customselector.listeners.ImageSelectListener -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity import fr.free.nrw.commons.customselector.ui.selector.ImageLoader import kotlinx.coroutines.Dispatchers diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivityTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivityTest.kt index b1d66ee4d3..760e772577 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivityTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivityTest.kt @@ -9,7 +9,7 @@ import fr.free.nrw.commons.OkHttpConnectionFactory import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.contributions.ContributionDao import fr.free.nrw.commons.createTestClient -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter import org.junit.Before import org.junit.Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/FolderFragmentTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/FolderFragmentTest.kt index 49da532591..e50442e32e 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/FolderFragmentTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/FolderFragmentTest.kt @@ -16,8 +16,8 @@ import fr.free.nrw.commons.OkHttpConnectionFactory import fr.free.nrw.commons.R import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.createTestClient -import fr.free.nrw.commons.customselector.model.CallbackStatus -import fr.free.nrw.commons.customselector.model.Result +import fr.free.nrw.commons.customselector.domain.model.CallbackStatus +import fr.free.nrw.commons.customselector.domain.model.Result import fr.free.nrw.commons.customselector.ui.adapter.FolderAdapter import org.junit.Before import org.junit.Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageFragmentTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageFragmentTest.kt index eeb6db46a5..5cf5c19669 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageFragmentTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageFragmentTest.kt @@ -20,9 +20,9 @@ import fr.free.nrw.commons.R import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.contributions.ContributionDao import fr.free.nrw.commons.createTestClient -import fr.free.nrw.commons.customselector.model.CallbackStatus -import fr.free.nrw.commons.customselector.model.Image -import fr.free.nrw.commons.customselector.model.Result +import fr.free.nrw.commons.customselector.domain.model.CallbackStatus +import fr.free.nrw.commons.customselector.domain.model.Image +import fr.free.nrw.commons.customselector.domain.model.Result import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter import org.junit.Before import org.junit.Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageLoaderTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageLoaderTest.kt index 64447384b8..0ae53c858b 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageLoaderTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageLoaderTest.kt @@ -11,7 +11,7 @@ import fr.free.nrw.commons.TestUtility.setFinalStatic import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao import fr.free.nrw.commons.customselector.database.UploadedStatus import fr.free.nrw.commons.customselector.database.UploadedStatusDao -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.model.Image import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter import fr.free.nrw.commons.filepicker.PickedFiles import fr.free.nrw.commons.filepicker.UploadableFile diff --git a/app/src/test/kotlin/fr/free/nrw/commons/media/ZoomableActivityUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/media/ZoomableActivityUnitTests.kt index 848e0881aa..f8c387c9ff 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/media/ZoomableActivityUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/media/ZoomableActivityUnitTests.kt @@ -9,9 +9,9 @@ import com.facebook.soloader.SoLoader import fr.free.nrw.commons.OkHttpConnectionFactory import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.createTestClient -import fr.free.nrw.commons.customselector.model.CallbackStatus -import fr.free.nrw.commons.customselector.model.Image -import fr.free.nrw.commons.customselector.model.Result +import fr.free.nrw.commons.customselector.domain.model.CallbackStatus +import fr.free.nrw.commons.customselector.domain.model.Image +import fr.free.nrw.commons.customselector.domain.model.Result import org.junit.Assert import org.junit.Before import org.junit.Test From 443e7139557bd66529417d8dfd982c896e1b20b7 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Fri, 3 Jan 2025 22:49:05 +0530 Subject: [PATCH 21/23] refactor Ui components for custom selector --- .../customselector/ui/components/Buttons.kt | 4 +++ .../ui/components/CustomSelectorBottomBar.kt | 12 ++++++-- .../ui/components/CustomSelectorTopBar.kt | 28 ++++++++++--------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt index 7720cfca14..e473d427ed 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt @@ -22,12 +22,14 @@ fun PrimaryButton( text: String, onClick: ()-> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, shape: Shape = RoundedCornerShape(12.dp), ) { Button( onClick = onClick, modifier = modifier, shape = shape, + enabled = enabled, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 4.dp) ) { Text( @@ -42,11 +44,13 @@ fun SecondaryButton( text: String, onClick: ()-> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, shape: Shape = RoundedCornerShape(12.dp), ) { OutlinedButton( onClick = onClick, modifier = modifier, + enabled = enabled, border = BorderStroke(1.dp, color = MaterialTheme.colorScheme.primary), shape = shape, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt index 62129de952..a7b0d91d22 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt @@ -19,14 +19,21 @@ import fr.free.nrw.commons.ui.theme.CommonsTheme fun CustomSelectorBottomBar( onPrimaryAction: ()-> Unit, onSecondaryAction: ()-> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isAnyImageNotForUpload: Boolean = false ) { + val buttonText = if (isAnyImageNotForUpload) { + R.string.unmark_as_not_for_upload + } else { + R.string.mark_as_not_for_upload + } + Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { SecondaryButton( - text = stringResource(R.string.mark_as_not_for_upload).uppercase(), + text = stringResource(buttonText).uppercase(), onClick = onSecondaryAction, modifier = Modifier.weight(1f) ) @@ -34,6 +41,7 @@ fun CustomSelectorBottomBar( PrimaryButton( text = stringResource(R.string.upload).uppercase(), onClick = onPrimaryAction, + enabled = !isAnyImageNotForUpload, modifier = Modifier .weight(1f) .height(IntrinsicSize.Max) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt index 0eac774ca0..dd9a530ee7 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt @@ -1,12 +1,14 @@ package fr.free.nrw.commons.customselector.ui.components import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft import androidx.compose.material.icons.filled.Close @@ -20,6 +22,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -92,25 +95,24 @@ fun CustomSelectorTopBar( colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.primary ), - elevation = CardDefaults.elevatedCardElevation(8.dp), - shape = CircleShape, - modifier = Modifier.semantics { contentDescription = "$selectionCount Selected" } - .padding(end = 8.dp) + shape = RoundedCornerShape(50), + modifier = Modifier.padding(end = 8.dp) + .semantics { contentDescription = "$selectionCount Selected" } ) { Row( - modifier = Modifier.padding(start = 16.dp, end = 8.dp), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) + .widthIn(min = 52.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.SpaceEvenly ) { - Text( - text = "$selectionCount", - modifier = Modifier.padding(vertical = 8.dp) - ) - + Text(text = "$selectionCount") Icon( imageVector = Icons.Default.Close, contentDescription = null, - modifier = Modifier.clickable { onUnselectAllAction() } + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onUnselectAllAction() } ) } } From e611cbc86f57f0817570a3af20b8474789401649 Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Fri, 3 Jan 2025 23:10:04 +0530 Subject: [PATCH 22/23] update UI states and refactor code Also, add repository as Single Source of Truth for performing operations on images --- .../data/ImageRepositoryImpl.kt | 29 ++++++ .../customselector/domain/ImageRepository.kt | 15 ++++ .../domain/use_case/ImageUseCase.kt | 89 +++++++++++++++++++ .../ui/screens/CustomSelectorScreen.kt | 8 +- .../ui/screens/CustomSelectorState.kt | 13 --- .../ui/screens/CustomSelectorViewModel.kt | 49 +++++++--- .../customselector/ui/screens/Folder.kt | 2 + .../customselector/ui/screens/ImagesPane.kt | 12 +-- .../ui/screens/ViewImageScreen.kt | 4 +- .../ui/selector/CustomSelectorActivity.kt | 19 ++-- .../ui/states/CustomSelectorUiState.kt | 17 ++++ .../customselector/ui/states/ImageUiState.kt | 20 +++++ .../utils/CustomSelectorViewModelFactory.kt | 21 +++++ .../commons/di/CommonsApplicationModule.java | 13 +++ 14 files changed, 262 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/data/ImageRepositoryImpl.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/domain/ImageRepository.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/domain/use_case/ImageUseCase.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/states/CustomSelectorUiState.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/ui/states/ImageUiState.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/utils/CustomSelectorViewModelFactory.kt diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/data/ImageRepositoryImpl.kt b/app/src/main/java/fr/free/nrw/commons/customselector/data/ImageRepositoryImpl.kt new file mode 100644 index 0000000000..6f64293e03 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/data/ImageRepositoryImpl.kt @@ -0,0 +1,29 @@ +package fr.free.nrw.commons.customselector.data + +import fr.free.nrw.commons.customselector.database.NotForUploadStatus +import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao +import fr.free.nrw.commons.customselector.domain.ImageRepository +import fr.free.nrw.commons.customselector.domain.model.Image +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class ImageRepositoryImpl @Inject constructor( + private val mediaReader: MediaReader, + private val notForUploadStatusDao: NotForUploadStatusDao +): ImageRepository { + override suspend fun getImagesFromDevice(): Flow { + return mediaReader.getImages() + } + + override suspend fun markAsNotForUpload(imageSHA: String) { + notForUploadStatusDao.insert(NotForUploadStatus(imageSHA)) + } + + override suspend fun unmarkAsNotForUpload(imageSHA: String) { + notForUploadStatusDao.deleteWithImageSHA1(imageSHA) + } + + override suspend fun isNotForUpload(imageSHA: String): Boolean { + return notForUploadStatusDao.find(imageSHA) > 0 + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/domain/ImageRepository.kt b/app/src/main/java/fr/free/nrw/commons/customselector/domain/ImageRepository.kt new file mode 100644 index 0000000000..eab5668952 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/domain/ImageRepository.kt @@ -0,0 +1,15 @@ +package fr.free.nrw.commons.customselector.domain + +import fr.free.nrw.commons.customselector.domain.model.Image +import kotlinx.coroutines.flow.Flow + +interface ImageRepository { + + suspend fun getImagesFromDevice(): Flow + + suspend fun markAsNotForUpload(imageSHA: String) + + suspend fun unmarkAsNotForUpload(imageSHA: String) + + suspend fun isNotForUpload(imageSHA: String): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/domain/use_case/ImageUseCase.kt b/app/src/main/java/fr/free/nrw/commons/customselector/domain/use_case/ImageUseCase.kt new file mode 100644 index 0000000000..2eb24e4ef0 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/domain/use_case/ImageUseCase.kt @@ -0,0 +1,89 @@ +package fr.free.nrw.commons.customselector.domain.use_case + +import android.content.Context +import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import fr.free.nrw.commons.customselector.ui.selector.ImageLoader +import fr.free.nrw.commons.filepicker.PickedFiles +import fr.free.nrw.commons.media.MediaClient +import fr.free.nrw.commons.upload.FileProcessor +import fr.free.nrw.commons.upload.FileUtilsWrapper +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okio.FileNotFoundException +import timber.log.Timber +import java.io.IOException +import java.net.UnknownHostException +import javax.inject.Inject + +class ImageUseCase @Inject constructor( + private val fileUtilsWrapper: FileUtilsWrapper, + private val fileProcessor: FileProcessor, + private val mediaClient: MediaClient, + private val context: Context +) { + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO + + /** + * Retrieves the SHA1 hash of an image from its URI. + * + * @param uri The URI of the image. + * @return The SHA1 hash of the image, or an empty string if the image is not found. + */ + suspend fun getImageSHA1(uri: Uri): String = withContext(ioDispatcher) { + try { + val inputStream = context.contentResolver.openInputStream(uri) + fileUtilsWrapper.getSHA1(inputStream) + } catch (e: FileNotFoundException) { + Timber.e(e) + "" + } + } + + /** + * Generates a modified SHA1 hash of an image after redacting sensitive EXIF tags. + * + * @param imageUri The URI of the image to process. + * @return The modified SHA1 hash of the image. + */ + suspend fun generateModifiedSHA1(imageUri: Uri): String = withContext(ioDispatcher) { + val uploadableFile = PickedFiles.pickedExistingPicture(context, imageUri) + val exifInterface: ExifInterface? = try { + ExifInterface(uploadableFile.file!!) + } catch (e: IOException) { + Timber.e(e) + null + } + fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact()) + + val sha1 = fileUtilsWrapper.getSHA1( + fileUtilsWrapper.getFileInputStream(uploadableFile.filePath)) + uploadableFile.file.delete() + sha1 + } + + /** + * Checks whether a file with the given SHA1 hash exists on Wikimedia Commons. + * + * @param sha1 The SHA1 hash of the file to check. + * @return An ImageLoader.Result indicating the existence of the file on Commons. + */ + suspend fun checkWhetherFileExistsOnCommonsUsingSHA1( + sha1: String + ): ImageLoader.Result = withContext(ioDispatcher) { + return@withContext try { + if (mediaClient.checkFileExistsUsingSha(sha1).blockingGet()) { + ImageLoader.Result.TRUE + } else { + ImageLoader.Result.FALSE + } + } catch (e: UnknownHostException) { + Timber.e(e, "Network Connection Error") + ImageLoader.Result.ERROR + } catch (e: Exception) { + e.printStackTrace() + ImageLoader.Result.ERROR + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt index de73ffc2ad..0b5ee070f1 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt @@ -56,12 +56,14 @@ import fr.free.nrw.commons.R import fr.free.nrw.commons.customselector.ui.components.CustomSelectorBottomBar import fr.free.nrw.commons.customselector.ui.components.CustomSelectorTopBar import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog +import fr.free.nrw.commons.customselector.ui.states.CustomSelectorUiState +import fr.free.nrw.commons.customselector.ui.states.ImageUiState import fr.free.nrw.commons.ui.theme.CommonsTheme @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun CustomSelectorScreen( - uiState: CustomSelectorState, + uiState: CustomSelectorUiState, onEvent: (CustomSelectorEvent)-> Unit, selectedImageIds: ()-> Set, onViewImage: (id: Long)-> Unit, @@ -112,7 +114,7 @@ fun CustomSelectorScreen( @Composable fun FoldersPane( - uiState: CustomSelectorState, + uiState: CustomSelectorUiState, onFolderClick: (Folder)-> Unit, onUnselectAll: ()-> Unit, adaptiveInfo: WindowAdaptiveInfo, @@ -270,7 +272,7 @@ private fun FolderItemPreview() { private fun CustomSelectorScreenPreview() { CommonsTheme { CustomSelectorScreen( - uiState = CustomSelectorState(), + uiState = CustomSelectorUiState(), onViewImage = { }, onEvent = { }, selectedImageIds = { emptySet() }, diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt deleted file mode 100644 index 11d88bf9bb..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorState.kt +++ /dev/null @@ -1,13 +0,0 @@ -package fr.free.nrw.commons.customselector.ui.screens - -import fr.free.nrw.commons.customselector.model.Image - -data class CustomSelectorState( - val isLoading: Boolean = false, - val folders: List = emptyList(), - val filteredImages: List = emptyList(), - val selectedImageIds: Set = emptySet() -) { - val inSelectionMode: Boolean - get() = selectedImageIds.isNotEmpty() -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt index 842ee8ac68..58b29e624e 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt @@ -1,44 +1,67 @@ package fr.free.nrw.commons.customselector.ui.screens +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import fr.free.nrw.commons.customselector.data.MediaReader -import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.domain.ImageRepository +import fr.free.nrw.commons.customselector.domain.model.Image +import fr.free.nrw.commons.customselector.domain.use_case.ImageUseCase +import fr.free.nrw.commons.customselector.ui.states.CustomSelectorUiState +import fr.free.nrw.commons.customselector.ui.states.ImageUiState +import fr.free.nrw.commons.customselector.ui.states.toImageUiState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import javax.inject.Inject -class CustomSelectorViewModel(private val mediaReader: MediaReader): ViewModel() { +typealias imageId = Long +typealias imageSHA = String - private val _uiState = MutableStateFlow(CustomSelectorState()) +class CustomSelectorViewModel @Inject constructor( + private val imageRepository: ImageRepository, + private val imageUseCase: ImageUseCase +): ViewModel() { + + private val _uiState = MutableStateFlow(CustomSelectorUiState()) val uiState = _uiState.asStateFlow() + private val cacheSHA1 = mutableMapOf() + + private val allImages = mutableListOf() private val foldersMap = mutableMapOf>() init { - _uiState.update { it.copy(isLoading = true) } viewModelScope.launch { - mediaReader.getImages().collect { image-> + imageRepository.getImagesFromDevice().collect { image -> val bucketId = image.bucketId + + allImages.add(image.toImageUiState()) foldersMap.getOrPut(bucketId) { mutableListOf() }.add(image) } - val foldersList = foldersMap.map { (bucketId, images)-> + val folders = foldersMap.map { (bucketId, images)-> val firstImage = images.first() Folder( - bucketId = bucketId, bucketName = firstImage.bucketName, - preview = firstImage.uri, itemsCount = images.size + bucketId = bucketId, + bucketName = firstImage.bucketName, + preview = firstImage.uri, + itemsCount = images.size, + images = images ) } - _uiState.update { it.copy(isLoading = false, folders = foldersList) } + _uiState.update { it.copy(isLoading = false, folders = folders) } } } fun onEvent(e: CustomSelectorEvent) { when(e) { - is CustomSelectorEvent.OnFolderClick-> { + is CustomSelectorEvent.OnFolderClick -> { _uiState.update { - it.copy(filteredImages = foldersMap[e.bucketId]?.toList() ?: emptyList()) + it.copy( + filteredImages = foldersMap[e.bucketId]?.map { + img -> img.toImageUiState() + } ?: emptyList() + ) } } @@ -46,7 +69,7 @@ class CustomSelectorViewModel(private val mediaReader: MediaReader): ViewModel() _uiState.update { state -> val updatedSelectedIds = if (state.selectedImageIds.contains(e.imageId)) { state.selectedImageIds - e.imageId // Remove if already selected - } else{ + } else { state.selectedImageIds + e.imageId // Add if not selected } state.copy(selectedImageIds = updatedSelectedIds) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/Folder.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/Folder.kt index 840e9d6bf4..303edad3cd 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/Folder.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/Folder.kt @@ -2,6 +2,7 @@ package fr.free.nrw.commons.customselector.ui.screens import android.net.Uri import android.os.Parcelable +import fr.free.nrw.commons.customselector.domain.model.Image import kotlinx.parcelize.Parcelize @Parcelize @@ -9,5 +10,6 @@ data class Folder( val bucketId: Long, val bucketName: String, val preview: Uri, + val images: List, val itemsCount: Int ): Parcelable diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt index 3a036239e0..381119ed21 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset @@ -54,18 +55,20 @@ import androidx.compose.ui.unit.toIntRect import androidx.window.core.layout.WindowWidthSizeClass import coil.compose.rememberAsyncImagePainter import fr.free.nrw.commons.R -import fr.free.nrw.commons.customselector.domain.model.Image import fr.free.nrw.commons.customselector.ui.components.CustomSelectorBottomBar import fr.free.nrw.commons.customselector.ui.components.CustomSelectorTopBar import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog +import fr.free.nrw.commons.customselector.ui.states.CustomSelectorUiState +import fr.free.nrw.commons.customselector.ui.states.ImageUiState import fr.free.nrw.commons.ui.theme.CommonsTheme +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @OptIn(ExperimentalFoundationApi::class) @Composable fun ImagesPane( - uiState: CustomSelectorState, + uiState: CustomSelectorUiState, selectedFolder: Folder, selectedImages: () -> Set, onNavigateBack: () -> Unit, @@ -74,7 +77,6 @@ fun ImagesPane( adaptiveInfo: WindowAdaptiveInfo, hasPartialAccess: Boolean = false ) { -// val inSelectionMode by remember { derivedStateOf { selectedImages().isNotEmpty() } } val lazyGridState = rememberLazyGridState() var autoScrollSpeed by remember { mutableFloatStateOf(0f) } val isCompatWidth by remember(adaptiveInfo.windowSizeClass) { @@ -255,7 +257,7 @@ private fun ImageItemPreview() { */ fun Modifier.imageGridDragHandler( gridState: LazyGridState, - imageList: List, + imageList: List, selectedImageIds: () -> Set, autoScrollThreshold: Float, setSelectedImageIds: (Set) -> Unit, @@ -337,7 +339,7 @@ fun Modifier.imageGridDragHandler( * @param pointerKey The ending index of the range. * @return A set of image IDs within the specified range. */ -fun List.getImageIdsInRange(initialKey: Int, pointerKey: Int): Set { +fun List.getImageIdsInRange(initialKey: Int, pointerKey: Int): Set { val setOfKeys = mutableSetOf() if (initialKey < pointerKey) { (initialKey..pointerKey).forEach { diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt index a9acf25908..0cb3bfcb8b 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt @@ -27,13 +27,13 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest -import fr.free.nrw.commons.customselector.domain.model.Image +import fr.free.nrw.commons.customselector.ui.states.ImageUiState import kotlin.math.abs @Composable fun ViewImageScreen( currentImageIndex: Int, - imageList: List, + imageList: List, ) { var imageScale by remember { mutableFloatStateOf(1f) } var imageOffset by remember { mutableStateOf(Offset.Zero) } diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index efff0a07c7..d1999eabce 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -27,21 +27,20 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import fr.free.nrw.commons.R -import fr.free.nrw.commons.customselector.data.MediaReader import fr.free.nrw.commons.customselector.database.NotForUploadStatus import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao +import fr.free.nrw.commons.customselector.domain.model.Image import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants import fr.free.nrw.commons.customselector.helper.FolderDeletionHelper import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.ImageSelectListener -import fr.free.nrw.commons.customselector.domain.model.Image import fr.free.nrw.commons.customselector.ui.screens.CustomSelectorScreen import fr.free.nrw.commons.customselector.ui.screens.ViewImageScreen +import fr.free.nrw.commons.customselector.utils.CustomSelectorViewModelFactory import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding @@ -191,17 +190,11 @@ class CustomSelectorActivity : // setContentView(view) prefs = applicationContext.getSharedPreferences("CustomSelector", MODE_PRIVATE) - viewModel = - ViewModelProvider(this, customSelectorViewModelFactory).get( - CustomSelectorViewModel::class.java, - ) - - val mediaReader = MediaReader(this) setContent { - val csViewModel = viewModel { - fr.free.nrw.commons.customselector.ui.screens.CustomSelectorViewModel(mediaReader) - } + val csViewModel = ViewModelProvider(this, customSelectorViewModelFactory).get( + fr.free.nrw.commons.customselector.ui.screens.CustomSelectorViewModel::class.java + ) val uiState by csViewModel.uiState.collectAsStateWithLifecycle() @@ -265,7 +258,7 @@ class CustomSelectorActivity : override fun onResume() { super.onResume() - fetchData() +// fetchData() } /** diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/states/CustomSelectorUiState.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/states/CustomSelectorUiState.kt new file mode 100644 index 0000000000..346801a718 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/states/CustomSelectorUiState.kt @@ -0,0 +1,17 @@ +package fr.free.nrw.commons.customselector.ui.states + +import fr.free.nrw.commons.customselector.ui.screens.Folder +import fr.free.nrw.commons.customselector.ui.screens.imageId + +typealias isNotForUpload = Boolean + +data class CustomSelectorUiState( + val isLoading: Boolean = true, + val folders: List = emptyList(), + val filteredImages: List = emptyList(), + val selectedImageIds: Set = emptySet(), + val imagesNotForUpload: Map = emptyMap() +) { + val inSelectionMode: Boolean + get() = selectedImageIds.isNotEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/states/ImageUiState.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/states/ImageUiState.kt new file mode 100644 index 0000000000..e986c83875 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/states/ImageUiState.kt @@ -0,0 +1,20 @@ +package fr.free.nrw.commons.customselector.ui.states + +import android.net.Uri +import fr.free.nrw.commons.customselector.domain.model.Image + +data class ImageUiState( + val id: Long, + val name: String, + val uri: Uri, + val bucketId: Long, + val isNotForUpload: Boolean = false, + val isUploaded: Boolean = false +) + +fun Image.toImageUiState() = ImageUiState( + id = id, + name = name, + uri = uri, + bucketId = bucketId +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/utils/CustomSelectorViewModelFactory.kt b/app/src/main/java/fr/free/nrw/commons/customselector/utils/CustomSelectorViewModelFactory.kt new file mode 100644 index 0000000000..10d78c7ddd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/utils/CustomSelectorViewModelFactory.kt @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.customselector.utils + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import fr.free.nrw.commons.customselector.domain.ImageRepository +import fr.free.nrw.commons.customselector.domain.use_case.ImageUseCase +import fr.free.nrw.commons.customselector.ui.screens.CustomSelectorViewModel +import javax.inject.Inject + +class CustomSelectorViewModelFactory @Inject constructor( + private val imageRepository: ImageRepository, + private val imageUseCase: ImageUseCase +): ViewModelProvider.Factory { + override fun create( + modelClass: Class + ): CustomSelectorViewModel { + return CustomSelectorViewModel( + imageRepository, imageUseCase + ) as CustomSelectorViewModel + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java index cd7324c633..3e57163509 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java @@ -17,8 +17,12 @@ import fr.free.nrw.commons.auth.AccountUtil; import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.contributions.ContributionDao; +import fr.free.nrw.commons.customselector.data.ImageRepositoryImpl; +import fr.free.nrw.commons.customselector.data.MediaReader; import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao; import fr.free.nrw.commons.customselector.database.UploadedStatusDao; +import fr.free.nrw.commons.customselector.domain.ImageRepository; +import fr.free.nrw.commons.customselector.domain.use_case.ImageUseCase; import fr.free.nrw.commons.customselector.ui.selector.ImageFileLoader; import fr.free.nrw.commons.data.DBOpenHelper; import fr.free.nrw.commons.db.AppDatabase; @@ -317,4 +321,13 @@ public ReviewDao providesReviewDao(AppDatabase appDatabase){ public ContentResolver providesContentResolver(Context context){ return context.getContentResolver(); } + + @Provides + public ImageRepository providesImageRepository( + MediaReader mediaReader, + NotForUploadStatusDao notForUploadStatusDao, + ImageUseCase imageUseCase + ) { + return new ImageRepositoryImpl(mediaReader, notForUploadStatusDao); + } } From d1fdab4d8b2ea2e54f6bb3c62b54bb8d003b485f Mon Sep 17 00:00:00 2001 From: Rohit Verma Date: Fri, 3 Jan 2025 23:14:04 +0530 Subject: [PATCH 23/23] add logic to mark or unmark images as not for upload --- .../ui/screens/CustomSelectorEvent.kt | 8 ++- .../ui/screens/CustomSelectorViewModel.kt | 70 ++++++++++++++++++- .../customselector/ui/screens/ImagesPane.kt | 48 +++++++++++-- 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt index b1af47f9a3..dc79c26e16 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt @@ -1,8 +1,14 @@ package fr.free.nrw.commons.customselector.ui.screens -interface CustomSelectorEvent { +import fr.free.nrw.commons.customselector.ui.states.ImageUiState +import kotlinx.coroutines.CoroutineScope + +sealed interface CustomSelectorEvent { data class OnFolderClick(val bucketId: Long): CustomSelectorEvent data class OnImageSelection(val imageId: Long): CustomSelectorEvent data class OnDragImageSelection(val imageIds: Set): CustomSelectorEvent data object OnUnselectAll: CustomSelectorEvent + data class OnUpdateImageStatus(val scope: CoroutineScope, val image: ImageUiState) : CustomSelectorEvent + data object MarkAsNotForUpload: CustomSelectorEvent + data object UnmarkAsNotForUpload: CustomSelectorEvent } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt index 58b29e624e..b146749830 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt @@ -1,6 +1,5 @@ package fr.free.nrw.commons.customselector.ui.screens -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import fr.free.nrw.commons.customselector.domain.ImageRepository @@ -83,6 +82,75 @@ class CustomSelectorViewModel @Inject constructor( CustomSelectorEvent.OnUnselectAll-> { _uiState.update { it.copy(selectedImageIds = emptySet()) } } + + is CustomSelectorEvent.OnUpdateImageStatus -> { + e.scope.launch { updateNotForUploadStatus(e.image) } + } + + is CustomSelectorEvent.MarkAsNotForUpload -> { + viewModelScope.launch { + val selectedImageIds = _uiState.value.selectedImageIds + + val selectedImages = allImages.filter { image -> + selectedImageIds.contains(image.id) + } + + selectedImages.forEach { image -> + cacheSHA1[image.id]?.let { sha -> + if(!imageRepository.isNotForUpload(sha)) { + imageRepository.markAsNotForUpload(sha) + updateImageStatus(true, image.id) + _uiState.update { it.copy(selectedImageIds = emptySet()) } + } + } + } + } + } + + CustomSelectorEvent.UnmarkAsNotForUpload -> { + viewModelScope.launch { + val selectedImageIds = _uiState.value.selectedImageIds + + val selectedImages = allImages.filter { image -> + selectedImageIds.contains(image.id) + } + + selectedImages.forEach { image -> + cacheSHA1[image.id]?.let { sha -> + if(imageRepository.isNotForUpload(sha)) { + imageRepository.unmarkAsNotForUpload(sha) + updateImageStatus(false, image.id) + _uiState.update { it.copy(selectedImageIds = emptySet()) } + } + } + } + } + } } } + + private fun updateImageStatus(isNotForUpload: Boolean, imageId: Long) { + _uiState.update { state -> + val updatedImages = state.filteredImages.map { + if (it.id == imageId) { + it.copy(isNotForUpload = isNotForUpload) + } else { + it + } + } + val updateMap = state.imagesNotForUpload.toMutableMap() + updateMap[imageId] = isNotForUpload + + state.copy(filteredImages = updatedImages, imagesNotForUpload = updateMap) + } + } + + private suspend fun updateNotForUploadStatus(image: ImageUiState) { + val imageSHA = cacheSHA1.getOrPut(image.id) { + imageUseCase.getImageSHA1(image.uri).also { sha -> cacheSHA1[image.id] = sha } + } + + val isNotForUpload = imageRepository.isNotForUpload(imageSHA) + updateImageStatus(isNotForUpload, image.id) + } } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt index 381119ed21..feb9930cfa 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt @@ -85,6 +85,9 @@ fun ImagesPane( .windowWidthSizeClass == WindowWidthSizeClass.COMPACT } } + val isSelectedImageNotForUpload by remember(uiState.selectedImageIds) { + derivedStateOf { uiState.selectedImageIds.any { uiState.imagesNotForUpload[it] == true } } + } LaunchedEffect(autoScrollSpeed) { if (autoScrollSpeed != 0f) { @@ -117,8 +120,16 @@ fun ImagesPane( Surface(tonalElevation = 1.dp) { CustomSelectorBottomBar( onPrimaryAction = { /*TODO("Implement action to upload selected images")*/ }, - onSecondaryAction = { /*TODO("Implement action to mark/unmark as not for upload")*/ }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + onSecondaryAction = { + if(isSelectedImageNotForUpload) { + onEvent(CustomSelectorEvent.UnmarkAsNotForUpload) + } else { + onEvent(CustomSelectorEvent.MarkAsNotForUpload) + } + }, + isAnyImageNotForUpload = isSelectedImageNotForUpload, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) .navigationBarsPadding() ) } @@ -161,6 +172,10 @@ fun ImagesPane( imagePainter = rememberAsyncImagePainter(model = image.uri), isSelected = isSelected, inSelectionMode = uiState.inSelectionMode, + isNotForUpload = image.isNotForUpload, + onImageStatusChange = { scope -> + onEvent(CustomSelectorEvent.OnUpdateImageStatus(scope, image)) + }, modifier = Modifier.combinedClickable( onClick = { if (uiState.inSelectionMode) { @@ -184,9 +199,16 @@ fun ImagesPane( fun ImageItem( imagePainter: Painter, isSelected: Boolean, + onImageStatusChange: (scope: CoroutineScope) -> Unit, modifier: Modifier = Modifier, - inSelectionMode: Boolean = false + inSelectionMode: Boolean = false, + isNotForUpload: Boolean = false ) { + // This side-effect updates the image status, like:- isNotForUpload, for visible image only + LaunchedEffect(Unit) { + onImageStatusChange(this) + } + Box(modifier = modifier.clip(RoundedCornerShape(12.dp))) { Image( painter = imagePainter, @@ -217,11 +239,25 @@ fun ImageItem( modifier = Modifier .size(24.dp) .clip(RoundedCornerShape(bottomEnd = 12.dp)) - .background(color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)) - .padding(2.dp) + .background( + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + ).padding(2.dp) ) } } + + if(isNotForUpload) { + Icon( + painter = painterResource(id = R.drawable.not_for_upload), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .align(Alignment.TopEnd) + .clip(RoundedCornerShape(bottomStart = 12.dp)) + .background(color = MaterialTheme.colorScheme.errorContainer) + .padding(4.dp) + ) + } } } @@ -234,6 +270,8 @@ private fun ImageItemPreview() { imagePainter = painterResource(id = R.drawable.image_placeholder_96), isSelected = false, inSelectionMode = true, + isNotForUpload = true, + onImageStatusChange = { }, modifier = Modifier .padding(16.dp) .size(116.dp)