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 4 weeks ago
Alex Baker 9190930745 Random reminder fixes
- Make random reminder calculation deterministic
- Don't fire reminders immediately on recurring tasks
4 weeks ago
Alex Baker 40961dad87 Refactor custom and random reminder dialogs 4 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 }} GOOGLE_KEY: ${{ secrets.GOOGLE_KEY }}
run: bundle exec fastlane bundle run: bundle exec fastlane bundle
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: release name: release
path: | path: |

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

@ -24,7 +24,7 @@ jobs:
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true bundler-cache: true
- uses: actions/download-artifact@v5 - uses: actions/download-artifact@v6
with: with:
name: release name: release
path: . 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) ### 14.8.2 (2025-09-14)
* Fix blank widgets on Android 16 QPR1 [#3847](https://github.com/tasks/tasks/issues/3847) * Fix blank widgets on Android 16 QPR1 [#3847](https://github.com/tasks/tasks/issues/3847)

@ -15,7 +15,7 @@ Please visit [tasks.org](https://tasks.org) for end user documentation and suppo
[![PayPal donate button](https://img.shields.io/badge/paypal-donate-yellow.svg?logo=paypal)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=alex@tasks.org) [![PayPal donate button](https://img.shields.io/badge/paypal-donate-yellow.svg?logo=paypal)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=alex@tasks.org)
[![Liberapay donate button](https://img.shields.io/liberapay/receives/tasks.svg?logo=liberapay)](https://liberapay.com/tasks/donate) [![Liberapay donate button](https://img.shields.io/liberapay/receives/tasks.svg?logo=liberapay)](https://liberapay.com/tasks/donate)
[![build](https://github.com/tasks/tasks/actions/workflows/bundle.yml/badge.svg)](https://github.com/tasks/tasks/actions/workflows/bundle.yml) [![weblate](https://hosted.weblate.org/widgets/tasks/-/android/svg-badge.svg)](https://hosted.weblate.org/engage/tasks/?utm_source=widget) [![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 ### Contributing

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

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

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

@ -38,7 +38,6 @@ import org.tasks.location.GeofenceApi
import org.tasks.opentasks.OpenTaskContentObserver import org.tasks.opentasks.OpenTaskContentObserver
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.receivers.RefreshReceiver import org.tasks.receivers.RefreshReceiver
import org.tasks.receivers.ScreenUnlockReceiver
import org.tasks.scheduling.NotificationSchedulerIntentService import org.tasks.scheduling.NotificationSchedulerIntentService
import org.tasks.sync.SyncAdapters import org.tasks.sync.SyncAdapters
import org.tasks.themes.ThemeBase import org.tasks.themes.ThemeBase
@ -142,7 +141,6 @@ class TasksApplication : Application(), Configuration.Provider {
geofenceApi.get().registerAll() geofenceApi.get().registerAll()
appWidgetManager.get().reconfigureWidgets() appWidgetManager.get().reconfigureWidgets()
CaldavSynchronizer.registerFactories() CaldavSynchronizer.registerFactories()
registerScreenUnlockReceiver()
} }
override val workManagerConfiguration: Configuration override val workManagerConfiguration: Configuration
@ -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 { companion object {
@Suppress("KotlinConstantConditions") @Suppress("KotlinConstantConditions")
const val IS_GOOGLE_PLAY = BuildConfig.FLAVOR == "googleplay" 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -62,22 +63,53 @@ import java.util.concurrent.TimeUnit
@ExperimentalComposeUiApi @ExperimentalComposeUiApi
object AddReminderDialog { object AddReminderDialog {
// Helper functions for converting between Alarm properties and UI state
private fun unitIndexToMillis(unitIndex: Int): Long = when (unitIndex) {
1 -> TimeUnit.HOURS.toMillis(1)
2 -> TimeUnit.DAYS.toMillis(1)
3 -> TimeUnit.DAYS.toMillis(7)
else -> TimeUnit.MINUTES.toMillis(1)
}
private fun timeToAmountAndUnit(time: Long): Pair<Int, Int> {
val absTime = kotlin.math.abs(time)
return when {
absTime == 0L -> 0 to 0 // Default to minutes when time is 0
absTime % TimeUnit.DAYS.toMillis(7) == 0L ->
(absTime / TimeUnit.DAYS.toMillis(7)).toInt() to 3
absTime % TimeUnit.DAYS.toMillis(1) == 0L ->
(absTime / TimeUnit.DAYS.toMillis(1)).toInt() to 2
absTime % TimeUnit.HOURS.toMillis(1) == 0L ->
(absTime / TimeUnit.HOURS.toMillis(1)).toInt() to 1
else ->
(absTime / TimeUnit.MINUTES.toMillis(1)).toInt() to 0
}
}
@Composable @Composable
fun AddRandomReminderDialog( fun AddRandomReminderDialog(
viewState: ViewState, alarm: Alarm?,
addAlarm: (Alarm) -> Unit, updateAlarm: (Alarm) -> Unit,
closeDialog: () -> Unit, closeDialog: () -> Unit,
) { ) {
val time = rememberSaveable { mutableStateOf(15) } // Create working copy from alarm or use defaults
val units = rememberSaveable { mutableStateOf(0) } var workingCopy by rememberSaveable {
if (viewState.showRandomDialog) { mutableStateOf(alarm ?: Alarm(time = 15 * TimeUnit.MINUTES.toMillis(1), type = TYPE_RANDOM))
}
AlertDialog( AlertDialog(
onDismissRequest = closeDialog, onDismissRequest = closeDialog,
text = { AddRandomReminder(time, units) }, text = {
AddRandomReminder(
alarm = workingCopy,
updateAlarm = { workingCopy = it }
)
},
confirmButton = { confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = { Constants.TextButton(text = R.string.ok, onClick = {
time.value.takeIf { it > 0 }?.let { i -> val (amount, _) = timeToAmountAndUnit(workingCopy.time)
addAlarm(Alarm(time = i * units.millis, type = TYPE_RANDOM)) if (amount > 0) {
updateAlarm(workingCopy)
closeDialog() closeDialog()
} }
}) })
@ -89,52 +121,40 @@ object AddReminderDialog {
) )
}, },
) )
} else {
time.value = 15
units.value = 0
}
} }
@Composable @Composable
fun AddCustomReminderDialog( fun AddCustomReminderDialog(
viewState: ViewState, alarm: Alarm?,
addAlarm: (Alarm) -> Unit, updateAlarm: (Alarm) -> Unit,
closeDialog: () -> Unit, closeDialog: () -> Unit,
) { ) {
val openDialog = viewState.showCustomDialog // Create working copy from alarm or use defaults
val time = rememberSaveable { mutableStateOf(15) } var workingCopy by rememberSaveable {
val units = rememberSaveable { mutableStateOf(0) } mutableStateOf(
val openRecurringDialog = rememberSaveable { mutableStateOf(false) } alarm ?: Alarm(
val interval = rememberSaveable { mutableStateOf(0) } time = -1 * 15 * TimeUnit.MINUTES.toMillis(1),
val recurringUnits = rememberSaveable { mutableStateOf(0) } type = TYPE_REL_END
val repeat = rememberSaveable { mutableStateOf(0) } )
if (openDialog) { )
if (!openRecurringDialog.value) { }
var showRecurringDialog by rememberSaveable { mutableStateOf(false) }
if (!showRecurringDialog) {
AlertDialog( AlertDialog(
onDismissRequest = closeDialog, onDismissRequest = closeDialog,
text = { text = {
AddCustomReminder( AddCustomReminder(
time, alarm = workingCopy,
units, updateAlarm = { workingCopy = it },
interval, showRecurring = { showRecurringDialog = true }
recurringUnits,
repeat,
showRecurring = {
openRecurringDialog.value = true
}
) )
}, },
confirmButton = { confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = { Constants.TextButton(text = R.string.ok, onClick = {
time.value.takeIf { it >= 0 }?.let { i -> val (amount, _) = timeToAmountAndUnit(workingCopy.time)
addAlarm( if (amount >= 0) {
Alarm( updateAlarm(workingCopy)
time = -1 * i * units.millis,
type = TYPE_REL_END,
repeat = repeat.value,
interval = interval.value * recurringUnits.millis
)
)
closeDialog() closeDialog()
} }
}) })
@ -147,56 +167,51 @@ object AddReminderDialog {
}, },
) )
} }
if (showRecurringDialog) {
AddRepeatReminderDialog( AddRepeatReminderDialog(
openDialog = openRecurringDialog, alarm = workingCopy,
initialInterval = interval.value, updateAlarm = { workingCopy = it },
initialUnits = recurringUnits.value, closeDialog = { showRecurringDialog = false }
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 @Composable
fun AddRepeatReminderDialog( fun AddRepeatReminderDialog(
openDialog: MutableState<Boolean>, alarm: Alarm,
initialInterval: Int, updateAlarm: (Alarm) -> Unit,
initialUnits: Int, closeDialog: () -> Unit,
initialRepeat: Int,
selected: (Int, Int, Int) -> Unit,
) { ) {
val interval = rememberSaveable { mutableStateOf(initialInterval) } // Create working copy with defaults if no recurrence set
val units = rememberSaveable { mutableStateOf(initialUnits) } var workingCopy by rememberSaveable {
val repeat = rememberSaveable { mutableStateOf(initialRepeat) } mutableStateOf(
val closeDialog = { if (alarm.interval == 0L && alarm.repeat == 0) {
openDialog.value = false // Default to 15 minutes, 4 times
alarm.copy(
interval = 15 * TimeUnit.MINUTES.toMillis(1),
repeat = 4
)
} else {
alarm
}
)
} }
if (openDialog.value) {
AlertDialog( AlertDialog(
onDismissRequest = closeDialog, onDismissRequest = closeDialog,
text = { text = {
AddRecurringReminder( AddRecurringReminder(
openDialog.value, alarm = workingCopy,
interval, updateAlarm = { workingCopy = it }
units,
repeat,
) )
}, },
confirmButton = { confirmButton = {
Constants.TextButton(text = R.string.ok, onClick = { Constants.TextButton(text = R.string.ok, onClick = {
if (interval.value > 0 && repeat.value > 0) { val (intervalAmount, _) = timeToAmountAndUnit(workingCopy.interval)
selected(interval.value, units.value, repeat.value) if (intervalAmount > 0 && workingCopy.repeat > 0) {
openDialog.value = false updateAlarm(workingCopy)
closeDialog()
} }
}) })
}, },
@ -207,19 +222,18 @@ object AddReminderDialog {
) )
}, },
) )
} else {
interval.value = initialInterval.takeIf { it > 0 } ?: 15
units.value = initialUnits
repeat.value = initialRepeat.takeIf { it > 0 } ?: 4
}
} }
@Composable @Composable
fun AddRandomReminder( fun AddRandomReminder(
time: MutableState<Int>, alarm: Alarm,
units: MutableState<Int>, 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() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -228,14 +242,27 @@ object AddReminderDialog {
CenteredH6(text = stringResource(id = R.string.randomly_every, "").trim()) CenteredH6(text = stringResource(id = R.string.randomly_every, "").trim())
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
OutlinedIntInput( OutlinedIntInput(
time, value = amount,
onValueChange = { newAmount ->
val amt = newAmount ?: 0
updateAlarm(alarm.copy(time = amt * unitIndexToMillis(selectedUnit)))
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(focusRequester) .focusRequester(focusRequester)
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
options.forEachIndexed { index, option -> options.forEachIndexed { index, option ->
RadioRow(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) ShowKeyboard(true, focusRequester)
} }
@ -243,14 +270,19 @@ object AddReminderDialog {
@Composable @Composable
fun AddCustomReminder( fun AddCustomReminder(
time: MutableState<Int>, alarm: Alarm,
units: MutableState<Int>, updateAlarm: (Alarm) -> Unit,
interval: MutableState<Int>,
recurringUnits: MutableState<Int>,
repeat: MutableState<Int>,
showRecurring: () -> Unit, showRecurring: () -> Unit,
) { ) {
val (initialAmount, initialUnit) = timeToAmountAndUnit(alarm.time)
var selectedUnit by rememberSaveable { mutableStateOf(initialUnit) }
val amount = if (alarm.time == 0L) 0 else kotlin.math.abs(alarm.time / unitIndexToMillis(selectedUnit)).toInt()
val (initialIntervalAmount, initialIntervalUnit) = timeToAmountAndUnit(alarm.interval)
val intervalAmount = if (alarm.interval == 0L) 0 else (alarm.interval / unitIndexToMillis(initialIntervalUnit)).toInt()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -259,7 +291,11 @@ object AddReminderDialog {
CenteredH6(resId = R.string.custom_notification) CenteredH6(resId = R.string.custom_notification)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
OutlinedIntInput( OutlinedIntInput(
time, value = amount,
onValueChange = { newAmount ->
val amt = newAmount ?: 0
updateAlarm(alarm.copy(time = -1 * amt * unitIndexToMillis(selectedUnit)))
},
minValue = 0, minValue = 0,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -267,7 +303,17 @@ object AddReminderDialog {
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
options.forEachIndexed { index, option -> 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) Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp)
Row(modifier = Modifier 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) { val text = if (repeating) {
LocalContext.current.resources.getRepeatString( LocalContext.current.resources.getRepeatString(
repeat.value, alarm.repeat,
interval.value * recurringUnits.millis alarm.interval
) )
} else { } else {
stringResource(id = R.string.repeat_option_does_not_repeat) stringResource(id = R.string.repeat_option_does_not_repeat)
@ -305,11 +351,9 @@ object AddReminderDialog {
.align(CenterVertically) .align(CenterVertically)
) )
if (repeating) { if (repeating) {
ClearButton { ClearButton(onClick = {
repeat.value = 0 updateAlarm(alarm.copy(repeat = 0, interval = 0))
interval.value = 0 })
recurringUnits.value = 0
}
} }
} }
ShowKeyboard(true, focusRequester) ShowKeyboard(true, focusRequester)
@ -318,12 +362,14 @@ object AddReminderDialog {
@Composable @Composable
fun AddRecurringReminder( fun AddRecurringReminder(
openDialog: Boolean, alarm: Alarm,
interval: MutableState<Int>, updateAlarm: (Alarm) -> Unit,
units: MutableState<Int>,
repeat: MutableState<Int>
) { ) {
val (initialIntervalAmount, initialIntervalUnit) = timeToAmountAndUnit(alarm.interval)
var selectedUnit by rememberSaveable { mutableStateOf(initialIntervalUnit) }
val intervalAmount = if (alarm.interval == 0L) 0 else (alarm.interval / unitIndexToMillis(selectedUnit)).toInt()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -332,24 +378,40 @@ object AddReminderDialog {
CenteredH6(text = stringResource(id = R.string.repeats_plural, "").trim()) CenteredH6(text = stringResource(id = R.string.repeats_plural, "").trim())
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
OutlinedIntInput( OutlinedIntInput(
time = interval, value = intervalAmount,
onValueChange = { newAmount ->
val amt = newAmount ?: 0
updateAlarm(alarm.copy(interval = amt * unitIndexToMillis(selectedUnit)))
},
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
options.forEachIndexed { index, option -> options.forEachIndexed { index, option ->
RadioRow(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) Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 1.dp)
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
OutlinedIntInput( OutlinedIntInput(
time = repeat, value = alarm.repeat,
onValueChange = { newRepeat ->
updateAlarm(alarm.copy(repeat = newRepeat ?: 0))
},
modifier = Modifier.weight(0.5f), modifier = Modifier.weight(0.5f),
autoSelect = false, autoSelect = false,
) )
BodyText( BodyText(
text = LocalContext.current.resources.getQuantityString( text = LocalContext.current.resources.getQuantityString(
R.plurals.repeat_times, R.plurals.repeat_times,
repeat.value alarm.repeat
), ),
modifier = Modifier modifier = Modifier
.weight(0.5f) .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_days,
R.plurals.reminder_week, R.plurals.reminder_week,
) )
private val MutableState<Int>.millis: Long
get() = when (value) {
1 -> TimeUnit.HOURS.toMillis(1)
2 -> TimeUnit.DAYS.toMillis(1)
3 -> TimeUnit.DAYS.toMillis(7)
else -> TimeUnit.MINUTES.toMillis(1)
}
} }
@ExperimentalComposeUiApi @ExperimentalComposeUiApi
@ -391,25 +445,48 @@ fun ShowKeyboard(visible: Boolean, focusRequester: FocusRequester) {
@Composable @Composable
fun OutlinedIntInput( fun OutlinedIntInput(
time: MutableState<Int>, value: Int?,
onValueChange: (Int?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
minValue: Int = 1, minValue: Int = 1,
autoSelect: Boolean = true, autoSelect: Boolean = true,
) { ) {
val value = rememberSaveable(stateSaver = TextFieldValue.Saver) { var textFieldValue by remember {
val text = time.value.toString()
mutableStateOf( mutableStateOf(
TextFieldValue( TextFieldValue(
text = text, text = value?.toString() ?: "",
selection = TextRange(0, if (autoSelect) text.length else 0) 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( OutlinedTextField(
value = value.value, value = textFieldValue,
onValueChange = { onValueChange = {
value.value = it.copy(text = it.text.filter { t -> t.isDigit() }) textFieldValue = it.copy(text = it.text.filter { t -> t.isDigit() })
time.value = value.value.text.toIntOrNull() ?: 0 onValueChange(textFieldValue.text.toIntOrNull())
}, },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = modifier.padding(horizontal = 16.dp), modifier = modifier.padding(horizontal = 16.dp),
@ -419,7 +496,7 @@ fun OutlinedIntInput(
focusedBorderColor = MaterialTheme.colorScheme.onSurface, focusedBorderColor = MaterialTheme.colorScheme.onSurface,
unfocusedBorderColor = 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( fun RadioRow(
index: Int, index: Int,
option: Int, option: Int,
time: MutableState<Int>, timeAmount: Int,
units: MutableState<Int>, unitIndex: Int,
onUnitSelected: (Int) -> Unit,
formatString: Int? = null, formatString: Int? = null,
) { ) {
val optionString = LocalContext.current.resources.getQuantityString(option, time.value) val optionString = LocalContext.current.resources.getQuantityString(option, timeAmount)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { units.value = index } .clickable { onUnitSelected(index) }
) { ) {
RadioButton( RadioButton(
selected = index == units.value, selected = index == unitIndex,
onClick = { units.value = index }, onClick = { onUnitSelected(index) },
modifier = Modifier.align(CenterVertically) modifier = Modifier.align(CenterVertically)
) )
BodyText( BodyText(
text = if (index == units.value) { text = if (index == unitIndex) {
formatString formatString
?.let { stringResource(id = formatString, optionString) } ?.let { stringResource(id = formatString, optionString) }
?: optionString ?: optionString
@ -506,8 +584,14 @@ fun AddAlarmDialog(
dismiss() dismiss()
return return
} }
// TODO: if replacing custom alarm show custom picker TYPE_REL_END -> {
// TODO: prepopulate pickers with existing values if (viewState.replace.time < 0) {
// Custom reminder (before due)
addCustom()
dismiss()
return
}
}
} }
} }
CustomDialog(visible = viewState.showAddAlarm, onDismiss = dismiss) { CustomDialog(visible = viewState.showAddAlarm, onDismiss = dismiss) {
@ -555,11 +639,11 @@ fun AddAlarmDialog(
fun AddCustomReminderOne() = fun AddCustomReminderOne() =
TasksTheme { TasksTheme {
AddReminderDialog.AddCustomReminder( AddReminderDialog.AddCustomReminder(
time = remember { mutableStateOf(1) }, alarm = Alarm(
units = remember { mutableStateOf(0) }, time = -1 * TimeUnit.MINUTES.toMillis(1),
interval = remember { mutableStateOf(0) }, type = TYPE_REL_END
recurringUnits = remember { mutableStateOf(0) }, ),
repeat = remember { mutableStateOf(0) }, updateAlarm = {},
showRecurring = {}, showRecurring = {},
) )
} }
@ -571,11 +655,11 @@ fun AddCustomReminderOne() =
fun AddCustomReminder() = fun AddCustomReminder() =
TasksTheme { TasksTheme {
AddReminderDialog.AddCustomReminder( AddReminderDialog.AddCustomReminder(
time = remember { mutableStateOf(15) }, alarm = Alarm(
units = remember { mutableStateOf(1) }, time = -15 * TimeUnit.HOURS.toMillis(1),
interval = remember { mutableStateOf(0) }, type = TYPE_REL_END
recurringUnits = remember { mutableStateOf(0) }, ),
repeat = remember { mutableStateOf(0) }, updateAlarm = {},
showRecurring = {}, showRecurring = {},
) )
} }
@ -587,10 +671,13 @@ fun AddCustomReminder() =
fun AddRepeatingReminderOne() = fun AddRepeatingReminderOne() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRecurringReminder( AddReminderDialog.AddRecurringReminder(
openDialog = true, alarm = Alarm(
interval = remember { mutableStateOf(1) }, time = -1 * TimeUnit.MINUTES.toMillis(1),
units = remember { mutableStateOf(0) }, type = TYPE_REL_END,
repeat = remember { mutableStateOf(1) }, interval = TimeUnit.MINUTES.toMillis(1),
repeat = 1
),
updateAlarm = {},
) )
} }
@ -601,10 +688,13 @@ fun AddRepeatingReminderOne() =
fun AddRepeatingReminder() = fun AddRepeatingReminder() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRecurringReminder( AddReminderDialog.AddRecurringReminder(
openDialog = true, alarm = Alarm(
interval = remember { mutableStateOf(15) }, time = -15 * TimeUnit.HOURS.toMillis(1),
units = remember { mutableStateOf(1) }, type = TYPE_REL_END,
repeat = remember { mutableStateOf(4) }, interval = 15 * TimeUnit.HOURS.toMillis(1),
repeat = 4
),
updateAlarm = {},
) )
} }
@ -615,8 +705,11 @@ fun AddRepeatingReminder() =
fun AddRandomReminderOne() = fun AddRandomReminderOne() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRandomReminder( AddReminderDialog.AddRandomReminder(
time = remember { mutableStateOf(1) }, alarm = Alarm(
units = remember { mutableStateOf(0) } time = TimeUnit.MINUTES.toMillis(1),
type = TYPE_RANDOM
),
updateAlarm = {}
) )
} }
@ -627,8 +720,11 @@ fun AddRandomReminderOne() =
fun AddRandomReminder() = fun AddRandomReminder() =
TasksTheme { TasksTheme {
AddReminderDialog.AddRandomReminder( AddReminderDialog.AddRandomReminder(
time = remember { mutableStateOf(15) }, alarm = Alarm(
units = remember { mutableStateOf(1) } 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.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Abc import androidx.compose.material.icons.outlined.Abc
@ -54,6 +55,7 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -384,16 +386,31 @@ object FilterCondition {
Row { Row {
for (index in items.indices) { for (index in items.indices) {
val highlight = (index == selected.intValue) val highlight = (index == selected.intValue)
val color =
if (highlight) MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)
else MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f)
OutlinedButton( OutlinedButton(
onClick = { selected.intValue = index }, onClick = { selected.intValue = index },
border = BorderStroke(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( colors = ButtonDefaults.outlinedButtonColors(
containerColor = color.copy(alpha = 0.2f), containerColor = if (highlight) {
contentColor = MaterialTheme.colorScheme.onBackground), MaterialTheme.colorScheme.primary
shape = RoundedCornerShape(Constants.HALF_KEYLINE) } 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]) Text(items[index])
} }
@ -484,6 +501,9 @@ object FilterCondition {
contentDescription = null contentDescription = null
) )
}, },
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences
),
textStyle = MaterialTheme.typography.bodyMedium.copy( textStyle = MaterialTheme.typography.bodyMedium.copy(
textDirection = TextDirection.Content textDirection = TextDirection.Content
), ),

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -28,7 +28,7 @@
<string name="show_unstarted">Näytä aloittamattomat</string> <string name="show_unstarted">Näytä aloittamattomat</string>
<string name="show_completed">Näytä valmiit</string> <string name="show_completed">Näytä valmiit</string>
<string name="wear_unknown_error">Tuntematon virhe</string> <string name="wear_unknown_error">Tuntematon virhe</string>
<string name="requires_pro_subscription">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="subscription_required_description">Avaa kaikkien ominaisuuksien lukitus jopa 1 $ USD/vuosi</string>
<string name="add_task">Lisää tehtävä</string> <string name="add_task">Lisää tehtävä</string>
<string name="search_no_results">Ei tuloksia</string> <string name="search_no_results">Ei tuloksia</string>

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

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

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

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

Loading…
Cancel
Save