Compare commits

...

52 Commits
14.8.2 ... main

Author SHA1 Message Date
renovate[bot] fbfcbdc555
Update dependency androidx.sqlite:sqlite-bundled to v2.6.2 (#4006)
* Update dependency androidx.sqlite:sqlite-bundled to v2.6.2

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2 weeks ago
renovate[bot] 51e347f22b
Update dependency androidx.compose.ui:ui-tooling-preview-android to v1.9.5 (#4005)
* Update dependency androidx.compose.ui:ui-tooling-preview-android to v1.9.5

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2 weeks ago
renovate[bot] d0c28baf7b
Update dependency androidx.compose:compose-bom to v2025.11.01 (#4002)
* Update dependency androidx.compose:compose-bom to v2025.11.01

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2 weeks ago
Pierfrancesco Passerini 3d4d44849e Translated using Weblate (Italian)
Currently translated at 99.6% (654 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/it/
3 weeks ago
renovate[bot] dfa41c515a
Update protobuf monorepo to v4.33.1 (#3998)
* Update protobuf monorepo to v4.33.1

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 weeks ago
M a539b3a3e4
Remove codebeat badge (#3996) 3 weeks ago
renovate[bot] 8d6de19b2a
Update agp to v8.13.1 (#3997)
* Update agp to v8.13.1

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 weeks ago
Alex Baker 2a6e1638c9 Update version and changelog 3 weeks ago
Alex Baker 9190930745 Random reminder fixes
- Make random reminder calculation deterministic
- Don't fire reminders immediately on recurring tasks
3 weeks ago
Alex Baker 40961dad87 Refactor custom and random reminder dialogs 3 weeks ago
renovate[bot] 9fbe27345d
Update dependency com.google.android.gms:oss-licenses-plugin to v0.10.9 (#3968)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 5f67e0ea3a
Update okhttp monorepo to v5 (major) (#3710)
* Update okhttp monorepo to v5

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] d51171b17e
Update plugin jetbrains-compose to v1.9.3 (#3983)
* Update plugin jetbrains-compose to v1.9.3

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] b657773a2d
Update wearCompose to v1.5.5 (#3973)
* Update wearCompose to v1.5.5

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] d84effc447
Update dependency androidx.compose.material3.adaptive:adaptive-navigation-android to v1.2.0 (#3976)
* Update dependency androidx.compose.material3.adaptive:adaptive-navigation-android to v1.2.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 21db540614
Update dependency androidx.core:core-splashscreen to v1.2.0 (#3977)
* Update dependency androidx.core:core-splashscreen to v1.2.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
Alex Baker 8e9f27c46e Fall back to internal storage...
if external private storage is unavailable
4 weeks ago
Alex Baker 94ad2a381e Remove use of internal padding 4 weeks ago
renovate[bot] 747928c8c7
Update dependency io.grpc:grpc-kotlin-stub to v1.5.0 (#3981)
* Update dependency io.grpc:grpc-kotlin-stub to v1.5.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 9a28f1062b
Update dependency com.google.apis:google-api-services-tasks to v1-rev20251102-2.0.0 (#3970)
* Update dependency com.google.apis:google-api-services-tasks to v1-rev20251102-2.0.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] ffe749bf0c
Update dependency androidx.compose.material3.adaptive:adaptive-layout-android to v1.2.0 (#3975)
* Update dependency androidx.compose.material3.adaptive:adaptive-layout-android to v1.2.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] b0ae0129ae
Update ktor monorepo to v3.3.2 (#3702)
* Update ktor monorepo to v3.3.2

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 6715369d93
Update dependency androidx.work:work-runtime-ktx to v2.11.0 (#3979)
* Update dependency androidx.work:work-runtime-ktx to v2.11.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 4efb678699
Update dependency androidx.sqlite:sqlite-bundled to v2.6.1 (#3978)
* Update dependency androidx.sqlite:sqlite-bundled to v2.6.1

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 581b789a0b
Update lifecycle to v2.9.4 (#3972)
* Update lifecycle to v2.9.4

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 15d8b3aa59
Update dependency com.google.gms:google-services to v4.4.4 (#3971)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] dcd5d8c094
Update mockito monorepo to v5.20.0 (#3982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] f3253e6188
Update protobuf monorepo to v4.33.0 (#3984)
* Update protobuf monorepo to v4.33.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 34b0c62ef8
Update dependency androidx.navigation:navigation-compose to v2.9.6 (#3967)
* Update dependency androidx.navigation:navigation-compose to v2.9.6

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 3754196714
Update room to v2.8.3 (#3985)
* Update room to v2.8.3

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
Igor Sorocean b94a91efbe Translated using Weblate (Romanian)
Currently translated at 100.0% (656 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/ro/
4 weeks ago
renovate[bot] 2c6066c378
Update dependency androidx.compose:compose-bom to v2025.11.00 (#3974)
* Update dependency androidx.compose:compose-bom to v2025.11.00

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 03e15a8c35
Update GitHub Artifact Actions (major) (#3987)
Update GitHub Artifact Actions

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] e63add73bc
Update dagger.hilt to v2.57.2 (#3965)
* Update dagger.hilt to v2.57.2

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] bf676bcea7
Update dependency ruby to v3.4.7 (#3896)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 6de8fe2fa0
Update dependency com.google.apis:google-api-services-drive to v3-rev20251019-2.0.0 (#3969)
* Update dependency com.google.apis:google-api-services-drive to v3-rev20251019-2.0.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
renovate[bot] 5907f27172
Update dependency androidx.compose.ui:ui-tooling-preview-android to v1.9.4 (#3966)
* Update dependency androidx.compose.ui:ui-tooling-preview-android to v1.9.4

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
4 weeks ago
Alex Baker 38119d4560 Update version and changelog 4 weeks ago
Alex Baker 52848a5308 Attempt to fix flashing widgets 4 weeks ago
Alex Baker 4c492120b3 Update Google Play Billing to 7.1.1 4 weeks ago
Alex Baker 20e995b19b Fix locale support for Hebrew and Indonesian 4 weeks ago
Alex Baker 2b63e33de2 Fix reentrant deadlock 4 weeks ago
hasak 5980bd497d Translated using Weblate (Bosnian)
Currently translated at 17.0% (112 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/bs/
2 months ago
Erigmac X e16b5cd6cd Translated using Weblate (Indonesian)
Currently translated at 84.8% (28 of 33 strings)

Translation: Tasks.org/Desktop
Translate-URL: https://hosted.weblate.org/projects/tasks/multiplatform/id/
2 months ago
Akihiko Suzuki (array) 3d9945c798 Translated using Weblate (Japanese)
Currently translated at 87.8% (29 of 33 strings)

Translation: Tasks.org/Desktop
Translate-URL: https://hosted.weblate.org/projects/tasks/multiplatform/ja/
2 months ago
Petri Hämäläinen 5c9eb1c35f Translated using Weblate (Finnish)
Currently translated at 100.0% (33 of 33 strings)

Translation: Tasks.org/Desktop
Translate-URL: https://hosted.weblate.org/projects/tasks/multiplatform/fi/
2 months ago
Petri Hämäläinen 6b594a3213 Translated using Weblate (Finnish)
Currently translated at 100.0% (656 of 656 strings)

Translation: Tasks.org/Android
Translate-URL: https://hosted.weblate.org/projects/tasks/android/fi/
2 months ago
Alex Baker 9e0e01f89b Custom filter creation improvements 2 months ago
Alex Baker 932b8b0540 Update version and changelog 3 months ago
Alex Baker 152a9684e5 Downgrade Room and BundledSQLite
https://issuetracker.google.com/issues/442032108
3 months ago
renovate[bot] 88c817b770
Update room to v2.8.0 (#3895)
* Update room to v2.8.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago
renovate[bot] a368960073
Update hilt to v1.3.0 (#3891)
* Update hilt to v1.3.0

* Update dependency diffs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
3 months ago

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

@ -29,7 +29,7 @@ jobs:
run: bundle exec fastlane lint
- name: Archive lint reports
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
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@v4
uses: actions/upload-artifact@v5
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@v5
- uses: actions/download-artifact@v6
with:
name: release
path: .

@ -1 +1 @@
3.4.5
3.4.7

@ -1,3 +1,22 @@
### 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)
* 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)
[![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) [![codebeat badge](https://codebeat.co/badges/07924fca-2f18-4eff-99a3-120ec5ac2d5f)](https://codebeat.co/projects/github-com-tasks-tasks-main)
[![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)
### Contributing

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

@ -0,0 +1,25 @@
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,27 +4,24 @@ 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.SkuType
import com.android.billingclient.api.BillingClient.ProductType
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.ProrationMode
import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams
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.SkuDetailsParams
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
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
@ -49,32 +46,61 @@ class BillingClientImpl(
override suspend fun getSkus(skus: List<String>): List<Sku> =
executeServiceRequest {
val skuDetailsResult = withContext(Dispatchers.IO) {
billingClient.querySkuDetails(
SkuDetailsParams
.newBuilder()
.setType(SkuType.SUBS)
.setSkusList(skus)
.build()
)
val productList = skus.map {
QueryProductDetailsParams.Product.newBuilder()
.setProductId(it)
.setProductType(ProductType.SUBS)
.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)
}
}
}
skuDetailsResult.billingResult.let {
productDetailsResult.first.let {
if (!it.success) {
throw IllegalStateException(it.responseCodeString)
}
}
val json = Json { ignoreUnknownKeys = true }
skuDetailsResult
.skuDetailsList
?.map { json.decodeFromString<Sku>(it.originalJson) }
?: emptyList()
productDetailsResult.second?.map { productDetails ->
Sku(
productId = productDetails.productId,
price = productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice
?: productDetails.oneTimePurchaseOfferDetails?.formattedPrice
?: ""
)
} ?: emptyList()
}
override suspend fun queryPurchases(throwError: Boolean) = try {
executeServiceRequest {
withContext(Dispatchers.IO + NonCancellable) {
val subs = billingClient.queryPurchasesAsync(SkuType.SUBS)
val iaps = billingClient.queryPurchasesAsync(SkuType.INAPP)
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))
}
}
if (subs.success || iaps.success) {
withContext(Dispatchers.Main) {
inventory.clear()
@ -105,7 +131,7 @@ class BillingClientImpl(
purchases?.forEach {
firebase.reportIabResult(
result.responseCodeString,
it.skus.joinToString(","),
it.products.joinToString(","),
it.purchaseState.purchaseStateString
)
}
@ -122,31 +148,57 @@ class BillingClientImpl(
oldPurchase: Purchase?
) {
executeServiceRequest {
val skuDetailsResult = withContext(Dispatchers.IO) {
billingClient.querySkuDetails(
SkuDetailsParams.newBuilder().setSkusList(listOf(sku)).setType(skuType)
.build()
)
val productList = listOf(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(sku)
.setProductType(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)
}
}
}
skuDetailsResult.billingResult.let {
productDetailsResult.first.let {
if (!it.success) {
throw IllegalStateException(it.responseCodeString)
}
}
val skuDetails =
skuDetailsResult
.skuDetailsList
?.firstOrNull()
?: throw IllegalStateException("Sku $sku not found")
val params = BillingFlowParams.newBuilder().setSkuDetails(skuDetails)
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))
oldPurchase?.let {
params.setSubscriptionUpdateParams(
SubscriptionUpdateParams.newBuilder()
.setOldSkuPurchaseToken(it.purchaseToken)
.setReplaceSkusProrationMode(ProrationMode.IMMEDIATE_WITH_TIME_PRORATION)
.setOldPurchaseToken(it.purchaseToken)
.setSubscriptionReplacementMode(BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION)
.build()
)
}
if (activity is OnPurchasesUpdated) {
onPurchasesUpdated = activity
}
@ -214,17 +266,28 @@ class BillingClientImpl(
ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build(),
)
Timber.d("consume purchase: ${result.billingResult.responseCodeString}")
queryPurchases()
queryPurchases(throwError = false)
}
}
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 = SkuType.SUBS
const val TYPE_SUBS = ProductType.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
@ -251,11 +314,5 @@ 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.skus.first()
get() = purchase.products.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,10 +75,16 @@ 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(
task.reminderLast
.coerceAtLeast(task.creationDate)
.plus((reminderPeriod * (0.85f + 0.3f * random.nextFloat())).toLong()),
baseline.plus((reminderPeriod * multiplier).toLong()),
task.hideUntil
)
} else {

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

@ -23,6 +23,7 @@ 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
@ -54,6 +55,7 @@ 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
@ -384,16 +386,31 @@ 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(1.dp, SolidColor(color.copy(alpha = 0.5f))),
border = BorderStroke(
width = 1.dp,
brush = SolidColor(
if (highlight) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f)
}
)
),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = color.copy(alpha = 0.2f),
contentColor = MaterialTheme.colorScheme.onBackground),
shape = RoundedCornerShape(Constants.HALF_KEYLINE)
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),
) {
Text(items[index])
}
@ -484,6 +501,9 @@ object FilterCondition {
contentDescription = null
)
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences
),
textStyle = MaterialTheme.typography.bodyMedium.copy(
textDirection = TextDirection.Content
),

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

@ -5,10 +5,13 @@ 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
@ -17,6 +20,7 @@ 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
@ -46,6 +50,7 @@ fun ListSettingsScaffold(
content: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
topBar = {
Column {
val context = LocalContext.current

@ -5,6 +5,7 @@ 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
@ -24,6 +25,7 @@ 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
@ -73,6 +75,9 @@ 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 externalStorage: Uri
val appPrivateStorage: Uri
get() = root.uri
val attachmentsDirectory: Uri?
@ -412,13 +412,12 @@ class Preferences @JvmOverloads constructor(
?: getDefaultFileLocation(name)?.let { Uri.fromFile(it) }
private val root: DocumentFile
get() = DocumentFile.fromFile(context.getExternalFilesDir(null)!!)
get() = DocumentFile.fromFile(context.getExternalFilesDir(null) ?: context.filesDir)
private fun getDefaultFileLocation(type: String): File? {
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
val baseDir = context.getExternalFilesDir(null) ?: context.filesDir
val path = File(baseDir, type)
return if (path.isDirectory || path.mkdirs()) path else null
}
private fun hasWritePermission(context: Context, uri: Uri): Boolean =

@ -2,6 +2,7 @@ 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
@ -51,8 +52,16 @@ class PreferencesViewModel @Inject constructor(
get() = isStale(lastDriveBackup.value) && isStale(lastAndroidBackup.value)
val usingPrivateStorage: Boolean
get() = preferences.backupDirectory.let {
it == null || it.toString().startsWith(preferences.externalStorage.toString())
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())
}
val driveAccount: String?

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

@ -1,19 +0,0 @@
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()
}
}
}
}

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

@ -0,0 +1,14 @@
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,18 +1,20 @@
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.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
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
@ -22,6 +24,19 @@ 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
@ -37,31 +52,11 @@ class AppWidgetManager @Inject constructor(
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
.apply { action = AppWidgetManager.ACTION_APPWIDGET_UPDATE }
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
)
context.sendBroadcast(intent)
}
fun updateWidgets() = scope.launch {
val appWidgetIds = widgetIds
Timber.d("updateWidgets: ${appWidgetIds.joinToString { it.toString() }}")
notifyAppWidgetViewDataChanged(appWidgetIds)
fun updateWidgets() {
updateChannel.trySend(Unit)
}
fun exists(id: Int) = appWidgetManager?.getAppWidgetInfo(id) != null

@ -57,7 +57,6 @@ 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 {
@ -94,7 +93,6 @@ 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,4 +109,7 @@
<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ää ammattilaistilauksen</string>
<string name="requires_pro_subscription">Edellyttää pro-tilauksen</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,4 +696,30 @@
<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">Smart Chip</string>
<string name="chip_appearance">Aspetto Chip</string>
<string name="chips">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">Reamintire implicită</string>
<string name="default_reminder">Memento 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,4 +748,5 @@
<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,8 +29,10 @@
<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.seed = 0.3865f
random.stub = 0.3865f
freezeAt(now) {
val alarm = alarmCalculator.toAlarmEntry(
newTask(
@ -316,7 +316,7 @@ class AlarmCalculatorTest {
@Test
fun scheduleOverdueRandomReminderForHiddenTask() {
random.seed = 0.3865f
random.stub = 0.3865f
freezeAt(now) {
val task = newTask(
with(REMINDER_LAST, now.minusDays(14)),
@ -335,7 +335,7 @@ class AlarmCalculatorTest {
@Test
fun scheduleInitialRandomReminder() {
random.seed = 0.3865f
random.stub = 0.3865f
freezeAt(now) {
val alarm = alarmCalculator.toAlarmEntry(
@ -358,7 +358,7 @@ class AlarmCalculatorTest {
@Test
fun scheduleNextRandomReminder() {
random.seed = 0.3865f
random.stub = 0.3865f
freezeAt(now) {
val alarm = alarmCalculator.toAlarmEntry(
@ -379,9 +379,28 @@ 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 seed = 1.0f
var stub = 1.0f
override fun nextFloat() = seed
override fun nextFloat(seed: Long) = this.stub
}
}

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

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

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

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

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

@ -1,7 +1,7 @@
[versions]
versionCode = "140816" # increment by 2
versionName = "14.8.2"
agp = "8.13.0"
versionCode = "140822" # increment by 2
versionName = "14.8.4"
agp = "8.13.1"
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.09.00"
compose = "2025.11.01"
constraintlayout = "2.2.1"
dagger-hilt = "2.57.1"
dagger-hilt = "2.57.2"
dashclock-api = "2.0.0"
dav4jvm = "2.2.1"
desugar_jdk_libs = "2.1.5"
@ -21,11 +21,11 @@ etebase = "2.3.2"
firebase = "33.16.0"
firebase-crashlytics-gradle = "3.0.6"
google-oauth2 = "1.39.0"
google-api-drive = "v3-rev20250829-2.0.0"
google-api-tasks = "v1-rev20250518-2.0.0"
google-services = "4.4.3"
google-api-drive = "v3-rev20251019-2.0.0"
google-api-tasks = "v1-rev20251102-2.0.0"
google-services = "4.4.4"
grpc = "1.73.0"
hilt = "1.2.0"
hilt = "1.3.0"
horologist = "0.7.15"
ical4android = "fcb0311ca7"
jchronic = "0.2.6"
@ -34,47 +34,47 @@ junit-junit = "4.13.2"
junit = "1.3.0"
kotlin = "2.1.21"
kotlinx-coroutines = "1.10.2"
ktor = "3.1.3"
ktor = "3.3.2"
leakcanary = "2.14"
lib-recur = "0.11.4"
lifecycle = "2.9.3"
lifecycle = "2.9.4"
locale = "1.0.4"
make-it-easy = "4.0.1"
markwon = "4.6.2"
material = "1.12.0"
mockito = "5.19.0"
okhttp = "4.12.0"
mockito = "5.20.0"
okhttp = "5.3.0"
opentasks = "562fec5"
osmdroid = "6.1.20"
oss-licenses-plugin = "0.10.8"
oss-licenses-plugin = "0.10.9"
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.32.1"
protobuf = "4.33.1"
recyclerview = "1.4.0"
rfc5545-datetime = "0.2.4"
room = "2.7.2"
room = "2.8.3"
shortcut-badger = "1.1.22"
timber = "5.0.1"
swiperefreshlayout = "1.1.0"
work = "2.10.4"
work = "2.11.0"
androidx-test = "1.7.0"
androidx-test-runner = "1.7.0"
xpp3 = "1.1.6"
wearCompose = "1.5.1"
wearCompose = "1.5.5"
[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.1.0" }
androidx-adaptive-navigation-android = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation-android", version = "1.2.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.1.0" }
androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout-android", version = "1.2.0" }
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version = "1.0.1" }
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version = "1.2.0" }
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.4" }
androidx-navigation = { module = "androidx.navigation:navigation-compose", version = "2.9.6" }
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.0" }
androidx-sqlite = { module = "androidx.sqlite:sqlite-bundled", version = "2.6.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 = "6.2.1" }
play-billing-ktx = { module = "com.android.billingclient:billing-ktx", version = "7.1.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.1" }
androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version = "1.9.5" }
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.4.3" }
io-grpc-grpc-kotlin = { group = "io.grpc", name = "grpc-kotlin-stub", version = "1.5.0" }
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.8.2" }
jetbrains-compose = { id = "org.jetbrains.compose", version = "1.9.3" }
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,9 +28,9 @@
<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">Ammattilaisominaisuus</string>
<string name="requires_pro_subscription">Pro-ominaisuus</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>
<string name="wear_install_app">Asenna puhelimeen</string>
</resources>
</resources>

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

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

@ -53,7 +53,6 @@ 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
@ -71,9 +70,6 @@ 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(
@ -286,7 +282,6 @@ fun RowScope.MenuSearchBar(
query: String,
onQueryChange: (String) -> Unit,
) {
val density = LocalDensity.current
var hasFocus by remember { mutableStateOf(false) }
SearchBar(
modifier = Modifier
@ -294,7 +289,7 @@ fun RowScope.MenuSearchBar(
.padding(
start = 8.dp,
end = if (hasFocus) 8.dp else 0.dp,
bottom = with(density) { SEARCH_BAR_BOTTOM_PADDING.toDp() }
bottom = 8.dp
)
.weight(1f)
.animateContentSize(

@ -12,6 +12,17 @@ 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(),
@ -21,13 +32,6 @@ suspend fun CaldavDao.newLocalAccount(): CaldavAccount = mutex.withLock {
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