Compare commits

..

No commits in common. 'main' and '14.8.2' have entirely different histories.
main ... 14.8.2

@ -44,7 +44,7 @@ jobs:
GOOGLE_KEY: ${{ secrets.GOOGLE_KEY }} GOOGLE_KEY: ${{ secrets.GOOGLE_KEY }}
run: bundle exec fastlane bundle run: bundle exec fastlane bundle
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v4
with: with:
name: release name: release
path: | path: |

@ -29,7 +29,7 @@ jobs:
run: bundle exec fastlane lint run: bundle exec fastlane lint
- name: Archive lint reports - name: Archive lint reports
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v4
if: ${{ always() }} if: ${{ always() }}
with: with:
name: lint-reports name: lint-reports
@ -89,7 +89,7 @@ jobs:
script: ./gradlew -Pcoverage app:test${{ matrix.flavor }}DebugUnitTest app:connected${{ matrix.flavor }}DebugAndroidTest script: ./gradlew -Pcoverage app:test${{ matrix.flavor }}DebugUnitTest app:connected${{ matrix.flavor }}DebugAndroidTest
- name: Upload test reports - name: Upload test reports
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v4
if: ${{ always() }} if: ${{ always() }}
with: with:
name: test-reports-${{ matrix.flavor }} name: test-reports-${{ matrix.flavor }}

@ -24,7 +24,7 @@ jobs:
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true bundler-cache: true
- uses: actions/download-artifact@v6 - uses: actions/download-artifact@v5
with: with:
name: release name: release
path: . path: .

@ -1 +1 @@
3.4.7 3.4.5

