Compare commits

..

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

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

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

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

@ -1 +1 @@
3.4.7
3.4.5

@ -1,18 +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

@ -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)
[![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

@ -349,7 +349,7 @@ class DateUtilitiesTest {
}
@Test
fun hebrewDateTimeNoYear() = withLocale(Locale.forLanguageTag("he")) {
fun hebrewDateTimeNoYear() = withLocale(Locale.forLanguageTag("iw")) {
freezeAt(DateTime(2018, 12, 12)) {
assertMatches(
"יום ראשון, 14 בינואר( בשעה)? 13:45",
@ -359,7 +359,7 @@ class DateUtilitiesTest {
}
@Test
fun hebrewDateTimeWithYear() = withLocale(Locale.forLanguageTag("he")) {
fun hebrewDateTimeWithYear() = withLocale(Locale.forLanguageTag("iw")) {
freezeAt(DateTime(2017, 12, 12)) {
assertMatches(
"יום ראשון, 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 com.android.billingclient.api.AcknowledgePurchaseParams
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.BillingClientStateListener
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.BillingResult
import com.android.billingclient.api.ConsumeParams
import com.android.billingclient.api.Purchase.PurchaseState
import com.android.billingclient.api.PurchasesResult
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.SkuDetailsParams
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import org.tasks.BuildConfig
import org.tasks.analytics.Firebase
import org.tasks.jobs.WorkManager
@ -46,61 +49,32 @@ class BillingClientImpl(
override suspend fun getSkus(skus: List<String>): List<Sku> =
executeServiceRequest {
val productList = skus.map {
QueryProductDetailsParams.Product.newBuilder()
.setProductId(it)
.setProductType(ProductType.SUBS)
val skuDetailsResult = withContext(Dispatchers.IO) {
billingClient.querySkuDetails(
SkuDetailsParams
.newBuilder()
.setType(SkuType.SUBS)
.setSkusList(skus)
.build()
)
}
val params = QueryProductDetailsParams.newBuilder()
.setProductList(productList)
.build()
val productDetailsResult = withContext(Dispatchers.IO) {
suspendCoroutine { cont ->
billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
cont.resume(billingResult to productDetailsList)
}
}
}
productDetailsResult.first.let {
skuDetailsResult.billingResult.let {
if (!it.success) {
throw IllegalStateException(it.responseCodeString)
}
}
productDetailsResult.second?.map { productDetails ->
Sku(
productId = productDetails.productId,
price = productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice
?: productDetails.oneTimePurchaseOfferDetails?.formattedPrice
?: ""
)
} ?: emptyList()
val json = Json { ignoreUnknownKeys = true }
skuDetailsResult
.skuDetailsList
?.map { json.decodeFromString<Sku>(it.originalJson) }
?: emptyList()
}
override suspend fun queryPurchases(throwError: Boolean) = try {
executeServiceRequest {
withContext(Dispatchers.IO + NonCancellable) {
val subsParams = QueryPurchasesParams.newBuilder()
.setProductType(ProductType.SUBS)
.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))
}
}
val subs = billingClient.queryPurchasesAsync(SkuType.SUBS)
val iaps = billingClient.queryPurchasesAsync(SkuType.INAPP)
if (subs.success || iaps.success) {
withContext(Dispatchers.Main) {
inventory.clear()
@ -131,7 +105,7 @@ class BillingClientImpl(
purchases?.forEach {
firebase.reportIabResult(
result.responseCodeString,
it.products.joinToString(","),
it.skus.joinToString(","),
it.purchaseState.purchaseStateString
)
}
@ -148,57 +122,31 @@ class BillingClientImpl(
oldPurchase: Purchase?
) {
executeServiceRequest {
val productList = listOf(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(sku)
.setProductType(skuType)
val skuDetailsResult = withContext(Dispatchers.IO) {
billingClient.querySkuDetails(
SkuDetailsParams.newBuilder().setSkusList(listOf(sku)).setType(skuType)
.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)
}
}
}
productDetailsResult.first.let {
skuDetailsResult.billingResult.let {
if (!it.success) {
throw IllegalStateException(it.responseCodeString)
}
}
val productDetails = productDetailsResult.second?.firstOrNull()
?: throw IllegalStateException("Product $sku not found")
val productDetailsParamsBuilder = ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
// 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))
val skuDetails =
skuDetailsResult
.skuDetailsList
?.firstOrNull()
?: throw IllegalStateException("Sku $sku not found")
val params = BillingFlowParams.newBuilder().setSkuDetails(skuDetails)
oldPurchase?.let {
params.setSubscriptionUpdateParams(
SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(it.purchaseToken)
.setSubscriptionReplacementMode(BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION)
.setOldSkuPurchaseToken(it.purchaseToken)
.setReplaceSkusProrationMode(ProrationMode.IMMEDIATE_WITH_TIME_PRORATION)
.build()
)
}
if (activity is OnPurchasesUpdated) {
onPurchasesUpdated = activity
}
@ -266,28 +214,17 @@ class BillingClientImpl(
ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build(),
)
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 {
const val TYPE_SUBS = ProductType.SUBS
const val TYPE_SUBS = SkuType.SUBS
const val STATE_PURCHASED = PurchaseState.PURCHASED
private val PurchasesResult.success: Boolean
get() = billingResult.responseCode == BillingResponseCode.OK
private val BillingResult.success: Boolean
get() = responseCode == BillingResponseCode.OK
@ -314,5 +251,11 @@ class BillingClientImpl(
PurchaseState.PENDING -> "PENDING"
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
val sku: String
get() = purchase.products.first()
get() = purchase.skus.first()
val purchaseToken: String
get() = purchase.purchaseToken
@ -55,7 +55,7 @@ class Purchase(private val purchase: Purchase) {
get() {
val matcher = PATTERN.matcher(sku)
if (matcher.matches()) {
val price = matcher.group(2)?.toInt()
val price = matcher.group(2).toInt()
return if (price == 499) 5 else price
}
return null

@ -75,16 +75,10 @@ class AlarmCalculator(
*/
private fun calculateNextRandomReminder(random: Random, task: Task, reminderPeriod: Long) =
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(
baseline.plus((reminderPeriod * multiplier).toLong()),
task.reminderLast
.coerceAtLeast(task.creationDate)
.plus((reminderPeriod * (0.85f + 0.3f * random.nextFloat())).toLong()),
task.hideUntil
)
} else {

@ -38,6 +38,7 @@ import org.tasks.location.GeofenceApi
import org.tasks.opentasks.OpenTaskContentObserver
import org.tasks.preferences.Preferences
import org.tasks.receivers.RefreshReceiver
import org.tasks.receivers.ScreenUnlockReceiver
import org.tasks.scheduling.NotificationSchedulerIntentService
import org.tasks.sync.SyncAdapters
import org.tasks.themes.ThemeBase
@ -141,6 +142,7 @@ class TasksApplication : Application(), Configuration.Provider {
geofenceApi.get().registerAll()
appWidgetManager.get().reconfigureWidgets()
CaldavSynchronizer.registerFactories()
registerScreenUnlockReceiver()
}
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 {
@Suppress("KotlinConstantConditions")
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@ -63,53 +62,22 @@ import java.util.concurrent.TimeUnit
@ExperimentalComposeUiApi
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
fun AddRandomReminderDialog(
alarm: Alarm?,
updateAlarm: (Alarm) -> Unit,
viewState: ViewState,
addAlarm: (Alarm) -> Unit,
closeDialog: () -> Unit,
) {
// Create working copy from alarm or use defaults
var workingCopy by rememberSaveable {
mutableStateOf(alarm ?: Alarm(time = 15 * TimeUnit.MINUTES.toMillis(1), type = TYPE_RANDOM))
}
val time = rememberSaveable { mutableStateOf(15) }
val units = rememberSaveable { mutableStateOf(0) }
if (viewState.showRandomDialog) {
AlertDialog(
onDismissRequest = closeDialog,
text = {
AddRandomReminder(
alarm = workingCopy,
updateAlarm = { workingCopy = it }
)
},
text = { AddRandomReminder(time, units) },
confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = {
val (amount, _) = timeToAmountAndUnit(workingCopy.time)
if (amount > 0) {
updateAlarm(workingCopy)
time.value.takeIf { it > 0 }?.let { i ->
addAlarm(Alarm(time = i * units.millis, type = TYPE_RANDOM))
closeDialog()
}
})
@ -121,40 +89,52 @@ object AddReminderDialog {
)
},
)
} else {
time.value = 15
units.value = 0
}
}
@Composable
fun AddCustomReminderDialog(
alarm: Alarm?,
updateAlarm: (Alarm) -> Unit,
viewState: ViewState,
addAlarm: (Alarm) -> Unit,
closeDialog: () -> Unit,
) {
// Create working copy from alarm or use defaults
var workingCopy by rememberSaveable {
mutableStateOf(
alarm ?: Alarm(
time = -1 * 15 * TimeUnit.MINUTES.toMillis(1),
type = TYPE_REL_END
)
)
}
var showRecurringDialog by rememberSaveable { mutableStateOf(false) }
if (!showRecurringDialog) {
val openDialog = viewState.showCustomDialog
val time = rememberSaveable { mutableStateOf(15) }
val units = rememberSaveable { mutableStateOf(0) }
val openRecurringDialog = rememberSaveable { mutableStateOf(false) }
val interval = rememberSaveable { mutableStateOf(0) }
val recurringUnits = rememberSaveable { mutableStateOf(0) }
val repeat = rememberSaveable { mutableStateOf(0) }
if (openDialog) {
if (!openRecurringDialog.value) {
AlertDialog(
onDismissRequest = closeDialog,
text = {
AddCustomReminder(
alarm = workingCopy,
updateAlarm = { workingCopy = it },
showRecurring = { showRecurringDialog = true }
time,
units,
interval,
recurringUnits,
repeat,
showRecurring = {
openRecurringDialog.value = true
}
)
},
confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = {
val (amount, _) = timeToAmountAndUnit(workingCopy.time)
if (amount >= 0) {
updateAlarm(workingCopy)
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()
}
})
@ -167,51 +147,56 @@ object AddReminderDialog {
},
)
}
if (showRecurringDialog) {
AddRepeatReminderDialog(
alarm = workingCopy,
updateAlarm = { workingCopy = it },
closeDialog = { showRecurringDialog = false }
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
}
}
@Composable
fun AddRepeatReminderDialog(
alarm: Alarm,
updateAlarm: (Alarm) -> Unit,
closeDialog: () -> Unit,
openDialog: MutableState<Boolean>,
initialInterval: Int,
initialUnits: Int,
initialRepeat: Int,
selected: (Int, Int, Int) -> 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
}
)
val interval = rememberSaveable { mutableStateOf(initialInterval) }
val units = rememberSaveable { mutableStateOf(initialUnits) }
val repeat = rememberSaveable { mutableStateOf(initialRepeat) }
val closeDialog = {
openDialog.value = false
}
if (openDialog.value) {
AlertDialog(
onDismissRequest = closeDialog,
text = {
AddRecurringReminder(
alarm = workingCopy,
updateAlarm = { workingCopy = it }
openDialog.value,
interval,
units,
repeat,
)
},
confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = {
val (intervalAmount, _) = timeToAmountAndUnit(workingCopy.interval)
if (intervalAmount > 0 && workingCopy.repeat > 0) {
updateAlarm(workingCopy)
closeDialog()
if (interval.value > 0 && repeat.value > 0) {
selected(interval.value, units.value, repeat.value)
openDialog.value = false
}
})
},
@ -222,18 +207,19 @@ object AddReminderDialog {
)
},
)
} else {
interval.value = initialInterval.takeIf { it > 0 } ?: 15
units.value = initialUnits
repeat.value = initialRepeat.takeIf { it > 0 } ?: 4
}
}
@Composable
fun AddRandomReminder(
alarm: Alarm,
updateAlarm: (Alarm) -> Unit,
time: MutableState<Int>,
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()
Column(
modifier = Modifier
.fillMaxWidth()
@ -242,27 +228,14 @@ object AddReminderDialog {
CenteredH6(text = stringResource(id = R.string.randomly_every, "").trim())
val focusRequester = remember { FocusRequester() }
OutlinedIntInput(
value = amount,
onValueChange = { newAmount ->
val amt = newAmount ?: 0
updateAlarm(alarm.copy(time = amt * unitIndexToMillis(selectedUnit)))
},
time,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
Spacer(modifier = Modifier.height(16.dp))
options.forEachIndexed { index, option ->
RadioRow(
index = index,
option = option,
timeAmount = amount,
unitIndex = selectedUnit,
onUnitSelected = { newUnit ->
selectedUnit = newUnit
updateAlarm(alarm.copy(time = amount * unitIndexToMillis(newUnit)))
}
)
RadioRow(index, option, time, units)
}
ShowKeyboard(true, focusRequester)
}
@ -270,19 +243,14 @@ object AddReminderDialog {
@Composable
fun AddCustomReminder(
alarm: Alarm,
updateAlarm: (Alarm) -> Unit,
time: MutableState<Int>,
units: MutableState<Int>,
interval: MutableState<Int>,
recurringUnits: MutableState<Int>,
repeat: MutableState<Int>,
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()
Column(
modifier = Modifier
.fillMaxWidth()
@ -291,11 +259,7 @@ object AddReminderDialog {
CenteredH6(resId = R.string.custom_notification)
val focusRequester = remember { FocusRequester() }
OutlinedIntInput(
value = amount,
onValueChange = { newAmount ->
val amt = newAmount ?: 0
updateAlarm(alarm.copy(time = -1 * amt * unitIndexToMillis(selectedUnit)))
},
time,
minValue = 0,
modifier = Modifier
.fillMaxWidth()
@ -303,17 +267,7 @@ object AddReminderDialog {
)
Spacer(modifier = Modifier.height(16.dp))
options.forEachIndexed { index, option ->
RadioRow(
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
)
RadioRow(index, option, time, units, R.string.alarm_before_due)
}
Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp)
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) {
LocalContext.current.resources.getRepeatString(
alarm.repeat,
alarm.interval
repeat.value,
interval.value * recurringUnits.millis
)
} else {
stringResource(id = R.string.repeat_option_does_not_repeat)
@ -351,9 +305,11 @@ object AddReminderDialog {
.align(CenterVertically)
)
if (repeating) {
ClearButton(onClick = {
updateAlarm(alarm.copy(repeat = 0, interval = 0))
})
ClearButton {
repeat.value = 0
interval.value = 0
recurringUnits.value = 0
}
}
}
ShowKeyboard(true, focusRequester)
@ -362,14 +318,12 @@ object AddReminderDialog {
@Composable
fun AddRecurringReminder(
alarm: Alarm,
updateAlarm: (Alarm) -> Unit,
openDialog: Boolean,
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()
Column(
modifier = Modifier
.fillMaxWidth()
@ -378,40 +332,24 @@ object AddReminderDialog {
CenteredH6(text = stringResource(id = R.string.repeats_plural, "").trim())
val focusRequester = remember { FocusRequester() }
OutlinedIntInput(
value = intervalAmount,
onValueChange = { newAmount ->
val amt = newAmount ?: 0
updateAlarm(alarm.copy(interval = amt * unitIndexToMillis(selectedUnit)))
},
time = interval,
modifier = Modifier.focusRequester(focusRequester),
)
Spacer(modifier = Modifier.height(16.dp))
options.forEachIndexed { index, option ->
RadioRow(
index = index,
option = option,
timeAmount = intervalAmount,
unitIndex = selectedUnit,
onUnitSelected = { newUnit ->
selectedUnit = newUnit
updateAlarm(alarm.copy(interval = intervalAmount * unitIndexToMillis(newUnit)))
}
)
RadioRow(index, option, interval, units)
}
Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp)
Row(modifier = Modifier.fillMaxWidth()) {
OutlinedIntInput(
value = alarm.repeat,
onValueChange = { newRepeat ->
updateAlarm(alarm.copy(repeat = newRepeat ?: 0))
},
time = repeat,
modifier = Modifier.weight(0.5f),
autoSelect = false,
)
BodyText(
text = LocalContext.current.resources.getQuantityString(
R.plurals.repeat_times,
alarm.repeat
repeat.value
),
modifier = Modifier
.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_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
@ -445,48 +391,25 @@ fun ShowKeyboard(visible: Boolean, focusRequester: FocusRequester) {
@Composable
fun OutlinedIntInput(
value: Int?,
onValueChange: (Int?) -> Unit,
time: MutableState<Int>,
modifier: Modifier = Modifier,
minValue: Int = 1,
autoSelect: Boolean = true,
) {
var textFieldValue by remember {
val value = rememberSaveable(stateSaver = TextFieldValue.Saver) {
val text = time.value.toString()
mutableStateOf(
TextFieldValue(
text = value?.toString() ?: "",
selection = if (autoSelect) {
TextRange(0, value?.toString()?.length ?: 0)
} else {
TextRange.Zero
}
text = text,
selection = TextRange(0, if (autoSelect) text.length else 0)
)
)
}
// 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(
value = textFieldValue,
value = value.value,
onValueChange = {
textFieldValue = it.copy(text = it.text.filter { t -> t.isDigit() })
onValueChange(textFieldValue.text.toIntOrNull())
value.value = it.copy(text = it.text.filter { t -> t.isDigit() })
time.value = value.value.text.toIntOrNull() ?: 0
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = modifier.padding(horizontal = 16.dp),
@ -496,7 +419,7 @@ fun OutlinedIntInput(
focusedBorderColor = 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(
index: Int,
option: Int,
timeAmount: Int,
unitIndex: Int,
onUnitSelected: (Int) -> Unit,
time: MutableState<Int>,
units: MutableState<Int>,
formatString: Int? = null,
) {
val optionString = LocalContext.current.resources.getQuantityString(option, timeAmount)
val optionString = LocalContext.current.resources.getQuantityString(option, time.value)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onUnitSelected(index) }
.clickable { units.value = index }
) {
RadioButton(
selected = index == unitIndex,
onClick = { onUnitSelected(index) },
selected = index == units.value,
onClick = { units.value = index },
modifier = Modifier.align(CenterVertically)
)
BodyText(
text = if (index == unitIndex) {
text = if (index == units.value) {
formatString
?.let { stringResource(id = formatString, optionString) }
?: optionString
@ -584,14 +506,8 @@ fun AddAlarmDialog(
dismiss()
return
}
TYPE_REL_END -> {
if (viewState.replace.time < 0) {
// Custom reminder (before due)
addCustom()
dismiss()
return
}
}
// TODO: if replacing custom alarm show custom picker
// TODO: prepopulate pickers with existing values
}
}
CustomDialog(visible = viewState.showAddAlarm, onDismiss = dismiss) {
@ -639,11 +555,11 @@ fun AddAlarmDialog(
fun AddCustomReminderOne() =
TasksTheme {
AddReminderDialog.AddCustomReminder(
alarm = Alarm(
time = -1 * TimeUnit.MINUTES.toMillis(1),
type = TYPE_REL_END
),
updateAlarm = {},
time = remember { mutableStateOf(1) },
units = remember { mutableStateOf(0) },
interval = remember { mutableStateOf(0) },
recurringUnits = remember { mutableStateOf(0) },
repeat = remember { mutableStateOf(0) },
showRecurring = {},
)
}
@ -655,11 +571,11 @@ fun AddCustomReminderOne() =
fun AddCustomReminder() =
TasksTheme {
AddReminderDialog.AddCustomReminder(
alarm = Alarm(
time = -15 * TimeUnit.HOURS.toMillis(1),
type = TYPE_REL_END
),
updateAlarm = {},
time = remember { mutableStateOf(15) },
units = remember { mutableStateOf(1) },
interval = remember { mutableStateOf(0) },
recurringUnits = remember { mutableStateOf(0) },
repeat = remember { mutableStateOf(0) },
showRecurring = {},
)
}
@ -671,13 +587,10 @@ fun AddCustomReminder() =
fun AddRepeatingReminderOne() =
TasksTheme {
AddReminderDialog.AddRecurringReminder(
alarm = Alarm(
time = -1 * TimeUnit.MINUTES.toMillis(1),
type = TYPE_REL_END,
interval = TimeUnit.MINUTES.toMillis(1),
repeat = 1
),
updateAlarm = {},
openDialog = true,
interval = remember { mutableStateOf(1) },
units = remember { mutableStateOf(0) },
repeat = remember { mutableStateOf(1) },
)
}
@ -688,13 +601,10 @@ fun AddRepeatingReminderOne() =
fun AddRepeatingReminder() =
TasksTheme {
AddReminderDialog.AddRecurringReminder(
alarm = Alarm(
time = -15 * TimeUnit.HOURS.toMillis(1),
type = TYPE_REL_END,
interval = 15 * TimeUnit.HOURS.toMillis(1),
repeat = 4
),
updateAlarm = {},
openDialog = true,
interval = remember { mutableStateOf(15) },
units = remember { mutableStateOf(1) },
repeat = remember { mutableStateOf(4) },
)
}
@ -705,11 +615,8 @@ fun AddRepeatingReminder() =
fun AddRandomReminderOne() =
TasksTheme {
AddReminderDialog.AddRandomReminder(
alarm = Alarm(
time = TimeUnit.MINUTES.toMillis(1),
type = TYPE_RANDOM
),
updateAlarm = {}
time = remember { mutableStateOf(1) },
units = remember { mutableStateOf(0) }
)
}
@ -720,11 +627,8 @@ fun AddRandomReminderOne() =
fun AddRandomReminder() =
TasksTheme {
AddReminderDialog.AddRandomReminder(
alarm = Alarm(
time = 15 * TimeUnit.HOURS.toMillis(1),
type = TYPE_RANDOM
),
updateAlarm = {}
time = remember { mutableStateOf(15) },
units = remember { mutableStateOf(1) }
)
}

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

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

@ -5,13 +5,10 @@ import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@ -20,7 +17,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@ -50,7 +46,6 @@ fun ListSettingsScaffold(
content: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
topBar = {
Column {
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.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalContentColor
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.text.TextStyle
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.unit.dp
import androidx.compose.ui.unit.sp
@ -75,9 +73,6 @@ fun TitleInput(
color = LocalContentColor.current
),
onValueChange = { setText(it) },
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences
),
cursorBrush = SolidColor(errorState), // SolidColor(LocalContentColor.current),
modifier = Modifier
.fillMaxWidth()

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

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

@ -257,7 +257,7 @@ class Backups : InjectingPreferenceFragment() {
pref.summary = """
$location
${requireContext().getString(R.string.backup_location_warning, FileHelper.uri2String(preferences.appPrivateStorage))}
${requireContext().getString(R.string.backup_location_warning, FileHelper.uri2String(preferences.externalStorage))}
""".trimIndent()
} else {
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
import android.app.Activity
import android.appwidget.AppWidgetManager
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.tasks.R
import org.tasks.compose.throttleLatest
import org.tasks.injection.ApplicationScope
import timber.log.Timber
import javax.inject.Inject
@ -24,19 +22,6 @@ class AppWidgetManager @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
) {
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
get() = appWidgetManager
@ -52,11 +37,31 @@ class AppWidgetManager @Inject constructor(
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
.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() {
updateChannel.trySend(Unit)
fun updateWidgets() = scope.launch {
val appWidgetIds = widgetIds
Timber.d("updateWidgets: ${appWidgetIds.joinToString { it.toString() }}")
notifyAppWidgetViewDataChanged(appWidgetIds)
}
fun exists(id: Int) = appWidgetManager?.getAppWidgetInfo(id) != null

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

@ -109,7 +109,4 @@
<string name="CFC_importance_name">Prioritet…</string>
<string name="week_before_due">Sedmica prije roka</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>

@ -371,7 +371,7 @@
<string name="cannot_access_account">Tiliin ei päästä käsiksi</string>
<string name="logout">Kirjaudu ulos</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="about">Tietoja sovelluksesta</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="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="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>

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

@ -672,7 +672,7 @@
<string name="enable_reminders">Activează memento-uri</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="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="consent_agree">De acord</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="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="widget_view_more_tasks">Vezi mai multe sarcini</string>
</resources>

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

@ -295,7 +295,7 @@ class AlarmCalculatorTest {
@Test
fun scheduleOverdueRandomReminder() {
random.stub = 0.3865f
random.seed = 0.3865f
freezeAt(now) {
val alarm = alarmCalculator.toAlarmEntry(
newTask(
@ -316,7 +316,7 @@ class AlarmCalculatorTest {
@Test
fun scheduleOverdueRandomReminderForHiddenTask() {
random.stub = 0.3865f
random.seed = 0.3865f
freezeAt(now) {
val task = newTask(
with(REMINDER_LAST, now.minusDays(14)),
@ -335,7 +335,7 @@ class AlarmCalculatorTest {
@Test
fun scheduleInitialRandomReminder() {
random.stub = 0.3865f
random.seed = 0.3865f
freezeAt(now) {
val alarm = alarmCalculator.toAlarmEntry(
@ -358,7 +358,7 @@ class AlarmCalculatorTest {
@Test
fun scheduleNextRandomReminder() {
random.stub = 0.3865f
random.seed = 0.3865f
freezeAt(now) {
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() {
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,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]
versionCode = "140822" # increment by 2
versionName = "14.8.4"
agp = "8.13.1"
versionCode = "140818" # increment by 2
versionName = "14.8.3"
agp = "8.13.0"
android-compileSdk = "36"
android-minSdk = "26"
android-targetSdk = "35"
@ -11,9 +11,9 @@ appauth = "0.11.1"
appcompat = "1.7.1"
cert4android = "7814052"
coil = "2.7.0"
compose = "2025.11.01"
compose = "2025.09.00"
constraintlayout = "2.2.1"
dagger-hilt = "2.57.2"
dagger-hilt = "2.57.1"
dashclock-api = "2.0.0"
dav4jvm = "2.2.1"
desugar_jdk_libs = "2.1.5"
@ -21,9 +21,9 @@ etebase = "2.3.2"
firebase = "33.16.0"
firebase-crashlytics-gradle = "3.0.6"
google-oauth2 = "1.39.0"
google-api-drive = "v3-rev20251019-2.0.0"
google-api-tasks = "v1-rev20251102-2.0.0"
google-services = "4.4.4"
google-api-drive = "v3-rev20250829-2.0.0"
google-api-tasks = "v1-rev20250518-2.0.0"
google-services = "4.4.3"
grpc = "1.73.0"
hilt = "1.3.0"
horologist = "0.7.15"
@ -34,47 +34,47 @@ junit-junit = "4.13.2"
junit = "1.3.0"
kotlin = "2.1.21"
kotlinx-coroutines = "1.10.2"
ktor = "3.3.2"
ktor = "3.1.3"
leakcanary = "2.14"
lib-recur = "0.11.4"
lifecycle = "2.9.4"
lifecycle = "2.9.3"
locale = "1.0.4"
make-it-easy = "4.0.1"
markwon = "4.6.2"
material = "1.12.0"
mockito = "5.20.0"
okhttp = "5.3.0"
mockito = "5.19.0"
okhttp = "4.12.0"
opentasks = "562fec5"
osmdroid = "6.1.20"
oss-licenses-plugin = "0.10.9"
oss-licenses-plugin = "0.10.8"
persistent-cookiejar = "1.0.1"
play-services-maps = "19.2.0"
play-services-location = "21.3.0"
play-services-oss-licenses = "17.3.0"
preference = "1.2.1"
protobuf = "4.33.1"
protobuf = "4.32.1"
recyclerview = "1.4.0"
rfc5545-datetime = "0.2.4"
room = "2.8.3"
room = "2.7.2"
shortcut-badger = "1.1.22"
timber = "5.0.1"
swiperefreshlayout = "1.1.0"
work = "2.11.0"
work = "2.10.4"
androidx-test = "1.7.0"
androidx-test-runner = "1.7.0"
xpp3 = "1.1.6"
wearCompose = "1.5.5"
wearCompose = "1.5.1"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
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-compose = { module = "androidx.compose:compose-bom", version.ref = "compose" }
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-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-fragment-compose = { module = "androidx.fragment:fragment-compose", version = "1.8.9" }
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-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
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-preference = { module = "androidx.preference:preference", version.ref = "preference" }
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
androidx-room = { module = "androidx.room:room-runtime", 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.5.2" }
androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" }
androidx-test-core = { module = "androidx.test:core", 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" }
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" }
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-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" }
@ -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" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
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" }
play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version = "19.0.0" }
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" }
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-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" }
protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }
[plugins]
android-application = { id = "com.android.application", 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-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }

@ -28,7 +28,7 @@
<string name="show_unstarted">Näytä aloittamattomat</string>
<string name="show_completed">Näytä valmiit</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="add_task">Lisää tehtävä</string>
<string name="search_no_results">Ei tuloksia</string>

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

@ -27,6 +27,4 @@
<string name="today_lowercase">今日</string>
<string name="show_unstarted">着手前のタスクを表示</string>
<string name="show_completed">完了したタスクを表示</string>
<string name="search_no_results">結果なし</string>
<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.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -70,6 +71,9 @@ import tasks.kmp.generated.resources.subscribe
import java.util.Locale
import kotlin.math.roundToInt
@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
private val SEARCH_BAR_BOTTOM_PADDING = androidx.compose.material3.OutlinedTextFieldTopPadding
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskListDrawer(
@ -282,6 +286,7 @@ fun RowScope.MenuSearchBar(
query: String,
onQueryChange: (String) -> Unit,
) {
val density = LocalDensity.current
var hasFocus by remember { mutableStateOf(false) }
SearchBar(
modifier = Modifier
@ -289,7 +294,7 @@ fun RowScope.MenuSearchBar(
.padding(
start = 8.dp,
end = if (hasFocus) 8.dp else 0.dp,
bottom = 8.dp
bottom = with(density) { SEARCH_BAR_BOTTOM_PADDING.toDp() }
)
.weight(1f)
.animateContentSize(

@ -12,17 +12,6 @@ import tasks.kmp.generated.resources.default_list
private val mutex = Mutex()
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(
accountType = CaldavAccount.TYPE_LOCAL,
uuid = UUIDHelper.newUUID(),
@ -32,6 +21,13 @@ private suspend fun CaldavDao.newLocalAccountUnsafe(): CaldavAccount {
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 =
getCalendarsByAccount(account.uuid!!).getOrNull(0)
?: CaldavCalendar(

Loading…
Cancel
Save