diff --git a/CHANGELOG.md b/CHANGELOG.md index b65b71c..aa29348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +* Colored borders +* New setting to control the compression behavior when importing photos + +## [v1.18.1] - 2024-11-07 + +### Fixed + +* The loading icon would be visible behind transparent widgets + +## [v1.18.0] - 2024-11-06 + +### Added + +* New tap action to pause/resume automatic photo cycling +* New app theme option for using a true black background + +### Changed + +* Skip the unsaved changes warning when there are no changes +* Added device details to bug reports + ## [v1.17.0] - 2024-10-27 ### Added diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a239212..a11b9a6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ object AppInfo { const val APPLICATION_ID = "com.fibelatti.photowidget" private const val VERSION_MAJOR = 1 - private const val VERSION_MINOR = 17 - private const val VERSION_PATCH = 0 + private const val VERSION_MINOR = 18 + private const val VERSION_PATCH = 1 private const val VERSION_BUILD = 0 val versionCode: Int = @@ -191,6 +191,7 @@ dependencies { implementation(libs.ucrop) implementation(libs.coil) implementation(libs.reorderable) + implementation(libs.colorpicker.compose) implementation(libs.about.libraries) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a642b00..d64dedb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -78,6 +78,13 @@ android:launchMode="singleInstance" android:theme="@style/AppTheme.TransparentActivity" /> + + diff --git a/app/src/main/java/com/fibelatti/photowidget/configure/PhotoCropActivity.kt b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoCropActivity.kt index 6e9cfc4..9a70d53 100644 --- a/app/src/main/java/com/fibelatti/photowidget/configure/PhotoCropActivity.kt +++ b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoCropActivity.kt @@ -13,7 +13,6 @@ import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.updatePadding import com.fibelatti.photowidget.databinding.PhotoCropActivityBinding -import com.fibelatti.photowidget.model.PhotoWidget import com.fibelatti.photowidget.model.PhotoWidgetAspectRatio import com.fibelatti.photowidget.platform.getAttributeColor import com.fibelatti.photowidget.platform.intentExtras @@ -68,7 +67,6 @@ class PhotoCropActivity : AppCompatActivity(), UCropFragmentCallback { withAspectRatio(intent.aspectRatio.x, intent.aspectRatio.y) } } - .withMaxResultSize(PhotoWidget.MAX_STORAGE_DIMENSION, PhotoWidget.MAX_STORAGE_DIMENSION) .withOptions( UCrop.Options().apply { setCompressionFormat(Bitmap.CompressFormat.PNG) diff --git a/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetBorderPicker.kt b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetBorderPicker.kt new file mode 100644 index 0000000..bde8ee4 --- /dev/null +++ b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetBorderPicker.kt @@ -0,0 +1,320 @@ +package com.fibelatti.photowidget.configure + +import android.content.Context +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.fibelatti.photowidget.R +import com.fibelatti.photowidget.model.PhotoWidget +import com.fibelatti.photowidget.model.PhotoWidgetAspectRatio +import com.fibelatti.photowidget.platform.ComposeBottomSheetDialog +import com.fibelatti.photowidget.platform.withRoundedCorners +import com.fibelatti.photowidget.ui.SliderSmallThumb +import com.fibelatti.ui.preview.ThemePreviews +import com.fibelatti.ui.theme.ExtendedTheme +import com.github.skydoves.colorpicker.compose.BrightnessSlider +import com.github.skydoves.colorpicker.compose.HsvColorPicker +import com.github.skydoves.colorpicker.compose.rememberColorPickerController + +object PhotoWidgetBorderPicker { + + fun show( + context: Context, + currentColorHex: String?, + currentWidth: Int, + onApplyClick: (String?, Int) -> Unit, + ) { + ComposeBottomSheetDialog(context) { + BorderPickerContent( + currentColorHex = currentColorHex, + currentWidth = currentWidth, + ) { newColor, newWidth -> + onApplyClick(newColor, newWidth) + dismiss() + } + }.show() + } +} + +@Composable +private fun BorderPickerContent( + currentColorHex: String?, + currentWidth: Int, + onApplyClick: (String?, Int) -> Unit, +) { + var color: String? by remember { mutableStateOf(currentColorHex) } + var width: Int by remember { mutableIntStateOf(currentWidth) } + + Column( + modifier = Modifier + .fillMaxWidth() + .nestedScroll(rememberNestedScrollInteropConnection()) + .verticalScroll(rememberScrollState()) + .padding(all = 16.dp), + ) { + Text( + text = stringResource(R.string.photo_widget_configure_border), + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + ) + + Spacer(modifier = Modifier.size(8.dp)) + + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + ) { + val buttonBorderColor = SegmentedButtonDefaults.borderStroke( + color = SegmentedButtonDefaults.colors().activeBorderColor, + ) + + SegmentedButton( + selected = color == null, + onClick = { + color = null + width = 0 + }, + shape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp), + border = buttonBorderColor, + label = { + Text( + text = stringResource(R.string.photo_widget_configure_border_none), + style = MaterialTheme.typography.bodySmall, + ) + }, + ) + + SegmentedButton( + selected = color != null, + onClick = { + if (color == null) { + color = "ffffff" + width = 20 + } + }, + shape = RoundedCornerShape(topEnd = 16.dp, bottomEnd = 16.dp), + border = buttonBorderColor, + label = { + Text( + text = stringResource(R.string.photo_widget_configure_border_color), + style = MaterialTheme.typography.bodySmall, + ) + }, + ) + } + + Spacer(modifier = Modifier.size(16.dp)) + + if (color != null) { + ColorBorderContent( + currentColorHex = requireNotNull(color), + onColorChange = { color = it }, + currentWidth = width, + onWidthChange = { width = it }, + ) + } + + FilledTonalButton( + onClick = { onApplyClick(color, width) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(id = R.string.photo_widget_action_apply), + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun ColorBorderContent( + currentColorHex: String, + onColorChange: (String) -> Unit, + currentWidth: Int, + onWidthChange: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val localContext = LocalContext.current + val baseBitmap = remember { + BitmapFactory.decodeResource(localContext.resources, R.drawable.image_sample) + } + val colorPickerController = rememberColorPickerController() + var colorHex by remember { mutableStateOf(currentColorHex) } + val hexChars = remember { + charArrayOf( + '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', + 'a', 'b', 'c', 'd', 'e', 'f', + 'A', 'B', 'C', 'D', 'E', 'F', + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + bitmap = baseBitmap + .withRoundedCorners( + aspectRatio = PhotoWidgetAspectRatio.SQUARE, + radius = PhotoWidget.DEFAULT_CORNER_RADIUS, + borderColorHex = currentColorHex, + borderWidth = currentWidth, + ) + .asImageBitmap(), + contentDescription = null, + modifier = Modifier + .weight(1f) + .aspectRatio(1f), + ) + + HsvColorPicker( + modifier = Modifier + .weight(1f) + .aspectRatio(1f), + controller = colorPickerController, + onColorChanged = { colorEnvelope -> + colorHex = colorEnvelope.hexCode.drop(2) + onColorChange(colorHex) + }, + initialColor = Color(android.graphics.Color.parseColor("#$currentColorHex")), + ) + } + + Row( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BrightnessSlider( + modifier = Modifier + .fillMaxHeight() + .weight(1f) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + shape = RoundedCornerShape(6.dp), + ), + controller = colorPickerController, + ) + + TextField( + value = colorHex, + onValueChange = { newValue -> + if (newValue.length <= 6 && newValue.all { it in hexChars }) { + colorHex = newValue + + if (colorHex.length == 6) { + val androidColor = android.graphics.Color.parseColor("#$colorHex") + colorPickerController.selectByColor(color = Color(androidColor), fromUser = true) + onColorChange(colorHex) + } + } + }, + modifier = Modifier.width(100.dp), + textStyle = MaterialTheme.typography.labelMedium, + placeholder = { + Text( + text = "ffffff", + style = MaterialTheme.typography.labelMedium, + ) + }, + prefix = { + Text( + text = "#", + style = MaterialTheme.typography.labelMedium, + ) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + singleLine = true, + maxLines = 1, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Slider( + value = currentWidth.toFloat(), + onValueChange = { onWidthChange(it.toInt()) }, + modifier = Modifier.weight(1f), + valueRange = 0f..40f, + thumb = { SliderSmallThumb() }, + ) + + Text( + text = "$currentWidth", + modifier = Modifier.width(40.dp), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +@ThemePreviews +private fun BorderPickerContentPreview() { + ExtendedTheme { + BorderPickerContent( + currentColorHex = "86D986", + currentWidth = 20, + onApplyClick = { _, _ -> }, + ) + } +} diff --git a/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureActivity.kt b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureActivity.kt index 272e72c..8fd86e4 100644 --- a/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureActivity.kt +++ b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureActivity.kt @@ -30,6 +30,7 @@ import com.fibelatti.photowidget.model.PhotoWidgetSource import com.fibelatti.photowidget.model.PhotoWidgetTapAction import com.fibelatti.photowidget.platform.AppTheme import com.fibelatti.photowidget.platform.SelectionDialog +import com.fibelatti.photowidget.platform.setIdentifierCompat import com.fibelatti.photowidget.widget.PhotoWidgetProvider import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint @@ -85,9 +86,10 @@ class PhotoWidgetConfigureActivity : AppCompatActivity() { PhotoWidgetConfigureScreen( photoWidget = state.photoWidget, + isUpdating = intent.appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID, selectedPhoto = state.selectedPhoto, isProcessing = state.isProcessing, - onNavClick = ::handleBackNav, + onNavClick = onBackPressedDispatcher::onBackPressed, onAspectRatioClick = ::showAspectRatioPicker, onCropClick = viewModel::requestCrop, onRemoveClick = viewModel::photoRemoved, @@ -104,14 +106,15 @@ class PhotoWidgetConfigureActivity : AppCompatActivity() { onTapActionPickerClick = ::showTapActionPicker, onShapeChange = viewModel::shapeSelected, onCornerRadiusChange = viewModel::cornerRadiusSelected, + onBorderChange = viewModel::borderSelected, onOpacityChange = viewModel::opacitySelected, onOffsetChange = viewModel::offsetSelected, onPaddingChange = viewModel::paddingSelected, onAddToHomeClick = viewModel::addNewWidget, ) - LaunchedEffect(state.photoWidget.photos.isNotEmpty()) { - onBackPressedCallback.isEnabled = state.photoWidget.photos.isNotEmpty() + LaunchedEffect(state.hasEdits) { + onBackPressedCallback.isEnabled = state.hasEdits } LaunchedEffect(state.messages) { @@ -309,11 +312,13 @@ class PhotoWidgetConfigureActivity : AppCompatActivity() { AppWidgetManager.EXTRA_APPWIDGET_PREVIEW to remoteViews, ) - val callbackIntent = Intent(this, PhotoWidgetPinnedReceiver::class.java) - .apply { this.photoWidget = photoWidget } + val callbackIntent = Intent(this, PhotoWidgetPinnedReceiver::class.java).apply { + setIdentifierCompat("$PIN_REQUEST_CODE") + this.photoWidget = photoWidget + } val successCallback = PendingIntent.getBroadcast( /* context = */ this, - /* requestCode = */ 0, + /* requestCode = */ PIN_REQUEST_CODE, /* intent = */ callbackIntent, /* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) @@ -330,6 +335,7 @@ class PhotoWidgetConfigureActivity : AppCompatActivity() { companion object { + private const val PIN_REQUEST_CODE = 1001 const val ACTION_FINISH = "FINISH_PHOTO_WIDGET_CONFIGURE_ACTIVITY" } } diff --git a/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureScreen.kt b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureScreen.kt index e2ad715..869e09e 100644 --- a/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureScreen.kt +++ b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureScreen.kt @@ -119,6 +119,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState @Composable fun PhotoWidgetConfigureScreen( photoWidget: PhotoWidget, + isUpdating: Boolean, selectedPhoto: LocalPhoto?, isProcessing: Boolean, onNavClick: () -> Unit, @@ -138,6 +139,7 @@ fun PhotoWidgetConfigureScreen( onTapActionPickerClick: (PhotoWidgetTapAction) -> Unit, onShapeChange: (String) -> Unit, onCornerRadiusChange: (Float) -> Unit, + onBorderChange: (String?, Int) -> Unit, onOpacityChange: (Float) -> Unit, onOffsetChange: (horizontalOffset: Int, verticalOffset: Int) -> Unit, onPaddingChange: (Int) -> Unit, @@ -157,6 +159,7 @@ fun PhotoWidgetConfigureScreen( PhotoWidgetConfigureContent( photoWidget = photoWidget, + isUpdating = isUpdating, selectedPhoto = selectedPhoto, onNavClick = onNavClick, onMoveLeftClick = onMoveLeftClick, @@ -195,6 +198,14 @@ fun PhotoWidgetConfigureScreen( ) }.show() }, + onBorderClick = { + PhotoWidgetBorderPicker.show( + context = localContext, + currentColorHex = photoWidget.borderColor, + currentWidth = photoWidget.borderWidth, + onApplyClick = onBorderChange, + ) + }, onOpacityClick = { ComposeBottomSheetDialog(localContext) { OpacityPicker( @@ -255,6 +266,7 @@ fun PhotoWidgetConfigureScreen( @Composable private fun PhotoWidgetConfigureContent( photoWidget: PhotoWidget, + isUpdating: Boolean, selectedPhoto: LocalPhoto?, onNavClick: () -> Unit, onAspectRatioClick: () -> Unit, @@ -273,6 +285,7 @@ private fun PhotoWidgetConfigureContent( onTapActionPickerClick: (PhotoWidgetTapAction) -> Unit, onShapeClick: () -> Unit, onCornerRadiusClick: () -> Unit, + onBorderClick: () -> Unit, onOpacityClick: () -> Unit, onOffsetClick: () -> Unit, onPaddingClick: () -> Unit, @@ -297,6 +310,7 @@ private fun PhotoWidgetConfigureContent( PhotoWidgetConfigureContentSettings( photoWidget = photoWidget, + isUpdating = isUpdating, onChangeSource = onChangeSource, onShuffleClick = onShuffleClick, onPhotoPickerClick = onPhotoPickerClick, @@ -307,6 +321,7 @@ private fun PhotoWidgetConfigureContent( onAspectRatioClick = onAspectRatioClick, onShapeClick = onShapeClick, onCornerRadiusClick = onCornerRadiusClick, + onBorderClick = onBorderClick, onOpacityClick = onOpacityClick, onOffsetClick = onOffsetClick, onPaddingClick = onPaddingClick, @@ -333,6 +348,7 @@ private fun PhotoWidgetConfigureContent( PhotoWidgetConfigureContentSettings( photoWidget = photoWidget, + isUpdating = isUpdating, onChangeSource = onChangeSource, onShuffleClick = onShuffleClick, onPhotoPickerClick = onPhotoPickerClick, @@ -343,6 +359,7 @@ private fun PhotoWidgetConfigureContent( onAspectRatioClick = onAspectRatioClick, onShapeClick = onShapeClick, onCornerRadiusClick = onCornerRadiusClick, + onBorderClick = onBorderClick, onOpacityClick = onOpacityClick, onOffsetClick = onOffsetClick, onPaddingClick = onPaddingClick, @@ -378,6 +395,8 @@ private fun PhotoWidgetConfigureContentViewer( shapeId = photoWidget.shapeId, modifier = Modifier.fillMaxSize(), cornerRadius = photoWidget.cornerRadius, + borderColorHex = photoWidget.borderColor, + borderWidth = photoWidget.borderWidth, opacity = photoWidget.opacity, ) @@ -418,6 +437,7 @@ private fun PhotoWidgetConfigureContentViewer( @Composable private fun PhotoWidgetConfigureContentSettings( photoWidget: PhotoWidget, + isUpdating: Boolean, onChangeSource: (currentSource: PhotoWidgetSource, syncedDir: Set) -> Unit, onShuffleClick: () -> Unit, onPhotoPickerClick: () -> Unit, @@ -428,6 +448,7 @@ private fun PhotoWidgetConfigureContentSettings( onAspectRatioClick: () -> Unit, onShapeClick: () -> Unit, onCornerRadiusClick: () -> Unit, + onBorderClick: () -> Unit, onOpacityClick: () -> Unit, onOffsetClick: () -> Unit, onPaddingClick: () -> Unit, @@ -496,6 +517,19 @@ private fun PhotoWidgetConfigureContentSettings( ) } + if (PhotoWidgetAspectRatio.FILL_WIDGET != photoWidget.aspectRatio) { + PickerDefault( + title = stringResource(R.string.photo_widget_configure_border), + currentValue = if (photoWidget.borderColor == null) { + stringResource(id = R.string.photo_widget_configure_border_none) + } else { + "#${photoWidget.borderColor}" + }, + onClick = onBorderClick, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + PickerDefault( title = stringResource(id = R.string.widget_defaults_opacity), currentValue = photoWidget.opacity.toInt().toString(), @@ -569,7 +603,15 @@ private fun PhotoWidgetConfigureContentSettings( .fillMaxWidth() .padding(horizontal = 16.dp), ) { - Text(text = stringResource(id = R.string.photo_widget_configure_add_to_home)) + Text( + text = stringResource( + id = if (isUpdating) { + R.string.photo_widget_configure_save_changes + } else { + R.string.photo_widget_configure_add_to_home + }, + ), + ) } } } @@ -580,6 +622,8 @@ private fun PhotoWidgetViewer( aspectRatio: PhotoWidgetAspectRatio, shapeId: String, cornerRadius: Float, + borderColorHex: String?, + borderWidth: Int, opacity: Float, modifier: Modifier = Modifier, ) { @@ -621,6 +665,8 @@ private fun PhotoWidgetViewer( ) .padding(start = 32.dp, top = 32.dp, end = 32.dp, bottom = 48.dp) .fillMaxHeight(), + borderColorHex = borderColorHex, + borderWidth = borderWidth, ) } } @@ -935,6 +981,7 @@ private fun PendingDeletionPhotoPicker( Text( text = stringResource(R.string.photo_widget_configure_photos_pending_deletion), modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.onBackground, style = MaterialTheme.typography.titleMedium, ) @@ -1043,6 +1090,8 @@ fun ShapedPhoto( cornerRadius: Float, opacity: Float, modifier: Modifier = Modifier, + borderColorHex: String? = null, + borderWidth: Int = 0, badge: @Composable BoxScope.() -> Unit = {}, isLoading: Boolean = false, ) { @@ -1054,7 +1103,7 @@ fun ShapedPhoto( else -> null } }, - dataKey = arrayOf(photo, shapeId, aspectRatio, cornerRadius, opacity), + dataKey = arrayOf(photo, shapeId, aspectRatio, cornerRadius, opacity, borderColorHex, borderWidth), isLoading = isLoading, contentScale = if (aspectRatio.isConstrained) { ContentScale.FillWidth @@ -1068,12 +1117,16 @@ fun ShapedPhoto( withPolygonalShape( shapeId = shapeId, opacity = opacity, + borderColorHex = borderColorHex, + borderWidth = borderWidth, ) } else { withRoundedCorners( aspectRatio = aspectRatio, radius = cornerRadius, opacity = opacity, + borderColorHex = borderColorHex, + borderWidth = borderWidth, ) } } @@ -1132,6 +1185,7 @@ private fun PhotoWidgetConfigureScreenPreview() { shapeId = PhotoWidget.DEFAULT_SHAPE_ID, cornerRadius = PhotoWidget.DEFAULT_CORNER_RADIUS, ), + isUpdating = false, selectedPhoto = LocalPhoto(name = "photo-1"), isProcessing = false, onNavClick = {}, @@ -1151,6 +1205,7 @@ private fun PhotoWidgetConfigureScreenPreview() { onTapActionPickerClick = {}, onShapeChange = {}, onCornerRadiusChange = {}, + onBorderChange = { _, _ -> }, onOpacityChange = {}, onOffsetChange = { _, _ -> }, onPaddingChange = {}, @@ -1180,6 +1235,7 @@ private fun PhotoWidgetConfigureScreenTallPreview() { cornerRadius = PhotoWidget.DEFAULT_CORNER_RADIUS, opacity = 80f, ), + isUpdating = true, selectedPhoto = LocalPhoto(name = "photo-1"), isProcessing = false, onNavClick = {}, @@ -1199,6 +1255,7 @@ private fun PhotoWidgetConfigureScreenTallPreview() { onTapActionPickerClick = {}, onShapeChange = {}, onCornerRadiusChange = {}, + onBorderChange = { _, _ -> }, onOpacityChange = {}, onOffsetChange = { _, _ -> }, onPaddingChange = {}, diff --git a/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureState.kt b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureState.kt index 62b637c..73309e5 100644 --- a/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureState.kt +++ b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureState.kt @@ -12,6 +12,7 @@ data class PhotoWidgetConfigureState( val cropQueue: List = emptyList(), val messages: List = emptyList(), val markedForDeletion: Set = emptySet(), + val hasEdits: Boolean = false, ) { sealed class Message { diff --git a/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureViewModel.kt b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureViewModel.kt index bcb8cd4..9eaab03 100644 --- a/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureViewModel.kt +++ b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetConfigureViewModel.kt @@ -25,9 +25,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.withIndex import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -80,6 +83,7 @@ class PhotoWidgetConfigureViewModel @Inject constructor( ), selectedPhoto = photoWidget.photos.firstOrNull(), isProcessing = photoWidget.isLoading, + hasEdits = sourceWidget != null, ) } } @@ -89,11 +93,19 @@ class PhotoWidgetConfigureViewModel @Inject constructor( } else { loadPhotoWidgetUseCase(appWidgetId = appWidgetId) .onEach { photoWidget -> updateState(photoWidget) } + .onCompletion { trackEdits() } .launchIn(viewModelScope) } } } + private fun trackEdits() { + viewModelScope.launch { + state.withIndex().first { (index, value) -> index > 0 && !value.hasEdits } + _state.update { current -> current.copy(hasEdits = true) } + } + } + fun changeSource() { val newSource = when (_state.value.photoWidget.source) { PhotoWidgetSource.PHOTOS -> PhotoWidgetSource.DIRECTORY @@ -133,6 +145,11 @@ class PhotoWidgetConfigureViewModel @Inject constructor( } else { PhotoWidget.DEFAULT_CORNER_RADIUS }, + borderColor = if (PhotoWidgetAspectRatio.FILL_WIDGET == photoWidgetAspectRatio) { + null + } else { + current.photoWidget.borderColor + }, ), ) } @@ -325,11 +342,17 @@ class PhotoWidgetConfigureViewModel @Inject constructor( fun restorePhoto(photo: LocalPhoto) { _state.update { current -> + val updatedPhotos = current.photoWidget.photos + photo current.copy( photoWidget = current.photoWidget.copy( - photos = current.photoWidget.photos + photo, + photos = updatedPhotos, photosPendingDeletion = current.photoWidget.photosPendingDeletion.filterNot { it.name == photo.name }, ), + selectedPhoto = if (updatedPhotos.size == 1) { + updatedPhotos.firstOrNull() + } else { + current.selectedPhoto + }, markedForDeletion = current.markedForDeletion - photo.name, ) } @@ -413,6 +436,17 @@ class PhotoWidgetConfigureViewModel @Inject constructor( } } + fun borderSelected(colorHex: String?, width: Int) { + _state.update { current -> + current.copy( + photoWidget = current.photoWidget.copy( + borderColor = colorHex, + borderWidth = width, + ), + ) + } + } + fun opacitySelected(opacity: Float) { _state.update { current -> current.copy( diff --git a/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetTapActionPicker.kt b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetTapActionPicker.kt index 93d6b8e..cd780f7 100644 --- a/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetTapActionPicker.kt +++ b/app/src/main/java/com/fibelatti/photowidget/configure/PhotoWidgetTapActionPicker.kt @@ -35,7 +35,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -61,6 +60,7 @@ import com.fibelatti.photowidget.model.PhotoWidgetAspectRatio import com.fibelatti.photowidget.model.PhotoWidgetTapAction import com.fibelatti.photowidget.platform.ComposeBottomSheetDialog import com.fibelatti.photowidget.platform.withRoundedCorners +import com.fibelatti.photowidget.ui.Toggle import com.fibelatti.ui.foundation.ColumnToggleButtonGroup import com.fibelatti.ui.foundation.ToggleButtonGroup import com.fibelatti.ui.preview.DevicePreviews @@ -195,14 +195,14 @@ private fun TapActionPickerContent( ) { Toggle( title = stringResource(id = R.string.photo_widget_configure_tap_action_increase_brightness), - enabled = value.increaseBrightness, - onChange = { tapAction = value.copy(increaseBrightness = it) }, + checked = value.increaseBrightness, + onCheckedChange = { tapAction = value.copy(increaseBrightness = it) }, ) Toggle( title = stringResource(R.string.photo_widget_configure_tap_action_view_original_photo), - enabled = value.viewOriginalPhoto, - onChange = { tapAction = value.copy(viewOriginalPhoto = it) }, + checked = value.viewOriginalPhoto, + onCheckedChange = { tapAction = value.copy(viewOriginalPhoto = it) }, ) } } @@ -225,6 +225,18 @@ private fun TapActionPickerContent( ) } + is PhotoWidgetTapAction.ToggleCycling -> { + Text( + text = stringResource( + id = R.string.photo_widget_configure_tap_action_toggle_cycling_description, + ), + modifier = customOptionModifier, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMedium, + ) + } + PhotoWidgetTapAction.None -> Unit } } @@ -341,31 +353,6 @@ private fun TapOptionsPicker( ) } -@Composable -private fun Toggle( - title: String, - enabled: Boolean, - onChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Switch( - checked = enabled, - onCheckedChange = onChange, - ) - - Text( - text = title, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - ) - } -} - @Composable private fun AppPicker( onChooseApp: () -> Unit, diff --git a/app/src/main/java/com/fibelatti/photowidget/configure/SavePhotoWidgetUseCase.kt b/app/src/main/java/com/fibelatti/photowidget/configure/SavePhotoWidgetUseCase.kt index 26b1294..66d6e81 100644 --- a/app/src/main/java/com/fibelatti/photowidget/configure/SavePhotoWidgetUseCase.kt +++ b/app/src/main/java/com/fibelatti/photowidget/configure/SavePhotoWidgetUseCase.kt @@ -1,6 +1,7 @@ package com.fibelatti.photowidget.configure import com.fibelatti.photowidget.model.PhotoWidget +import com.fibelatti.photowidget.model.PhotoWidgetAspectRatio import com.fibelatti.photowidget.model.PhotoWidgetSource import com.fibelatti.photowidget.model.PhotoWidgetTapAction import com.fibelatti.photowidget.widget.PhotoWidgetAlarmManager @@ -68,6 +69,14 @@ class SavePhotoWidgetUseCase @Inject constructor( cornerRadius = photoWidget.cornerRadius, ) + photoWidgetStorage.saveWidgetBorderColor( + appWidgetId = appWidgetId, + colorHex = photoWidget.borderColor.takeUnless { + PhotoWidgetAspectRatio.FILL_WIDGET == photoWidget.aspectRatio + }, + width = photoWidget.borderWidth, + ) + photoWidgetStorage.saveWidgetOpacity( appWidgetId = appWidgetId, opacity = photoWidget.opacity, diff --git a/app/src/main/java/com/fibelatti/photowidget/di/PhotoWidgetEntryPoint.kt b/app/src/main/java/com/fibelatti/photowidget/di/PhotoWidgetEntryPoint.kt index 44697ae..c18d12b 100644 --- a/app/src/main/java/com/fibelatti/photowidget/di/PhotoWidgetEntryPoint.kt +++ b/app/src/main/java/com/fibelatti/photowidget/di/PhotoWidgetEntryPoint.kt @@ -2,6 +2,7 @@ package com.fibelatti.photowidget.di import com.fibelatti.photowidget.configure.SavePhotoWidgetUseCase import com.fibelatti.photowidget.platform.PhotoDecoder +import com.fibelatti.photowidget.preferences.UserPreferencesStorage import com.fibelatti.photowidget.widget.CyclePhotoUseCase import com.fibelatti.photowidget.widget.LoadPhotoWidgetUseCase import com.fibelatti.photowidget.widget.PhotoWidgetAlarmManager @@ -15,6 +16,8 @@ import kotlinx.coroutines.CoroutineScope @InstallIn(SingletonComponent::class) interface PhotoWidgetEntryPoint { + fun userPreferencesStorage(): UserPreferencesStorage + fun photoWidgetStorage(): PhotoWidgetStorage fun photoWidgetAlarmManager(): PhotoWidgetAlarmManager diff --git a/app/src/main/java/com/fibelatti/photowidget/home/HomeActivity.kt b/app/src/main/java/com/fibelatti/photowidget/home/HomeActivity.kt index 516e417..396a765 100644 --- a/app/src/main/java/com/fibelatti/photowidget/home/HomeActivity.kt +++ b/app/src/main/java/com/fibelatti/photowidget/home/HomeActivity.kt @@ -2,6 +2,7 @@ package com.fibelatti.photowidget.home import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Parcelable import androidx.activity.compose.setContent @@ -10,7 +11,11 @@ import androidx.activity.viewModels import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.core.app.ShareCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -28,8 +33,10 @@ import com.fibelatti.photowidget.platform.AppTheme import com.fibelatti.photowidget.platform.ComposeBottomSheetDialog import com.fibelatti.photowidget.platform.SelectionDialog import com.fibelatti.photowidget.preferences.Appearance +import com.fibelatti.photowidget.preferences.DataSaverPicker import com.fibelatti.photowidget.preferences.UserPreferencesStorage import com.fibelatti.photowidget.preferences.WidgetDefaultsActivity +import com.fibelatti.photowidget.ui.Toggle import com.fibelatti.photowidget.widget.PhotoWidgetProvider import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint @@ -59,6 +66,7 @@ class HomeActivity : AppCompatActivity() { onCurrentWidgetClick = ::showExistingWidgetMenu, onRemovedWidgetClick = ::showRemovedWidgetMenu, onDefaultsClick = ::showDefaults, + onDataSaverClick = ::showDataSaverPicker, onAppearanceClick = ::showAppearancePicker, onColorsClick = ::showAppColorsPicker, onSendFeedbackClick = ::sendFeedback, @@ -184,6 +192,10 @@ class HomeActivity : AppCompatActivity() { startActivity(Intent(this, WidgetDefaultsActivity::class.java)) } + private fun showDataSaverPicker() { + DataSaverPicker.show(context = this) + } + private fun showAppearancePicker() { SelectionDialog.show( context = this, @@ -209,6 +221,14 @@ class HomeActivity : AppCompatActivity() { AppCompatDelegate.setDefaultNightMode(mode) }, + footer = { + Toggle( + title = stringResource(R.string.photo_widget_home_true_black_background), + checked = userPreferencesStorage.useTrueBlack, + onCheckedChange = { newValue -> userPreferencesStorage.useTrueBlack = newValue }, + modifier = Modifier.padding(all = 16.dp), + ) + }, ) } @@ -243,12 +263,21 @@ class HomeActivity : AppCompatActivity() { } private fun sendFeedback() { + val emailBody = StringBuilder().apply { + appendLine("Android Version: ${Build.VERSION.SDK_INT}") + appendLine("Device Manufacturer: ${Build.MANUFACTURER}") + appendLine("---") + appendLine(getString(R.string.help_email_body)) + appendLine() + } + val emailIntent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:")).apply { putExtra(Intent.EXTRA_EMAIL, arrayOf("appsupport@fibelatti.com")) putExtra( Intent.EXTRA_SUBJECT, "Material Photo Widget (${BuildConfig.VERSION_NAME}) — Feature request / Bug report", ) + putExtra(Intent.EXTRA_TEXT, emailBody.toString()) } startActivity(Intent.createChooser(emailIntent, getString(R.string.photo_widget_home_feedback))) @@ -274,6 +303,7 @@ class HomeActivity : AppCompatActivity() { private enum class MyWidgetOptions( @StringRes val label: Int, ) { + EDIT(label = R.string.photo_widget_home_my_widget_action_edit), DUPLICATE(label = R.string.photo_widget_home_my_widget_action_duplicate), } @@ -281,6 +311,7 @@ class HomeActivity : AppCompatActivity() { private enum class RemovedWidgetOptions( @StringRes val label: Int, ) { + RESTORE(label = R.string.photo_widget_home_removed_widget_action_restore), DELETE(label = R.string.photo_widget_home_removed_widget_action_delete), } diff --git a/app/src/main/java/com/fibelatti/photowidget/home/HomeScreen.kt b/app/src/main/java/com/fibelatti/photowidget/home/HomeScreen.kt index 6e65587..55f6b7f 100644 --- a/app/src/main/java/com/fibelatti/photowidget/home/HomeScreen.kt +++ b/app/src/main/java/com/fibelatti/photowidget/home/HomeScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -89,6 +90,7 @@ import com.fibelatti.photowidget.model.PhotoWidgetCycleMode import com.fibelatti.photowidget.model.PhotoWidgetShapeBuilder import com.fibelatti.photowidget.model.PhotoWidgetSource import com.fibelatti.photowidget.model.PhotoWidgetTapAction +import com.fibelatti.photowidget.ui.ShapesBanner import com.fibelatti.ui.foundation.conditional import com.fibelatti.ui.foundation.grayScale import com.fibelatti.ui.preview.DevicePreviews @@ -105,6 +107,7 @@ fun HomeScreen( onCurrentWidgetClick: (appWidgetId: Int) -> Unit, onRemovedWidgetClick: (appWidgetId: Int) -> Unit, onDefaultsClick: () -> Unit, + onDataSaverClick: () -> Unit, onAppearanceClick: () -> Unit, onColorsClick: () -> Unit, onSendFeedbackClick: () -> Unit, @@ -135,7 +138,11 @@ fun HomeScreen( .background(color = MaterialTheme.colorScheme.background) .fillMaxSize() .padding(paddingValues), - transitionSpec = { fadeIn() togetherWith fadeOut() }, + transitionSpec = { + (fadeIn(animationSpec = tween(220)) + + scaleIn(initialScale = 0.97f, animationSpec = tween(220))) + .togetherWith(fadeOut(animationSpec = tween(90))) + }, contentAlignment = Alignment.Center, label = "Home_Navigation", ) { destination -> @@ -158,6 +165,7 @@ fun HomeScreen( HomeNavigationDestination.SETTINGS -> { SettingsScreen( onDefaultsClick = onDefaultsClick, + onDataSaverClick = onDataSaverClick, onAppearanceClick = onAppearanceClick, onColorsClick = onColorsClick, onSendFeedbackClick = onSendFeedbackClick, @@ -252,20 +260,25 @@ private fun NewWidgetScreen( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { + ShapesBanner( + modifier = Modifier.align(Alignment.TopCenter), + ) + Column( modifier = Modifier .widthIn(max = 600.dp) .fillMaxWidth() + .padding(top = 68.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(32.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { AutoSizeText( text = stringResource(id = R.string.photo_widget_home_title), modifier = Modifier.padding(horizontal = 32.dp), maxLines = 2, textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, ) Text( @@ -463,6 +476,8 @@ private fun MyWidgetsScreen( predicate = isRemoved, ifTrue = { grayScale() }, ), + borderColorHex = widget.borderColor, + borderWidth = widget.borderWidth, isLoading = widget.isLoading, ) } @@ -563,6 +578,7 @@ private fun MyWidgetsScreen( @Composable private fun SettingsScreen( onDefaultsClick: () -> Unit, + onDataSaverClick: () -> Unit, onAppearanceClick: () -> Unit, onColorsClick: () -> Unit, onSendFeedbackClick: () -> Unit, @@ -595,6 +611,12 @@ private fun SettingsScreen( onClick = onDefaultsClick, ) + SettingsAction( + icon = R.drawable.ic_hard_drive, + label = R.string.photo_widget_home_data_saver, + onClick = onDataSaverClick, + ) + SettingsAction( icon = R.drawable.ic_appearance, label = R.string.photo_widget_home_appearance, @@ -770,6 +792,7 @@ private fun HomeScreenPreview() { onCurrentWidgetClick = {}, onRemovedWidgetClick = {}, onDefaultsClick = {}, + onDataSaverClick = {}, onAppearanceClick = {}, onColorsClick = {}, onSendFeedbackClick = {}, @@ -837,6 +860,7 @@ private fun SettingsScreenPreview() { ExtendedTheme { SettingsScreen( onDefaultsClick = {}, + onDataSaverClick = {}, onAppearanceClick = {}, onColorsClick = {}, onSendFeedbackClick = {}, diff --git a/app/src/main/java/com/fibelatti/photowidget/model/PhotoWidget.kt b/app/src/main/java/com/fibelatti/photowidget/model/PhotoWidget.kt index 054b422..578b0fd 100644 --- a/app/src/main/java/com/fibelatti/photowidget/model/PhotoWidget.kt +++ b/app/src/main/java/com/fibelatti/photowidget/model/PhotoWidget.kt @@ -16,6 +16,8 @@ data class PhotoWidget( val aspectRatio: PhotoWidgetAspectRatio = PhotoWidgetAspectRatio.SQUARE, val shapeId: String = DEFAULT_SHAPE_ID, val cornerRadius: Float = DEFAULT_CORNER_RADIUS, + val borderColor: String? = null, + val borderWidth: Int = 0, val opacity: Float = DEFAULT_OPACITY, val horizontalOffset: Int = 0, val verticalOffset: Int = 0, @@ -45,7 +47,6 @@ data class PhotoWidget( companion object { const val MAX_WIDGET_DIMENSION: Int = 720 - const val MAX_STORAGE_DIMENSION: Int = 3_840 const val DEFAULT_SHAPE_ID = "rounded-square" const val DEFAULT_CORNER_RADIUS: Float = 64f diff --git a/app/src/main/java/com/fibelatti/photowidget/model/PhotoWidgetTapAction.kt b/app/src/main/java/com/fibelatti/photowidget/model/PhotoWidgetTapAction.kt index ec3fd41..7081519 100644 --- a/app/src/main/java/com/fibelatti/photowidget/model/PhotoWidgetTapAction.kt +++ b/app/src/main/java/com/fibelatti/photowidget/model/PhotoWidgetTapAction.kt @@ -58,6 +58,16 @@ sealed interface PhotoWidgetTapAction : Parcelable { override val serializedName: String = "APP_SHORTCUT" } + @Parcelize + data object ToggleCycling : PhotoWidgetTapAction { + + @IgnoredOnParcel + override val label = R.string.photo_widget_configure_tap_action_toggle_cycling + + @IgnoredOnParcel + override val serializedName: String = "TOGGLE_CYCLING" + } + companion object { val DEFAULT: PhotoWidgetTapAction = None @@ -68,6 +78,7 @@ sealed interface PhotoWidgetTapAction : Parcelable { ViewFullScreen(), ViewInGallery, AppShortcut(), + ToggleCycling, ) } diff --git a/app/src/main/java/com/fibelatti/photowidget/platform/AppTheme.kt b/app/src/main/java/com/fibelatti/photowidget/platform/AppTheme.kt index 7f784e3..7e7e438 100644 --- a/app/src/main/java/com/fibelatti/photowidget/platform/AppTheme.kt +++ b/app/src/main/java/com/fibelatti/photowidget/platform/AppTheme.kt @@ -21,6 +21,7 @@ fun AppTheme( ExtendedTheme( dynamicColor = userPreferences.dynamicColors, + useTrueBlack = userPreferences.useTrueBlack, content = content, ) } diff --git a/app/src/main/java/com/fibelatti/photowidget/platform/BitmapKtx.kt b/app/src/main/java/com/fibelatti/photowidget/platform/BitmapKtx.kt index 803ec26..e5dd6ea 100644 --- a/app/src/main/java/com/fibelatti/photowidget/platform/BitmapKtx.kt +++ b/app/src/main/java/com/fibelatti/photowidget/platform/BitmapKtx.kt @@ -2,10 +2,12 @@ package com.fibelatti.photowidget.platform import android.graphics.Bitmap import android.graphics.Canvas +import android.graphics.Color import android.graphics.Paint import android.graphics.PorterDuff import android.graphics.PorterDuffXfermode import android.graphics.Rect +import androidx.annotation.IntRange import androidx.core.graphics.toRectF import androidx.graphics.shapes.toPath import com.fibelatti.photowidget.model.PhotoWidget @@ -19,9 +21,13 @@ fun Bitmap.withRoundedCorners( aspectRatio: PhotoWidgetAspectRatio, radius: Float = PhotoWidget.DEFAULT_CORNER_RADIUS, opacity: Float = PhotoWidget.DEFAULT_OPACITY, + borderColorHex: String? = null, + @IntRange(from = 0) borderWidth: Int = 0, ): Bitmap = withTransformation( aspectRatio = aspectRatio, opacity = opacity, + borderColorHex = borderColorHex, + borderWidth = borderWidth, ) { canvas, rect, paint -> canvas.drawRoundRect(rect.toRectF(), radius, radius, paint) } @@ -29,9 +35,13 @@ fun Bitmap.withRoundedCorners( fun Bitmap.withPolygonalShape( shapeId: String, opacity: Float = PhotoWidget.DEFAULT_OPACITY, + borderColorHex: String? = null, + @IntRange(from = 0) borderWidth: Int = 0, ): Bitmap = withTransformation( aspectRatio = PhotoWidgetAspectRatio.SQUARE, opacity = opacity, + borderColorHex = borderColorHex, + borderWidth = borderWidth, ) { canvas, rect, paint -> try { val shape = PhotoWidgetShapeBuilder.buildShape( @@ -52,6 +62,8 @@ fun Bitmap.withPolygonalShape( private inline fun Bitmap.withTransformation( aspectRatio: PhotoWidgetAspectRatio, opacity: Float, + borderColorHex: String? = null, + @IntRange(from = 0) borderWidth: Int = 0, body: (Canvas, Rect, Paint) -> Unit, ): Bitmap { val source = when (aspectRatio) { @@ -116,5 +128,15 @@ private inline fun Bitmap.withTransformation( paint.setXfermode(PorterDuffXfermode(PorterDuff.Mode.SRC_IN)) canvas.drawBitmap(this, source, destination, paint) + val borderColor = runCatching { Color.parseColor("#$borderColorHex") }.getOrNull() + if (borderColor != null && borderWidth > 0) { + val stroke = paint.apply { + style = Paint.Style.STROKE + color = borderColor + strokeWidth = borderWidth.toFloat() + } + body(canvas, if (PhotoWidgetAspectRatio.SQUARE == aspectRatio) source else destination, stroke) + } + return output } diff --git a/app/src/main/java/com/fibelatti/photowidget/platform/IntentKtx.kt b/app/src/main/java/com/fibelatti/photowidget/platform/IntentKtx.kt new file mode 100644 index 0000000..32301f3 --- /dev/null +++ b/app/src/main/java/com/fibelatti/photowidget/platform/IntentKtx.kt @@ -0,0 +1,10 @@ +package com.fibelatti.photowidget.platform + +import android.content.Intent +import android.os.Build + +fun Intent.setIdentifierCompat(value: String?): Intent = apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setIdentifier(value) + } +} diff --git a/app/src/main/java/com/fibelatti/photowidget/platform/PhotoDecoder.kt b/app/src/main/java/com/fibelatti/photowidget/platform/PhotoDecoder.kt index bd0dc62..27ab65f 100644 --- a/app/src/main/java/com/fibelatti/photowidget/platform/PhotoDecoder.kt +++ b/app/src/main/java/com/fibelatti/photowidget/platform/PhotoDecoder.kt @@ -20,14 +20,14 @@ class PhotoDecoder @Inject constructor( suspend fun decode( data: Any?, - maxDimension: Int, + maxDimension: Int? = null, ): Bitmap? = withContext(Dispatchers.IO) { Timber.d("Decoding $data into a bitmap (maxDimension=$maxDimension)") val request = ImageRequest.Builder(context) .data(data) .allowHardware(enable = false) - .size(maxDimension) + .apply { if (maxDimension != null) size(maxDimension) } .build() imageLoader.execute(request) diff --git a/app/src/main/java/com/fibelatti/photowidget/platform/SelectionDialog.kt b/app/src/main/java/com/fibelatti/photowidget/platform/SelectionDialog.kt index 288a355..52e29e5 100644 --- a/app/src/main/java/com/fibelatti/photowidget/platform/SelectionDialog.kt +++ b/app/src/main/java/com/fibelatti/photowidget/platform/SelectionDialog.kt @@ -29,6 +29,7 @@ object SelectionDialog { optionName: (T) -> String, optionIcon: (T) -> Int? = { null }, onOptionSelected: (T) -> Unit, + footer: @Composable () -> Unit = {}, ) { ComposeBottomSheetDialog(context) { SelectionDialogContent( @@ -40,6 +41,7 @@ object SelectionDialog { onOptionSelected(option) dismiss() }, + footer = footer, ) }.show() } @@ -53,6 +55,7 @@ private fun SelectionDialogContent( optionName: (T) -> String, optionIcon: (T) -> Int? = { null }, onOptionSelected: (T) -> Unit, + footer: @Composable () -> Unit = {}, ) { LazyColumn( modifier = Modifier @@ -93,5 +96,9 @@ private fun SelectionDialogContent( } } } + + item { + footer() + } } } diff --git a/app/src/main/java/com/fibelatti/photowidget/preferences/DataSaverPicker.kt b/app/src/main/java/com/fibelatti/photowidget/preferences/DataSaverPicker.kt new file mode 100644 index 0000000..8526168 --- /dev/null +++ b/app/src/main/java/com/fibelatti/photowidget/preferences/DataSaverPicker.kt @@ -0,0 +1,86 @@ +package com.fibelatti.photowidget.preferences + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fibelatti.photowidget.R +import com.fibelatti.photowidget.di.PhotoWidgetEntryPoint +import com.fibelatti.photowidget.di.entryPoint +import com.fibelatti.photowidget.platform.ComposeBottomSheetDialog +import com.fibelatti.photowidget.ui.Toggle +import com.fibelatti.ui.preview.LocalePreviews +import com.fibelatti.ui.preview.ThemePreviews +import com.fibelatti.ui.theme.ExtendedTheme + +object DataSaverPicker { + + fun show(context: Context) { + val userPreferencesStorage = entryPoint(context).userPreferencesStorage() + + ComposeBottomSheetDialog(context) { + val preferences by userPreferencesStorage.userPreferences.collectAsStateWithLifecycle() + + DataSaverPickerContent( + dataSaver = preferences.dataSaver, + onDataSaverChange = { newValue -> userPreferencesStorage.dataSaver = newValue }, + ) + }.show() + } +} + +@Composable +private fun DataSaverPickerContent( + dataSaver: Boolean, + onDataSaverChange: (Boolean) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(all = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.photo_widget_home_data_saver), + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + ) + + Toggle( + title = stringResource(R.string.preferences_data_saver), + checked = dataSaver, + onCheckedChange = onDataSaverChange, + ) + + Text( + text = stringResource(R.string.preferences_data_saver_description), + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +@ThemePreviews +@LocalePreviews +private fun DataSaverPickerContentPreview() { + ExtendedTheme { + DataSaverPickerContent( + dataSaver = true, + onDataSaverChange = {}, + ) + } +} diff --git a/app/src/main/java/com/fibelatti/photowidget/preferences/UserPreferences.kt b/app/src/main/java/com/fibelatti/photowidget/preferences/UserPreferences.kt index fd99c07..f5f0d74 100644 --- a/app/src/main/java/com/fibelatti/photowidget/preferences/UserPreferences.kt +++ b/app/src/main/java/com/fibelatti/photowidget/preferences/UserPreferences.kt @@ -5,7 +5,9 @@ import com.fibelatti.photowidget.model.PhotoWidgetSource import com.fibelatti.photowidget.model.PhotoWidgetTapAction data class UserPreferences( + val dataSaver: Boolean, val appearance: Appearance, + val useTrueBlack: Boolean, val dynamicColors: Boolean, val defaultSource: PhotoWidgetSource, val defaultShuffle: Boolean, diff --git a/app/src/main/java/com/fibelatti/photowidget/preferences/UserPreferencesStorage.kt b/app/src/main/java/com/fibelatti/photowidget/preferences/UserPreferencesStorage.kt index aa704a4..5009c74 100644 --- a/app/src/main/java/com/fibelatti/photowidget/preferences/UserPreferencesStorage.kt +++ b/app/src/main/java/com/fibelatti/photowidget/preferences/UserPreferencesStorage.kt @@ -29,7 +29,9 @@ class UserPreferencesStorage @Inject constructor(@ApplicationContext context: Co private val _userPreferences = MutableStateFlow( UserPreferences( + dataSaver = dataSaver, appearance = appearance, + useTrueBlack = useTrueBlack, dynamicColors = dynamicColors, defaultSource = defaultSource, defaultShuffle = defaultShuffle, @@ -43,6 +45,15 @@ class UserPreferencesStorage @Inject constructor(@ApplicationContext context: Co val userPreferences: StateFlow = _userPreferences.asStateFlow() // region App + var dataSaver: Boolean + get() { + return sharedPreferences.getBoolean(Preference.DATA_SAVER.value, true) + } + set(value) { + sharedPreferences.edit { putBoolean(Preference.DATA_SAVER.value, value) } + _userPreferences.update { current -> current.copy(dataSaver = value) } + } + var appearance: Appearance get() { val name = sharedPreferences.getString(Preference.APP_APPEARANCE.value, null) @@ -53,6 +64,15 @@ class UserPreferencesStorage @Inject constructor(@ApplicationContext context: Co _userPreferences.update { current -> current.copy(appearance = value) } } + var useTrueBlack: Boolean + get() { + return sharedPreferences.getBoolean(Preference.USE_TRUE_BLACK.value, false) + } + set(value) { + sharedPreferences.edit { putBoolean(Preference.USE_TRUE_BLACK.value, value) } + _userPreferences.update { current -> current.copy(useTrueBlack = value) } + } + var dynamicColors: Boolean get() { return sharedPreferences.getBoolean(Preference.APP_DYNAMIC_COLORS.value, true) @@ -235,7 +255,9 @@ class UserPreferencesStorage @Inject constructor(@ApplicationContext context: Co } _userPreferences.update { UserPreferences( + dataSaver = dataSaver, appearance = appearance, + useTrueBlack = useTrueBlack, dynamicColors = dynamicColors, defaultSource = defaultSource, defaultShuffle = defaultShuffle, @@ -249,7 +271,9 @@ class UserPreferencesStorage @Inject constructor(@ApplicationContext context: Co } private enum class Preference(val value: String) { + DATA_SAVER(value = "user_preferences_data_saver"), APP_APPEARANCE(value = "user_preferences_appearance"), + USE_TRUE_BLACK(value = "user_preferences_use_true_black"), APP_DYNAMIC_COLORS(value = "user_preferences_dynamic_colors"), DEFAULT_ASPECT_RATIO(value = "default_aspect_ratio"), diff --git a/app/src/main/java/com/fibelatti/photowidget/preferences/WidgetDefaultsScreen.kt b/app/src/main/java/com/fibelatti/photowidget/preferences/WidgetDefaultsScreen.kt index 43a4c39..47d8e00 100644 --- a/app/src/main/java/com/fibelatti/photowidget/preferences/WidgetDefaultsScreen.kt +++ b/app/src/main/java/com/fibelatti/photowidget/preferences/WidgetDefaultsScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -180,6 +181,9 @@ private fun WidgetDefaultsScreen( ) } }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), ) }, contentWindowInsets = WindowInsets.safeDrawing, @@ -601,7 +605,9 @@ private fun WidgetDefaultsScreenPreview() { ExtendedTheme { WidgetDefaultsScreen( userPreferences = UserPreferences( + dataSaver = true, appearance = Appearance.FOLLOW_SYSTEM, + useTrueBlack = false, dynamicColors = true, defaultSource = PhotoWidgetSource.PHOTOS, defaultShuffle = false, diff --git a/app/src/main/java/com/fibelatti/photowidget/ui/AsyncPhotoViewer.kt b/app/src/main/java/com/fibelatti/photowidget/ui/AsyncPhotoViewer.kt index d3d2ff3..7ed44d3 100644 --- a/app/src/main/java/com/fibelatti/photowidget/ui/AsyncPhotoViewer.kt +++ b/app/src/main/java/com/fibelatti/photowidget/ui/AsyncPhotoViewer.kt @@ -23,13 +23,15 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.max import com.fibelatti.photowidget.R import com.fibelatti.photowidget.di.PhotoWidgetEntryPoint import com.fibelatti.photowidget.di.entryPoint import com.fibelatti.photowidget.model.PhotoWidget +import com.fibelatti.ui.foundation.dpToPx +import kotlin.math.roundToInt import kotlinx.coroutines.delay @Composable @@ -39,6 +41,7 @@ fun AsyncPhotoViewer( isLoading: Boolean, contentScale: ContentScale, modifier: Modifier = Modifier, + constrainBitmapSize: Boolean = true, transformer: (Bitmap?) -> Bitmap? = { it }, badge: @Composable BoxScope.() -> Unit = {}, ) { @@ -70,10 +73,11 @@ fun AsyncPhotoViewer( val decoder by remember { lazy { entryPoint(localContext).photoDecoder() } } - val maxDimension = with(LocalDensity.current) { - remember(maxWidth) { - maxWidth.toPx().toInt().coerceAtMost(maximumValue = PhotoWidget.MAX_WIDGET_DIMENSION) - } + val largestSize = max(maxWidth, maxHeight) + val maxDimension = if (constrainBitmapSize) { + largestSize.dpToPx().roundToInt().coerceAtMost(maximumValue = PhotoWidget.MAX_WIDGET_DIMENSION) + } else { + null } LaunchedEffect(*dataKey) { diff --git a/app/src/main/java/com/fibelatti/photowidget/ui/ShapesBanner.kt b/app/src/main/java/com/fibelatti/photowidget/ui/ShapesBanner.kt new file mode 100644 index 0000000..5ce0053 --- /dev/null +++ b/app/src/main/java/com/fibelatti/photowidget/ui/ShapesBanner.kt @@ -0,0 +1,97 @@ +package com.fibelatti.photowidget.ui + +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.fibelatti.photowidget.configure.ColoredShape +import com.fibelatti.photowidget.model.PhotoWidgetShapeBuilder +import com.fibelatti.ui.preview.DevicePreviews +import com.fibelatti.ui.theme.ExtendedTheme + +@Composable +fun ShapesBanner( + modifier: Modifier = Modifier, + polygonSize: Dp = 48.dp, +) { + val polygons = remember { + listOf("scallop", "medal", "clover", "octagon", "hexagon") + .shuffled() + .map(PhotoWidgetShapeBuilder::buildShape) + } + var isAnimating by remember { mutableStateOf(false) } + val transition = rememberInfiniteTransition(label = "ShapesBannerTransition") + var desiredEasing by remember { mutableFloatStateOf(0f) } + val coefficient by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + tween( + durationMillis = 10_000, + easing = { + if (isAnimating) { + desiredEasing += 0.0016669631f + + if (desiredEasing >= 1f) { + desiredEasing = 0f + } + } + + desiredEasing + }, + ), + ), + label = "ShapesBannerAnimation_Rotation", + ) + + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 10.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + isAnimating = !isAnimating + }, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + polygons.forEach { roundedPolygon -> + ColoredShape( + polygon = roundedPolygon, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(polygonSize) + .graphicsLayer { + rotationZ = 360f * coefficient + }, + ) + } + } +} + +@Composable +@DevicePreviews +private fun ShapesBannerPreview() { + ExtendedTheme { + ShapesBanner() + } +} diff --git a/app/src/main/java/com/fibelatti/photowidget/ui/Toggle.kt b/app/src/main/java/com/fibelatti/photowidget/ui/Toggle.kt new file mode 100644 index 0000000..c4dd156 --- /dev/null +++ b/app/src/main/java/com/fibelatti/photowidget/ui/Toggle.kt @@ -0,0 +1,37 @@ +package com.fibelatti.photowidget.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun Toggle( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge, + ) + } +} diff --git a/app/src/main/java/com/fibelatti/photowidget/viewer/PhotoWidgetViewerActivity.kt b/app/src/main/java/com/fibelatti/photowidget/viewer/PhotoWidgetViewerActivity.kt index 57ff97b..3b4b76a 100644 --- a/app/src/main/java/com/fibelatti/photowidget/viewer/PhotoWidgetViewerActivity.kt +++ b/app/src/main/java/com/fibelatti/photowidget/viewer/PhotoWidgetViewerActivity.kt @@ -181,6 +181,7 @@ private fun ScreenContent( } .transformable(state = state) .aspectRatio(ratio = aspectRatio.aspectRatio), + constrainBitmapSize = false, ) Row( diff --git a/app/src/main/java/com/fibelatti/photowidget/widget/LoadPhotoWidgetUseCase.kt b/app/src/main/java/com/fibelatti/photowidget/widget/LoadPhotoWidgetUseCase.kt index 879d896..e7b8adc 100644 --- a/app/src/main/java/com/fibelatti/photowidget/widget/LoadPhotoWidgetUseCase.kt +++ b/app/src/main/java/com/fibelatti/photowidget/widget/LoadPhotoWidgetUseCase.kt @@ -30,6 +30,8 @@ class LoadPhotoWidgetUseCase @Inject constructor( aspectRatio = getWidgetAspectRatio(appWidgetId = appWidgetId), shapeId = getWidgetShapeId(appWidgetId = appWidgetId), cornerRadius = getWidgetCornerRadius(appWidgetId = appWidgetId), + borderColor = getWidgetBorderColorHex(appWidgetId = appWidgetId), + borderWidth = getWidgetBorderWidth(appWidgetId = appWidgetId), opacity = getWidgetOpacity(appWidgetId = appWidgetId), horizontalOffset = horizontalOffset, verticalOffset = verticalOffset, diff --git a/app/src/main/java/com/fibelatti/photowidget/widget/PhotoWidgetAlarmManager.kt b/app/src/main/java/com/fibelatti/photowidget/widget/PhotoWidgetAlarmManager.kt index 9274052..bd14dfc 100644 --- a/app/src/main/java/com/fibelatti/photowidget/widget/PhotoWidgetAlarmManager.kt +++ b/app/src/main/java/com/fibelatti/photowidget/widget/PhotoWidgetAlarmManager.kt @@ -11,6 +11,7 @@ import com.fibelatti.photowidget.configure.appWidgetId import com.fibelatti.photowidget.di.PhotoWidgetEntryPoint import com.fibelatti.photowidget.di.entryPoint import com.fibelatti.photowidget.model.PhotoWidgetCycleMode +import com.fibelatti.photowidget.platform.setIdentifierCompat import com.fibelatti.photowidget.widget.data.PhotoWidgetStorage import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Calendar @@ -168,6 +169,7 @@ class ExactRepeatingAlarmReceiver : BroadcastReceiver() { appWidgetId: Int, ): PendingIntent { val intent = Intent(context, ExactRepeatingAlarmReceiver::class.java).apply { + setIdentifierCompat("$appWidgetId") this.appWidgetId = appWidgetId } return PendingIntent.getBroadcast( diff --git a/app/src/main/java/com/fibelatti/photowidget/widget/PhotoWidgetProvider.kt b/app/src/main/java/com/fibelatti/photowidget/widget/PhotoWidgetProvider.kt index f3c3a45..2e67f57 100644 --- a/app/src/main/java/com/fibelatti/photowidget/widget/PhotoWidgetProvider.kt +++ b/app/src/main/java/com/fibelatti/photowidget/widget/PhotoWidgetProvider.kt @@ -22,6 +22,7 @@ import com.fibelatti.photowidget.model.PhotoWidget import com.fibelatti.photowidget.model.PhotoWidgetAspectRatio import com.fibelatti.photowidget.model.PhotoWidgetTapAction import com.fibelatti.photowidget.platform.WidgetSizeProvider +import com.fibelatti.photowidget.platform.setIdentifierCompat import com.fibelatti.photowidget.platform.withPolygonalShape import com.fibelatti.photowidget.platform.withRoundedCorners import com.fibelatti.photowidget.viewer.PhotoWidgetViewerActivity @@ -102,13 +103,17 @@ class PhotoWidgetProvider : AppWidgetProvider() { val entryPoint = entryPoint(context) val coroutineScope = entryPoint.coroutineScope() val loadPhotoWidgetUseCase = entryPoint.loadPhotoWidgetUseCase() + val photoWidgetStorage = entryPoint.photoWidgetStorage() if (loadedWidgets[appWidgetId] != true) { Timber.d("Dispatching pre-load remote views to AppWidgetManager") - appWidgetManager.updateAppWidget( - /* appWidgetId = */ appWidgetId, - /* views = */ RemoteViews(context.packageName, R.layout.photo_widget_placeholder), - ) + + val aspectRatio = photoWidgetStorage.getWidgetAspectRatio(appWidgetId) + val remoteViews = createBaseView(context = context, aspectRatio = aspectRatio).apply { + setImageViewResource(R.id.iv_placeholder, R.drawable.ic_hourglass) + } + + appWidgetManager.updateAppWidget(appWidgetId, remoteViews) loadedWidgets[appWidgetId] = true } @@ -154,7 +159,11 @@ class PhotoWidgetProvider : AppWidgetProvider() { recoveryMode: Boolean = false, ): RemoteViews { Timber.d("Creating remote views") - val currentPhoto = photoWidget.currentPhoto ?: return createErrorView(context = context) + val errorView = createBaseView(context = context, aspectRatio = photoWidget.aspectRatio).apply { + setImageViewResource(R.id.iv_placeholder, R.drawable.ic_file_not_found) + } + + val currentPhoto = photoWidget.currentPhoto ?: return errorView val entryPoint = entryPoint(context) val decoder = entryPoint.photoDecoder() @@ -164,7 +173,7 @@ class PhotoWidgetProvider : AppWidgetProvider() { val data = when { !currentPhoto.path.isNullOrEmpty() -> currentPhoto.path currentPhoto.externalUri != null -> currentPhoto.externalUri - else -> return createErrorView(context = context) + else -> return errorView } val displayMetrics: DisplayMetrics = context.resources.displayMetrics val maxMemoryAllowed: Int = if (!recoveryMode) { @@ -189,7 +198,7 @@ class PhotoWidgetProvider : AppWidgetProvider() { requireNotNull(decoder.decode(data = data, maxDimension = maxDimension)) } catch (_: Exception) { - return createErrorView(context = context) + return errorView } Timber.d("Transforming the bitmap") @@ -197,6 +206,8 @@ class PhotoWidgetProvider : AppWidgetProvider() { bitmap.withPolygonalShape( shapeId = photoWidget.shapeId, opacity = photoWidget.opacity, + borderColorHex = photoWidget.borderColor, + borderWidth = photoWidget.borderWidth, ) } else { bitmap.withRoundedCorners( @@ -207,16 +218,13 @@ class PhotoWidgetProvider : AppWidgetProvider() { photoWidget.cornerRadius }, opacity = photoWidget.opacity, + borderColorHex = photoWidget.borderColor, + borderWidth = photoWidget.borderWidth, ) } - val layoutId = if (PhotoWidgetAspectRatio.FILL_WIDGET == photoWidget.aspectRatio) { - R.layout.photo_widget_fill - } else { - R.layout.photo_widget - } - - return RemoteViews(context.packageName, layoutId).apply { + return createBaseView(context = context, aspectRatio = photoWidget.aspectRatio).apply { + setViewVisibility(R.id.iv_placeholder, View.GONE) setImageViewBitmap(R.id.iv_widget, transformedBitmap) setViewPadding( /* viewId = */ R.id.iv_widget, @@ -228,10 +236,14 @@ class PhotoWidgetProvider : AppWidgetProvider() { } } - private fun createErrorView(context: Context): RemoteViews { - return RemoteViews(context.packageName, R.layout.photo_widget_placeholder).apply { - setImageViewResource(R.id.image_view_placeholder, R.drawable.ic_file_not_found) + private fun createBaseView(context: Context, aspectRatio: PhotoWidgetAspectRatio): RemoteViews { + val layoutId = if (PhotoWidgetAspectRatio.FILL_WIDGET == aspectRatio) { + R.layout.photo_widget_fill + } else { + R.layout.photo_widget } + + return RemoteViews(context.packageName, layoutId) } private fun getDimensionValue(context: Context, value: Int): Int { @@ -306,13 +318,14 @@ class PhotoWidgetProvider : AppWidgetProvider() { is PhotoWidgetTapAction.ViewFullScreen -> { val clickIntent = Intent(context, PhotoWidgetViewerActivity::class.java).apply { + setIdentifierCompat("$appWidgetId") this.appWidgetId = appWidgetId } return PendingIntent.getActivity( - context, - appWidgetId, - clickIntent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + /* context = */ context, + /* requestCode = */ appWidgetId, + /* intent = */ clickIntent, + /* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) } @@ -320,14 +333,15 @@ class PhotoWidgetProvider : AppWidgetProvider() { if (externalUri == null) return null val intent = Intent(Intent.ACTION_VIEW, externalUri).apply { + setIdentifierCompat("$appWidgetId") flags = Intent.FLAG_GRANT_READ_URI_PERMISSION } return PendingIntent.getActivity( - context, - appWidgetId, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + /* context = */ context, + /* requestCode = */ appWidgetId, + /* intent = */ intent, + /* flags = */ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) } @@ -348,11 +362,26 @@ class PhotoWidgetProvider : AppWidgetProvider() { if (launchIntent == null) return null + launchIntent.setIdentifierCompat("$appWidgetId") + + return PendingIntent.getActivity( + /* context = */ context, + /* requestCode = */ appWidgetId, + /* intent = */ launchIntent, + /* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + is PhotoWidgetTapAction.ToggleCycling -> { + val intent = Intent(context, ToggleCyclingFeedbackActivity::class.java).apply { + setIdentifierCompat("$appWidgetId") + this.appWidgetId = appWidgetId + } return PendingIntent.getActivity( - context, - appWidgetId, - launchIntent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + /* context = */ context, + /* requestCode = */ appWidgetId, + /* intent = */ intent, + /* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) } } @@ -364,6 +393,7 @@ class PhotoWidgetProvider : AppWidgetProvider() { flipBackwards: Boolean = false, ): PendingIntent { val intent = Intent(context, PhotoWidgetProvider::class.java).apply { + setIdentifierCompat("$appWidgetId") this.appWidgetId = appWidgetId this.action = if (flipBackwards) ACTION_VIEW_PREVIOUS_PHOTO else ACTION_VIEW_NEXT_PHOTO } diff --git a/app/src/main/java/com/fibelatti/photowidget/widget/PhotoWidgetRescheduleReceiver.kt b/app/src/main/java/com/fibelatti/photowidget/widget/PhotoWidgetRescheduleReceiver.kt index 6289123..93c831a 100644 --- a/app/src/main/java/com/fibelatti/photowidget/widget/PhotoWidgetRescheduleReceiver.kt +++ b/app/src/main/java/com/fibelatti/photowidget/widget/PhotoWidgetRescheduleReceiver.kt @@ -33,9 +33,10 @@ class PhotoWidgetRescheduleReceiver : BroadcastReceiver() { coroutineScope.launch { for (id in ids) { val cycleMode = photoWidgetStorage.getWidgetCycleMode(appWidgetId = id) - Timber.d("Processing widget (id=$id, cycleMode=$cycleMode)") + val paused = photoWidgetStorage.getWidgetCyclePaused(appWidgetId = id) + Timber.d("Processing widget (id=$id, cycleMode=$cycleMode, paused=$paused)") - if (cycleMode !is PhotoWidgetCycleMode.Disabled) { + if (cycleMode !is PhotoWidgetCycleMode.Disabled && !paused) { photoWidgetAlarmManager.setup(appWidgetId = id) } diff --git a/app/src/main/java/com/fibelatti/photowidget/widget/ToggleCyclingFeedbackActivity.kt b/app/src/main/java/com/fibelatti/photowidget/widget/ToggleCyclingFeedbackActivity.kt new file mode 100644 index 0000000..1750cb4 --- /dev/null +++ b/app/src/main/java/com/fibelatti/photowidget/widget/ToggleCyclingFeedbackActivity.kt @@ -0,0 +1,52 @@ +package com.fibelatti.photowidget.widget + +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.fibelatti.photowidget.R +import com.fibelatti.photowidget.configure.appWidgetId +import com.fibelatti.photowidget.widget.data.PhotoWidgetStorage +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +/** + * This could have been a `BroadcastReceiver` but the action would be quite confusing without any + * sort of user feedback. This transparent activity does the work and shows a toast at the end to + * confirm. + */ +@AndroidEntryPoint +class ToggleCyclingFeedbackActivity : AppCompatActivity() { + + @Inject + lateinit var photoWidgetStorage: PhotoWidgetStorage + + @Inject + lateinit var photoWidgetAlarmManager: PhotoWidgetAlarmManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val paused = photoWidgetStorage.getWidgetCyclePaused(appWidgetId = intent.appWidgetId) + + if (paused) { + photoWidgetAlarmManager.setup(appWidgetId = intent.appWidgetId) + } else { + photoWidgetAlarmManager.cancel(appWidgetId = intent.appWidgetId) + } + + photoWidgetStorage.saveWidgetCyclePaused(appWidgetId = intent.appWidgetId, value = !paused) + + Toast.makeText( + /* context = */ this, + /* resId = */ + if (paused) { + R.string.photo_widget_cycling_feedback_resumed + } else { + R.string.photo_widget_cycling_feedback_paused + }, + /* duration = */ Toast.LENGTH_SHORT, + ).show() + + finish() + } +} diff --git a/app/src/main/java/com/fibelatti/photowidget/widget/data/PhotoWidgetInternalFileStorage.kt b/app/src/main/java/com/fibelatti/photowidget/widget/data/PhotoWidgetInternalFileStorage.kt index 748b5ff..9138cb9 100644 --- a/app/src/main/java/com/fibelatti/photowidget/widget/data/PhotoWidgetInternalFileStorage.kt +++ b/app/src/main/java/com/fibelatti/photowidget/widget/data/PhotoWidgetInternalFileStorage.kt @@ -3,10 +3,11 @@ package com.fibelatti.photowidget.widget.data import android.content.Context import android.graphics.Bitmap import android.net.Uri +import android.webkit.MimeTypeMap import com.fibelatti.photowidget.model.LocalPhoto -import com.fibelatti.photowidget.model.PhotoWidget import com.fibelatti.photowidget.model.PhotoWidgetSource import com.fibelatti.photowidget.platform.PhotoDecoder +import com.fibelatti.photowidget.preferences.UserPreferencesStorage import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.io.FileInputStream @@ -22,9 +23,12 @@ import timber.log.Timber class PhotoWidgetInternalFileStorage @Inject constructor( @ApplicationContext private val context: Context, + private val userPreferencesStorage: UserPreferencesStorage, private val decoder: PhotoDecoder, ) { + private val contentResolver = context.contentResolver + private val mimeTypeMap = MimeTypeMap.getSingleton() private val rootDir by lazy { File("${context.filesDir}/widgets").apply { mkdirs() @@ -33,36 +37,43 @@ class PhotoWidgetInternalFileStorage @Inject constructor( suspend fun newWidgetPhoto(appWidgetId: Int, source: Uri): LocalPhoto? { return withContext(Dispatchers.IO) { - Timber.d("New widget photo: $source (appWidgetId=$appWidgetId)") - val widgetDir = getWidgetDir(appWidgetId = appWidgetId) - val originalPhotosDir = File("$widgetDir/original").apply { mkdirs() } - val newPhotoName = "${UUID.randomUUID()}.png" + runCatching { + Timber.d("New widget photo: $source (appWidgetId=$appWidgetId)") - val originalPhoto = File("$originalPhotosDir/$newPhotoName") - val croppedPhoto = File("$widgetDir/$newPhotoName") + val widgetDir = getWidgetDir(appWidgetId = appWidgetId) + val originalPhotosDir = File("$widgetDir/original").apply { mkdirs() } + val extension = mimeTypeMap.getExtensionFromMimeType(contentResolver.getType(source)) ?: "png" + val newPhotoName = "${UUID.randomUUID()}.$extension" - val newFiles = listOf(originalPhoto, croppedPhoto) + val originalPhoto = File("$originalPhotosDir/$newPhotoName") + val croppedPhoto = File("$widgetDir/$newPhotoName") - runCatching { - decoder.decode(data = source, maxDimension = PhotoWidget.MAX_STORAGE_DIMENSION)?.let { importedPhoto -> - newFiles.map { file -> - file.createNewFile() + val newFiles = listOf(originalPhoto, croppedPhoto) + val dataSaver = userPreferencesStorage.dataSaver + + Timber.d("Data saver: $dataSaver") + if (dataSaver) { + decoder.decode(data = source, maxDimension = 2560)?.let { importedPhoto -> + val format = if (extension == "png") Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG + newFiles.map { file -> + async { + writeToFile(file) { fos -> importedPhoto.compress(format, 95, fos) } + } + }.awaitAll() + } + } else { + newFiles.map { file -> async { - FileOutputStream(file).use { fos -> - importedPhoto.compress(Bitmap.CompressFormat.PNG, 100, fos) - Timber.d("$source saved to $file") + contentResolver.openInputStream(source)?.use { input -> + writeToFile(file, input::copyTo) } } }.awaitAll() - } ?: return@withContext null // Exit early if the bitmap can't be decoded + } - // Safety check to ensure the photos were copied correctly return@withContext if (newFiles.all { it.exists() }) { - LocalPhoto( - name = newPhotoName, - path = croppedPhoto.path, - ) + LocalPhoto(name = newPhotoName, path = croppedPhoto.path) } else { null } @@ -70,6 +81,18 @@ class PhotoWidgetInternalFileStorage @Inject constructor( } } + private fun writeToFile(file: File, operation: (FileOutputStream) -> Unit) { + file.createNewFile() + runCatching { + FileOutputStream(file).use { fos -> operation(fos) } + }.onSuccess { + Timber.d("Successfully saved to $file") + }.onFailure { + Timber.d("Failed to save to $file") + file.delete() + } + } + suspend fun getCropSources(appWidgetId: Int, localPhoto: LocalPhoto): Pair = withContext(Dispatchers.IO) { val widgetDir = getWidgetDir(appWidgetId = appWidgetId) val croppedPhoto = File("$widgetDir/${localPhoto.name}").apply { createNewFile() } diff --git a/app/src/main/java/com/fibelatti/photowidget/widget/data/PhotoWidgetSharedPreferences.kt b/app/src/main/java/com/fibelatti/photowidget/widget/data/PhotoWidgetSharedPreferences.kt index 785c488..860b93a 100644 --- a/app/src/main/java/com/fibelatti/photowidget/widget/data/PhotoWidgetSharedPreferences.kt +++ b/app/src/main/java/com/fibelatti/photowidget/widget/data/PhotoWidgetSharedPreferences.kt @@ -144,6 +144,16 @@ class PhotoWidgetSharedPreferences @Inject constructor( return sharedPreferences.getLong("${PreferencePrefix.NEXT_CYCLE_TIME}$appWidgetId", -1) } + fun saveWidgetCyclePaused(appWidgetId: Int, value: Boolean) { + sharedPreferences.edit { + putBoolean("${PreferencePrefix.CYCLE_PAUSED}$appWidgetId", value) + } + } + + fun getWidgetCyclePaused(appWidgetId: Int): Boolean { + return sharedPreferences.getBoolean("${PreferencePrefix.CYCLE_PAUSED}$appWidgetId", false) + } + private fun getWidgetInterval(appWidgetId: Int): PhotoWidgetLoopingInterval { val legacyName = sharedPreferences.getString("${PreferencePrefix.LEGACY_INTERVAL}$appWidgetId", null) val legacyValue = enumValueOfOrNull(legacyName) @@ -229,6 +239,26 @@ class PhotoWidgetSharedPreferences @Inject constructor( ) } + fun saveWidgetBorderColor(appWidgetId: Int, colorHex: String?, width: Int) { + sharedPreferences.edit { + if (colorHex != null) { + putString("${PreferencePrefix.BORDER_COLOR_HEX}$appWidgetId", colorHex) + putInt("${PreferencePrefix.BORDER_WIDTH}$appWidgetId", width) + } else { + remove("${PreferencePrefix.BORDER_COLOR_HEX}$appWidgetId") + remove("${PreferencePrefix.BORDER_WIDTH}$appWidgetId") + } + } + } + + fun getWidgetBorderColorHex(appWidgetId: Int): String? { + return sharedPreferences.getString("${PreferencePrefix.BORDER_COLOR_HEX}$appWidgetId", null) + } + + fun getWidgetBorderWidth(appWidgetId: Int): Int { + return sharedPreferences.getInt("${PreferencePrefix.BORDER_WIDTH}$appWidgetId", 0) + } + fun saveWidgetOpacity(appWidgetId: Int, opacity: Float) { sharedPreferences.edit { putFloat("${PreferencePrefix.OPACITY}$appWidgetId", opacity) @@ -363,11 +393,14 @@ class PhotoWidgetSharedPreferences @Inject constructor( INTERVAL_ENABLED(value = "appwidget_interval_enabled_"), SCHEDULE(value = "appwidget_schedule_"), NEXT_CYCLE_TIME(value = "appwidget_next_cycle_time_"), + CYCLE_PAUSED(value = "appwidget_cycle_paused_"), INDEX(value = "appwidget_index_"), PAST_INDICES(value = "appwidget_past_indices_"), RATIO(value = "appwidget_aspect_ratio_"), SHAPE(value = "appwidget_shape_"), CORNER_RADIUS(value = "appwidget_corner_radius_"), + BORDER_COLOR_HEX(value = "appwidget_border_color_hex_"), + BORDER_WIDTH(value = "appwidget_border_width_"), OPACITY(value = "appwidget_opacity_"), HORIZONTAL_OFFSET(value = "appwidget_horizontal_offset_"), VERTICAL_OFFSET(value = "appwidget_vertical_offset_"), diff --git a/app/src/main/java/com/fibelatti/photowidget/widget/data/PhotoWidgetStorage.kt b/app/src/main/java/com/fibelatti/photowidget/widget/data/PhotoWidgetStorage.kt index 6b7972a..0614d5d 100644 --- a/app/src/main/java/com/fibelatti/photowidget/widget/data/PhotoWidgetStorage.kt +++ b/app/src/main/java/com/fibelatti/photowidget/widget/data/PhotoWidgetStorage.kt @@ -171,6 +171,14 @@ class PhotoWidgetStorage @Inject constructor( return sharedPreferences.getWidgetNextCycleTime(appWidgetId = appWidgetId) } + fun saveWidgetCyclePaused(appWidgetId: Int, value: Boolean) { + sharedPreferences.saveWidgetCyclePaused(appWidgetId = appWidgetId, value = value) + } + + fun getWidgetCyclePaused(appWidgetId: Int): Boolean { + return sharedPreferences.getWidgetCyclePaused(appWidgetId = appWidgetId) + } + fun saveWidgetIndex(appWidgetId: Int, index: Int) { sharedPreferences.saveWidgetIndex(appWidgetId = appWidgetId, index = index) } @@ -211,6 +219,18 @@ class PhotoWidgetStorage @Inject constructor( return sharedPreferences.getWidgetCornerRadius(appWidgetId = appWidgetId) } + fun saveWidgetBorderColor(appWidgetId: Int, colorHex: String?, width: Int) { + sharedPreferences.saveWidgetBorderColor(appWidgetId = appWidgetId, colorHex = colorHex, width = width) + } + + fun getWidgetBorderColorHex(appWidgetId: Int): String? { + return sharedPreferences.getWidgetBorderColorHex(appWidgetId = appWidgetId) + } + + fun getWidgetBorderWidth(appWidgetId: Int): Int { + return sharedPreferences.getWidgetBorderWidth(appWidgetId = appWidgetId) + } + fun saveWidgetOpacity(appWidgetId: Int, opacity: Float) { sharedPreferences.saveWidgetOpacity(appWidgetId = appWidgetId, opacity = opacity) } diff --git a/app/src/main/res/drawable/ic_hard_drive.xml b/app/src/main/res/drawable/ic_hard_drive.xml new file mode 100644 index 0000000..2879948 --- /dev/null +++ b/app/src/main/res/drawable/ic_hard_drive.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/layout/photo_widget.xml b/app/src/main/res/layout/photo_widget.xml index a2ad24d..462ec94 100644 --- a/app/src/main/res/layout/photo_widget.xml +++ b/app/src/main/res/layout/photo_widget.xml @@ -8,6 +8,14 @@ tools:layout_width="400dp" tools:targetApi="s"> + + + + - - - diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4aaa8d3..b9171ad 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -11,7 +11,9 @@ Nuevo Widget Mis Widgets Ajustes + Almacenamiento optimizado Tema de la aplicación + Fondo negro verdadero Colores de la aplicación Comparte la aplicación Escribe una reseña @@ -37,12 +39,15 @@ Los widgets en gris se eliminaron de su pantalla de inicio y se eliminarán permanentemente después de una semana - Editar + Ver / Editar Duplicar Restaurar Borrar permanentemente + Reducir el uso del almacenamiento + Cuando está habilitada, las fotos agregadas a los widgets se comprimen para ahorrar espacio de almacenamiento. Por lo general, esto no afectará la calidad, pero si las fotos contienen texto pequeño, se recomienda desactivarla.\n\nEsta configuración solo afecta las fotos agregadas después de que se modifique.\n\nNo se aplica a las carpetas sincronizadas, ya que esas fotos se leen directamente desde el almacenamiento de su dispositivo. + Seguir el sistema Claro Oscuro @@ -117,7 +122,7 @@ El widget mostrará la próxima foto en cada intervalo de tiempo seleccionado: El widget mostrará la próxima foto en cada horario seleccionado: - El widget no mostrará la próxima foto automáticamente. Usa la acción de toque para alternar entre las fotos, o elige otro modo. + El widget no pasará de una foto a otra automáticamente. Toca los bordes del widget para pasar a la siguiente foto. Cada %s @@ -161,6 +166,10 @@ Android restringe el trabajo en segundo plano para optimizar la duración de la batería, lo que puede llevar a intervalos más largos entre cada foto.\n\nSi deseas que los widgets se actualicen precisamente en el intervalo seleccionado, abre la configuración y habilita este permiso. Abrir configuración + Borde + Ninguno + Color + Desplazamiento Horizontal: %1$d, Vertical: %2$d Horizontal: %1$d @@ -174,6 +183,8 @@ Ver foto en pantalla completa Ver en galería Abrir una aplicación + Habilitar/deshabilitar ciclo + Toque para pausar el ciclo automático de fotos. Toque nuevamente para reanudarlo. Aumentar automáticamente el brillo Ver foto original (sin recorte ni rotación) @@ -181,6 +192,7 @@ Elige la aplicación Agregar a la pantalla principal + Guardar cambios Seleccione la hora @@ -199,11 +211,17 @@ ¿Algo más no está funcionando? + Escribe tu mensaje a continuación. Si estás informando un problema, intenta describir lo que estabas haciendo cuando se produjo el problema. + Pellizcar para hacer zoom Arrastra para mover Toca para cerrar + + Ciclo pausado + Ciclo reanudado + No diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2d702e2..7b2bc3c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -11,7 +11,9 @@ Nouveau widget Mes widgets Paramètres + Stockage optimisé Thème de l\'application + Fond noir véritable Couleurs de l\'application Partager l\'application Écrire un avis @@ -37,12 +39,15 @@ Les widgets grisés ont été supprimés de votre écran d\'accueil et seront définitivement supprimés après une semaine - Modifier + Afficher / Modifier Dupliquer Restaurer Supprimer définitivement + Réduire l\'utilisation du stockage + Lorsque cette option est activée, les photos ajoutées aux widgets sont compressées pour économiser de l\'espace de stockage. Cela n\'affecte généralement pas la qualité, mais si vos photos contiennent du texte de petite taille, il est recommandé de la désactiver.\n\nCe paramètre affecte uniquement les photos ajoutées après sa modification.\n\nIl ne s\'applique pas aux dossiers synchronisés, car ces photos sont lues directement à partir du stockage de votre appareil. + Suivre le système Clair Sombre @@ -55,7 +60,7 @@ Ces valeurs sont préchargées lorsque vous créez un nouveau widget. Elles sont utiles pour configurer plusieurs widgets avec la même configuration, sans avoir besoin d\'ajuster les paramètres à chaque fois. Notez que toute modification de ces valeurs n\'affectera pas les widgets existants. Source Aléatoire - Changement de photo + Cycle des photos Forme Arrondi Opacité @@ -110,14 +115,14 @@ Activé Désactivé - Changement de photo + Cycle des photos Intervalle Planification Désactivé Le widget passera automatiquement à la photo suivante à l\'intervalle suivant : Le widget passera automatiquement à la photo suivante tous les jours aux horaires suivants : - Le widget ne fera pas défiler les photos automatiquement. Configurez une action tactile pour faire défiler les photos ou choisissez un autre mode. + Le widget ne parcourt pas automatiquement les photos. Appuyez sur les bords du widget pour passer à la photo suivante. Toutes les %s @@ -161,6 +166,10 @@ Android limite le travail en arrière-plan pour optimiser l\'autonomie de la batterie, ce qui peut entraîner des intervalles plus longs entre chaque photo.\n\nSi vous souhaitez que les widgets se mettent à jour précisément à l\'intervalle sélectionné, ouvrez les paramètres et activez cette autorisation. Ouvrir les paramètres + Bordure + Aucune + Couleur + Décalage Horizontal : %1$d, Vertical : %2$d Horizontal : %1$d @@ -174,6 +183,8 @@ Afficher la photo en plein écran Voir dans la galerie Raccourci vers l\'application + Activer/désactiver le cycle + Appuyez pour mettre en pause le cycle automatique des photos. Appuyez à nouveau pour reprendre. Augmenter automatiquement la luminosité Voir la photo originale (sans recadrage ni rotation) @@ -181,6 +192,7 @@ Choisir une application Ajouter à l\'écran d\'accueil + Enregistrer les modifications Sélectionnez l\'heure @@ -199,11 +211,17 @@ Autre chose ne fonctionne pas ? + Rédigez votre message ci-dessous. Si vous signalez un problème, essayez de décrire ce que vous faisiez lorsque le problème est survenu. + Pincer pour zoomer Faites glisser pour déplacer Appuyez pour fermer + + Cycle en pause + Cycle repris + Oui Non diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 450639b..9cac47d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -11,7 +11,9 @@ Novo Widget Meus Widgets Configurações + Armazenamento otimizado Tema do app + Fundo preto verdadeiro Cores do app Compartilhe o app Deixe sua avaliação @@ -37,12 +39,15 @@ Os widgets acinzentados foram removidos da tela inicial e serão excluídos permanentemente após uma semana - Editar + Ver / Editar Duplicar Restaurar Apagar permanentemente + Reduzir o uso de armazenamento + Quando habilitado, as fotos adicionadas aos widgets são compactadas para economizar espaço de armazenamento. Isso geralmente não afeta a qualidade, mas se suas fotos contiverem texto pequeno, é recomendável desativá-lo.\n\nEsta configuração afeta apenas as fotos adicionadas após sua alteração.\n\nEla não se aplica a pastas sincronizadas, pois essas fotos são lidas diretamente do armazenamento do seu dispositivo. + Mesmo do sistema Claro Escuro @@ -117,7 +122,7 @@ O widget irá mostrar a próxima foto a cada intervalo de tempo selecionado: O widget irá mostrar a próxima foto a cada horário selecionado: - O widget não irá mostrar a próxima foto automaticamente. Use a ação de toque para alternar entre as fotos, ou escolha outro modo. + O widget não alternará entre as fotos automaticamente. Toque nas bordas do widget para alternar para a próxima foto. A cada %s @@ -161,6 +166,10 @@ O Android restringe atividades em segundo plano para otimizar a duração da bateria, o que pode levar a intervalos mais longos entre cada foto.\n\nSe você deseja que os widgets sejam atualizados precisamente no intervalo selecionado, abra as configurações e habilite esta permissão. Abrir configurações + Borda + Nenhuma + Cor + Deslocamento Horizontal: %1$d, Vertical: %2$d Horizontal: %1$d @@ -174,6 +183,8 @@ Ver foto em tela cheia Ver na galeria Abrir um app + Habilitar/desabilitar ciclo + Toque para pausar o ciclo automático de fotos. Toque novamente para retomar. Aumentar o brilho automaticamente Ver foto original (sem corte ou rotação) @@ -181,6 +192,7 @@ Escolher app Adicionar à tela inicial + Salvar alterações Escolha o horário @@ -199,11 +211,17 @@ Encontrou algum outro problema? + Escreva sua mensagem abaixo. Se estiver relatando um problema, tente descrever o que estava fazendo quando o problema aconteceu. + Pince para ampliar Arraste para mover Toque para fechar + + Ciclo pausado + Ciclo retomado + Sim Não diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c70e287..3a646a1 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -11,7 +11,9 @@ Новый виджет Мои виджеты Настройки + Оптимизированное хранилище Тема приложения + Настоящий черный фон Цвета приложения Поделиться приложением Оставить отзыв @@ -37,12 +39,15 @@ Затемнённые виджеты были удалены с главного экрана и будут окончательно удалены через неделю - Редактировать + Просмотреть / Редактировать Дублировать Восстановить Удалить навсегда + Уменьшить использование хранилища + При включении фотографии, добавленные в виджеты, сжимаются для экономии места. Обычно это не влияет на качество, но если ваши фотографии содержат мелкий текст, рекомендуется отключить эту функцию.\n\nЭта настройка влияет только на фотографии, добавленные после ее изменения.\n\nОна не применяется к синхронизированным папкам, так как эти фотографии считываются непосредственно из хранилища вашего устройства. + Следовать за системой Светлая Тёмная @@ -117,7 +122,7 @@ Виджет будет автоматически переключаться на следующую фотографию через следующий интервал: Виджет будет автоматически переключаться на следующую фотографию каждый день в следующие запланированные часы: - Виджет не будет автоматически переключаться между фотографиями. Установите действие по нажатию для переключения между фотографиями или выберите другой режим. + Виджет не будет автоматически переключаться между фотографиями. Коснитесь краев виджета, чтобы переключиться на следующую фотографию. Каждые %s @@ -161,6 +166,10 @@ Android ограничивает фоновую работу для оптимизации времени работы от батареи, что может привести к более длительным интервалам между каждой фотографией.\n\nЕсли вы хотите, чтобы виджеты обновлялись точно в выбранный интервал, откройте настройки и включите это разрешение. Открыть настройки + Граница + Нет + Цвет + Смещение Горизонтальное: %1$d, Вертикальное: %2$d Горизонтальное: %1$d @@ -174,6 +183,8 @@ Просмотр фотографии на весь экран Просмотреть в галерее Ярлык приложения + Включить/выключить цикл + Нажмите, чтобы приостановить автоматическую смену фотографий. Нажмите еще раз, чтобы возобновить. Автоматически увеличить яркость Просмотреть оригинальную фотографию (без обрезки или поворота) @@ -181,6 +192,7 @@ Выбрать приложение Добавить на главный экран + Сохранить изменения Выбрать время @@ -199,11 +211,17 @@ Что-то еще не работает? + Напишите свое сообщение ниже. Если вы сообщаете о проблеме, постарайтесь описать, что вы делали, когда возникла проблема. + Сожмите для увеличения Перетащите для перемещения Нажмите, чтобы закрыть + + Цикл приостановлен + Цикл возобновлен + Да Нет diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 64d7ebb..518c67b 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -11,7 +11,9 @@ Yeni Widget Widget\'larım Ayarlar + Optimize edilmiş depolama Uygulama teması + Gerçek siyah arka plan Uygulama renkleri Uygulamayı paylaş Yorum yaz @@ -36,12 +38,15 @@ Gri renkteki widget\'lar ana ekranınızdan kaldırıldı ve bir hafta sonra kalıcı olarak silinecek - Düzenle + Görüntüle / Düzenle Çoğalt Eski haline getirmek kalıcı olarak sil + Depolama kullanımını azaltın + Etkinleştirildiğinde, widget\'lara eklenen fotoğraflar depolama alanından tasarruf etmek için sıkıştırılır. Bu genellikle kaliteyi etkilemez, ancak fotoğraflarınız küçük metin içeriyorsa, bunu kapatmanız önerilir.\n\nBu ayar yalnızca değiştirildikten sonra eklenen fotoğrafları etkiler.\n\nSenkronize klasörler için geçerli değildir, çünkü bu fotoğraflar doğrudan cihazınızın depolama alanından okunur. + Sistemi takip et Aydınlık Karanlık @@ -116,7 +121,7 @@ Widget, belirli aralıklarla otomatik olarak bir sonraki fotoğrafa geçecektir: Widget, her gün belirlenen zamanlarda otomatik olarak bir sonraki fotoğrafa geçecektir: - Widget, fotoğraflar arasında otomatik olarak geçiş yapmayacaktır. Fotoğraflar arasında geçiş yapmak için bir dokunma eylemi ayarlayın veya farklı bir mod seçin. + Widget fotoğraflar arasında otomatik olarak geçiş yapmaz. Bir sonraki fotoğrafa geçmek için widget kenarlarına dokunun. Her %s @@ -155,6 +160,10 @@ Android, pil ömrünü optimize etmek için arka plan çalışmalarını kısıtlar, bu da her fotoğraf arasındaki aralıkların daha uzun olmasına neden olabilir.\n\nWidget\'ların seçilen aralıkta tam olarak güncellenmesini istiyorsanız, ayarları açın ve bu izni etkinleştirin. Ayarları aç + Kenarlık + Yok + Renk + Ofset Yatay: %1$d, Dikey: %2$d Yatay: %1$d @@ -168,6 +177,8 @@ Fotoğrafı tam ekranda görüntüle Galeride görüntüle Uygulama kısayolu + Döngüyü etkinleştir/devre dışı bırak + Otomatik fotoğraf döngüsünü duraklatmak için dokunun. Devam etmek için tekrar dokunun. Parlaklığı otomatik olarak artır Orijinal fotoğrafı görüntüle (kırpma veya döndürme yok) @@ -175,6 +186,7 @@ Uygulama seç Ana ekrana ekle + Değişiklikleri kaydet Zamanı seçin @@ -193,11 +205,17 @@ Başka bir şey mi çalışmıyor? + Mesajınızı aşağıya yazın. Bir sorun bildiriyorsanız, sorun oluştuğunda ne yaptığınızı açıklamaya çalışın. + Yakınlaştırmak için kıstır Taşımak için sürükle Kapatmak için dokun + + Duraklatılan döngü + Döngü devam ettirildi + Evet Hayır diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 104154e..5725433 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,7 +11,9 @@ New Widget My Widgets Settings + Optimized storage App theme + True black background App colors Share the app Write a review @@ -36,12 +38,15 @@ Grayed out widgets were removed from your home screen and will be permanently deleted after a week - Edit + View / Edit Duplicate Restore Delete permanently + Reduce storage usage + When enabled, photos added to widgets are compressed to save storage. This usually won\'t affect quality, but if your photos contain small text, it\'s recommended to turn this off.\n\nThis setting only affects photos added after it is changed.\n\nIt does not apply to synced folders, as those photos are read directly from your device storage. + Follow system Light Dark @@ -116,7 +121,7 @@ The widget will switch automatically to the next photo at the following interval: The widget will switch automatically to the next photo every day at the following scheduled times: - The widget won\'t cycle through photos automatically. Set a tap action to cycle through photos or choose a different mode. + The widget won\'t cycle through photos automatically. Tap the widget edges to switch to the next photo. Every %s @@ -155,6 +160,10 @@ Android restricts background work to optimize battery life which can lead to longer intervals between each photo.\n\nIf you want widgets to update precisely at the selected interval open settings and enable this permission. Open settings + Border + None + Color + Offset Horizontal: %1$d, Vertical: %2$d Horizontal: %1$d @@ -168,6 +177,8 @@ View photo in full screen View in gallery App shortcut + Enable/disable cycle + Tap to pause the automatic photo cycling. Tap again to resume. Automatically increase brightness View original photo (no crop or rotation) @@ -175,6 +186,7 @@ Choose app Add to home screen + Save changes Select time @@ -193,11 +205,17 @@ Something else is not working? + Write your message below. If you\'re reporting an issue, try to describe what you were doing when the problem happened. + Pinch to zoom Drag to move Tap to dismiss + + Cycle paused + Cycle resumed + Yes No diff --git a/fastlane/metadata/android/en-US/changelogs/1180000.txt b/fastlane/metadata/android/en-US/changelogs/1180000.txt new file mode 100644 index 0000000..0d8ac3e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1180000.txt @@ -0,0 +1,6 @@ +Version 1.18.0 is now available with these updates: + +* New tap action: pause and resume the automatic photo cycle +* New app theme option for using a true black background + +We welcome your feedback and suggestions! Visit the project page to contribute. diff --git a/fastlane/metadata/android/en-US/changelogs/1180100.txt b/fastlane/metadata/android/en-US/changelogs/1180100.txt new file mode 100644 index 0000000..30add0d --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1180100.txt @@ -0,0 +1,6 @@ +Version 1.18.1 is now available with these updates: + +* New tap action: pause and resume the automatic photo cycle +* New app theme option for using a true black background + +We welcome your feedback and suggestions! Visit the project page to contribute. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a40e89d..2d64e55 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.7.1" +agp = "8.7.2" coroutines = "1.9.0" hilt = "2.52" hiltExtensions = "1.2.0" @@ -19,7 +19,7 @@ material = { module = "com.google.android.material:material", version = "1.12.0" lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version = "2.8.7" } graphics-shapes = { module = "androidx.graphics:graphics-shapes", version = "1.0.1" } -compose-bom = { module = "androidx.compose:compose-bom", version = "2024.10.01" } +compose-bom = { module = "androidx.compose:compose-bom", version = "2024.11.00" } compose-runtime = { module = "androidx.compose.runtime:runtime" } compose-material3 = { module = "androidx.compose.material3:material3" } compose-ui = { module = "androidx.compose.ui:ui" } @@ -36,6 +36,7 @@ room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } ucrop = { module = "com.github.yalantis:ucrop", version = "2.2.9" } coil = { module = "io.coil-kt:coil", version = "2.7.0" } reorderable = { module = "sh.calvin.reorderable:reorderable", version = "2.4.0" } +colorpicker-compose = { module = "com.github.skydoves:colorpicker-compose", version = "1.1.2" } about-libraries = { module = "com.mikepenz:aboutlibraries-compose", version.ref = "aboutLibraries" } @@ -48,7 +49,7 @@ android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -ksp = { id = "com.google.devtools.ksp", version = "2.0.21-1.0.26" } +ksp = { id = "com.google.devtools.ksp", version = "2.0.21-1.0.27" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } room = { id = "androidx.room", version.ref = "room" } spotless = { id = "com.diffplug.spotless", version = "6.25.0" } diff --git a/ui/src/main/java/com/fibelatti/ui/theme/ExtendedTheme.kt b/ui/src/main/java/com/fibelatti/ui/theme/ExtendedTheme.kt index 5c70161..e4abf3b 100644 --- a/ui/src/main/java/com/fibelatti/ui/theme/ExtendedTheme.kt +++ b/ui/src/main/java/com/fibelatti/ui/theme/ExtendedTheme.kt @@ -9,21 +9,39 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @Composable fun ExtendedTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, + useTrueBlack: Boolean = false, content: @Composable () -> Unit, ) { val colorScheme: ColorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + if (darkTheme) { + val dynamicDarkColorScheme = dynamicDarkColorScheme(context) + if (useTrueBlack) { + dynamicDarkColorScheme.copy(background = Color.Black) + } else { + dynamicDarkColorScheme + } + } else { + dynamicLightColorScheme(context) + } + } + + darkTheme -> { + if (useTrueBlack) { + DarkColorScheme.copy(background = Color.Black) + } else { + DarkColorScheme + } } - darkTheme -> DarkColorScheme else -> LightColorScheme }