@ -1,22 +1,3 @@
### 14.8.4 (2025-11-09)
* Fix flashing widgets [#3902](https://github.com/tasks/tasks/issues/3902)
* Fix random reminder scheduling
* Fix random reminders firing immediately on recurring tasks [#3904](https://github.com/tasks/tasks/issues/3904)
* Fix deadlock when adding new task
* Fix crash in settings when backup location unavailable [#3989](https://github.com/tasks/tasks/issues/3989)
* Fix Hebrew and Indonesian support [#3928](https://github.com/tasks/tasks/issues/3928)
* Update translations
* Bosnian - @hasak
* Finnish - @pHamala
* Indonesian - @erigmac
* Japanese - @array
* Romanian - @ygorigor
### 14.8.3 (2025-09-16)
* Fix crash on Android 10 and below
### 14.8.2 (2025-09-14) ### 14.8.2 (2025-09-14)
* Fix blank widgets on Android 16 QPR1 [#3847](https://github.com/tasks/tasks/issues/3847) * Fix blank widgets on Android 16 QPR1 [#3847](https://github.com/tasks/tasks/issues/3847)

@ -15,7 +15,7 @@ Please visit [tasks.org](https://tasks.org) for end user documentation and suppo
[![PayPal donate button](https://img.shields.io/badge/paypal-donate-yellow.svg?logo=paypal)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=alex@tasks.org) [![PayPal donate button](https://img.shields.io/badge/paypal-donate-yellow.svg?logo=paypal)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=alex@tasks.org)
[![Liberapay donate button](https://img.shields.io/liberapay/receives/tasks.svg?logo=liberapay)](https://liberapay.com/tasks/donate) [![Liberapay donate button](https://img.shields.io/liberapay/receives/tasks.svg?logo=liberapay)](https://liberapay.com/tasks/donate)
[![build](https://github.com/tasks/tasks/actions/workflows/bundle.yml/badge.svg)](https://github.com/tasks/tasks/actions/workflows/bundle.yml) [![weblate](https://hosted.weblate.org/widgets/tasks/-/android/svg-badge.svg)](https://hosted.weblate.org/engage/tasks/?utm_source=widget) [![build](https://github.com/tasks/tasks/actions/workflows/bundle.yml/badge.svg)](https://github.com/tasks/tasks/actions/workflows/bundle.yml) [![weblate](https://hosted.weblate.org/widgets/tasks/-/android/svg-badge.svg)](https://hosted.weblate.org/engage/tasks/?utm_source=widget) [![codebeat badge](https://codebeat.co/badges/07924fca-2f18-4eff-99a3-120ec5ac2d5f)](https://codebeat.co/projects/github-com-tasks-tasks-main)
### Contributing ### Contributing

@ -349,7 +349,7 @@ class DateUtilitiesTest {
} }
@Test @Test
fun hebrewDateTimeNoYear() = withLocale(Locale.forLanguageTag("he")) { fun hebrewDateTimeNoYear() = withLocale(Locale.forLanguageTag("iw")) {
freezeAt(DateTime(2018, 12, 12)) { freezeAt(DateTime(2018, 12, 12)) {
assertMatches( assertMatches(
"יום ראשון, 14 בינואר( בשעה)? 13:45", "יום ראשון, 14 בינואר( בשעה)? 13:45",
@ -359,7 +359,7 @@ class DateUtilitiesTest {
} }
@Test @Test
fun hebrewDateTimeWithYear() = withLocale(Locale.forLanguageTag("he")) { fun hebrewDateTimeWithYear() = withLocale(Locale.forLanguageTag("iw")) {
freezeAt(DateTime(2017, 12, 12)) { freezeAt(DateTime(2017, 12, 12)) {
assertMatches( assertMatches(
"יום ראשון, 14 בינואר 2018( בשעה)? 13:45", "יום ראשון, 14 בינואר 2018( בשעה)? 13:45",

@ -1,25 +0,0 @@
package org.tasks.data
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertTrue
import org.junit.Test
import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavAccount
import org.tasks.injection.InjectingTestCase
import javax.inject.Inject
@HiltAndroidTest
class CaldavDaoExtensionsTest : InjectingTestCase() {
@Inject lateinit var caldavDao: CaldavDao
@Test
fun getLocalListCreatesAccountIfNeeded() = runBlocking {
withTimeout(5000L) {
assertTrue(caldavDao.getAccounts().isEmpty())
caldavDao.getLocalList()
assertTrue(caldavDao.getAccounts(CaldavAccount.TYPE_LOCAL).isNotEmpty())
}
}
}

@ -4,24 +4,27 @@ import android.app.Activity
import android.content.Context import android.content.Context
import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient.BillingResponseCode import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingClient.ProductType import com.android.billingclient.api.BillingClient.SkuType
import com.android.billingclient.api.BillingClient.newBuilder import com.android.billingclient.api.BillingClient.newBuilder
import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams import com.android.billingclient.api.BillingFlowParams.ProrationMode
import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams
import com.android.billingclient.api.BillingResult import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ConsumeParams import com.android.billingclient.api.ConsumeParams
import com.android.billingclient.api.Purchase.PurchaseState import com.android.billingclient.api.Purchase.PurchaseState
import com.android.billingclient.api.PurchasesResult
import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.SkuDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.consumePurchase import com.android.billingclient.api.consumePurchase
import com.android.billingclient.api.queryPurchasesAsync
import com.android.billingclient.api.querySkuDetails
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import org.tasks.BuildConfig import org.tasks.BuildConfig
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import org.tasks.jobs.WorkManager import org.tasks.jobs.WorkManager
@ -46,61 +49,32 @@ class BillingClientImpl(
override suspend fun getSkus(skus: List<String>): List<Sku> = override suspend fun getSkus(skus: List<String>): List<Sku> =
executeServiceRequest { executeServiceRequest {
val productList = skus.map { val skuDetailsResult = withContext(Dispatchers.IO) {
QueryProductDetailsParams.Product.newBuilder() billingClient.querySkuDetails(
.setProductId(it) SkuDetailsParams
.setProductType(ProductType.SUBS) .newBuilder()
.build() .setType(SkuType.SUBS)
} .setSkusList(skus)
val params = QueryProductDetailsParams.newBuilder() .build()
.setProductList(productList) )
.build()
val productDetailsResult = withContext(Dispatchers.IO) {
suspendCoroutine { cont ->
billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
cont.resume(billingResult to productDetailsList)
}
}
} }
skuDetailsResult.billingResult.let {
productDetailsResult.first.let {
if (!it.success) { if (!it.success) {
throw IllegalStateException(it.responseCodeString) throw IllegalStateException(it.responseCodeString)
} }
} }
val json = Json { ignoreUnknownKeys = true }
productDetailsResult.second?.map { productDetails -> skuDetailsResult
Sku( .skuDetailsList
productId = productDetails.productId, ?.map { json.decodeFromString<Sku>(it.originalJson) }
price = productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice ?: emptyList()
?: productDetails.oneTimePurchaseOfferDetails?.formattedPrice
?: ""
)
} ?: emptyList()
} }
override suspend fun queryPurchases(throwError: Boolean) = try { override suspend fun queryPurchases(throwError: Boolean) = try {
executeServiceRequest { executeServiceRequest {
withContext(Dispatchers.IO + NonCancellable) { withContext(Dispatchers.IO + NonCancellable) {
val subsParams = QueryPurchasesParams.newBuilder() val subs = billingClient.queryPurchasesAsync(SkuType.SUBS)
.setProductType(ProductType.SUBS) val iaps = billingClient.queryPurchasesAsync(SkuType.INAPP)
.build()
val iapsParams = QueryPurchasesParams.newBuilder()
.setProductType(ProductType.INAPP)
.build()
val subs = suspendCoroutine { cont ->
billingClient.queryPurchasesAsync(subsParams) { billingResult, purchases ->
cont.resume(PurchasesResult(billingResult, purchases))
}
}
val iaps = suspendCoroutine { cont ->
billingClient.queryPurchasesAsync(iapsParams) { billingResult, purchases ->
cont.resume(PurchasesResult(billingResult, purchases))
}
}
if (subs.success || iaps.success) { if (subs.success || iaps.success) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
inventory.clear() inventory.clear()
@ -131,7 +105,7 @@ class BillingClientImpl(
purchases?.forEach { purchases?.forEach {
firebase.reportIabResult( firebase.reportIabResult(
result.responseCodeString, result.responseCodeString,
it.products.joinToString(","), it.skus.joinToString(","),
it.purchaseState.purchaseStateString it.purchaseState.purchaseStateString
) )
} }
@ -148,57 +122,31 @@ class BillingClientImpl(
oldPurchase: Purchase? oldPurchase: Purchase?
) { ) {
executeServiceRequest { executeServiceRequest {
val productList = listOf( val skuDetailsResult = withContext(Dispatchers.IO) {
QueryProductDetailsParams.Product.newBuilder() billingClient.querySkuDetails(
.setProductId(sku) SkuDetailsParams.newBuilder().setSkusList(listOf(sku)).setType(skuType)
.setProductType(skuType) .build()
.build() )
)
val queryParams = QueryProductDetailsParams.newBuilder()
.setProductList(productList)
.build()
val productDetailsResult = withContext(Dispatchers.IO) {
suspendCoroutine { cont ->
billingClient.queryProductDetailsAsync(queryParams) { billingResult, productDetailsList ->
cont.resume(billingResult to productDetailsList)
}
}
} }
skuDetailsResult.billingResult.let {
productDetailsResult.first.let {
if (!it.success) { if (!it.success) {
throw IllegalStateException(it.responseCodeString) throw IllegalStateException(it.responseCodeString)
} }
} }
val skuDetails =
val productDetails = productDetailsResult.second?.firstOrNull() skuDetailsResult
?: throw IllegalStateException("Product $sku not found") .skuDetailsList
?.firstOrNull()
val productDetailsParamsBuilder = ProductDetailsParams.newBuilder() ?: throw IllegalStateException("Sku $sku not found")
.setProductDetails(productDetails) val params = BillingFlowParams.newBuilder().setSkuDetails(skuDetails)
// For subscriptions (including legacy subscriptions), we need to provide an offer token
if (skuType == ProductType.SUBS) {
val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken
?: throw IllegalStateException("No offer token found for subscription $sku")
productDetailsParamsBuilder.setOfferToken(offerToken)
}
val productDetailsParams = productDetailsParamsBuilder.build()
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
oldPurchase?.let { oldPurchase?.let {
params.setSubscriptionUpdateParams( params.setSubscriptionUpdateParams(
SubscriptionUpdateParams.newBuilder() SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(it.purchaseToken) .setOldSkuPurchaseToken(it.purchaseToken)
.setSubscriptionReplacementMode(BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION) .setReplaceSkusProrationMode(ProrationMode.IMMEDIATE_WITH_TIME_PRORATION)
.build() .build()
) )
} }
if (activity is OnPurchasesUpdated) { if (activity is OnPurchasesUpdated) {
onPurchasesUpdated = activity onPurchasesUpdated = activity
} }
@ -266,28 +214,17 @@ class BillingClientImpl(
ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build(), ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build(),
) )
Timber.d("consume purchase: ${result.billingResult.responseCodeString}") Timber.d("consume purchase: ${result.billingResult.responseCodeString}")
queryPurchases(throwError = false) queryPurchases()
} }
} }
private data class PurchasesResult(
val billingResult: BillingResult,
val purchasesList: List<com.android.billingclient.api.Purchase>
) {
val success: Boolean
get() = billingResult.responseCode == BillingResponseCode.OK
val responseCodeString: String
get() = billingResult.responseCodeString
val purchases: List<com.android.billingclient.api.Purchase>
get() = purchasesList
}
companion object { companion object {
const val TYPE_SUBS = ProductType.SUBS const val TYPE_SUBS = SkuType.SUBS
const val STATE_PURCHASED = PurchaseState.PURCHASED const val STATE_PURCHASED = PurchaseState.PURCHASED
private val PurchasesResult.success: Boolean
get() = billingResult.responseCode == BillingResponseCode.OK
private val BillingResult.success: Boolean private val BillingResult.success: Boolean
get() = responseCode == BillingResponseCode.OK get() = responseCode == BillingResponseCode.OK
@ -314,5 +251,11 @@ class BillingClientImpl(
PurchaseState.PENDING -> "PENDING" PurchaseState.PENDING -> "PENDING"
else -> this.toString() else -> this.toString()
} }
private val PurchasesResult.responseCodeString: String
get() = billingResult.responseCodeString
private val PurchasesResult.purchases: List<com.android.billingclient.api.Purchase>
get() = purchasesList
} }
} }

@ -31,7 +31,7 @@ class Purchase(private val purchase: Purchase) {
get() = purchase.signature get() = purchase.signature
val sku: String val sku: String
get() = purchase.products.first() get() = purchase.skus.first()
val purchaseToken: String val purchaseToken: String
get() = purchase.purchaseToken get() = purchase.purchaseToken
@ -55,7 +55,7 @@ class Purchase(private val purchase: Purchase) {
get() { get() {
val matcher = PATTERN.matcher(sku) val matcher = PATTERN.matcher(sku)
if (matcher.matches()) { if (matcher.matches()) {
val price = matcher.group(2)?.toInt() val price = matcher.group(2).toInt()
return if (price == 499) 5 else price return if (price == 499) 5 else price
} }
return null return null

@ -75,16 +75,10 @@ class AlarmCalculator(
*/ */
private fun calculateNextRandomReminder(random: Random, task: Task, reminderPeriod: Long) = private fun calculateNextRandomReminder(random: Random, task: Task, reminderPeriod: Long) =
if (reminderPeriod > 0) { if (reminderPeriod > 0) {
val baseline = when {
task.reminderLast > 0 -> task.reminderLast
task.isRecurring -> task.modificationDate
else -> task.creationDate
}
val multiplier = 0.85f + 0.3f * random.nextFloat(task.id + baseline)
maxOf( maxOf(
baseline.plus((reminderPeriod * multiplier).toLong()), task.reminderLast
.coerceAtLeast(task.creationDate)
.plus((reminderPeriod * (0.85f + 0.3f * random.nextFloat())).toLong()),
task.hideUntil task.hideUntil
) )
} else { } else {

@ -38,6 +38,7 @@ import org.tasks.location.GeofenceApi
import org.tasks.opentasks.OpenTaskContentObserver import org.tasks.opentasks.OpenTaskContentObserver
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.receivers.RefreshReceiver import org.tasks.receivers.RefreshReceiver
import org.tasks.receivers.ScreenUnlockReceiver
import org.tasks.scheduling.NotificationSchedulerIntentService import org.tasks.scheduling.NotificationSchedulerIntentService
import org.tasks.sync.SyncAdapters import org.tasks.sync.SyncAdapters
import org.tasks.themes.ThemeBase import org.tasks.themes.ThemeBase
@ -141,6 +142,7 @@ class TasksApplication : Application(), Configuration.Provider {
geofenceApi.get().registerAll() geofenceApi.get().registerAll()
appWidgetManager.get().reconfigureWidgets() appWidgetManager.get().reconfigureWidgets()
CaldavSynchronizer.registerFactories() CaldavSynchronizer.registerFactories()
registerScreenUnlockReceiver()
} }
override val workManagerConfiguration: Configuration override val workManagerConfiguration: Configuration
@ -165,6 +167,16 @@ class TasksApplication : Application(), Configuration.Provider {
} }
} }
private fun registerScreenUnlockReceiver() {
registerReceiver(
ScreenUnlockReceiver(appWidgetManager.get()),
IntentFilter().apply {
addAction(Intent.ACTION_USER_PRESENT)
addAction(Intent.ACTION_SCREEN_ON)
}
)
}
companion object { companion object {
@Suppress("KotlinConstantConditions") @Suppress("KotlinConstantConditions")
const val IS_GOOGLE_PLAY = BuildConfig.FLAVOR == "googleplay" const val IS_GOOGLE_PLAY = BuildConfig.FLAVOR == "googleplay"

@ -25,11 +25,10 @@ import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -63,99 +62,141 @@ import java.util.concurrent.TimeUnit
@ExperimentalComposeUiApi @ExperimentalComposeUiApi
object AddReminderDialog { object AddReminderDialog {
// Helper functions for converting between Alarm properties and UI state
private fun unitIndexToMillis(unitIndex: Int): Long = when (unitIndex) {
1 -> TimeUnit.HOURS.toMillis(1)
2 -> TimeUnit.DAYS.toMillis(1)
3 -> TimeUnit.DAYS.toMillis(7)
else -> TimeUnit.MINUTES.toMillis(1)
}
private fun timeToAmountAndUnit(time: Long): Pair<Int, Int> {
val absTime = kotlin.math.abs(time)
return when {
absTime == 0L -> 0 to 0 // Default to minutes when time is 0
absTime % TimeUnit.DAYS.toMillis(7) == 0L ->
(absTime / TimeUnit.DAYS.toMillis(7)).toInt() to 3
absTime % TimeUnit.DAYS.toMillis(1) == 0L ->
(absTime / TimeUnit.DAYS.toMillis(1)).toInt() to 2
absTime % TimeUnit.HOURS.toMillis(1) == 0L ->
(absTime / TimeUnit.HOURS.toMillis(1)).toInt() to 1
else ->
(absTime / TimeUnit.MINUTES.toMillis(1)).toInt() to 0
}
}
@Composable @Composable
fun AddRandomReminderDialog( fun AddRandomReminderDialog(
alarm: Alarm?, viewState: ViewState,
updateAlarm: (Alarm) -> Unit, addAlarm: (Alarm) -> Unit,
closeDialog: () -> Unit, closeDialog: () -> Unit,
) { ) {
// Create working copy from alarm or use defaults val time = rememberSaveable { mutableStateOf(15) }
var workingCopy by rememberSaveable { val units = rememberSaveable { mutableStateOf(0) }
mutableStateOf(alarm ?: Alarm(time = 15 * TimeUnit.MINUTES.toMillis(1), type = TYPE_RANDOM)) if (viewState.showRandomDialog) {
AlertDialog(
onDismissRequest = closeDialog,
text = { AddRandomReminder(time, units) },
confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = {
time.value.takeIf { it > 0 }?.let { i ->
addAlarm(Alarm(time = i * units.millis, type = TYPE_RANDOM))
closeDialog()
}
})
},
dismissButton = {
Constants.TextButton(
text = R.string.cancel,
onClick = closeDialog
)
},
)
} else {
time.value = 15
units.value = 0
} }
AlertDialog(
onDismissRequest = closeDialog,
text = {
AddRandomReminder(
alarm = workingCopy,
updateAlarm = { workingCopy = it }
)
},
confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = {
val (amount, _) = timeToAmountAndUnit(workingCopy.time)
if (amount > 0) {
updateAlarm(workingCopy)
closeDialog()
}
})
},
dismissButton = {
Constants.TextButton(
text = R.string.cancel,
onClick = closeDialog
)
},
)
} }
@Composable @Composable
fun AddCustomReminderDialog( fun AddCustomReminderDialog(
alarm: Alarm?, viewState: ViewState,
updateAlarm: (Alarm) -> Unit, addAlarm: (Alarm) -> Unit,
closeDialog: () -> Unit, closeDialog: () -> Unit,
) { ) {
// Create working copy from alarm or use defaults val openDialog = viewState.showCustomDialog
var workingCopy by rememberSaveable { val time = rememberSaveable { mutableStateOf(15) }
mutableStateOf( val units = rememberSaveable { mutableStateOf(0) }
alarm ?: Alarm( val openRecurringDialog = rememberSaveable { mutableStateOf(false) }
time = -1 * 15 * TimeUnit.MINUTES.toMillis(1), val interval = rememberSaveable { mutableStateOf(0) }
type = TYPE_REL_END val recurringUnits = rememberSaveable { mutableStateOf(0) }
val repeat = rememberSaveable { mutableStateOf(0) }
if (openDialog) {
if (!openRecurringDialog.value) {
AlertDialog(
onDismissRequest = closeDialog,
text = {
AddCustomReminder(
time,
units,
interval,
recurringUnits,
repeat,
showRecurring = {
openRecurringDialog.value = true
}
)
},
confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = {
time.value.takeIf { it >= 0 }?.let { i ->
addAlarm(
Alarm(
time = -1 * i * units.millis,
type = TYPE_REL_END,
repeat = repeat.value,
interval = interval.value * recurringUnits.millis
)
)
closeDialog()
}
})
},
dismissButton = {
Constants.TextButton(
text = R.string.cancel,
onClick = closeDialog
)
},
) )
}
AddRepeatReminderDialog(
openDialog = openRecurringDialog,
initialInterval = interval.value,
initialUnits = recurringUnits.value,
initialRepeat = repeat.value,
selected = { i, u, r ->
interval.value = i
recurringUnits.value = u
repeat.value = r
}
) )
} else {
time.value = 15
units.value = 0
interval.value = 0
recurringUnits.value = 0
repeat.value = 0
} }
var showRecurringDialog by rememberSaveable { mutableStateOf(false) } }
if (!showRecurringDialog) { @Composable
fun AddRepeatReminderDialog(
openDialog: MutableState<Boolean>,
initialInterval: Int,
initialUnits: Int,
initialRepeat: Int,
selected: (Int, Int, Int) -> Unit,
) {
val interval = rememberSaveable { mutableStateOf(initialInterval) }
val units = rememberSaveable { mutableStateOf(initialUnits) }
val repeat = rememberSaveable { mutableStateOf(initialRepeat) }
val closeDialog = {
openDialog.value = false
}
if (openDialog.value) {
AlertDialog( AlertDialog(
onDismissRequest = closeDialog, onDismissRequest = closeDialog,
text = { text = {
AddCustomReminder( AddRecurringReminder(
alarm = workingCopy, openDialog.value,
updateAlarm = { workingCopy = it }, interval,
showRecurring = { showRecurringDialog = true } units,
repeat,
) )
}, },
confirmButton = { confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = { Constants.TextButton(text = R.string.ok, onClick = {
val (amount, _) = timeToAmountAndUnit(workingCopy.time) if (interval.value > 0 && repeat.value > 0) {
if (amount >= 0) { selected(interval.value, units.value, repeat.value)
updateAlarm(workingCopy) openDialog.value = false
closeDialog()
} }
}) })
}, },
@ -166,74 +207,19 @@ object AddReminderDialog {
) )
}, },
) )
} else {
interval.value = initialInterval.takeIf { it > 0 } ?: 15
units.value = initialUnits
repeat.value = initialRepeat.takeIf { it > 0 } ?: 4
} }
if (showRecurringDialog) {
AddRepeatReminderDialog(
alarm = workingCopy,
updateAlarm = { workingCopy = it },
closeDialog = { showRecurringDialog = false }
)
}
}
@Composable
fun AddRepeatReminderDialog(
alarm: Alarm,
updateAlarm: (Alarm) -> Unit,
closeDialog: () -> Unit,
) {
// Create working copy with defaults if no recurrence set
var workingCopy by rememberSaveable {
mutableStateOf(
if (alarm.interval == 0L && alarm.repeat == 0) {
// Default to 15 minutes, 4 times
alarm.copy(
interval = 15 * TimeUnit.MINUTES.toMillis(1),
repeat = 4
)
} else {
alarm
}
)
}
AlertDialog(
onDismissRequest = closeDialog,
text = {
AddRecurringReminder(
alarm = workingCopy,
updateAlarm = { workingCopy = it }
)
},
confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = {
val (intervalAmount, _) = timeToAmountAndUnit(workingCopy.interval)
if (intervalAmount > 0 && workingCopy.repeat > 0) {
updateAlarm(workingCopy)
closeDialog()
}
})
},
dismissButton = {
Constants.TextButton(
text = R.string.cancel,
onClick = closeDialog
)
},
)
} }
@Composable @Composable
fun AddRandomReminder( fun AddRandomReminder(
alarm: Alarm, time: MutableState<Int>,
updateAlarm: (Alarm) -> Unit, units: MutableState<Int>,
) { ) {
val (initialAmount, initialUnit) = timeToAmountAndUnit(alarm.time)
var selectedUnit by rememberSaveable { mutableStateOf(initialUnit) }
val amount = if (alarm.time == 0L) 0 else (alarm.time / unitIndexToMillis(selectedUnit)).toInt()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -242,27 +228,14 @@ object AddReminderDialog {
CenteredH6(text = stringResource(id = R.string.randomly_every, "").trim()) CenteredH6(text = stringResource(id = R.string.randomly_every, "").trim())
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
OutlinedIntInput( OutlinedIntInput(
value = amount, time,
onValueChange = { newAmount ->
val amt = newAmount ?: 0
updateAlarm(alarm.copy(time = amt * unitIndexToMillis(selectedUnit)))
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(focusRequester) .focusRequester(focusRequester)
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
options.forEachIndexed { index, option -> options.forEachIndexed { index, option ->
RadioRow( RadioRow(index, option, time, units)
index = index,
option = option,
timeAmount = amount,
unitIndex = selectedUnit,
onUnitSelected = { newUnit ->
selectedUnit = newUnit
updateAlarm(alarm.copy(time = amount * unitIndexToMillis(newUnit)))
}
)
} }
ShowKeyboard(true, focusRequester) ShowKeyboard(true, focusRequester)
} }
@ -270,19 +243,14 @@ object AddReminderDialog {
@Composable @Composable
fun AddCustomReminder( fun AddCustomReminder(
alarm: Alarm, time: MutableState<Int>,
updateAlarm: (Alarm) -> Unit, units: MutableState<Int>,
interval: MutableState<Int>,
recurringUnits: MutableState<Int>,
repeat: MutableState<Int>,
showRecurring: () -> Unit, showRecurring: () -> Unit,
) { ) {
val (initialAmount, initialUnit) = timeToAmountAndUnit(alarm.time)
var selectedUnit by rememberSaveable { mutableStateOf(initialUnit) }
val amount = if (alarm.time == 0L) 0 else kotlin.math.abs(alarm.time / unitIndexToMillis(selectedUnit)).toInt()
val (initialIntervalAmount, initialIntervalUnit) = timeToAmountAndUnit(alarm.interval)
val intervalAmount = if (alarm.interval == 0L) 0 else (alarm.interval / unitIndexToMillis(initialIntervalUnit)).toInt()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -291,11 +259,7 @@ object AddReminderDialog {
CenteredH6(resId = R.string.custom_notification) CenteredH6(resId = R.string.custom_notification)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
OutlinedIntInput( OutlinedIntInput(
value = amount, time,
onValueChange = { newAmount ->
val amt = newAmount ?: 0
updateAlarm(alarm.copy(time = -1 * amt * unitIndexToMillis(selectedUnit)))
},
minValue = 0, minValue = 0,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -303,17 +267,7 @@ object AddReminderDialog {
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
options.forEachIndexed { index, option -> options.forEachIndexed { index, option ->
RadioRow( RadioRow(index, option, time, units, R.string.alarm_before_due)
index = index,
option = option,
timeAmount = amount,
unitIndex = selectedUnit,
onUnitSelected = { newUnit ->
selectedUnit = newUnit
updateAlarm(alarm.copy(time = -1 * amount * unitIndexToMillis(newUnit)))
},
formatString = R.string.alarm_before_due
)
} }
Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp) Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp)
Row(modifier = Modifier Row(modifier = Modifier
@ -334,11 +288,11 @@ object AddReminderDialog {
), ),
) )
} }
val repeating = alarm.repeat > 0 && intervalAmount > 0 val repeating = repeat.value > 0 && interval.value > 0
val text = if (repeating) { val text = if (repeating) {
LocalContext.current.resources.getRepeatString( LocalContext.current.resources.getRepeatString(
alarm.repeat, repeat.value,
alarm.interval interval.value * recurringUnits.millis
) )
} else { } else {
stringResource(id = R.string.repeat_option_does_not_repeat) stringResource(id = R.string.repeat_option_does_not_repeat)
@ -351,9 +305,11 @@ object AddReminderDialog {
.align(CenterVertically) .align(CenterVertically)
) )
if (repeating) { if (repeating) {
ClearButton(onClick = { ClearButton {
updateAlarm(alarm.copy(repeat = 0, interval = 0)) repeat.value = 0
}) interval.value = 0
recurringUnits.value = 0
}
} }
} }
ShowKeyboard(true, focusRequester) ShowKeyboard(true, focusRequester)
@ -362,14 +318,12 @@ object AddReminderDialog {
@Composable @Composable
fun AddRecurringReminder( fun AddRecurringReminder(
alarm: Alarm, openDialog: Boolean,
updateAlarm: (Alarm) -> Unit, interval: MutableState<Int>,
units: MutableState<Int>,
repeat: MutableState<Int>
) { ) {
val (initialIntervalAmount, initialIntervalUnit) = timeToAmountAndUnit(alarm.interval)
var selectedUnit by rememberSaveable { mutableStateOf(initialIntervalUnit) }
val intervalAmount = if (alarm.interval == 0L) 0 else (alarm.interval / unitIndexToMillis(selectedUnit)).toInt()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -378,40 +332,24 @@ object AddReminderDialog {
CenteredH6(text = stringResource(id = R.string.repeats_plural, "").trim()) CenteredH6(text = stringResource(id = R.string.repeats_plural, "").trim())
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
OutlinedIntInput( OutlinedIntInput(
value = intervalAmount, time = interval,
onValueChange = { newAmount ->
val amt = newAmount ?: 0
updateAlarm(alarm.copy(interval = amt * unitIndexToMillis(selectedUnit)))
},
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
options.forEachIndexed { index, option -> options.forEachIndexed { index, option ->
RadioRow( RadioRow(index, option, interval, units)
index = index,
option = option,
timeAmount = intervalAmount,
unitIndex = selectedUnit,
onUnitSelected = { newUnit ->
selectedUnit = newUnit
updateAlarm(alarm.copy(interval = intervalAmount * unitIndexToMillis(newUnit)))
}
)
} }
Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp) Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp)
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
OutlinedIntInput( OutlinedIntInput(
value = alarm.repeat, time = repeat,
onValueChange = { newRepeat ->
updateAlarm(alarm.copy(repeat = newRepeat ?: 0))
},
modifier = Modifier.weight(0.5f), modifier = Modifier.weight(0.5f),
autoSelect = false, autoSelect = false,
) )
BodyText( BodyText(
text = LocalContext.current.resources.getQuantityString( text = LocalContext.current.resources.getQuantityString(
R.plurals.repeat_times, R.plurals.repeat_times,
alarm.repeat repeat.value
), ),
modifier = Modifier modifier = Modifier
.weight(0.5f) .weight(0.5f)
@ -419,7 +357,7 @@ object AddReminderDialog {
) )
} }
ShowKeyboard(true, focusRequester) ShowKeyboard(openDialog, focusRequester)
} }
} }
@ -429,6 +367,14 @@ object AddReminderDialog {
R.plurals.reminder_days, R.plurals.reminder_days,
R.plurals.reminder_week, R.plurals.reminder_week,
) )
private val MutableState<Int>.millis: Long
get() = when (value) {
1 -> TimeUnit.HOURS.toMillis(1)
2 -> TimeUnit.DAYS.toMillis(1)
3 -> TimeUnit.DAYS.toMillis(7)
else -> TimeUnit.MINUTES.toMillis(1)
}
} }
@ExperimentalComposeUiApi @ExperimentalComposeUiApi
@ -445,48 +391,25 @@ fun ShowKeyboard(visible: Boolean, focusRequester: FocusRequester) {
@Composable @Composable
fun OutlinedIntInput( fun OutlinedIntInput(
value: Int?, time: MutableState<Int>,
onValueChange: (Int?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
minValue: Int = 1, minValue: Int = 1,
autoSelect: Boolean = true, autoSelect: Boolean = true,
) { ) {
var textFieldValue by remember { val value = rememberSaveable(stateSaver = TextFieldValue.Saver) {
val text = time.value.toString()
mutableStateOf( mutableStateOf(
TextFieldValue( TextFieldValue(
text = value?.toString() ?: "", text = text,
selection = if (autoSelect) { selection = TextRange(0, if (autoSelect) text.length else 0)
TextRange(0, value?.toString()?.length ?: 0)
} else {
TextRange.Zero
}
) )
) )
} }
// Sync when external value changes, but don't interfere with user editing
LaunchedEffect(value) {
val currentParsedValue = textFieldValue.text.toIntOrNull()
// Only sync if the new value is different from what we currently parse to,
// and don't sync if the text field is empty (user is actively deleting)
if (currentParsedValue != value && textFieldValue.text.isNotEmpty()) {
val newText = value?.toString() ?: ""
textFieldValue = TextFieldValue(
text = newText,
selection = if (autoSelect) {
TextRange(0, newText.length)
} else {
textFieldValue.selection
}
)
}
}
OutlinedTextField( OutlinedTextField(
value = textFieldValue, value = value.value,
onValueChange = { onValueChange = {
textFieldValue = it.copy(text = it.text.filter { t -> t.isDigit() }) value.value = it.copy(text = it.text.filter { t -> t.isDigit() })
onValueChange(textFieldValue.text.toIntOrNull()) time.value = value.value.text.toIntOrNull() ?: 0
}, },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = modifier.padding(horizontal = 16.dp), modifier = modifier.padding(horizontal = 16.dp),
@ -496,7 +419,7 @@ fun OutlinedIntInput(
focusedBorderColor = MaterialTheme.colorScheme.onSurface, focusedBorderColor = MaterialTheme.colorScheme.onSurface,
unfocusedBorderColor = MaterialTheme.colorScheme.onSurface, unfocusedBorderColor = MaterialTheme.colorScheme.onSurface,
), ),
isError = textFieldValue.text.toIntOrNull()?.let { it < minValue } ?: true, isError = value.value.text.toIntOrNull()?.let { it < minValue } ?: true,
) )
} }
@ -522,24 +445,23 @@ fun CenteredH6(text: String) {
fun RadioRow( fun RadioRow(
index: Int, index: Int,
option: Int, option: Int,
timeAmount: Int, time: MutableState<Int>,
unitIndex: Int, units: MutableState<Int>,
onUnitSelected: (Int) -> Unit,
formatString: Int? = null, formatString: Int? = null,
) { ) {
val optionString = LocalContext.current.resources.getQuantityString(option, timeAmount) val optionString = LocalContext.current.resources.getQuantityString(option, time.value)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onUnitSelected(index) } .clickable { units.value = index }
) { ) {
RadioButton( RadioButton(
selected = index == unitIndex, selected = index == units.value,
onClick = { onUnitSelected(index) }, onClick = { units.value = index },
modifier = Modifier.align(CenterVertically) modifier = Modifier.align(CenterVertically)
) )
BodyText( BodyText(
text = if (index == unitIndex) { text = if (index == units.value) {
formatString formatString
?.let { stringResource(id = formatString, optionString) } ?.let { stringResource(id = formatString, optionString) }
?: optionString ?: optionString
@ -584,14 +506,8 @@ fun AddAlarmDialog(
dismiss() dismiss()
return return
} }
TYPE_REL_END -> { // TODO: if replacing custom alarm show custom picker
if (viewState.replace.time < 0) { // TODO: prepopulate pickers with existing values
// Custom reminder (before due)
addCustom()
dismiss()
return
}
}
} }
} }
CustomDialog(visible = viewState.showAddAlarm, onDismiss = dismiss) { CustomDialog(visible = viewState.showAddAlarm, onDismiss = dismiss) {
@ -639,11 +555,11 @@ fun AddAlarmDialog(
fun AddCustomReminderOne() = fun AddCustomReminderOne() =
TasksTheme { TasksTheme {
AddReminderDialog.AddCustomReminder( AddReminderDialog.AddCustomReminder(
alarm = Alarm( time = remember { mutableStateOf(1) },
time = -1 * TimeUnit.MINUTES.toMillis(1), units = remember { mutableStateOf(0) },
type = TYPE_REL_END interval = remember { mutableStateOf(0) },
), recurringUnits = remember { mutableStateOf(0) },
updateAlarm = {}, repeat = remember { mutableStateOf(0) },
showRecurring = {}, showRecurring = {},
) )
} }
@ -655,11 +571,11 @@ fun AddCustomReminderOne() =
fun AddCustomReminder() = fun AddCustomReminder() =
TasksTheme { TasksTheme {
AddReminderDialog.AddCustomReminder( AddReminderDialog.AddCustomReminder(
alarm = Alarm( time = remember { mutableStateOf(15) },
time = -15 * TimeUnit.HOURS.toMillis(1), units = remember { mutableStateOf(1) },
type = TYPE_REL_END interval = remember { mutableStateOf(0) },
), recurringUnits = remember { mutableStateOf(0) },
updateAlarm = {}, repeat = remember { mutableStateOf(0) },
showRecurring = {}, showRecurring = {},
) )
} }
@ -671,13 +587,10 @@ fun AddCustomReminder() =
fun AddRepeatingReminderOne() = fun AddRepeatingReminderOne() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRecurringReminder( AddReminderDialog.AddRecurringReminder(
alarm = Alarm( openDialog = true,
time = -1 * TimeUnit.MINUTES.toMillis(1), interval = remember { mutableStateOf(1) },
type = TYPE_REL_END, units = remember { mutableStateOf(0) },
interval = TimeUnit.MINUTES.toMillis(1), repeat = remember { mutableStateOf(1) },
repeat = 1
),
updateAlarm = {},
) )
} }
@ -688,13 +601,10 @@ fun AddRepeatingReminderOne() =
fun AddRepeatingReminder() = fun AddRepeatingReminder() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRecurringReminder( AddReminderDialog.AddRecurringReminder(
alarm = Alarm( openDialog = true,
time = -15 * TimeUnit.HOURS.toMillis(1), interval = remember { mutableStateOf(15) },
type = TYPE_REL_END, units = remember { mutableStateOf(1) },
interval = 15 * TimeUnit.HOURS.toMillis(1), repeat = remember { mutableStateOf(4) },
repeat = 4
),
updateAlarm = {},
) )
} }
@ -705,11 +615,8 @@ fun AddRepeatingReminder() =
fun AddRandomReminderOne() = fun AddRandomReminderOne() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRandomReminder( AddReminderDialog.AddRandomReminder(
alarm = Alarm( time = remember { mutableStateOf(1) },
time = TimeUnit.MINUTES.toMillis(1), units = remember { mutableStateOf(0) }
type = TYPE_RANDOM
),
updateAlarm = {}
) )
} }
@ -720,11 +627,8 @@ fun AddRandomReminderOne() =
fun AddRandomReminder() = fun AddRandomReminder() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRandomReminder( AddReminderDialog.AddRandomReminder(
alarm = Alarm( time = remember { mutableStateOf(15) },
time = 15 * TimeUnit.HOURS.toMillis(1), units = remember { mutableStateOf(1) }
type = TYPE_RANDOM
),
updateAlarm = {}
) )
} }

@ -23,7 +23,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Abc import androidx.compose.material.icons.outlined.Abc
@ -55,7 +54,6 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -386,31 +384,16 @@ object FilterCondition {
Row { Row {
for (index in items.indices) { for (index in items.indices) {
val highlight = (index == selected.intValue) val highlight = (index == selected.intValue)
val color =
if (highlight) MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)
else MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f)
OutlinedButton( OutlinedButton(
onClick = { selected.intValue = index }, onClick = { selected.intValue = index },
border = BorderStroke( border = BorderStroke(1.dp, SolidColor(color.copy(alpha = 0.5f))),
width = 1.dp,
brush = SolidColor(
if (highlight) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f)
}
)
),
colors = ButtonDefaults.outlinedButtonColors( colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (highlight) { containerColor = color.copy(alpha = 0.2f),
MaterialTheme.colorScheme.primary contentColor = MaterialTheme.colorScheme.onBackground),
} else { shape = RoundedCornerShape(Constants.HALF_KEYLINE)
MaterialTheme.colorScheme.onBackground.copy(alpha = 0.2f)
},
contentColor = if (highlight) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onBackground
},
),
shape = RoundedCornerShape(Constants.HALF_KEYLINE),
) { ) {
Text(items[index]) Text(items[index])
} }
@ -501,9 +484,6 @@ object FilterCondition {
contentDescription = null contentDescription = null
) )
}, },
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences
),
textStyle = MaterialTheme.typography.bodyMedium.copy( textStyle = MaterialTheme.typography.bodyMedium.copy(
textDirection = TextDirection.Content textDirection = TextDirection.Content
), ),

@ -106,27 +106,23 @@ fun AlarmRow(
dismiss = { vm.showAddAlarm(visible = false) }, dismiss = { vm.showAddAlarm(visible = false) },
) )
if (viewState.showCustomDialog) { AddReminderDialog.AddCustomReminderDialog(
AddReminderDialog.AddCustomReminderDialog( viewState = viewState,
alarm = viewState.replace, addAlarm = {
updateAlarm = { viewState.replace?.let(deleteAlarm)
viewState.replace?.let(deleteAlarm) addAlarm(it)
addAlarm(it) },
}, closeDialog = { vm.showCustomDialog(visible = false) }
closeDialog = { vm.showCustomDialog(visible = false) } )
)
}
if (viewState.showRandomDialog) { AddReminderDialog.AddRandomReminderDialog(
AddReminderDialog.AddRandomReminderDialog( viewState = viewState,
alarm = viewState.replace, addAlarm = {
updateAlarm = { viewState.replace?.let(deleteAlarm)
viewState.replace?.let(deleteAlarm) addAlarm(it)
addAlarm(it) },
}, closeDialog = { vm.showRandomDialog(visible = false) }
closeDialog = { vm.showRandomDialog(visible = false) } )
)
}
}, },
) )
} }

@ -5,13 +5,10 @@ import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -20,7 +17,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@ -50,7 +46,6 @@ fun ListSettingsScaffold(
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
Scaffold( Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
topBar = { topBar = {
Column { Column {
val context = LocalContext.current val context = LocalContext.current

@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
@ -25,7 +24,6 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -75,9 +73,6 @@ fun TitleInput(
color = LocalContentColor.current color = LocalContentColor.current
), ),
onValueChange = { setText(it) }, onValueChange = { setText(it) },
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences
),
cursorBrush = SolidColor(errorState), // SolidColor(LocalContentColor.current), cursorBrush = SolidColor(errorState), // SolidColor(LocalContentColor.current),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

@ -378,7 +378,7 @@ class Preferences @JvmOverloads constructor(
val backupDirectory: Uri? val backupDirectory: Uri?
get() = getDirectory(R.string.p_backup_dir, "backups") get() = getDirectory(R.string.p_backup_dir, "backups")
val appPrivateStorage: Uri val externalStorage: Uri
get() = root.uri get() = root.uri
val attachmentsDirectory: Uri? val attachmentsDirectory: Uri?
@ -412,12 +412,13 @@ class Preferences @JvmOverloads constructor(
?: getDefaultFileLocation(name)?.let { Uri.fromFile(it) } ?: getDefaultFileLocation(name)?.let { Uri.fromFile(it) }
private val root: DocumentFile private val root: DocumentFile
get() = DocumentFile.fromFile(context.getExternalFilesDir(null) ?: context.filesDir) get() = DocumentFile.fromFile(context.getExternalFilesDir(null)!!)
private fun getDefaultFileLocation(type: String): File? { private fun getDefaultFileLocation(type: String): File? {
val baseDir = context.getExternalFilesDir(null) ?: context.filesDir val externalFilesDir = context.getExternalFilesDir(null) ?: return null
val path = File(baseDir, type) val path = String.format("%s/%s", externalFilesDir.absolutePath, type)
return if (path.isDirectory || path.mkdirs()) path else null val file = File(path)
return if (file.isDirectory || file.mkdirs()) file else null
} }
private fun hasWritePermission(context: Context, uri: Uri): Boolean = private fun hasWritePermission(context: Context, uri: Uri): Boolean =

@ -2,7 +2,6 @@ package org.tasks.preferences
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -52,16 +51,8 @@ class PreferencesViewModel @Inject constructor(
get() = isStale(lastDriveBackup.value) && isStale(lastAndroidBackup.value) get() = isStale(lastDriveBackup.value) && isStale(lastAndroidBackup.value)
val usingPrivateStorage: Boolean val usingPrivateStorage: Boolean
get() = preferences.backupDirectory.let { backupDir -> get() = preferences.backupDirectory.let {
val backupDirStr = backupDir?.toString() ?: return true it == null || it.toString().startsWith(preferences.externalStorage.toString())
context
.getExternalFilesDir(null)
?.let {
if (backupDirStr.startsWith(Uri.fromFile(it).toString())) {
return true
}
}
return backupDirStr.startsWith(Uri.fromFile(context.filesDir).toString())
} }
val driveAccount: String? val driveAccount: String?

@ -257,7 +257,7 @@ class Backups : InjectingPreferenceFragment() {
pref.summary = """ pref.summary = """
$location $location
${requireContext().getString(R.string.backup_location_warning, FileHelper.uri2String(preferences.appPrivateStorage))} ${requireContext().getString(R.string.backup_location_warning, FileHelper.uri2String(preferences.externalStorage))}
""".trimIndent() """.trimIndent()
} else { } else {
pref.icon = null pref.icon = null

@ -0,0 +1,19 @@
package org.tasks.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.tasks.widget.AppWidgetManager
import timber.log.Timber
class ScreenUnlockReceiver(private val appWidgetManager: AppWidgetManager) : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_USER_PRESENT,
Intent.ACTION_SCREEN_ON -> {
Timber.d("refreshing widgets: ${intent.action}")
appWidgetManager.updateWidgets()
}
}
}
}

@ -0,0 +1,10 @@
package org.tasks.reminders;
public class Random {
private static final java.util.Random random = new java.util.Random();
public float nextFloat() {
return random.nextFloat();
}
}

@ -1,14 +0,0 @@
package org.tasks.reminders
import java.util.Random
open class Random {
open fun nextFloat(seed: Long): Float {
random.setSeed(seed)
return random.nextFloat()
}
companion object {
private val random = Random()
}
}

@ -1,20 +1,18 @@
package org.tasks.widget package org.tasks.widget
import android.app.Activity
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.tasks.R import org.tasks.R
import org.tasks.compose.throttleLatest
import org.tasks.injection.ApplicationScope import org.tasks.injection.ApplicationScope
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -24,19 +22,6 @@ class AppWidgetManager @Inject constructor(
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
) { ) {
private val appWidgetManager: AppWidgetManager? = AppWidgetManager.getInstance(context) private val appWidgetManager: AppWidgetManager? = AppWidgetManager.getInstance(context)
private val updateChannel = Channel<Unit>(Channel.CONFLATED)
init {
updateChannel
.consumeAsFlow()
.throttleLatest(1000)
.onEach {
val appWidgetIds = widgetIds
Timber.d("updateWidgets: ${appWidgetIds.joinToString { it.toString() }}")
notifyAppWidgetViewDataChanged(appWidgetIds)
}
.launchIn(scope)
}
val widgetIds: IntArray val widgetIds: IntArray
get() = appWidgetManager get() = appWidgetManager
@ -52,11 +37,31 @@ class AppWidgetManager @Inject constructor(
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) .putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
.apply { action = AppWidgetManager.ACTION_APPWIDGET_UPDATE } .apply { action = AppWidgetManager.ACTION_APPWIDGET_UPDATE }
context.sendBroadcast(intent) context.sendOrderedBroadcast(
intent,
null,
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
scope.launch {
Timber.d("Update widgets after reconfigure: ${appWidgetIds.joinToString { it.toString() }}")
// I don't like it, but this seems to give Android enough time to update
// the cache, and I don't have time to rewrite this in Glance right now
delay(100)
notifyAppWidgetViewDataChanged(ids)
}
}
},
null,
Activity.RESULT_OK,
null,
null
)
} }
fun updateWidgets() { fun updateWidgets() = scope.launch {
updateChannel.trySend(Unit) val appWidgetIds = widgetIds
Timber.d("updateWidgets: ${appWidgetIds.joinToString { it.toString() }}")
notifyAppWidgetViewDataChanged(appWidgetIds)
} }
fun exists(id: Int) = appWidgetManager?.getAppWidgetInfo(id) != null fun exists(id: Int) = appWidgetManager?.getAppWidgetInfo(id) != null

@ -57,6 +57,7 @@ class TasksWidget : AppWidgetProvider() {
appWidgetId, appWidgetId,
createWidget(context, appWidgetId, newOptions) createWidget(context, appWidgetId, newOptions)
) )
appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.list_view)
} }
private fun createWidget(context: Context, id: Int, options: Bundle): RemoteViews { private fun createWidget(context: Context, id: Int, options: Bundle): RemoteViews {
@ -93,6 +94,7 @@ class TasksWidget : AppWidgetProvider() {
R.id.list_view, R.id.list_view,
Intent(context, TasksWidgetAdapter::class.java) Intent(context, TasksWidgetAdapter::class.java)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id) .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
.putExtra("extra_cache_buster", cacheBuster)
.setData(cacheBuster) .setData(cacheBuster)
) )
setPendingIntentTemplate(R.id.list_view, getPendingIntentTemplate(context)) setPendingIntentTemplate(R.id.list_view, getPendingIntentTemplate(context))

@ -109,7 +109,4 @@
<string name="CFC_importance_name">Prioritet…</string> <string name="CFC_importance_name">Prioritet…</string>
<string name="week_before_due">Sedmica prije roka</string> <string name="week_before_due">Sedmica prije roka</string>
<string name="TEA_control_repeat">Ponovi</string> <string name="TEA_control_repeat">Ponovi</string>
<string name="app_settings">Postavke aplikacije</string>
<string name="customize_drawer">Prilagodi meni</string>
<string name="customize_drawer_summary">Povuci i pusti za promjenu rasporeda u meniju</string>
</resources> </resources>

@ -371,7 +371,7 @@
<string name="cannot_access_account">Tiliin ei päästä käsiksi</string> <string name="cannot_access_account">Tiliin ei päästä käsiksi</string>
<string name="logout">Kirjaudu ulos</string> <string name="logout">Kirjaudu ulos</string>
<string name="this_feature_requires_a_subscription">Tämä ominaisuus vaatii tilauksen</string> <string name="this_feature_requires_a_subscription">Tämä ominaisuus vaatii tilauksen</string>
<string name="requires_pro_subscription">Edellyttää pro-tilauksen</string> <string name="requires_pro_subscription">Edellyttää ammattilaistilauksen</string>
<string name="license_summary">Tasks on vapaa avoimen ohjelmakoodin ohjelmisto lisensöity GNU General Public License v3.0 -lisenssillä</string> <string name="license_summary">Tasks on vapaa avoimen ohjelmakoodin ohjelmisto lisensöity GNU General Public License v3.0 -lisenssillä</string>
<string name="about">Tietoja sovelluksesta</string> <string name="about">Tietoja sovelluksesta</string>
<string name="button_unsubscribe">Peruuta tilaus</string> <string name="button_unsubscribe">Peruuta tilaus</string>
@ -696,30 +696,4 @@
<string name="swipe_to_snooze_time_immediately">välittömästi</string> <string name="swipe_to_snooze_time_immediately">välittömästi</string>
<string name="enable_alarms">Saat ilmoituksen oikeaan aikaan</string> <string name="enable_alarms">Saat ilmoituksen oikeaan aikaan</string>
<string name="enable_alarms_description">Varmista, että saat ilmoituksen oikeaan aikaan, myöntämällä lupa asettaa hälytyksiä ja muistutuksia Asetuksissa</string> <string name="enable_alarms_description">Varmista, että saat ilmoituksen oikeaan aikaan, myöntämällä lupa asettaa hälytyksiä ja muistutuksia Asetuksissa</string>
<string name="app_settings">Sovellusasetukset</string>
<string name="delete_comment">kommentti</string>
<string name="comment">Kommentti</string>
<string name="yesterday">Eilen</string>
<string name="continue_without_sync">Jatka ilman synkronointia</string>
<string name="help_me_choose">Auta minua valitsemaan</string>
<string name="delete_tasks_warning">%s poistetaan. Tätä ei voi perua!</string>
<string name="banner_app_updated_title">Sovellus päivitetty</string>
<string name="banner_app_updated_description">Sovellus päivitettiin juuri %s. Haluatko lukea julkaisutiedot?</string>
<string name="subtasks_multilevel_microsoft">Microsoft To Do ei tue monitasoisia alitehtäviä</string>
<string name="price_per_month_with_currency">%s/kuukausi</string>
<string name="price_per_year_with_currency">%s/vuosi</string>
<string name="add_shortcut_to_home_screen">Lisää pikakuvake aloitusnäytölle</string>
<string name="add_widget_to_home_screen">Lisää pienoissovellus aloitusnäytölle</string>
<string name="cost_free">Hinta: Ilmainen</string>
<string name="cost_money">Hinta: $</string>
<string name="cost_more_money">Hinta: $$$</string>
<string name="multiline_title">Salli moniriviset otsikot</string>
<string name="multiline_title_on">Paina Enter-näppäintä lisätäksesi rivinvaihdon</string>
<string name="multiline_title_off">Paina Valmis tallentaaksesi tehtävän</string>
<string name="sync_warning_microsoft_title">Microsoft To Do synkronoinnista</string>
<string name="sync_warning_microsoft">Kaikkia tehtävän tietoja ei voi synkronoida Microsoft To Do:n kanssa.</string>
<string name="sync_warning_google_tasks_title">Google Taskista</string>
<string name="sync_warning_google_tasks">Kaikkia tehtävän tietoja ei voi synkronoida Google Taskin kanssa</string>
<string name="button_learn_more">Lue lisää</string>
<string name="widget_view_more_tasks">Lisää tehtäviä</string>
</resources> </resources>

@ -451,8 +451,8 @@
<string name="location_radius_meters">%s m</string> <string name="location_radius_meters">%s m</string>
<string name="subtasks">Attività secondaria</string> <string name="subtasks">Attività secondaria</string>
<string name="TEA_timer_controls">Timer</string> <string name="TEA_timer_controls">Timer</string>
<string name="chip_appearance">Aspetto Chip</string> <string name="chip_appearance">Aspetto chip</string>
<string name="chips">Chip</string> <string name="chips">Smart Chip</string>
<string name="custom_filter_not">NON</string> <string name="custom_filter_not">NON</string>
<string name="custom_filter_or">O</string> <string name="custom_filter_or">O</string>
<string name="custom_filter_and">E</string> <string name="custom_filter_and">E</string>

@ -672,7 +672,7 @@
<string name="enable_reminders">Activează memento-uri</string> <string name="enable_reminders">Activează memento-uri</string>
<string name="enable_reminders_description">Reamintirile sunt dezactivate în Setări Android</string> <string name="enable_reminders_description">Reamintirile sunt dezactivate în Setări Android</string>
<string name="TEA_creation_date">Data creării</string> <string name="TEA_creation_date">Data creării</string>
<string name="default_reminder">Memento implicit</string> <string name="default_reminder">Reamintire implicită</string>
<string name="rmd_time_description">Afișează notificări pentru sarcinile fără termene limită</string> <string name="rmd_time_description">Afișează notificări pentru sarcinile fără termene limită</string>
<string name="consent_agree">De acord</string> <string name="consent_agree">De acord</string>
<string name="consent_deny">Nu acum</string> <string name="consent_deny">Nu acum</string>
@ -748,5 +748,4 @@
<string name="delete_tasks_warning">%s va fi șters. Acest lucru nu poate fi anulat!</string> <string name="delete_tasks_warning">%s va fi șters. Acest lucru nu poate fi anulat!</string>
<string name="banner_app_updated_title">Aplicație actualizată</string> <string name="banner_app_updated_title">Aplicație actualizată</string>
<string name="banner_app_updated_description">Tasks a fost actualizată la versiunea %s. Dorești să vezi modificările făcute?</string> <string name="banner_app_updated_description">Tasks a fost actualizată la versiunea %s. Dorești să vezi modificările făcute?</string>
<string name="widget_view_more_tasks">Vezi mai multe sarcini</string>
</resources> </resources>

@ -29,10 +29,8 @@
<locale android:name="hy" /> <locale android:name="hy" />
<locale android:name="ia" /> <locale android:name="ia" />
<locale android:name="id" /> <locale android:name="id" />
<locale android:name="in" />
<locale android:name="it" /> <locale android:name="it" />
<locale android:name="iw" /> <locale android:name="iw" />
<locale android:name="he" />
<locale android:name="ja" /> <locale android:name="ja" />
<locale android:name="kmr" /> <locale android:name="kmr" />
<locale android:name="kn" /> <locale android:name="kn" />

@ -295,7 +295,7 @@ class AlarmCalculatorTest {
@Test @Test
fun scheduleOverdueRandomReminder() { fun scheduleOverdueRandomReminder() {
random.stub = 0.3865f random.seed = 0.3865f
freezeAt(now) { freezeAt(now) {
val alarm = alarmCalculator.toAlarmEntry( val alarm = alarmCalculator.toAlarmEntry(
newTask( newTask(
@ -316,7 +316,7 @@ class AlarmCalculatorTest {
@Test @Test
fun scheduleOverdueRandomReminderForHiddenTask() { fun scheduleOverdueRandomReminderForHiddenTask() {
random.stub = 0.3865f random.seed = 0.3865f
freezeAt(now) { freezeAt(now) {
val task = newTask( val task = newTask(
with(REMINDER_LAST, now.minusDays(14)), with(REMINDER_LAST, now.minusDays(14)),
@ -335,7 +335,7 @@ class AlarmCalculatorTest {
@Test @Test
fun scheduleInitialRandomReminder() { fun scheduleInitialRandomReminder() {
random.stub = 0.3865f random.seed = 0.3865f
freezeAt(now) { freezeAt(now) {
val alarm = alarmCalculator.toAlarmEntry( val alarm = alarmCalculator.toAlarmEntry(
@ -358,7 +358,7 @@ class AlarmCalculatorTest {
@Test @Test
fun scheduleNextRandomReminder() { fun scheduleNextRandomReminder() {
random.stub = 0.3865f random.seed = 0.3865f
freezeAt(now) { freezeAt(now) {
val alarm = alarmCalculator.toAlarmEntry( val alarm = alarmCalculator.toAlarmEntry(
@ -379,28 +379,9 @@ class AlarmCalculatorTest {
} }
} }
@Test
fun randomReminderIsDeterministic() {
val calculator = AlarmCalculator(
isDefaultDueTimeEnabled = true,
random = Random(),
defaultDueTime = TimeUnit.HOURS.toMillis(13).toInt(),
)
freezeAt(now) {
val task = newTask(with(CREATION_TIME, now.minusDays(1)))
val alarm = Alarm(time = ONE_WEEK, type = TYPE_RANDOM)
val first = calculator.toAlarmEntry(task, alarm)
val second = calculator.toAlarmEntry(task, alarm)
assertEquals(first, second)
}
}
internal class RandomStub : Random() { internal class RandomStub : Random() {
var stub = 1.0f var seed = 1.0f
override fun nextFloat(seed: Long) = this.stub override fun nextFloat() = seed
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -1 +0,0 @@
* Fix crash on Android 10 and below

@ -1 +0,0 @@
Initial WearOS release - work in progress!

@ -1,7 +0,0 @@
* Fix flashing widgets
* Fix random reminder scheduling
* Fix random reminders firing immediately on recurring tasks
* Fix deadlock when adding new task
* Fix crash in settings when backup location unavailable
* Fix Hebrew and Indonesian support
* Update translations

@ -1 +0,0 @@
Initial WearOS release - work in progress!

@ -1,7 +1,7 @@
[versions] [versions]
versionCode = "140822" # increment by 2 versionCode = "140816" # increment by 2
versionName = "14.8.4" versionName = "14.8.2"
agp = "8.13.1" agp = "8.13.0"
android-compileSdk = "36" android-compileSdk = "36"
android-minSdk = "26" android-minSdk = "26"
android-targetSdk = "35" android-targetSdk = "35"
@ -11,9 +11,9 @@ appauth = "0.11.1"
appcompat = "1.7.1" appcompat = "1.7.1"
cert4android = "7814052" cert4android = "7814052"
coil = "2.7.0" coil = "2.7.0"
compose = "2025.11.01" compose = "2025.09.00"
constraintlayout = "2.2.1" constraintlayout = "2.2.1"
dagger-hilt = "2.57.2" dagger-hilt = "2.57.1"
dashclock-api = "2.0.0" dashclock-api = "2.0.0"
dav4jvm = "2.2.1" dav4jvm = "2.2.1"
desugar_jdk_libs = "2.1.5" desugar_jdk_libs = "2.1.5"
@ -21,11 +21,11 @@ etebase = "2.3.2"
firebase = "33.16.0" firebase = "33.16.0"
firebase-crashlytics-gradle = "3.0.6" firebase-crashlytics-gradle = "3.0.6"
google-oauth2 = "1.39.0" google-oauth2 = "1.39.0"
google-api-drive = "v3-rev20251019-2.0.0" google-api-drive = "v3-rev20250829-2.0.0"
google-api-tasks = "v1-rev20251102-2.0.0" google-api-tasks = "v1-rev20250518-2.0.0"
google-services = "4.4.4" google-services = "4.4.3"
grpc = "1.73.0" grpc = "1.73.0"
hilt = "1.3.0" hilt = "1.2.0"
horologist = "0.7.15" horologist = "0.7.15"
ical4android = "fcb0311ca7" ical4android = "fcb0311ca7"
jchronic = "0.2.6" jchronic = "0.2.6"
@ -34,47 +34,47 @@ junit-junit = "4.13.2"
junit = "1.3.0" junit = "1.3.0"
kotlin = "2.1.21" kotlin = "2.1.21"
kotlinx-coroutines = "1.10.2" kotlinx-coroutines = "1.10.2"
ktor = "3.3.2" ktor = "3.1.3"
leakcanary = "2.14" leakcanary = "2.14"
lib-recur = "0.11.4" lib-recur = "0.11.4"
lifecycle = "2.9.4" lifecycle = "2.9.3"
locale = "1.0.4" locale = "1.0.4"
make-it-easy = "4.0.1" make-it-easy = "4.0.1"
markwon = "4.6.2" markwon = "4.6.2"
material = "1.12.0" material = "1.12.0"
mockito = "5.20.0" mockito = "5.19.0"
okhttp = "5.3.0" okhttp = "4.12.0"
opentasks = "562fec5" opentasks = "562fec5"
osmdroid = "6.1.20" osmdroid = "6.1.20"
oss-licenses-plugin = "0.10.9" oss-licenses-plugin = "0.10.8"
persistent-cookiejar = "1.0.1" persistent-cookiejar = "1.0.1"
play-services-maps = "19.2.0" play-services-maps = "19.2.0"
play-services-location = "21.3.0" play-services-location = "21.3.0"
play-services-oss-licenses = "17.3.0" play-services-oss-licenses = "17.3.0"
preference = "1.2.1" preference = "1.2.1"
protobuf = "4.33.1" protobuf = "4.32.1"
recyclerview = "1.4.0" recyclerview = "1.4.0"
rfc5545-datetime = "0.2.4" rfc5545-datetime = "0.2.4"
room = "2.8.3" room = "2.7.2"
shortcut-badger = "1.1.22" shortcut-badger = "1.1.22"
timber = "5.0.1" timber = "5.0.1"
swiperefreshlayout = "1.1.0" swiperefreshlayout = "1.1.0"
work = "2.11.0" work = "2.10.4"
androidx-test = "1.7.0" androidx-test = "1.7.0"
androidx-test-runner = "1.7.0" androidx-test-runner = "1.7.0"
xpp3 = "1.1.6" xpp3 = "1.1.6"
wearCompose = "1.5.5" wearCompose = "1.5.1"
[libraries] [libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
androidx-adaptive-navigation-android = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation-android", version = "1.2.0" } androidx-adaptive-navigation-android = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation-android", version = "1.1.0" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
androidx-compose = { module = "androidx.compose:compose-bom", version.ref = "compose" } androidx-compose = { module = "androidx.compose:compose-bom", version.ref = "compose" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout-android", version = "1.2.0" } androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout-android", version = "1.1.0" }
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version = "1.2.0" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version = "1.0.1" }
androidx-datastore = { module = "androidx.datastore:datastore-preferences", version = "1.1.7" } androidx-datastore = { module = "androidx.datastore:datastore-preferences", version = "1.1.7" }
androidx-fragment-compose = { module = "androidx.fragment:fragment-compose", version = "1.8.9" } androidx-fragment-compose = { module = "androidx.fragment:fragment-compose", version = "1.8.9" }
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt" } androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt" }
@ -86,13 +86,13 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" } androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
androidx-navigation = { module = "androidx.navigation:navigation-compose", version = "2.9.6" } androidx-navigation = { module = "androidx.navigation:navigation-compose", version = "2.9.4" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version = "3.3.6" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version = "3.3.6" }
androidx-preference = { module = "androidx.preference:preference", version.ref = "preference" } androidx-preference = { module = "androidx.preference:preference", version.ref = "preference" }
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
androidx-room = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room"} androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room"}
androidx-sqlite = { module = "androidx.sqlite:sqlite-bundled", version = "2.6.2" } androidx-sqlite = { module = "androidx.sqlite:sqlite-bundled", version = "2.6.0" }
androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" } androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" }
androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" }
androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" }
@ -167,7 +167,7 @@ okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.
osmdroid = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid" } osmdroid = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid" }
oss-licenses-plugin = { module = "com.google.android.gms:oss-licenses-plugin", version.ref = "oss-licenses-plugin" } oss-licenses-plugin = { module = "com.google.android.gms:oss-licenses-plugin", version.ref = "oss-licenses-plugin" }
persistent-cookiejar = { module = "com.github.franmontiel:PersistentCookieJar", version.ref = "persistent-cookiejar" } persistent-cookiejar = { module = "com.github.franmontiel:PersistentCookieJar", version.ref = "persistent-cookiejar" }
play-billing-ktx = { module = "com.android.billingclient:billing-ktx", version = "7.1.1" } play-billing-ktx = { module = "com.android.billingclient:billing-ktx", version = "6.2.1" }
play-review = { module = "com.google.android.play:review-ktx", version = "2.0.2" } play-review = { module = "com.google.android.play:review-ktx", version = "2.0.2" }
play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "play-services-maps" } play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "play-services-maps" }
play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "play-services-location" } play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "play-services-location" }
@ -175,7 +175,7 @@ play-services-oss-licenses = { module = "com.google.android.gms:play-services-os
shortcut-badger = { module = "me.leolin:ShortcutBadger", version.ref = "shortcut-badger" } shortcut-badger = { module = "me.leolin:ShortcutBadger", version.ref = "shortcut-badger" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
xpp3 = { module = "org.ogce:xpp3", version.ref = "xpp3" } xpp3 = { module = "org.ogce:xpp3", version.ref = "xpp3" }
androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version = "1.9.5" } androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version = "1.9.1" }
iconics = { module = "com.mikepenz:iconics-core", version = "5.5.0-b01" } iconics = { module = "com.mikepenz:iconics-core", version = "5.5.0-b01" }
play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version = "19.0.0" } play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version = "19.0.0" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui = { group = "androidx.compose.ui", name = "ui" }
@ -197,14 +197,14 @@ protobuf-protoc-gen-javalite = { group = "com.google.protobuf", name = "protoc-g
protobuf-protoc-stnd = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } protobuf-protoc-stnd = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
io-grpc-grpc-android = { group = "io.grpc", name = "grpc-android", version.ref = "grpc" } io-grpc-grpc-android = { group = "io.grpc", name = "grpc-android", version.ref = "grpc" }
io-grpc-grpc-binder = { group = "io.grpc", name = "grpc-binder", version.ref = "grpc" } io-grpc-grpc-binder = { group = "io.grpc", name = "grpc-binder", version.ref = "grpc" }
io-grpc-grpc-kotlin = { group = "io.grpc", name = "grpc-kotlin-stub", version = "1.5.0" } io-grpc-grpc-kotlin = { group = "io.grpc", name = "grpc-kotlin-stub", version = "1.4.3" }
io-grpc-protobuf-lite = { group = "io.grpc", name = "grpc-protobuf-lite", version.ref = "grpc" } io-grpc-protobuf-lite = { group = "io.grpc", name = "grpc-protobuf-lite", version.ref = "grpc" }
protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }
jetbrains-compose = { id = "org.jetbrains.compose", version = "1.9.3" } jetbrains-compose = { id = "org.jetbrains.compose", version = "1.8.2" }
kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }

@ -28,9 +28,9 @@
<string name="show_unstarted">Näytä aloittamattomat</string> <string name="show_unstarted">Näytä aloittamattomat</string>
<string name="show_completed">Näytä valmiit</string> <string name="show_completed">Näytä valmiit</string>
<string name="wear_unknown_error">Tuntematon virhe</string> <string name="wear_unknown_error">Tuntematon virhe</string>
<string name="requires_pro_subscription">Pro-ominaisuus</string> <string name="requires_pro_subscription">Ammattilaisominaisuus</string>
<string name="subscription_required_description">Avaa kaikkien ominaisuuksien lukitus jopa 1 $ USD/vuosi</string> <string name="subscription_required_description">Avaa kaikkien ominaisuuksien lukitus jopa 1 $ USD/vuosi</string>
<string name="add_task">Lisää tehtävä</string> <string name="add_task">Lisää tehtävä</string>
<string name="search_no_results">Ei tuloksia</string> <string name="search_no_results">Ei tuloksia</string>
<string name="wear_install_app">Asenna puhelimeen</string> <string name="wear_install_app">Asenna puhelimeen</string>
</resources> </resources>

@ -27,5 +27,4 @@
<string name="today_lowercase">hari ini</string> <string name="today_lowercase">hari ini</string>
<string name="show_unstarted">Tampilkan yang belum dimulai</string> <string name="show_unstarted">Tampilkan yang belum dimulai</string>
<string name="show_completed">Tampilkan selesai</string> <string name="show_completed">Tampilkan selesai</string>
<string name="requires_pro_subscription">Fitur Pro</string> </resources>
</resources>

@ -27,6 +27,4 @@
<string name="today_lowercase">今日</string> <string name="today_lowercase">今日</string>
<string name="show_unstarted">着手前のタスクを表示</string> <string name="show_unstarted">着手前のタスクを表示</string>
<string name="show_completed">完了したタスクを表示</string> <string name="show_completed">完了したタスクを表示</string>
<string name="search_no_results">結果なし</string> </resources>
<string name="subscription_required_description">すべての機能を1USD/年でアンロックすることができます</string>
</resources>

@ -53,6 +53,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -70,6 +71,9 @@ import tasks.kmp.generated.resources.subscribe
import java.util.Locale import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
private val SEARCH_BAR_BOTTOM_PADDING = androidx.compose.material3.OutlinedTextFieldTopPadding
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TaskListDrawer( fun TaskListDrawer(
@ -282,6 +286,7 @@ fun RowScope.MenuSearchBar(
query: String, query: String,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
) { ) {
val density = LocalDensity.current
var hasFocus by remember { mutableStateOf(false) } var hasFocus by remember { mutableStateOf(false) }
SearchBar( SearchBar(
modifier = Modifier modifier = Modifier
@ -289,7 +294,7 @@ fun RowScope.MenuSearchBar(
.padding( .padding(
start = 8.dp, start = 8.dp,
end = if (hasFocus) 8.dp else 0.dp, end = if (hasFocus) 8.dp else 0.dp,
bottom = 8.dp bottom = with(density) { SEARCH_BAR_BOTTOM_PADDING.toDp() }
) )
.weight(1f) .weight(1f)
.animateContentSize( .animateContentSize(

@ -12,17 +12,6 @@ import tasks.kmp.generated.resources.default_list
private val mutex = Mutex() private val mutex = Mutex()
suspend fun CaldavDao.newLocalAccount(): CaldavAccount = mutex.withLock { suspend fun CaldavDao.newLocalAccount(): CaldavAccount = mutex.withLock {
newLocalAccountUnsafe()
}
suspend fun CaldavDao.getLocalList() = mutex.withLock {
getLocalList(getLocalAccount())
}
suspend fun CaldavDao.getLocalAccount() =
getAccounts(CaldavAccount.TYPE_LOCAL).firstOrNull() ?: newLocalAccountUnsafe()
private suspend fun CaldavDao.newLocalAccountUnsafe(): CaldavAccount {
val account = CaldavAccount( val account = CaldavAccount(
accountType = CaldavAccount.TYPE_LOCAL, accountType = CaldavAccount.TYPE_LOCAL,
uuid = UUIDHelper.newUUID(), uuid = UUIDHelper.newUUID(),
@ -32,6 +21,13 @@ private suspend fun CaldavDao.newLocalAccountUnsafe(): CaldavAccount {
return account return account
} }
suspend fun CaldavDao.getLocalList() = mutex.withLock {
getLocalList(getLocalAccount())
}
suspend fun CaldavDao.getLocalAccount() =
getAccounts(CaldavAccount.TYPE_LOCAL).firstOrNull() ?: newLocalAccount()
private suspend fun CaldavDao.getLocalList(account: CaldavAccount): CaldavCalendar = private suspend fun CaldavDao.getLocalList(account: CaldavAccount): CaldavCalendar =
getCalendarsByAccount(account.uuid!!).getOrNull(0) getCalendarsByAccount(account.uuid!!).getOrNull(0)
?: CaldavCalendar( ?: CaldavCalendar(

Loading…
Cancel
Save