Compare commits

..

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

@ -20,16 +20,16 @@ jobs:
- name: Decode Keystore
run: |
echo ${{ secrets.KEY_STORE }} | base64 -di > "${RUNNER_TEMP}"/keystore.jks
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
@ -44,7 +44,7 @@ jobs:
GOOGLE_KEY: ${{ secrets.GOOGLE_KEY }}
run: bundle exec fastlane bundle
- name: Upload artifacts
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v4
with:
name: release
path: |

@ -11,16 +11,16 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Set up JDK 21
uses: actions/setup-java@v5
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
@ -29,7 +29,7 @@ jobs:
run: bundle exec fastlane lint
- name: Archive lint reports
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: lint-reports
@ -43,13 +43,13 @@ jobs:
api-level: [29]
steps:
- name: checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v5
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
@ -89,7 +89,7 @@ jobs:
script: ./gradlew -Pcoverage app:test${{ matrix.flavor }}DebugUnitTest app:connected${{ matrix.flavor }}DebugAndroidTest
- name: Upload test reports
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: test-reports-${{ matrix.flavor }}

@ -22,15 +22,15 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- name: Set up JDK
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

@ -17,14 +17,14 @@ jobs:
runs-on: ubuntu-latest
needs: [ bundle ]
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Fastlane key
run: |
echo "$FASTLANE" > ./fastlane.json
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v4
with:
name: release
path: .

@ -1 +1 @@
3.4.7
3.4.5

@ -1,59 +1,3 @@
### 14.8.4 (2025-11-09)
* Fix flashing widgets [#3902](https://github.com/tasks/tasks/issues/3902)
* Fix random reminder scheduling
* Fix random reminders firing immediately on recurring tasks [#3904](https://github.com/tasks/tasks/issues/3904)
* Fix deadlock when adding new task
* Fix crash in settings when backup location unavailable [#3989](https://github.com/tasks/tasks/issues/3989)
* Fix Hebrew and Indonesian support [#3928](https://github.com/tasks/tasks/issues/3928)
* Update translations
* Bosnian - @hasak
* Finnish - @pHamala
* Indonesian - @erigmac
* Japanese - @array
* Romanian - @ygorigor
### 14.8.3 (2025-09-16)
* Fix crash on Android 10 and below
### 14.8.2 (2025-09-14)
* Fix blank widgets on Android 16 QPR1 [#3847](https://github.com/tasks/tasks/issues/3847)
* Fix all-day calendar events [#1534](https://github.com/tasks/tasks/issues/1534)
* Fix alarm synchronization [#3859](https://github.com/tasks/tasks/issues/3859)
* Fix sync failure when migrating data from EteSync to CalDAV [#3869](https://github.com/tasks/tasks/issues/3869)
* Fix removing values from Microsoft To Do [#3862](https://github.com/tasks/tasks/issues/3862)
* Fix share invites for Nextcloud [#2386](https://github.com/tasks/tasks/issues/2386)
* Fix failure to delete source data when moving to Google Tasks [#3867](https://github.com/tasks/tasks/issues/3867)
* Fix crash when clearing completed while grouping by lists
* Update translations
* Croatian - @milotype
* Dutch - @fvbommel
* German - @MisterTechnik
* Italian - @glemco
* Serbian - @vale-decem
### 14.8.1 (2025-08-24)
* System bar scrim improvements
* Recover from Google Task 'Bad request' errors
* Improve layout on Z Folds
* Crash fixes
* Update translations
* Brazilian Portuguese - odnankenobi
* Catalan - @Crashillo, @ferranpujolcamins
* Danish - ERYpTION
* Esperanto - Don Zouras
* Galician - @Crashillo, @delthia
* Hungarian - @Antmajgra, @gthrepwood
* Italian - @ppasserini
* Korean - Jiho Min
* Polish - @Antmajgra
* Portuguese - @Crashillo
* Russian - Алексей Ежков
* Spanish - @Crashillo
### 14.8 (2025-08-02)
* Synchronize **list** icons for Tasks.org and CalDAV accounts

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

1
app/proguard.pro vendored

@ -59,6 +59,5 @@
-dontwarn com.google.android.libraries.identity.**
-dontwarn edu.umd.cs.findbugs.annotations.**
-dontwarn com.google.crypto.tink.subtle.**
-dontwarn net.jcip.annotations.**
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { <fields>; }

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

@ -56,7 +56,7 @@ class TaskMoverTest : InjectingTestCase() {
createTasks(1)
googleTaskDao.insert(newCaldavTask(with(TASK, 1), with(CALENDAR, "1")))
moveToGoogleTasks("2", 1)
val deleted = googleTaskDao.getDeletedByTaskId(1, "account1")
val deleted = googleTaskDao.getDeletedByTaskId(1)
assertEquals(1, deleted.size.toLong())
assertEquals(1, deleted[0].task)
assertTrue(deleted[0].deleted > 0)
@ -71,7 +71,7 @@ class TaskMoverTest : InjectingTestCase() {
googleTaskDao.insert(newCaldavTask(with(TASK, 1), with(CALENDAR, "1")))
googleTaskDao.insert(newCaldavTask(with(TASK, 2), with(CALENDAR, "1")))
moveToGoogleTasks("2", 1)
val deleted = googleTaskDao.getDeletedByTaskId(2, "account1")
val deleted = googleTaskDao.getDeletedByTaskId(2)
assertEquals(1, deleted.size.toLong())
assertEquals(2, deleted[0].task)
assertTrue(deleted[0].deleted > 0)
@ -249,7 +249,7 @@ class TaskMoverTest : InjectingTestCase() {
createTasks(1)
googleTaskDao.insert(newCaldavTask(with(TASK, 1), with(CALENDAR, "1")))
moveToGoogleTasks("1", 1)
assertTrue(googleTaskDao.getDeletedByTaskId(1, "account1").isEmpty())
assertTrue(googleTaskDao.getDeletedByTaskId(1).isEmpty())
assertEquals(1, googleTaskDao.getAllByTaskId(1).size.toLong())
}

@ -1,13 +1,13 @@
package org.tasks.caldav
import org.tasks.data.UUIDHelper
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
import org.tasks.data.UUIDHelper
import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_NEXTCLOUD
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OPEN_XCHANGE
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OWNCLOUD
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_SABREDAV
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_TASKS
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_UNKNOWN
@ -36,7 +36,7 @@ class ServerDetectionTest : CaldavTest() {
sync()
assertEquals(SERVER_NEXTCLOUD, loadAccount().serverType)
assertEquals(SERVER_OWNCLOUD, loadAccount().serverType)
}
@Test

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

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

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

@ -25,7 +25,7 @@ class WearRefresherImpl(
init {
phoneDataLayerAppHelper
.connectedAndInstalledNodes
.catch { Timber.e("${it.message}") }
.catch { Timber.e(it) }
.onEach { nodes ->
Timber.d("Connected nodes: ${nodes.joinToString()}")
watchConnected = nodes.isNotEmpty()

@ -387,13 +387,6 @@
android:resource="@xml/file_provider_paths"/>
</provider>
<provider
android:name=".widget.WidgetIconProvider"
android:authorities="${applicationId}.widgeticons"
android:exported="true"
android:grantUriPermissions="true"
tools:ignore="ExportedContentProvider" />
<receiver
android:name="org.dmfs.provider.tasks.TaskProviderBroadcastReceiver"
tools:node="remove"/>

@ -24,7 +24,7 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.LaunchedEffect
@ -123,11 +123,10 @@ class MainActivity : AppCompatActivity() {
lightScrim = Color.TRANSPARENT,
darkScrim = Color.TRANSPARENT
),
navigationBarStyle = if (theme.themeBase.isDarkTheme(this)) {
SystemBarStyle.dark(Color.TRANSPARENT)
} else {
SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT)
}
navigationBarStyle = SystemBarStyle.auto(
lightScrim = Color.TRANSPARENT,
darkScrim = Color.TRANSPARENT
)
)
setContent {
@ -242,7 +241,7 @@ class MainActivity : AppCompatActivity() {
}
)
val navigator = rememberListDetailPaneScaffoldNavigator(
calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth(
calculatePaneScaffoldDirective(
windowAdaptiveInfo = currentWindowAdaptiveInfo(),
).copy(
horizontalPartitionSpacerSize = 0.dp,

@ -91,7 +91,8 @@ class MainActivityViewModel @Inject constructor(
private val refreshReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
LocalBroadcastManager.REFRESH -> _updateFilters.update { currentTimeMillis() }
LocalBroadcastManager.REFRESH,
LocalBroadcastManager.REFRESH_LIST -> _updateFilters.update { currentTimeMillis() }
}
}
}
@ -215,12 +216,12 @@ class MainActivityViewModel @Inject constructor(
when (subheader.subheaderType) {
NavigationDrawerSubheader.SubheaderType.PREFERENCE -> {
tasksPreferences.set(booleanPreferencesKey(subheader.id), collapsed)
localBroadcastManager.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
NavigationDrawerSubheader.SubheaderType.CALDAV,
NavigationDrawerSubheader.SubheaderType.TASKS -> {
caldavDao.setCollapsed(subheader.id, collapsed)
localBroadcastManager.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
}
}

@ -22,6 +22,7 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LAYOUT_DIRECTION_LTR
import android.view.ViewGroup.MarginLayoutParams
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.BackHandler
import androidx.activity.result.contract.ActivityResultContracts
@ -337,9 +338,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
right = endInset,
)
binding.bottomAppBar.updatePadding(bottom = bottomInset)
val scrimLayoutParams = binding.systemBarScrim.layoutParams
scrimLayoutParams.height = bottomInset
binding.systemBarScrim.layoutParams = scrimLayoutParams
(binding.fab.layoutParams as MarginLayoutParams).bottomMargin = bottomInset / 2
}
@OptIn(ExperimentalPermissionsApi::class)
@ -366,14 +365,6 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
listViewModel.setFilter(filter)
(recyclerView.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false
recyclerView.layoutManager = LinearLayoutManager(context)
val baseFooterHeight = resources.getDimensionPixelSize(R.dimen.task_list_footer_height)
val additionalFabSpace = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
56f,
resources.displayMetrics
).toInt()
recyclerView.updatePadding(bottom = baseFooterHeight + additionalFabSpace)
lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
listViewModel.updateBannerState()
@ -403,11 +394,6 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
binding.bottomAppBar.performShow()
}
}
val typedValue = TypedValue()
requireContext().theme.resolveAttribute(android.R.attr.colorBackground, typedValue, true)
val scrimColor = typedValue.data
binding.systemBarScrim.setBackgroundColor((scrimColor and 0x00FFFFFF) or 0xCC000000.toInt()) // 80% opacity
with (binding.fab) {
backgroundTintList = ColorStateList.valueOf(themeColor.primaryColor)
imageTintList = ColorStateList.valueOf(themeColor.colorOnPrimary)
@ -782,12 +768,10 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
colorProvider.getPriorityColor(3))
}
@SuppressLint("NotifyDataSetChanged")
override fun onResume() {
super.onResume()
listViewModel.invalidate()
localBroadcastManager.registerTaskCompletedReceiver(repeatConfirmationReceiver)
recyclerAdapter?.notifyDataSetChanged() // force rebind to update timestamps (hidden/overdue)
}
private fun makeSnackbar(@StringRes res: Int, vararg args: Any?): Snackbar? {

@ -3,8 +3,8 @@ package com.todoroo.astrid.adapter
import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.service.TaskMover
import com.todoroo.astrid.subtasks.SubtasksFilterUpdater
import org.tasks.LocalBroadcastManager
import org.tasks.Strings.isNullOrEmpty
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.data.TaskContainer
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.GoogleTaskDao
@ -24,9 +24,9 @@ class AstridTaskAdapter internal constructor(
googleTaskDao: GoogleTaskDao,
caldavDao: CaldavDao,
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
taskMover: TaskMover,
) : TaskAdapter(false, googleTaskDao, caldavDao, taskDao, refreshBroadcaster, taskMover) {
) : TaskAdapter(false, googleTaskDao, caldavDao, taskDao, localBroadcastManager, taskMover) {
private val chainedCompletions = Collections.synchronizedMap(HashMap<String, ArrayList<String>>())
@ -56,7 +56,7 @@ class AstridTaskAdapter internal constructor(
for (i in 0 until abs(delta)) {
updater.indent(list, filter, targetTaskId, delta)
}
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
} catch (e: Exception) {
Timber.e(e)
}

@ -6,7 +6,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.activities.TagSettingsActivity
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity.Companion.EXTRA_CALDAV_ACCOUNT
import org.tasks.data.dao.CaldavDao
@ -30,7 +30,7 @@ class SubheaderClickHandler @Inject constructor(
private val activity: Activity,
private val tasksPreferences: TasksPreferences,
private val caldavDao: CaldavDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
): SubheaderViewHolder.ClickHandler {
override fun onClick(subheader: NavigationDrawerSubheader) {
(activity as AppCompatActivity).lifecycleScope.launch {
@ -40,7 +40,7 @@ class SubheaderClickHandler @Inject constructor(
CALDAV,
TASKS -> caldavDao.setCollapsed(subheader.id, collapsed)
}
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
}

@ -13,7 +13,7 @@ import com.todoroo.astrid.core.SortHelper.SORT_START
import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.service.TaskMover
import org.tasks.BuildConfig
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.data.TaskContainer
import org.tasks.data.createDueDate
import org.tasks.data.createHideUntil
@ -31,7 +31,7 @@ open class TaskAdapter(
private val googleTaskDao: GoogleTaskDao,
private val caldavDao: CaldavDao,
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val taskMover: TaskMover,
) {
private val selected = HashSet<Long>()
@ -296,7 +296,7 @@ open class TaskAdapter(
taskDao.setOrder(task.id, task.task.order)
taskDao.setParent(newParentId, listOf(task.id))
taskDao.touch(task.id)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
protected suspend fun moveGoogleTask(from: Int, to: Int, indent: Int) {
@ -375,7 +375,7 @@ open class TaskAdapter(
}
}
taskDao.touch(task.id)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
if (BuildConfig.DEBUG) {
googleTaskDao.validateSorting(task.caldav!!)
}
@ -407,7 +407,7 @@ open class TaskAdapter(
newPosition = newPosition,
)
taskDao.touch(task.id)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
private suspend fun changeCaldavParent(task: TaskContainer, indent: Int, to: Int): Long {

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

@ -5,7 +5,7 @@
*/
package com.todoroo.astrid.alarms
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.data.dao.AlarmDao
import org.tasks.data.dao.TaskDao
import org.tasks.data.db.DbUtils
@ -28,7 +28,7 @@ import javax.inject.Inject
class AlarmService @Inject constructor(
private val alarmDao: AlarmDao,
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val notificationManager: NotificationManager,
private val workManager: WorkManager,
private val alarmCalculator: AlarmCalculator,
@ -54,7 +54,7 @@ class AlarmService @Inject constructor(
changed = true
}
if (changed) {
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
return changed
}

@ -6,7 +6,7 @@
package com.todoroo.astrid.dao
import com.todoroo.astrid.timers.TimerPlugin
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.data.TaskContainer
import org.tasks.data.count
import org.tasks.data.dao.TaskDao
@ -28,7 +28,7 @@ import javax.inject.Inject
class TaskDao @Inject constructor(
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val notificationManager: NotificationManager,
private val geofenceApi: GeofenceApi,
private val timerPlugin: TimerPlugin,
@ -82,7 +82,7 @@ class TaskDao @Inject constructor(
suspend fun setCollapsed(id: Long, collapsed: Boolean) {
taskDao.setCollapsed(listOf(id), collapsed)
syncAdapters.sync()
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
suspend fun setCollapsed(preferences: Preferences, filter: Filter, collapsed: Boolean) {
@ -103,7 +103,7 @@ class TaskDao @Inject constructor(
Timber.d("Saved $task")
afterUpdate(task, original)
if (!task.isSuppressRefresh()) {
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
workManager.triggerNotifications()
workManager.scheduleRefresh()

@ -11,7 +11,6 @@ import android.content.Context
import android.net.Uri
import android.provider.CalendarContract
import android.text.format.Time
import androidx.core.net.toUri
import dagger.hilt.android.qualifiers.ApplicationContext
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
@ -20,7 +19,7 @@ import org.tasks.data.dao.TaskDao
import org.tasks.data.entity.Task
import org.tasks.preferences.PermissionChecker
import org.tasks.preferences.Preferences
import org.tasks.time.DateTime
import org.tasks.time.DateTimeUtils2.currentTimeMillis
import org.tasks.time.ONE_HOUR
import timber.log.Timber
import java.util.TimeZone
@ -31,8 +30,8 @@ class GCalHelper @Inject constructor(
private val taskDao: TaskDao,
private val preferences: Preferences,
private val permissionChecker: PermissionChecker,
private val calendarEventProvider: CalendarEventProvider,
) {
private val calendarEventProvider: CalendarEventProvider) {
private val cr: ContentResolver = context.contentResolver
private suspend fun getTaskEventUri(task: Task) =
@ -110,7 +109,7 @@ class GCalHelper @Inject constructor(
})
updateValues.put(CalendarContract.Events.DESCRIPTION, task.notes)
createStartAndEndDate(task, updateValues)
cr.update(uri.toUri(), updateValues, null, null)
cr.update(Uri.parse(uri), updateValues, null, null)
} catch (e: Exception) {
Timber.e(e, "Failed to update calendar: %s [%s]", uri, task)
}
@ -118,10 +117,10 @@ class GCalHelper @Inject constructor(
suspend fun rescheduleRepeatingTask(task: Task) {
val taskUri = getTaskEventUri(task)
if (taskUri.isNullOrBlank()) {
if (isNullOrEmpty(taskUri)) {
return
}
val eventUri = taskUri.toUri()
val eventUri = Uri.parse(taskUri)
val event = calendarEventProvider.getEvent(eventUri)
if (event == null) {
task.calendarURI = ""
@ -135,6 +134,11 @@ class GCalHelper @Inject constructor(
private fun createStartAndEndDate(task: Task, values: ContentValues) {
val dueDate = task.dueDate
val tzCorrectedDueDate = dueDate + TimeZone.getDefault().getOffset(dueDate)
val tzCorrectedDueDateNow = currentTimeMillis() + TimeZone.getDefault().getOffset(
currentTimeMillis()
)
// FIXME: doesn't respect timezones, see story 17443653
if (task.hasDueDate()) {
if (task.hasDueTime()) {
var estimatedTime = task.estimatedSeconds * 1000.toLong()
@ -148,19 +152,24 @@ class GCalHelper @Inject constructor(
values.put(CalendarContract.Events.DTSTART, dueDate - estimatedTime)
values.put(CalendarContract.Events.DTEND, dueDate)
}
// setting a duetime to a previously timeless event requires explicitly setting allDay=0
values.put(CalendarContract.Events.ALL_DAY, "0")
values.put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
} else {
val utcMidnight = DateTime(dueDate).toUTC().startOfDay()
values.put(CalendarContract.Events.DTSTART, utcMidnight.millis)
values.put(CalendarContract.Events.DTEND, utcMidnight.plusDays(1).millis)
values.put(CalendarContract.Events.DTSTART, tzCorrectedDueDate)
values.put(CalendarContract.Events.DTEND, tzCorrectedDueDate)
values.put(CalendarContract.Events.ALL_DAY, "1")
values.put(CalendarContract.Events.EVENT_TIMEZONE, Time.TIMEZONE_UTC)
}
} else {
Timber.w("Not creating calendar event, task has no due date: %s", task)
values.put(CalendarContract.Events.DTSTART, tzCorrectedDueDateNow)
values.put(CalendarContract.Events.DTEND, tzCorrectedDueDateNow)
values.put(CalendarContract.Events.ALL_DAY, "1")
}
if ("1" == values[CalendarContract.Events.ALL_DAY]) {
values.put(CalendarContract.Events.EVENT_TIMEZONE, Time.TIMEZONE_UTC)
} else {
values.put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
}
}
companion object {

@ -7,7 +7,7 @@ package com.todoroo.astrid.gtasks
import com.google.api.services.tasks.model.TaskList
import com.todoroo.astrid.service.TaskDeleter
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar
@ -16,7 +16,7 @@ import javax.inject.Inject
class GtasksListService @Inject constructor(
private val caldavDao: CaldavDao,
private val taskDeleter: TaskDeleter,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
) {
/**
@ -55,6 +55,6 @@ class GtasksListService @Inject constructor(
for (listId in previousLists) {
taskDeleter.delete(caldavDao.getCalendarById(listId)!!)
}
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
}

@ -7,7 +7,6 @@ import com.google.api.services.tasks.model.Task
import com.google.api.services.tasks.model.TaskList
import com.google.api.services.tasks.model.TaskLists
import org.tasks.googleapis.BaseInvoker
import timber.log.Timber
import java.io.IOException
/**
@ -44,30 +43,21 @@ class GtasksInvoker(
@Throws(IOException::class)
suspend fun getAllPositions(
listId: String?,
pageToken: String?,
): com.google.api.services.tasks.model.Tasks? =
execute(
service!!
.tasks()
.list(listId)
.setMaxResults(100)
.setShowDeleted(false)
.setShowHidden(false)
.setPageToken(pageToken)
.setFields("items(id,parent,position),nextPageToken")
)
listId: String?, pageToken: String?): com.google.api.services.tasks.model.Tasks? =
execute(
service!!
.tasks()
.list(listId)
.setMaxResults(100)
.setShowDeleted(false)
.setShowHidden(false)
.setPageToken(pageToken)
.setFields("items(id,parent,position),nextPageToken"))
@Throws(IOException::class)
suspend fun createGtask(
listId: String?,
task: Task?,
parent: String?,
previous: String?,
): Task? {
Timber.d("createGtask(listId=$listId, task=<redacted>, parent=$parent, previous=$previous)")
return execute(service!!.tasks().insert(listId, task).setParent(parent).setPrevious(previous))
}
listId: String?, task: Task?, parent: String?, previous: String?): Task? =
execute(service!!.tasks().insert(listId, task).setParent(parent).setPrevious(previous))
@Throws(IOException::class)
suspend fun updateGtask(listId: String?, task: Task) =
@ -75,26 +65,19 @@ class GtasksInvoker(
@Throws(IOException::class)
suspend fun moveGtask(
listId: String?,
taskId: String?,
parentId: String?,
previousId: String?,
): Task? {
Timber.d("moveGtask(listId=$listId, taskId=$taskId, parentId=$parentId, previousId=$previousId)")
return execute(
service!!
.tasks()
.move(listId, taskId)
.setParent(parentId)
.setPrevious(previousId)
)
}
listId: String?, taskId: String?, parentId: String?, previousId: String?): Task? =
execute(
service!!
.tasks()
.move(listId, taskId)
.setParent(parentId)
.setPrevious(previousId))
@Throws(IOException::class)
suspend fun deleteGtaskList(listId: String?) {
try {
execute(service!!.tasklists().delete(listId))
} catch (_: HttpNotFoundException) {
} catch (ignored: HttpNotFoundException) {
}
}
@ -108,10 +91,9 @@ class GtasksInvoker(
@Throws(IOException::class)
suspend fun deleteGtask(listId: String?, taskId: String?) {
Timber.d("deleteGtask(listId=$listId, taskId=$taskId)")
try {
execute(service!!.tasks().delete(listId, taskId))
} catch (_: HttpNotFoundException) {
} catch (ignored: HttpNotFoundException) {
}
}
}

@ -7,7 +7,7 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.caldav.VtodoCache
import org.tasks.data.dao.DeletionDao
import org.tasks.data.dao.LocationDao
@ -28,7 +28,7 @@ class TaskDeleter @Inject constructor(
@ApplicationContext private val context: Context,
private val deletionDao: DeletionDao,
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val syncAdapters: SyncAdapters,
private val vtodoCache: VtodoCache,
private val notificationManager: NotificationManager,
@ -50,7 +50,7 @@ class TaskDeleter @Inject constructor(
cleanup = { cleanup(it) }
)
syncAdapters.sync()
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
taskDao.fetch(ids)
}
@ -63,7 +63,7 @@ class TaskDeleter @Inject constructor(
ids = tasks,
cleanup = { cleanup(it) }
)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
suspend fun delete(list: CaldavCalendar) {
@ -72,7 +72,7 @@ class TaskDeleter @Inject constructor(
caldavCalendar = list,
cleanup = { cleanup(it) }
)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
suspend fun delete(account: CaldavAccount) {
@ -81,7 +81,7 @@ class TaskDeleter @Inject constructor(
caldavAccount = account,
cleanup = { cleanup(it) }
)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
private suspend fun cleanup(tasks: List<Long>) {

@ -2,7 +2,7 @@ package com.todoroo.astrid.service
import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.gcal.GCalHelper
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.data.dao.AlarmDao
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.GoogleTaskDao
@ -24,7 +24,7 @@ import javax.inject.Inject
class TaskDuplicator @Inject constructor(
private val gcalHelper: GCalHelper,
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val tagDao: TagDao,
private val tagDataDao: TagDataDao,
private val googleTaskDao: GoogleTaskDao,
@ -44,7 +44,7 @@ class TaskDuplicator @Inject constructor(
.let { taskDao.fetch(it) }
.filterNot { it.readOnly }
.map { clone(it, it.parent) }
.also { refreshBroadcaster.broadcastRefresh() }
.also { localBroadcastManager.broadcastRefresh() }
}
private suspend fun clone(task: Task, parentId: Long): Task {

@ -1,6 +1,6 @@
package com.todoroo.astrid.service
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.caldav.VtodoCache
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.GoogleTaskDao
@ -22,7 +22,7 @@ class TaskMover @Inject constructor(
private val caldavDao: CaldavDao,
private val googleTaskDao: GoogleTaskDao,
private val preferences: Preferences,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val syncAdapters: SyncAdapters,
private val vtodoCache: VtodoCache,
) {
@ -63,7 +63,7 @@ class TaskMover @Inject constructor(
taskIds.dbchunk().forEach {
taskDao.touch(it)
}
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
syncAdapters.sync()
}

@ -7,14 +7,13 @@ import android.content.IntentFilter
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.todoroo.astrid.api.AstridApiConstants
import dagger.hilt.android.qualifiers.ApplicationContext
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.widget.AppWidgetManager
import javax.inject.Inject
class LocalBroadcastManager @Inject constructor(
@ApplicationContext context: Context,
private val appWidgetManager: AppWidgetManager,
): RefreshBroadcaster {
) {
private val localBroadcastManager = LocalBroadcastManager.getInstance(context)
fun registerRefreshReceiver(broadcastReceiver: BroadcastReceiver?) {
@ -24,6 +23,7 @@ class LocalBroadcastManager @Inject constructor(
fun registerRefreshListReceiver(broadcastReceiver: BroadcastReceiver?) {
val intentFilter = IntentFilter()
intentFilter.addAction(REFRESH)
intentFilter.addAction(REFRESH_LIST)
localBroadcastManager.registerReceiver(broadcastReceiver!!, intentFilter)
}
@ -42,11 +42,15 @@ class LocalBroadcastManager @Inject constructor(
)
}
override fun broadcastRefresh() {
fun broadcastRefresh() {
localBroadcastManager.sendBroadcast(Intent(REFRESH))
appWidgetManager.updateWidgets()
}
fun broadcastRefreshList() {
localBroadcastManager.sendBroadcast(Intent(REFRESH_LIST))
}
fun broadcastPreferenceRefresh() {
localBroadcastManager.sendBroadcast(Intent(REFRESH_PREFERENCES))
}
@ -76,6 +80,7 @@ class LocalBroadcastManager @Inject constructor(
companion object {
const val REFRESH = "${BuildConfig.APPLICATION_ID}.REFRESH"
const val REFRESH_LIST = "${BuildConfig.APPLICATION_ID}.REFRESH_LIST"
private const val TASK_COMPLETED = "${BuildConfig.APPLICATION_ID}.REPEAT"
private const val REFRESH_PURCHASES = "${BuildConfig.APPLICATION_ID}.REFRESH_PURCHASES"
private const val REFRESH_PREFERENCES = "${BuildConfig.APPLICATION_ID}.REFRESH_PREFERENCES"

@ -6,7 +6,6 @@ import android.app.ApplicationExitInfo
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi

@ -28,7 +28,7 @@ import com.todoroo.astrid.api.TextInputCriterion
import com.todoroo.astrid.core.CriterionInstance
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.Strings
import org.tasks.compose.DeleteButton
@ -56,7 +56,7 @@ import javax.inject.Inject
class FilterSettingsActivity : BaseListSettingsActivity() {
@Inject lateinit var filterDao: FilterDao
@Inject lateinit var filterCriteriaProvider: FilterCriteriaProvider
@Inject lateinit var refreshBroadcaster: RefreshBroadcaster
@Inject lateinit var localBroadcastManager: LocalBroadcastManager
private val viewModel: FilterSettingsViewModel by viewModels()
@ -128,7 +128,7 @@ class FilterSettingsActivity : BaseListSettingsActivity() {
} else {
filterDao.update(f)
}
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
setResult(
Activity.RESULT_OK,
Intent(TaskListFragment.ACTION_RELOAD)

@ -15,7 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity.Companion.EXTRA_CALDAV_ACCOUNT
@ -35,7 +35,7 @@ import javax.inject.Inject
class GoogleTaskListSettingsActivity : BaseListSettingsActivity() {
@Inject lateinit var caldavDao: CaldavDao
@Inject lateinit var taskDeleter: TaskDeleter
@Inject lateinit var refreshBroadcaster: RefreshBroadcaster
@Inject lateinit var localBroadcastManager: LocalBroadcastManager
private val account: CaldavAccount
get() = intent.getParcelableExtra(EXTRA_CALDAV_ACCOUNT)!!
@ -122,7 +122,7 @@ class GoogleTaskListSettingsActivity : BaseListSettingsActivity() {
icon = baseViewModel.icon
)
)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
setResult(
Activity.RESULT_OK,
Intent(TaskListFragment.ACTION_RELOAD)

@ -140,7 +140,7 @@ class NavigationDrawerCustomization : ThemedInjectingAppCompatActivity(), Toolba
private inner class RefreshReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
val action = intent?.action
if (LocalBroadcastManager.REFRESH == action) {
if (LocalBroadcastManager.REFRESH == action || LocalBroadcastManager.REFRESH_LIST == action) {
updateFilters()
}
}

@ -27,7 +27,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import com.todoroo.astrid.activity.MainActivity
import com.todoroo.astrid.activity.TaskListFragment
import dagger.hilt.android.AndroidEntryPoint
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.compose.Constants
@ -60,7 +60,7 @@ class PlaceSettingsActivity : BaseListSettingsActivity(),
@Inject lateinit var map: MapFragment
@Inject lateinit var preferences: Preferences
@Inject lateinit var locale: Locale
@Inject lateinit var refreshBroadcaster: RefreshBroadcaster
@Inject lateinit var localBroadcastManager: LocalBroadcastManager
private lateinit var place: Place
override val defaultIcon = TasksIcons.PLACE
@ -172,7 +172,7 @@ class PlaceSettingsActivity : BaseListSettingsActivity(),
radius = sliderPos.floatValue.roundToInt(),
)
locationDao.update(place)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
setResult(
Activity.RESULT_OK,
Intent(TaskListFragment.ACTION_RELOAD)
@ -190,7 +190,7 @@ class PlaceSettingsActivity : BaseListSettingsActivity(),
locationDao.deleteGeofencesByPlace(place.uid!!)
locationDao.delete(place)
setResult(Activity.RESULT_OK, Intent(TaskListFragment.ACTION_DELETED))
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
finish()
}

@ -12,7 +12,7 @@ import androidx.activity.compose.setContent
import com.todoroo.astrid.activity.MainActivity
import com.todoroo.astrid.activity.TaskListFragment
import dagger.hilt.android.AndroidEntryPoint
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.data.dao.TagDao
@ -28,7 +28,7 @@ import javax.inject.Inject
class TagSettingsActivity : BaseListSettingsActivity() {
@Inject lateinit var tagDataDao: TagDataDao
@Inject lateinit var tagDao: TagDao
@Inject lateinit var refreshBroadcaster: RefreshBroadcaster
@Inject lateinit var localBroadcastManager: LocalBroadcastManager
private lateinit var tagData: TagData
private val isNewTag: Boolean
@ -88,7 +88,7 @@ class TagSettingsActivity : BaseListSettingsActivity() {
)
.let { it.copy(id = tagDataDao.insert(it)) }
.let {
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
setResult(
Activity.RESULT_OK,
Intent().putExtra(MainActivity.OPEN_FILTER, TagFilter(it))
@ -104,7 +104,7 @@ class TagSettingsActivity : BaseListSettingsActivity() {
.let {
tagDataDao.update(it)
tagDao.rename(it.remoteId!!, newName)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
setResult(
Activity.RESULT_OK,
Intent(TaskListFragment.ACTION_RELOAD)

@ -19,7 +19,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.caldav.VtodoCache
import org.tasks.data.GoogleTaskAccount
@ -65,7 +65,7 @@ class TasksJsonImporter @Inject constructor(
private val userActivityDao: UserActivityDao,
private val taskDao: TaskDao,
private val locationDao: LocationDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val alarmDao: AlarmDao,
private val tagDao: TagDao,
private val filterDao: FilterDao,
@ -110,7 +110,7 @@ class TasksJsonImporter @Inject constructor(
} catch (e: IOException) {
Timber.e(e)
}
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
result
}

@ -15,6 +15,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
@ -34,6 +35,7 @@ import org.tasks.data.entity.CaldavAccount.Companion.SERVER_TASKS
import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_OWNER
import org.tasks.themes.TasksTheme
import org.tasks.themes.colorOn
import javax.inject.Inject
@AndroidEntryPoint
@ -70,7 +72,7 @@ class CaldavCalendarSettingsActivity : BaseCaldavCalendarSettingsActivity() {
val openDialog = rememberSaveable { mutableStateOf(false) }
ShareInviteDialog(
openDialog,
email = caldavAccount.serverType !in listOf(SERVER_OWNCLOUD, SERVER_NEXTCLOUD),
email = caldavAccount.serverType != SERVER_OWNCLOUD
) { input ->
lifecycleScope.launch {
share(input)

@ -12,8 +12,6 @@ import org.tasks.data.UUIDHelper
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.PrincipalDao
import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_NEXTCLOUD
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OWNCLOUD
import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_READ_WRITE
import org.tasks.data.entity.CaldavCalendar.Companion.INVITE_UNKNOWN
@ -98,10 +96,10 @@ class CaldavCalendarViewModel @Inject constructor(
list: CaldavCalendar,
input: String
) = doRequest {
val href = when (account.serverType) {
SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> "principal:principals/users/$input"
else -> "mailto:$input"
}
val href = if (account.serverType == CaldavAccount.SERVER_OWNCLOUD)
"principal:principals/users/$input"
else
"mailto:$input"
withContext(Dispatchers.IO) {
provider.forAccount(account, list.url!!).share(account, href)
}

@ -225,8 +225,8 @@ open class CaldavClient(
href: String,
) {
when (account.serverType) {
SERVER_TASKS, SERVER_SABREDAV -> shareSabredav(href)
SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> shareOwncloud(href)
SERVER_TASKS, SERVER_SABREDAV, SERVER_NEXTCLOUD -> shareSabredav(href)
SERVER_OWNCLOUD -> shareOwncloud(href)
else -> throw IllegalArgumentException()
}
}
@ -265,8 +265,8 @@ open class CaldavClient(
href: String,
) {
when (account.serverType) {
SERVER_TASKS, SERVER_SABREDAV -> removeSabrePrincipal(calendar, href)
SERVER_OWNCLOUD, SERVER_NEXTCLOUD -> removeOwncloudPrincipal(calendar, href)
SERVER_TASKS, SERVER_SABREDAV, SERVER_NEXTCLOUD -> removeSabrePrincipal(calendar, href)
SERVER_OWNCLOUD -> removeOwncloudPrincipal(calendar, href)
else -> throw IllegalArgumentException()
}
}

@ -32,11 +32,11 @@ import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import org.tasks.BuildConfig
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.analytics.Firebase
import org.tasks.billing.Inventory
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.caldav.iCalendar.Companion.fromVtodo
import org.tasks.caldav.property.CalendarIcon
import org.tasks.caldav.property.Invite
@ -56,7 +56,6 @@ import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.PrincipalDao
import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavAccount.Companion.ERROR_UNAUTHORIZED
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_NEXTCLOUD
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OPEN_XCHANGE
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_OWNCLOUD
import org.tasks.data.entity.CaldavAccount.Companion.SERVER_SABREDAV
@ -85,7 +84,7 @@ class CaldavSynchronizer @Inject constructor(
@param:ApplicationContext private val context: Context,
private val caldavDao: CaldavDao,
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val taskDeleter: TaskDeleter,
private val inventory: Inventory,
private val firebase: Firebase,
@ -189,7 +188,7 @@ class CaldavSynchronizer @Inject constructor(
icon = icon ?: calendar.icon,
)
caldavDao.update(calendar)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
resource
.principals(account, calendar)
@ -204,11 +203,7 @@ class CaldavSynchronizer @Inject constructor(
private fun getServerType(account: CaldavAccount, headers: Headers) = when {
account.isTasksOrg -> SERVER_TASKS
headers["DAV"]?.contains("oc-resource-sharing") == true ->
if (headers["DAV"]?.let { it.contains("nextcloud-") || it.contains("nc-") } == true)
SERVER_NEXTCLOUD
else
SERVER_OWNCLOUD
headers["DAV"]?.contains("oc-resource-sharing") == true -> SERVER_OWNCLOUD
headers["x-sabre-version"]?.isNotBlank() == true -> SERVER_SABREDAV
headers["server"] == "Openexchange WebDAV" -> SERVER_OPEN_XCHANGE
else -> SERVER_UNKNOWN
@ -225,7 +220,7 @@ class CaldavSynchronizer @Inject constructor(
}
account.error = message
caldavDao.update(account)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
if (!isNullOrEmpty(message)) {
Timber.e(message)
}
@ -302,7 +297,7 @@ class CaldavSynchronizer @Inject constructor(
caldavDao.update(caldavCalendar)
Timber.d("Updating parents for ${caldavCalendar.uuid}")
caldavDao.updateParents(caldavCalendar.uuid!!)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
private suspend fun pushLocalChanges(
@ -330,17 +325,9 @@ class CaldavSynchronizer @Inject constructor(
caldavTask: CaldavTask
): Boolean {
try {
val objectId = caldavTask.obj
?: run {
Timber.e("null obj for caldavTask.id=${caldavTask.id} task.id=${caldavTask.task}")
caldavTask.obj = caldavTask.remoteId?.let { "$it.ics" }
caldavTask.obj
}
if (objectId?.isNotBlank() == true) {
if (!isNullOrEmpty(caldavTask.obj)) {
val remote = DavResource(
httpClient = httpClient,
location = httpUrl.newBuilder().addPathSegment(objectId).build(),
)
httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.obj!!).build())
remote.delete(null) {}
}
} catch (e: HttpException) {
@ -364,8 +351,8 @@ class CaldavSynchronizer @Inject constructor(
httpClient: OkHttpClient,
httpUrl: HttpUrl
) {
Timber.d("pushing %s", task)
val caldavTask = caldavDao.getTask(task.id) ?: return
Timber.d("pushing caldavTask=$caldavTask task=$task")
if (task.isDeleted) {
if (deleteRemoteResource(httpClient, httpUrl, calendar, caldavTask)) {
taskDeleter.delete(task)
@ -374,19 +361,9 @@ class CaldavSynchronizer @Inject constructor(
}
val data = iCal.toVtodo(account, calendar, caldavTask, task)
val requestBody = data.toRequestBody(contentType = MIME_ICALENDAR)
val objPath = caldavTask.obj
?: run {
Timber.e("null obj for caldavTask.id=${caldavTask.id} task.id=${task.id}")
caldavTask.obj = caldavTask.remoteId?.let { "$it.ics" }
caldavTask.obj
}
?: throw IllegalStateException("Push failed - missing UUID")
try {
val remote = DavResource(
httpClient = httpClient,
location = httpUrl.newBuilder().addPathSegment(objPath).build(),
)
httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.obj!!).build())
remote.put(requestBody) {
if (it.isSuccessful) {
fromResponse(it)?.eTag?.takeIf(String::isNotBlank)?.let { etag ->

@ -1,13 +1,16 @@
package org.tasks.caldav
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import javax.inject.Inject
class FileStorage(
rootPath: String
class FileStorage @Inject constructor(
@ApplicationContext context: Context
) {
val root = File(rootPath, "vtodo")
val root = File(context.filesDir, "vtodo")
@Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
fun getFile(vararg segments: String?): File? =

@ -1,15 +1,18 @@
package org.tasks.caldav
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.entity.CaldavTask
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
class VtodoCache(
@Singleton
class VtodoCache @Inject constructor(
private val caldavDao: CaldavDao,
private val fileStorage: FileStorage,
) {
@ -27,7 +30,7 @@ class VtodoCache(
?: return@withContext
source.copyTo(target, overwrite = true)
val deleted = source.delete()
Logger.d("VtodoCache") { "Moved $source to $target [success=${deleted}]" }
Timber.d("Moved $source to $target [success=${deleted}]")
}
suspend fun getVtodo(caldavTask: CaldavTask?): String? {
@ -66,28 +69,28 @@ class VtodoCache(
suspend fun delete(calendar: CaldavCalendar, caldavTask: CaldavTask) = withContext(Dispatchers.IO) {
fileStorage.getFile(calendar.account, caldavTask.calendar, caldavTask.obj)?.let {
val deleted = it.delete()
Logger.d("VtodoCache") { "Deleting $it [success=$deleted]" }
Timber.d("Deleting $it [success=$deleted]")
}
}
suspend fun delete(calendar: CaldavCalendar) = withContext(Dispatchers.IO) {
fileStorage.getFile(calendar.account, calendar.uuid)?.let {
val deleted = it.deleteRecursively()
Logger.d("VtodoCache") { "Deleting $it [success=$deleted]" }
Timber.d("Deleting $it [success=$deleted]")
}
}
suspend fun delete(account: CaldavAccount) = withContext(Dispatchers.IO) {
fileStorage.getFile(account.uuid)?.let {
val deleted = it.deleteRecursively()
Logger.d("VtodoCache") { "Deleting $it [success=$deleted]" }
Timber.d("Deleting $it [success=$deleted]")
}
}
suspend fun clear() = withContext(Dispatchers.IO) {
fileStorage.getFile()?.let {
val deleted = it.deleteRecursively()
Logger.d("VtodoCache") { "Deleting $it [success=$deleted]" }
Timber.d("Deleting $it [success=$deleted]")
}
}
}

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

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

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

@ -20,8 +20,6 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.material3.DrawerState
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -123,194 +121,173 @@ fun HomeScreen(
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Box(modifier = Modifier.fillMaxSize()) {
TaskListDrawer(
arrangement = if (state.menuQuery.isBlank()) Arrangement.Top else Arrangement.Bottom,
filters = if (state.menuQuery.isNotEmpty()) state.searchItems else state.drawerItems,
onClick = {
when (it) {
is DrawerItem.Filter -> {
viewModel.setFilter(it.filter)
scope.launch {
drawerState.close()
keyboard?.hide()
}
TaskListDrawer(
arrangement = if (state.menuQuery.isBlank()) Arrangement.Top else Arrangement.Bottom,
filters = if (state.menuQuery.isNotEmpty()) state.searchItems else state.drawerItems,
onClick = {
when (it) {
is DrawerItem.Filter -> {
viewModel.setFilter(it.filter)
scope.launch {
drawerState.close()
keyboard?.hide()
}
}
is DrawerItem.Header -> {
viewModel.toggleCollapsed(it.header)
}
is DrawerItem.Header -> {
viewModel.toggleCollapsed(it.header)
}
},
onAddClick = {
scope.launch {
drawerState.close()
when (it.header.addIntentRc) {
FilterProvider.REQUEST_NEW_FILTER ->
showNewFilterDialog()
}
},
onAddClick = {
scope.launch {
drawerState.close()
when (it.header.addIntentRc) {
FilterProvider.REQUEST_NEW_FILTER ->
showNewFilterDialog()
REQUEST_NEW_PLACE ->
newList.launch(Intent(context, LocationPickerActivity::class.java))
REQUEST_NEW_PLACE ->
newList.launch(Intent(context, LocationPickerActivity::class.java))
REQUEST_NEW_TAGS ->
newList.launch(Intent(context, TagSettingsActivity::class.java))
REQUEST_NEW_TAGS ->
newList.launch(Intent(context, TagSettingsActivity::class.java))
REQUEST_NEW_LIST ->
when (it.header.subheaderType) {
NavigationDrawerSubheader.SubheaderType.CALDAV,
NavigationDrawerSubheader.SubheaderType.TASKS ->
viewModel
.getAccount(it.header.id.toLong())
?.let {
newList.launch(
Intent(context, it.listSettingsClass())
.putExtra(EXTRA_CALDAV_ACCOUNT, it)
)
}
REQUEST_NEW_LIST ->
when (it.header.subheaderType) {
NavigationDrawerSubheader.SubheaderType.CALDAV,
NavigationDrawerSubheader.SubheaderType.TASKS ->
viewModel
.getAccount(it.header.id.toLong())
?.let {
newList.launch(
Intent(context, it.listSettingsClass())
.putExtra(EXTRA_CALDAV_ACCOUNT, it)
)
}
else -> {}
}
else -> {}
}
else -> Timber.e("Unhandled request code: $it")
}
else -> Timber.e("Unhandled request code: $it")
}
},
onErrorClick = {
context.startActivity(Intent(context, MainPreferences::class.java))
},
searchBar = {
MenuSearchBar(
begForMoney = state.begForMoney,
onDrawerAction = {
scope.launch {
drawerState.close()
when (it) {
DrawerAction.PURCHASE ->
if (TasksApplication.IS_GENERIC)
context.openUri(R.string.url_donate)
else
context.startActivity(
Intent(
context,
PurchaseActivity::class.java
)
)
DrawerAction.HELP_AND_FEEDBACK ->
}
},
onErrorClick = {
context.startActivity(Intent(context, MainPreferences::class.java))
},
searchBar = {
MenuSearchBar(
begForMoney = state.begForMoney,
onDrawerAction = {
scope.launch {
drawerState.close()
when (it) {
DrawerAction.PURCHASE ->
if (TasksApplication.IS_GENERIC)
context.openUri(R.string.url_donate)
else
context.startActivity(
Intent(
context,
HelpAndFeedback::class.java
PurchaseActivity::class.java
)
)
}
}
},
query = state.menuQuery,
onQueryChange = { viewModel.queryMenu(it) },
)
},
)
SystemBarScrim(
modifier = Modifier
.windowInsetsTopHeight(WindowInsets.systemBars)
.align(Alignment.TopCenter)
)
SystemBarScrim(
modifier = Modifier
.windowInsetsBottomHeight(WindowInsets.systemBars)
.align(Alignment.BottomCenter),
)
}
DrawerAction.HELP_AND_FEEDBACK ->
context.startActivity(
Intent(
context,
HelpAndFeedback::class.java
)
)
}
}
},
query = state.menuQuery,
onQueryChange = { viewModel.queryMenu(it) },
)
},
)
}
}
) {
Box(modifier = Modifier.fillMaxSize()) {
val scope = rememberCoroutineScope()
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
key (state.filter) {
val fragment = remember { mutableStateOf<TaskListFragment?>(null) }
val keyboardOpen = rememberImeState()
AndroidFragment<TaskListFragment>(
fragmentState = rememberFragmentState(),
arguments = remember(state.filter) {
Bundle()
.apply { putParcelable(EXTRA_FILTER, state.filter) }
},
modifier = Modifier
.fillMaxSize()
.imePadding(),
) { tlf ->
fragment.value = tlf
tlf.applyInsets(windowInsets.value)
tlf.setNavigationClickListener {
scope.launch { drawerState.open() }
}
}
LaunchedEffect(fragment, windowInsets, keyboardOpen.value) {
fragment.value?.applyInsets(
if (keyboardOpen.value) {
PaddingValues(
top = windowInsets.value.calculateTopPadding(),
)
} else {
windowInsets.value
}
)
}
}
},
detailPane = {
val direction = LocalLayoutDirection.current
Box(
val scope = rememberCoroutineScope()
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
key (state.filter) {
val fragment = remember { mutableStateOf<TaskListFragment?>(null) }
val keyboardOpen = rememberImeState()
AndroidFragment<TaskListFragment>(
fragmentState = rememberFragmentState(),
arguments = remember(state.filter) {
Bundle()
.apply { putParcelable(EXTRA_FILTER, state.filter) }
},
modifier = Modifier
.fillMaxSize()
.padding(
top = windowInsets.value.calculateTopPadding(),
start = windowInsets.value.calculateStartPadding(direction),
end = windowInsets.value.calculateEndPadding(direction),
bottom = if (rememberImeState().value)
WindowInsets.ime.asPaddingValues().calculateBottomPadding()
else
windowInsets.value.calculateBottomPadding()
),
contentAlignment = Alignment.Center,
) {
if (state.task == null) {
if (isListVisible && isDetailVisible) {
Icon(
painter = painterResource(org.tasks.kmp.R.drawable.ic_launcher_no_shadow_foreground),
contentDescription = null,
modifier = Modifier.size(192.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
key(state.task) {
AndroidFragment<TaskEditFragment>(
fragmentState = rememberFragmentState(),
arguments = remember(state.task) {
Bundle()
.apply { putParcelable(EXTRA_TASK, state.task) }
},
modifier = Modifier.fillMaxSize(),
.imePadding(),
) { tlf ->
fragment.value = tlf
tlf.applyInsets(windowInsets.value)
tlf.setNavigationClickListener {
scope.launch { drawerState.open() }
}
}
LaunchedEffect(fragment, windowInsets, keyboardOpen.value) {
fragment.value?.applyInsets(
if (keyboardOpen.value) {
PaddingValues(
top = windowInsets.value.calculateTopPadding(),
)
} else {
windowInsets.value
}
)
}
}
},
detailPane = {
val direction = LocalLayoutDirection.current
Box(
modifier = Modifier
.fillMaxSize()
.padding(
top = windowInsets.value.calculateTopPadding(),
start = windowInsets.value.calculateStartPadding(direction),
end = windowInsets.value.calculateEndPadding(direction),
bottom = if (rememberImeState().value)
WindowInsets.ime.asPaddingValues().calculateBottomPadding()
else
windowInsets.value.calculateBottomPadding()
),
contentAlignment = Alignment.Center,
) {
if (state.task == null) {
if (isListVisible && isDetailVisible) {
Icon(
painter = painterResource(org.tasks.kmp.R.drawable.ic_launcher_no_shadow_foreground),
contentDescription = null,
modifier = Modifier.size(192.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
key(state.task) {
AndroidFragment<TaskEditFragment>(
fragmentState = rememberFragmentState(),
arguments = remember(state.task) {
Bundle()
.apply { putParcelable(EXTRA_TASK, state.task) }
},
modifier = Modifier.fillMaxSize(),
)
}
}
},
)
SystemBarScrim(
modifier = Modifier
.windowInsetsTopHeight(WindowInsets.systemBars)
.align(Alignment.TopCenter),
)
}
}
},
)
}
}
}

@ -1,22 +0,0 @@
package org.tasks.compose.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@Composable
fun SystemBarScrim(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.background,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(color.copy(alpha = 0.8f))
.then(modifier)
)
}

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

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

@ -3,14 +3,15 @@ package org.tasks.data
import android.content.ContentUris
import android.database.Cursor
import android.net.Uri
import at.bitfire.ical4android.AndroidTask
import at.bitfire.ical4android.BatchOperation
import at.bitfire.ical4android.BatchOperation.CpoBuilder.Companion.newInsert
import at.bitfire.ical4android.BatchOperation.CpoBuilder.Companion.newUpdate
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.ICalendar
import at.bitfire.ical4android.Ical4Android
import at.bitfire.ical4android.Task
import at.bitfire.ical4android.UnknownProperty
import at.bitfire.ical4android.util.MiscUtils.toValues
import at.bitfire.ical4android.util.MiscUtils.CursorHelper.toValues
import net.fortuna.ical4j.model.Parameter
import net.fortuna.ical4j.model.parameter.RelType
import net.fortuna.ical4j.model.parameter.Related
@ -20,7 +21,7 @@ import org.tasks.data.OpenTaskDao.Companion.getLong
import java.util.Locale
import java.util.logging.Level
class MyAndroidTask() : DmfsTask(null) {
class MyAndroidTask() : AndroidTask(null) {
constructor(cursor: Cursor) : this() {
val values = cursor.toValues()
@ -98,7 +99,7 @@ class MyAndroidTask() : DmfsTask(null) {
.withValue(TaskContract.Property.Alarm.MESSAGE, alarm.description?.value ?: alarm.summary)
.withValue(TaskContract.Property.Alarm.ALARM_TYPE, alarmType)
logger.log(Level.FINE, "Inserting alarm", builder.build())
Ical4Android.log.log(Level.FINE, "Inserting alarm", builder.build())
batch.add(builder)
}
}
@ -109,7 +110,7 @@ class MyAndroidTask() : DmfsTask(null) {
.withTaskId(TaskContract.Property.Category.TASK_ID, idxTask)
.withValue(TaskContract.Property.Category.MIMETYPE, TaskContract.Property.Category.CONTENT_ITEM_TYPE)
.withValue(TaskContract.Property.Category.CATEGORY_NAME, category)
logger.log(Level.FINE, "Inserting category", builder.build())
Ical4Android.log.log(Level.FINE, "Inserting category", builder.build())
batch.add(builder)
}
}
@ -129,7 +130,7 @@ class MyAndroidTask() : DmfsTask(null) {
.withValue(TaskContract.Property.Relation.MIMETYPE, TaskContract.Property.Relation.CONTENT_ITEM_TYPE)
.withValue(TaskContract.Property.Relation.RELATED_UID, relatedTo.value)
.withValue(TaskContract.Property.Relation.RELATED_TYPE, relType)
logger.log(Level.FINE, "Inserting relation", builder.build())
Ical4Android.log.log(Level.FINE, "Inserting relation", builder.build())
batch.add(builder)
}
}
@ -137,7 +138,7 @@ class MyAndroidTask() : DmfsTask(null) {
private fun insertUnknownProperties(batch: MutableList<BatchOperation.CpoBuilder>, idxTask: Int?, uri: Uri) {
for (property in requireNotNull(task).unknownProperties) {
if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) {
logger.warning("Ignoring unknown property with ${property.value.length} octets (too long)")
Ical4Android.log.warning("Ignoring unknown property with ${property.value.length} octets (too long)")
return
}
@ -145,8 +146,8 @@ class MyAndroidTask() : DmfsTask(null) {
.withTaskId(TaskContract.Properties.TASK_ID, idxTask)
.withValue(TaskContract.Properties.MIMETYPE, UnknownProperty.CONTENT_ITEM_TYPE)
.withValue(UNKNOWN_PROPERTY_DATA, UnknownProperty.toJsonString(property))
logger.log(Level.FINE, "Inserting unknown property", builder.build())
Ical4Android.log.log(Level.FINE, "Inserting unknown property", builder.build())
batch.add(builder)
}
}
}
}

@ -86,7 +86,7 @@ class FilterPickerViewModel @Inject constructor(
else -> throw IllegalStateException()
}
localBroadcastManager.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
fun getIcon(filter: Filter): String? = filter.getIcon(inventory)

@ -8,8 +8,8 @@ import com.etebase.client.Item
import com.etebase.client.ItemMetadata
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavTask
import org.tasks.time.DateTimeUtils2.currentTimeMillis
import timber.log.Timber
@ -64,14 +64,7 @@ class EtebaseClient(
suspend fun updateItem(collection: Collection, task: CaldavTask, content: ByteArray): Item {
val itemManager = etebase.collectionManager.getItemManager(collection)
val obj = task.obj
?: run {
Timber.e("null obj for caldavTask.id=${task.id}")
task.obj = task.remoteId
task.obj
}
?: throw IllegalStateException("Update failed - missing UUID")
val item = cache.itemGet(itemManager, collection.uid, obj)
val item = cache.itemGet(itemManager, collection.uid, task.obj!!)
?: itemManager
.create(ItemMetadata().apply { name = task.remoteId!! }, "")
.apply {
@ -85,14 +78,7 @@ class EtebaseClient(
suspend fun deleteItem(collection: Collection, task: CaldavTask): Item? {
val itemManager = etebase.collectionManager.getItemManager(collection)
val objId = task.obj
?: run {
Timber.e("null obj for caldavTask.id=${task.id}")
task.obj = task.remoteId
task.obj
}
?: return null
return cache.itemGet(itemManager, collection.uid, objId)
return cache.itemGet(itemManager, collection.uid, task.obj!!)
?.takeIf { !it.isDeleted }
?.apply {
meta = updateMtime(meta)

@ -14,7 +14,7 @@ import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.qualifiers.ApplicationContext
import net.fortuna.ical4j.model.property.ProdId
import org.tasks.BuildConfig
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.billing.Inventory
@ -32,7 +32,7 @@ import javax.inject.Inject
class EtebaseSynchronizer @Inject constructor(
@param:ApplicationContext private val context: Context,
private val caldavDao: CaldavDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val taskDeleter: TaskDeleter,
private val inventory: Inventory,
private val clientProvider: EtebaseClientProvider,
@ -98,7 +98,7 @@ class EtebaseSynchronizer @Inject constructor(
calendar.name = meta.name
calendar.color = color
caldavDao.update(calendar)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
fetchChanges(account, client, calendar, collection)
pushLocalChanges(account, client, calendar, collection)
@ -112,7 +112,7 @@ class EtebaseSynchronizer @Inject constructor(
private suspend fun setError(account: CaldavAccount, message: String?) {
account.error = message
caldavDao.update(account)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
if (!isNullOrEmpty(message)) {
Timber.e(message)
}
@ -137,7 +137,7 @@ class EtebaseSynchronizer @Inject constructor(
caldavDao.update(caldavCalendar)
Timber.d("Updating parents for ${caldavCalendar.uuid}")
caldavDao.updateParents(caldavCalendar.uuid!!)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
private suspend fun pushLocalChanges(

@ -15,12 +15,11 @@ import com.todoroo.astrid.service.TaskCreator
import com.todoroo.astrid.service.TaskCreator.Companion.getDefaultAlarms
import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.analytics.Firebase
import org.tasks.data.createDueDate
import org.tasks.data.*
import org.tasks.data.dao.AlarmDao
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.GoogleTaskDao
@ -39,7 +38,7 @@ import java.net.HttpRetryException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.Collections
import java.util.*
import javax.inject.Inject
import javax.net.ssl.SSLException
import kotlin.math.max
@ -56,7 +55,7 @@ class GoogleTaskSynchronizer @Inject constructor(
private val defaultFilterProvider: DefaultFilterProvider,
private val permissionChecker: PermissionChecker,
private val googleAccountManager: GoogleAccountManager,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val taskDeleter: TaskDeleter,
private val invokers: InvokerFactory,
private val alarmDao: AlarmDao,
@ -94,7 +93,7 @@ class GoogleTaskSynchronizer @Inject constructor(
firebase.reportException(e)
} finally {
caldavDao.update(account)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
Timber.d("%s: end sync", account)
}
}
@ -126,50 +125,37 @@ class GoogleTaskSynchronizer @Inject constructor(
preferences.setString(R.string.p_default_list, null)
}
}
val failedTasks = mutableSetOf<Long>()
var retryTaskId = pushLocalChanges(account, gtasksInvoker)
while (retryTaskId != null) {
if (failedTasks.contains(retryTaskId)) {
throw IOException("Invalid Task ID: $retryTaskId")
}
failedTasks.add(retryTaskId)
Timber.d("Retrying push local changes due to stale task ID $retryTaskId (${failedTasks.size} total failed tasks)")
delay(1000)
retryTaskId = pushLocalChanges(account, gtasksInvoker)
}
pushLocalChanges(account, gtasksInvoker)
for (list in caldavDao.getCalendarsByAccount(account.uuid!!)) {
if (isNullOrEmpty(list.uuid)) {
firebase.reportException(RuntimeException("Empty remote id"))
continue
}
fetchAndApplyRemoteChanges(gtasksInvoker, list)
gtasksInvoker.updatePositions(list.uuid!!)
if (!preferences.isPositionHackEnabled) {
googleTaskDao.reposition(caldavDao, list.uuid!!)
}
}
if (preferences.isPositionHackEnabled) {
for (list in gtaskLists) {
val tasks = fetchPositions(gtasksInvoker, list.id)
for (task in tasks) {
googleTaskDao.updatePosition(task.id, task.parent, task.position)
}
googleTaskDao.reposition(caldavDao, list.id)
}
}
// account.etag = eTag
account.error = ""
}
@Throws(IOException::class)
private suspend fun GtasksInvoker.updatePositions(list: String) {
// Unfortunately this is necessary because Google broke the API
// https://issuetracker.google.com/issues/132432317
Timber.d("updatePositions(list=${list})")
fetchPositions(list).forEach { task ->
googleTaskDao.updatePosition(task.id, task.parent, task.position)
}
googleTaskDao.reposition(caldavDao, list)
}
@Throws(IOException::class)
private suspend fun GtasksInvoker.fetchPositions(listId: String): List<Task> {
private suspend fun fetchPositions(
gtasksInvoker: GtasksInvoker, listId: String): List<Task> {
val tasks: MutableList<Task> = ArrayList()
var nextPageToken: String? = null
do {
val taskList = getAllPositions(listId, nextPageToken)
val taskList = gtasksInvoker.getAllPositions(listId, nextPageToken)
taskList?.items?.let {
tasks.addAll(it)
}
@ -179,20 +165,16 @@ class GoogleTaskSynchronizer @Inject constructor(
}
@Throws(IOException::class)
private suspend fun pushLocalChanges(account: CaldavAccount, gtasksInvoker: GtasksInvoker): Long? {
private suspend fun pushLocalChanges(account: CaldavAccount, gtasksInvoker: GtasksInvoker) {
val tasks = taskDao.getGoogleTasksToPush(account.uuid!!)
for (task in tasks) {
val staleTaskId = pushTask(task, account.uuid!!, gtasksInvoker)
if (staleTaskId != null) {
return staleTaskId
}
pushTask(task, gtasksInvoker)
}
return null
}
@Throws(IOException::class)
private suspend fun pushTask(task: org.tasks.data.entity.Task, account: String, gtasksInvoker: GtasksInvoker): Long? {
for (deleted in googleTaskDao.getDeletedByTaskId(task.id, account)) {
private suspend fun pushTask(task: org.tasks.data.entity.Task, gtasksInvoker: GtasksInvoker) {
for (deleted in googleTaskDao.getDeletedByTaskId(task.id)) {
deleted.remoteId?.let {
try {
gtasksInvoker.deleteGtask(deleted.calendar, it)
@ -205,7 +187,7 @@ class GoogleTaskSynchronizer @Inject constructor(
}
googleTaskDao.delete(deleted)
}
val gtasksMetadata = googleTaskDao.getByTaskId(task.id) ?: return null
val gtasksMetadata = googleTaskDao.getByTaskId(task.id) ?: return
val remoteModel = Task()
var newlyCreated = false
val remoteId: String?
@ -226,7 +208,7 @@ class GoogleTaskSynchronizer @Inject constructor(
// creating a task which may end up being cancelled. Also don't sync new but already
// deleted tasks
if (newlyCreated && (isNullOrEmpty(task.title) || task.deletionDate > 0)) {
return null
return
}
// Update the remote model's changed properties
@ -249,11 +231,10 @@ class GoogleTaskSynchronizer @Inject constructor(
val parent = task.parent
val localParent = if (parent > 0) googleTaskDao.getRemoteId(parent) else null
val previous = googleTaskDao.getPrevious(
listId, if (isNullOrEmpty(localParent)) 0 else parent, task.order ?: 0)
listId!!, if (isNullOrEmpty(localParent)) 0 else parent, task.order ?: 0)
val created: Task? = try {
gtasksInvoker.createGtask(listId, remoteModel, localParent, previous)
} catch (e: HttpNotFoundException) {
Timber.e(e, "Failed to create task, retry without parent or order")
gtasksInvoker.createGtask(listId, remoteModel, null, null)
}
if (created != null) {
@ -261,10 +242,8 @@ class GoogleTaskSynchronizer @Inject constructor(
gtasksMetadata.remoteId = created.id
gtasksMetadata.calendar = listId
setOrderAndParent(gtasksMetadata, created, task)
Timber.d("Created new task: $gtasksMetadata")
} else {
Timber.e("Empty response when creating task")
return null
return
}
} else {
try {
@ -273,64 +252,29 @@ class GoogleTaskSynchronizer @Inject constructor(
val parent = task.parent
val localParent = if (parent > 0) googleTaskDao.getRemoteId(parent) else null
val previous = googleTaskDao.getPrevious(
listId,
listId!!,
if (localParent.isNullOrBlank()) 0 else parent,
task.order ?: 0,
)
task.order ?: 0)
gtasksInvoker
.moveGtask(
listId = listId,
taskId = remoteModel.id,
parentId = localParent,
previousId = previous,
)
?.let {
setOrderAndParent(
googleTask = gtasksMetadata,
task = it,
local = task,
)
}
.moveGtask(listId, remoteModel.id, localParent, previous)
?.let { setOrderAndParent(gtasksMetadata, it, task) }
} catch (e: GoogleJsonResponseException) {
if (e.statusCode == 400) {
Timber.w("HTTP 400: clearing parent and order")
firebase.reportException(e)
taskDao.setParent(0L, listOf(task.id))
taskDao.setOrder(task.id, 0L)
googleTaskDao.update(gtasksMetadata.copy(isMoved = false))
return task.id
Timber.e(e)
} else {
throw e
}
}
}
// TODO: don't updateGtask if it was only moved
try {
gtasksInvoker.updateGtask(listId, remoteModel)
} catch (e: GoogleJsonResponseException) {
if (e.statusCode == 400 && e.details?.message == "Invalid task ID") {
Timber.w("HTTP 400: Invalid task ID for ${remoteModel.id}, clearing to recreate on next sync")
firebase.reportException(e)
googleTaskDao.update(
gtasksMetadata.copy(
remoteId = "",
isMoved = false,
)
)
return task.id
} else {
throw e
}
}
} catch (_: HttpNotFoundException) {
Timber.w("HTTP 404, deleting $gtasksMetadata")
gtasksInvoker.updateGtask(listId, remoteModel)
} catch (e: HttpNotFoundException) {
googleTaskDao.delete(gtasksMetadata)
return null
return
}
}
gtasksMetadata.isMoved = false
write(task, gtasksMetadata)
return null
}
@Throws(IOException::class)

@ -11,13 +11,10 @@ import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import org.tasks.LocalBroadcastManager
import org.tasks.analytics.Firebase
import org.tasks.billing.BillingClient
import org.tasks.billing.BillingClientImpl
import org.tasks.billing.Inventory
import org.tasks.caldav.FileStorage
import org.tasks.caldav.VtodoCache
import org.tasks.compose.drawer.DrawerConfiguration
import org.tasks.data.dao.AlarmDao
import org.tasks.data.dao.Astrid2ContentProviderDao
@ -40,13 +37,9 @@ import org.tasks.jobs.WorkManager
import org.tasks.kmp.createDataStore
import org.tasks.preferences.Preferences
import org.tasks.preferences.TasksPreferences
import org.tasks.security.AndroidKeyStoreEncryption
import org.tasks.security.KeyStoreEncryption
import java.util.Locale
import javax.inject.Singleton
import org.tasks.broadcast.RefreshBroadcaster
@Module
@InstallIn(SingletonComponent::class)
class ApplicationModule {
@ -174,22 +167,4 @@ class ApplicationModule {
taskDao = taskDao,
tasksPreferences = tasksPreferences,
)
@Provides
@Singleton
fun providesFileStorage(@ApplicationContext context: Context) =
FileStorage(context.filesDir.absolutePath)
@Provides
@Singleton
fun providesVtodoCache(caldavDao: CaldavDao, fileStorage: FileStorage) =
VtodoCache(caldavDao, fileStorage)
@Provides
@Singleton
fun providesKeyStoreEncryption(): KeyStoreEncryption = AndroidKeyStoreEncryption()
@Provides
fun providesBroadcastRefresh(localBroadcastManager: LocalBroadcastManager): RefreshBroadcaster =
localBroadcastManager
}

@ -5,7 +5,7 @@ import androidx.hilt.work.HiltWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.analytics.Firebase
import org.tasks.data.dao.TaskDao
import org.tasks.date.DateTimeUtils
@ -16,13 +16,13 @@ class RefreshWork @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
firebase: Firebase,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val workManager: WorkManager,
private val taskDao: TaskDao,
) : RepeatingWorker(context, workerParams, firebase) {
override suspend fun run(): Result {
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
return Result.success()
}

@ -6,7 +6,7 @@ import androidx.hilt.work.HiltWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.analytics.Firebase
import org.tasks.data.dao.LocationDao
import org.tasks.data.entity.Place
@ -20,7 +20,7 @@ class ReverseGeocodeWork @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
firebase: Firebase,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val geocoder: Geocoder,
private val locationDao: LocationDao
) : BaseWorker(context, workerParams, firebase) {
@ -51,7 +51,7 @@ class ReverseGeocodeWork @AssistedInject constructor(
url = result.url,
)
)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
Timber.d("found $result")
Result.success()
} catch (e: Exception) {

@ -17,7 +17,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.analytics.Firebase
import org.tasks.billing.Inventory
@ -44,7 +44,7 @@ class SyncWork @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
firebase: Firebase,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val preferences: Preferences,
private val caldavDao: CaldavDao,
private val caldavSynchronizer: Lazy<CaldavSynchronizer>,
@ -74,7 +74,7 @@ class SyncWork @AssistedInject constructor(
}
preferences.setBoolean(syncStatus, true)
}
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
try {
doSync()
preferences.lastSync = currentTimeMillis()
@ -82,7 +82,7 @@ class SyncWork @AssistedInject constructor(
firebase.reportException(e)
} finally {
preferences.setBoolean(syncStatus, false)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
return Result.success()
}
@ -96,6 +96,9 @@ class SyncWork @AssistedInject constructor(
private val syncStatus = R.string.p_sync_ongoing
private suspend fun doSync() {
if (preferences.isManualSort) {
preferences.isPositionHackEnabled = true
}
val hasNetworkConnectivity = context.hasNetworkConnectivity()
if (hasNetworkConnectivity) {
googleTaskJobs().plus(caldavJobs()).awaitAll()

@ -13,7 +13,7 @@ import com.todoroo.astrid.utility.Constants
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.data.dao.LocationDao
import org.tasks.data.dao.NotificationDao
@ -45,7 +45,7 @@ class NotificationManager @Inject constructor(
private val notificationDao: NotificationDao,
private val taskDao: TaskDao,
private val locationDao: LocationDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val notificationManager: ThrottledNotificationManager,
private val markdownProvider: MarkdownProvider,
private val permissionChecker: PermissionChecker,
@ -176,7 +176,7 @@ class NotificationManager @Inject constructor(
useGroupKey = false,
)
}
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
@SuppressLint("MissingPermission")

@ -7,7 +7,7 @@ import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.qualifiers.ApplicationContext
import org.dmfs.tasks.contract.TaskContract
import org.dmfs.tasks.contract.TaskContract.Tasks
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.analytics.Constants
import org.tasks.analytics.Firebase
@ -36,7 +36,7 @@ class OpenTasksSynchronizer @Inject constructor(
@ApplicationContext private val context: Context,
private val caldavDao: CaldavDao,
private val taskDeleter: TaskDeleter,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val taskDao: TaskDao,
private val firebase: Firebase,
private val iCalendar: iCalendar,
@ -111,7 +111,7 @@ class OpenTasksSynchronizer @Inject constructor(
if (local.id == NO_ID) {
caldavDao.insert(local)
Timber.d("Created calendar: $local")
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
} else if (
local.name != remote.name ||
local.color != remote.color ||
@ -122,7 +122,7 @@ class OpenTasksSynchronizer @Inject constructor(
local.access = remote.access
caldavDao.update(local)
Timber.d("Updated calendar: $local")
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
return local
}
@ -184,7 +184,7 @@ class OpenTasksSynchronizer @Inject constructor(
caldavDao.update(calendar)
Timber.d("Updating parents for ${calendar.uuid}")
caldavDao.updateParents(calendar.uuid!!)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
private suspend fun removeDeleted(calendar: String, uids: List<String>) {
@ -202,7 +202,7 @@ class OpenTasksSynchronizer @Inject constructor(
private suspend fun setError(account: CaldavAccount, message: String?) {
account.error = message
caldavDao.update(account)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
if (!message.isNullOrBlank()) {
Timber.e(message)
}

@ -26,15 +26,15 @@ class Device @Inject constructor(
val pm = context.packageManager
val activities =
pm.queryIntentActivities(Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH), 0)
return activities.isNotEmpty()
return (activities.size != 0)
}
private fun isDontKeepActivitiesEnabled(): Boolean? {
private fun isDontKeepActivitiesEnabled(): Boolean {
return try {
Settings.Global.getInt(context.contentResolver, Settings.Global.ALWAYS_FINISH_ACTIVITIES) == 1
} catch (e: Exception) {
Timber.e("failed to fetch ${Settings.Global.ALWAYS_FINISH_ACTIVITIES}: ${e.message}")
null
Timber.e(e)
false
}
}

@ -6,10 +6,12 @@ import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.media.RingtoneManager
import android.net.Uri
import android.os.Binder
import androidx.compose.material3.DisplayMode
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
@ -28,6 +30,8 @@ import org.tasks.extensions.Context.getResourceUri
import org.tasks.kmp.org.tasks.themes.ColorProvider.BLUE_500
import org.tasks.themes.ThemeBase
import org.tasks.time.DateTime
import org.tasks.time.DateTimeUtils2.currentTimeMillis
import org.tasks.time.ONE_WEEK
import timber.log.Timber
import java.io.File
import java.net.URI
@ -378,7 +382,7 @@ class Preferences @JvmOverloads constructor(
val backupDirectory: Uri?
get() = getDirectory(R.string.p_backup_dir, "backups")
val appPrivateStorage: Uri
val externalStorage: Uri
get() = root.uri
val attachmentsDirectory: Uri?
@ -412,12 +416,13 @@ class Preferences @JvmOverloads constructor(
?: getDefaultFileLocation(name)?.let { Uri.fromFile(it) }
private val root: DocumentFile
get() = DocumentFile.fromFile(context.getExternalFilesDir(null) ?: context.filesDir)
get() = DocumentFile.fromFile(context.getExternalFilesDir(null)!!)
private fun getDefaultFileLocation(type: String): File? {
val baseDir = context.getExternalFilesDir(null) ?: context.filesDir
val path = File(baseDir, type)
return if (path.isDirectory || path.mkdirs()) path else null
val externalFilesDir = context.getExternalFilesDir(null) ?: return null
val path = String.format("%s/%s", externalFilesDir.absolutePath, type)
val file = File(path)
return if (file.isDirectory || file.mkdirs()) file else null
}
private fun hasWritePermission(context: Context, uri: Uri): Boolean =
@ -462,6 +467,10 @@ class Preferences @JvmOverloads constructor(
fun <T> getPrefs(c: Class<T>): Map<String, T> =
prefs.all.filter { (_, value) -> c.isInstance(value) } as Map<String, T>
var isPositionHackEnabled: Boolean
get() = getLong(R.string.p_google_tasks_position_hack, 0) > currentTimeMillis() - ONE_WEEK
set(value) { setLong(R.string.p_google_tasks_position_hack, if (value) currentTimeMillis() else 0) }
override var isManualSort: Boolean
get() = getBoolean(R.string.p_manual_sort, false)
set(value) { setBoolean(R.string.p_manual_sort, value) }

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

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

@ -4,8 +4,8 @@ import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.compose.FilterSelectionActivity.Companion.launch
import org.tasks.compose.FilterSelectionActivity.Companion.registerForFilterPickerResult
import org.tasks.injection.InjectingPreferenceFragment
@ -16,14 +16,14 @@ import javax.inject.Inject
class DashClock : InjectingPreferenceFragment() {
@Inject lateinit var defaultFilterProvider: DefaultFilterProvider
@Inject lateinit var refreshBroadcaster: RefreshBroadcaster
@Inject lateinit var localBroadcastManager: LocalBroadcastManager
private val listPickerLauncher = registerForFilterPickerResult {
defaultFilterProvider.dashclockFilter = it
lifecycleScope.launch {
refreshPreferences()
}
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
override fun getPreferenceXml() = R.xml.preferences_dashclock

@ -18,10 +18,10 @@ import com.google.android.material.color.DynamicColors
import com.todoroo.andlib.utility.AndroidUtilities.atLeastTiramisu
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.billing.Inventory
import org.tasks.billing.PurchaseActivity
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.compose.FilterSelectionActivity.Companion.launch
import org.tasks.compose.FilterSelectionActivity.Companion.registerForFilterPickerResult
import org.tasks.dialogs.ColorPalettePicker
@ -48,7 +48,7 @@ class LookAndFeel : InjectingPreferenceFragment() {
@Inject lateinit var themeBase: ThemeBase
@Inject lateinit var themeColor: ThemeColor
@Inject lateinit var preferences: Preferences
@Inject lateinit var refreshBroadcaster: RefreshBroadcaster
@Inject lateinit var localBroadcastManager: LocalBroadcastManager
@Inject lateinit var defaultFilterProvider: DefaultFilterProvider
@Inject lateinit var inventory: Inventory
@Inject lateinit var locale: Locale
@ -56,7 +56,7 @@ class LookAndFeel : InjectingPreferenceFragment() {
private val listPickerLauncher = registerForFilterPickerResult {
defaultFilterProvider.setDefaultOpenFilter(it)
findPreference(R.string.p_default_open_filter).summary = it.title
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
override fun getPreferenceXml() = R.xml.preferences_look_and_feel

@ -17,8 +17,8 @@ import com.todoroo.andlib.utility.AndroidUtilities
import com.todoroo.astrid.voice.VoiceOutputAssistant
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.compose.FilterSelectionActivity.Companion.launch
import org.tasks.compose.FilterSelectionActivity.Companion.registerForFilterPickerResult
import org.tasks.dialogs.MyTimePickerDialog.Companion.newTimePicker
@ -39,13 +39,13 @@ class Notifications : InjectingPreferenceFragment() {
@Inject lateinit var preferences: Preferences
@Inject lateinit var defaultFilterProvider: DefaultFilterProvider
@Inject lateinit var refreshBroadcaster: RefreshBroadcaster
@Inject lateinit var localBroadcastManager: LocalBroadcastManager
@Inject lateinit var voiceOutputAssistant: VoiceOutputAssistant
private val listPickerLauncher = registerForFilterPickerResult {
defaultFilterProvider.setBadgeFilter(it)
findPreference(R.string.p_badge_list).summary = it.title
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
override fun getPreferenceXml() = R.xml.preferences_notifications

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

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

@ -0,0 +1,97 @@
package org.tasks.security
import android.annotation.SuppressLint
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import org.tasks.Strings.isNullOrEmpty
import timber.log.Timber
import java.nio.charset.StandardCharsets
import java.security.KeyStore
import java.security.SecureRandom
import java.util.*
import javax.crypto.*
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class KeyStoreEncryption @Inject constructor() {
private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
fun encrypt(text: String): String? {
val iv = ByteArray(GCM_IV_LENGTH)
SecureRandom().nextBytes(iv)
val cipher = getCipher(Cipher.ENCRYPT_MODE, iv)
return try {
val output = cipher.doFinal(text.toByteArray(ENCODING))
val result = ByteArray(iv.size + output.size)
System.arraycopy(iv, 0, result, 0, iv.size)
System.arraycopy(output, 0, result, iv.size, output.size)
Base64.encodeToString(result, Base64.DEFAULT)
} catch (e: IllegalBlockSizeException) {
Timber.e(e)
null
} catch (e: BadPaddingException) {
Timber.e(e)
null
}
}
fun decrypt(text: String?): String? {
if (isNullOrEmpty(text)) {
return null
}
val decoded = Base64.decode(text, Base64.DEFAULT)
val iv = Arrays.copyOfRange(decoded, 0, GCM_IV_LENGTH)
val cipher = getCipher(Cipher.DECRYPT_MODE, iv)
return try {
val decrypted = cipher.doFinal(decoded, GCM_IV_LENGTH, decoded.size - GCM_IV_LENGTH)
String(decrypted, ENCODING)
} catch (e: IllegalBlockSizeException) {
Timber.e(e)
""
} catch (e: BadPaddingException) {
Timber.e(e)
""
}
}
private fun getCipher(cipherMode: Int, iv: ByteArray): Cipher {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(cipherMode, secretKey, GCMParameterSpec(GCM_TAG_LENGTH * java.lang.Byte.SIZE, iv))
return cipher
}
private val secretKey: SecretKey
get() {
val entry: KeyStore.Entry? = keyStore.getEntry(ALIAS, null)
return (entry as KeyStore.SecretKeyEntry?)?.secretKey ?: generateNewKey()
}
@SuppressLint("TrulyRandom")
private fun generateNewKey(): SecretKey {
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
keyGenerator.init(
KeyGenParameterSpec.Builder(
ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setRandomizedEncryptionRequired(false)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build())
return keyGenerator.generateKey()
}
init {
keyStore.load(null)
}
companion object {
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val ALIAS = "passwords"
private val ENCODING = StandardCharsets.UTF_8
private const val GCM_IV_LENGTH = 12
private const val GCM_TAG_LENGTH = 16
}
}

@ -5,7 +5,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.data.OpenTaskDao
import org.tasks.data.dao.CaldavDao
@ -34,7 +34,7 @@ class SyncAdapters @Inject constructor(
private val googleTaskDao: GoogleTaskDao,
private val openTaskDao: OpenTaskDao,
private val preferences: Preferences,
private val refreshBroadcaster: RefreshBroadcaster
private val localBroadcastManager: LocalBroadcastManager
) {
private val scope = CoroutineScope(newSingleThreadExecutor().asCoroutineDispatcher() + SupervisorJob())
private val sync = Debouncer(TAG_SYNC) { workManager.sync(it) }
@ -42,7 +42,7 @@ class SyncAdapters @Inject constructor(
val currentState = preferences.getBoolean(R.string.p_sync_ongoing_android, false)
if (currentState != newState && isOpenTaskSyncEnabled()) {
preferences.setBoolean(R.string.p_sync_ongoing_android, newState)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
}

@ -25,15 +25,14 @@ object MicrosoftConverter {
fun Task.applySubtask(
parent: Long,
parentCompletionDate: Long,
checklistItem: Tasks.Task.ChecklistItem,
) {
this.parent = parent
title = checklistItem.displayName
completionDate = if (checklistItem.isChecked) {
checklistItem.checkedDateTime.parseDateTime()
checklistItem.checkedDateTime?.parseDateTime() ?: System.currentTimeMillis()
} else {
parentCompletionDate
0L
}
creationDate = checklistItem.createdDateTime.parseDateTime()
}
@ -121,7 +120,7 @@ object MicrosoftConverter {
} else {
Tasks.Task.Status.notStarted
},
categories = tags.map { it.name!! }.takeIf { it.isNotEmpty() } ?: emptyList(),
categories = tags.map { it.name!! }.takeIf { it.isNotEmpty() },
dueDateTime = if (hasDueDate()) {
Tasks.Task.DateTime(
dateTime = DateTime(dueDate).startOfDay().toString(DATE_TIME_FORMAT),

@ -11,14 +11,15 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.call.body
import io.ktor.http.isSuccess
import kotlinx.serialization.json.Json
import org.tasks.LocalBroadcastManager
import org.tasks.Strings.isNullOrEmpty
import org.tasks.analytics.Firebase
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.caldav.VtodoCache
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.TagDao
import org.tasks.data.dao.TagDataDao
import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavAccount.Companion.ERROR_UNAUTHORIZED
import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_OWNER
import org.tasks.data.entity.CaldavCalendar.Companion.ACCESS_READ_WRITE
@ -51,7 +52,7 @@ class MicrosoftSynchronizer @Inject constructor(
@param:ApplicationContext private val context: Context,
private val caldavDao: CaldavDao,
private val taskDao: TaskDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
private val taskDeleter: TaskDeleter,
private val firebase: Firebase,
private val taskCreator: TaskCreator,
@ -129,7 +130,7 @@ class MicrosoftSynchronizer @Inject constructor(
} else if (local.name != remoteName || local.access != access) {
remote.applyTo(local)
caldavDao.update(local)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
}
if (local.ctag?.isNotBlank() == true) {
deltaSync(account, local, remote, microsoft)
@ -274,7 +275,7 @@ class MicrosoftSynchronizer @Inject constructor(
}
Timber.d("UPDATE $list")
caldavDao.update(list)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
private suspend fun getTaskLists(
@ -367,7 +368,7 @@ class MicrosoftSynchronizer @Inject constructor(
}
Timber.d("UPDATE $list")
caldavDao.update(list)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
private suspend fun updateTask(list: CaldavCalendar, remote: Tasks.Task) {
@ -402,7 +403,6 @@ class MicrosoftSynchronizer @Inject constructor(
list = list,
parentId = task.id,
parentRemoteId = caldavTask.remoteId!!,
parentCompletionDate = task.completionDate,
checklistItems = it,
)
}
@ -421,7 +421,6 @@ class MicrosoftSynchronizer @Inject constructor(
list: CaldavCalendar,
parentId: Long,
parentRemoteId: String,
parentCompletionDate: Long,
checklistItems: List<Tasks.Task.ChecklistItem>,
) {
val existingSubtasks: List<CaldavTask> = taskDao.getChildren(parentId).let { caldavDao.getTasks(it) }
@ -452,7 +451,6 @@ class MicrosoftSynchronizer @Inject constructor(
} else {
task.applySubtask(
parent = parentId,
parentCompletionDate = parentCompletionDate,
checklistItem = item,
)
}
@ -490,7 +488,7 @@ class MicrosoftSynchronizer @Inject constructor(
private suspend fun setError(account: CaldavAccount, message: String?) {
account.error = message
caldavDao.update(account)
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefreshList()
if (!isNullOrEmpty(message)) {
Timber.e(message)
}

@ -1,5 +1,3 @@
@file:OptIn(ExperimentalSerializationApi::class)
package org.tasks.sync.microsoft
import kotlinx.serialization.EncodeDefault
@ -19,18 +17,18 @@ data class Tasks(
@SerialName("@odata.etag") val etag: String? = null,
val id: String? = null,
@Redacted val title: String? = null,
@EncodeDefault val body: Body? = null,
val body: Body? = null,
@EncodeDefault val importance: Importance = Importance.low,
@EncodeDefault val status: Status = Status.notStarted,
val categories: List<String>? = null,
val isReminderOn: Boolean = false,
val createdDateTime: String? = null,
val lastModifiedDateTime: String? = null,
@EncodeDefault val completedDateTime: DateTime? = null,
@EncodeDefault val dueDateTime: DateTime? = null,
val completedDateTime: DateTime? = null,
val dueDateTime: DateTime? = null,
val linkedResources: List<LinkedResource>? = null,
@EncodeDefault val recurrence: Recurrence? = null,
@EncodeDefault val reminderDateTime: DateTime? = null,
val recurrence: Recurrence? = null,
val reminderDateTime: DateTime? = null,
val checklistItems: List<ChecklistItem>? = null,
@SerialName("@removed") val removed: Removed? = null,
) {
@ -108,7 +106,7 @@ data class Tasks(
val displayName: String,
val createdDateTime: String? = null,
val isChecked: Boolean,
@EncodeDefault val checkedDateTime: String? = null,
val checkedDateTime: String? = null,
)
enum class Importance {

@ -6,7 +6,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.LocalBroadcastManager
import org.tasks.compose.throttleLatest
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.TagDataDao
@ -23,7 +23,7 @@ import javax.inject.Singleton
class ChipListCache @Inject internal constructor(
caldavDao: CaldavDao,
tagDataDao: TagDataDao,
private val refreshBroadcaster: RefreshBroadcaster,
private val localBroadcastManager: LocalBroadcastManager,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val lists: MutableMap<String?, CaldavFilter> = HashMap()
@ -42,7 +42,7 @@ class ChipListCache @Inject internal constructor(
lists.clear()
it.associateByTo(lists) { filter -> filter.uuid }
}
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
private fun updateTags(updated: List<TagData>) {
@ -51,7 +51,7 @@ class ChipListCache @Inject internal constructor(
for (update in updated) {
tagDatas[update.remoteId] = TagFilter(update)
}
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
}
fun getCaldavList(caldav: String?): CaldavFilter? = lists[caldav]

@ -7,14 +7,9 @@ import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.tasks.R
import org.tasks.compose.throttleLatest
import org.tasks.injection.ApplicationScope
import timber.log.Timber
import javax.inject.Inject
@ -24,44 +19,29 @@ class AppWidgetManager @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
) {
private val appWidgetManager: AppWidgetManager? = AppWidgetManager.getInstance(context)
private val updateChannel = Channel<Unit>(Channel.CONFLATED)
init {
updateChannel
.consumeAsFlow()
.throttleLatest(1000)
.onEach {
val appWidgetIds = widgetIds
Timber.d("updateWidgets: ${appWidgetIds.joinToString { it.toString() }}")
notifyAppWidgetViewDataChanged(appWidgetIds)
}
.launchIn(scope)
}
val widgetIds: IntArray
get() = appWidgetManager
?.getAppWidgetIds(ComponentName(context, TasksWidget::class.java))
?: intArrayOf()
fun reconfigureWidgets(vararg appWidgetIds: Int) = scope.launch {
fun reconfigureWidgets(vararg appWidgetIds: Int) = scope.launch(Dispatchers.IO) {
Timber.d("reconfigureWidgets(${appWidgetIds.joinToString()})")
val ids = appWidgetIds.takeIf { it.isNotEmpty() } ?: widgetIds
val intent = Intent(context, TasksWidget::class.java)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
.apply { action = AppWidgetManager.ACTION_APPWIDGET_UPDATE }
intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
intent.putExtra(
AppWidgetManager.EXTRA_APPWIDGET_IDS,
appWidgetIds.takeIf { it.isNotEmpty() } ?: widgetIds)
context.sendBroadcast(intent)
}
fun updateWidgets() {
updateChannel.trySend(Unit)
fun updateWidgets() = scope.launch(Dispatchers.IO) {
val appWidgetIds = widgetIds
Timber.d("updateWidgets: ${appWidgetIds.joinToString()}")
withContext(Dispatchers.Main) {
appWidgetManager?.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.list_view)
}
}
fun exists(id: Int) = appWidgetManager?.getAppWidgetInfo(id) != null
private suspend fun notifyAppWidgetViewDataChanged(appWidgetIds: IntArray) = withContext(Dispatchers.Main) {
appWidgetManager?.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.list_view)
}
}
}

@ -5,10 +5,10 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.RemoteViews
import androidx.core.net.toUri
import com.todoroo.andlib.utility.AndroidUtilities.atLeastS
import com.todoroo.astrid.activity.MainActivity.Companion.FINISH_AFFINITY
import dagger.hilt.android.AndroidEntryPoint
@ -35,11 +35,13 @@ class TasksWidget : AppWidgetProvider() {
@Inject @ApplicationContext lateinit var context: Context
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
Timber.d("onUpdate appWidgetIds=${appWidgetIds.joinToString { it.toString() }}")
appWidgetIds.forEach { id ->
appWidgetIds.forEach { appWidgetId ->
try {
val options = appWidgetManager.getAppWidgetOptions(id)
appWidgetManager.updateAppWidget(id, createWidget(context, id, options))
val options = appWidgetManager.getAppWidgetOptions(appWidgetId)
appWidgetManager.updateAppWidget(
appWidgetId,
createWidget(context, appWidgetId, options)
)
} catch (e: Exception) {
Timber.e(e)
}
@ -52,7 +54,6 @@ class TasksWidget : AppWidgetProvider() {
appWidgetId: Int,
newOptions: Bundle
) {
Timber.d("onAppWidgetOptionsChanged appWidgetId=$appWidgetId")
appWidgetManager.updateAppWidget(
appWidgetId,
createWidget(context, appWidgetId, newOptions)
@ -68,8 +69,6 @@ class TasksWidget : AppWidgetProvider() {
val filter = runBlocking {
defaultFilterProvider.getFilterFromPreference(widgetPreferences.filterId)
}
Timber.d("createWidget id=$id filter=$filter")
return RemoteViews(context.packageName, R.layout.scrollable_widget).apply {
if (settings.showHeader) {
setViewVisibility(R.id.widget_header, View.VISIBLE)
@ -88,7 +87,7 @@ class TasksWidget : AppWidgetProvider() {
opacity = widgetPreferences.footerOpacity,
)
setOnClickPendingIntent(R.id.empty_view, getOpenListIntent(context, filter, id))
val cacheBuster = "tasks://widget/${currentTimeMillis()}".toUri()
val cacheBuster = Uri.parse("tasks://widget/" + currentTimeMillis())
setRemoteAdapter(
R.id.list_view,
Intent(context, TasksWidgetAdapter::class.java)
@ -233,4 +232,4 @@ class TasksWidget : AppWidgetProvider() {
private const val COMPACT_MAX = 275
private const val FLAGS = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
}
}

@ -13,7 +13,6 @@ import org.tasks.markdown.MarkdownProvider
import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.Preferences
import org.tasks.tasklist.HeaderFormatter
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
@ -33,7 +32,6 @@ class TasksWidgetAdapter : RemoteViewsService() {
val filter = runBlocking {
defaultFilterProvider.getFilterFromPreference(widgetPreferences.filterId)
}
Timber.d("onGetViewFactory $filter")
return TasksWidgetViewFactory(
subtasksHelper,
widgetPreferences,

@ -51,7 +51,7 @@ internal class TasksWidgetViewFactory(
private val markdown: Markdown,
private val headerFormatter: HeaderFormatter,
) : RemoteViewsFactory {
private val taskLimit = if (atLeastAndroid16()) 50 + 1 else Int.MAX_VALUE
private val taskLimit = if (atLeastAndroid16()) 25 + 1 else Int.MAX_VALUE
private val indentPadding = (20 * context.resources.displayMetrics.density).toInt()
private val settings = widgetPreferences.getWidgetListSettings()
private val hPad = context.resources.getDimension(R.dimen.widget_padding).toInt()
@ -66,12 +66,9 @@ internal class TasksWidgetViewFactory(
chipProvider.isDark = settings.isDark
}
override fun onCreate() {
Timber.d("onCreate widgetId:$widgetId filter:$filter")
}
override fun onCreate() {}
override fun onDataSetChanged() {
Timber.v("onDataSetChanged $filter")
runBlocking {
val collapsed = widgetPreferences.collapsed
tasks = SectionedDataSource(
@ -90,9 +87,7 @@ internal class TasksWidgetViewFactory(
}
}
override fun onDestroy() {
Timber.d("onDestroy widgetId:$widgetId")
}
override fun onDestroy() {}
override fun getCount() = tasks.size.coerceAtMost(taskLimit)
@ -222,7 +217,7 @@ internal class TasksWidgetViewFactory(
R.id.widget_row,
Intent(WidgetClickActivity.EDIT_TASK)
.putExtra(WidgetClickActivity.EXTRA_FILTER, filter)
.putExtra(WidgetClickActivity.EXTRA_TASK_ID, task.id)
.putExtra(WidgetClickActivity.EXTRA_TASK, task)
)
if (settings.showCheckboxes) {
setViewPadding(
@ -237,8 +232,7 @@ internal class TasksWidgetViewFactory(
setOnClickFillInIntent(
R.id.widget_complete_box,
Intent(WidgetClickActivity.COMPLETE_TASK)
.putExtra(WidgetClickActivity.EXTRA_TASK_ID, task.id)
.putExtra(WidgetClickActivity.EXTRA_COMPLETED, !task.isCompleted)
.putExtra(WidgetClickActivity.EXTRA_TASK, task)
)
} else {
setViewPadding(R.id.widget_complete_box, hPad, 0, 0, 0)
@ -256,7 +250,7 @@ internal class TasksWidgetViewFactory(
setOnClickFillInIntent(
R.id.chip,
Intent(WidgetClickActivity.TOGGLE_SUBTASKS)
.putExtra(WidgetClickActivity.EXTRA_TASK_ID, task.id)
.putExtra(WidgetClickActivity.EXTRA_TASK, task)
.putExtra(
WidgetClickActivity.EXTRA_COLLAPSED,
!taskContainer.isCollapsed
@ -338,10 +332,11 @@ internal class TasksWidgetViewFactory(
setOnClickFillInIntent(
dueDateRes,
Intent(WidgetClickActivity.RESCHEDULE_TASK)
.putExtra(WidgetClickActivity.EXTRA_TASK_ID, task.id)
.putExtra(WidgetClickActivity.EXTRA_TASK, task.task)
)
} else {
setViewVisibility(dueDateRes, View.GONE)
}
}
}

@ -3,6 +3,7 @@ package org.tasks.widget
import android.content.Context
import android.widget.RemoteViews
import androidx.annotation.ColorInt
import com.mikepenz.iconics.IconicsDrawable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.runBlocking
import org.tasks.BuildConfig
@ -18,6 +19,7 @@ import org.tasks.filters.Filter
import org.tasks.filters.PlaceFilter
import org.tasks.filters.TagFilter
import org.tasks.filters.getIcon
import org.tasks.icons.OutlinedGoogleMaterial
import org.tasks.kmp.org.tasks.time.getRelativeDateTime
import org.tasks.kmp.org.tasks.time.getTimeString
import org.tasks.time.startOfDay
@ -113,16 +115,14 @@ class WidgetChipProvider @Inject constructor(
setTextViewText(R.id.chip_text, filter.title)
filter
.getIcon(inventory)
?.let { iconName ->
?.let {
try {
val iconUri = WidgetIconProvider.getIconUri(
iconName = iconName,
)
setImageViewUri(R.id.chip_icon, iconUri)
} catch (_: Exception) {
setImageViewResource(R.id.chip_icon, defaultIcon)
OutlinedGoogleMaterial.getIcon("gmo_$it")
} catch (_: IllegalArgumentException) {
null
}
}
?.let { setImageViewBitmap(R.id.chip_icon, IconicsDrawable(context, it).toBitmap()) }
?: setImageViewResource(R.id.chip_icon, defaultIcon)
}
@ -138,4 +138,4 @@ class WidgetChipProvider @Inject constructor(
setColorFilter(R.id.chip_background, tint)
setTextColor(R.id.chip_text, tint)
}
}
}

@ -9,9 +9,10 @@ import com.todoroo.astrid.service.TaskCompleter
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.analytics.Firebase
import org.tasks.broadcast.RefreshBroadcaster
import org.tasks.data.entity.Task
import org.tasks.dialogs.BaseDateTimePicker.OnDismissHandler
import org.tasks.dialogs.DateTimePicker.Companion.newDateTimePicker
import org.tasks.filters.Filter
@ -24,7 +25,7 @@ import javax.inject.Inject
class WidgetClickActivity : AppCompatActivity(), OnDismissHandler {
@Inject lateinit var taskCompleter: TaskCompleter
@Inject lateinit var taskDao: TaskDao
@Inject lateinit var refreshBroadcaster: RefreshBroadcaster
@Inject lateinit var localBroadcastManager: LocalBroadcastManager
@Inject lateinit var preferences: Preferences
@Inject lateinit var firebase: Firebase
@ -33,37 +34,28 @@ class WidgetClickActivity : AppCompatActivity(), OnDismissHandler {
val intent = intent
val action = intent.action
if (action.isNullOrEmpty()) {
finish()
return
}
when (action) {
COMPLETE_TASK -> {
val taskId = intent.getLongExtra(EXTRA_TASK_ID, 0L)
val completed = intent.getBooleanExtra(EXTRA_COMPLETED, false)
Timber.tag("$action taskId=$taskId completed=$completed")
if (taskId > 0) {
lifecycleScope.launch(NonCancellable) {
taskCompleter.setComplete(taskId, completed)
firebase.completeTask("widget")
}
val task = task
Timber.tag("$action task=$task")
lifecycleScope.launch(NonCancellable) {
taskCompleter.setComplete(task, !task.isCompleted)
firebase.completeTask("widget")
}
finish()
}
EDIT_TASK -> {
val filter = intent.getParcelableExtra<Filter?>(EXTRA_FILTER)
val taskId = intent.getLongExtra(EXTRA_TASK_ID, 0L)
Timber.tag("$action taskId=$taskId filter=$filter")
lifecycleScope.launch {
if (taskId > 0) {
val task = taskDao.fetch(taskId)
startActivity(
TaskIntents
.getEditTaskIntent(this@WidgetClickActivity, filter, task)
.putExtra(FINISH_AFFINITY, true)
)
}
finish()
}
val task = task
Timber.tag("$action task=$task filter=$filter")
startActivity(
TaskIntents
.getEditTaskIntent(this, filter, task)
.putExtra(FINISH_AFFINITY, true)
)
finish()
}
OPEN_TASK_LIST -> {
val filter = intent.getParcelableExtra<Filter?>(EXTRA_FILTER)
@ -76,36 +68,23 @@ class WidgetClickActivity : AppCompatActivity(), OnDismissHandler {
finish()
}
TOGGLE_SUBTASKS -> {
val taskId = intent.getLongExtra(EXTRA_TASK_ID, 0L)
val task = task
val collapsed = intent.getBooleanExtra(EXTRA_COLLAPSED, false)
Timber.d("$action collapsed=$collapsed taskId=$taskId")
if (taskId > 0) {
lifecycleScope.launch(NonCancellable) {
taskDao.setCollapsed(taskId, collapsed)
}
Timber.d("$action collapsed=$collapsed task=$task")
lifecycleScope.launch(NonCancellable) {
taskDao.setCollapsed(task.id, collapsed)
}
finish()
}
RESCHEDULE_TASK -> {
val taskId = intent.getLongExtra(EXTRA_TASK_ID, 0L)
Timber.d("$action taskId=$taskId")
val task = task
Timber.d("$action task=$task")
val fragmentManager = supportFragmentManager
if (fragmentManager.findFragmentByTag(FRAG_TAG_DATE_TIME_PICKER) == null) {
lifecycleScope.launch {
val task = taskDao.fetch(taskId)
if (task != null) {
newDateTimePicker(
preferences.getBoolean(
R.string.p_auto_dismiss_datetime_widget,
false
),
task
)
.show(fragmentManager, FRAG_TAG_DATE_TIME_PICKER)
} else {
finish()
}
}
newDateTimePicker(
preferences.getBoolean(R.string.p_auto_dismiss_datetime_widget, false),
task)
.show(fragmentManager, FRAG_TAG_DATE_TIME_PICKER)
}
}
TOGGLE_GROUP -> {
@ -122,16 +101,18 @@ class WidgetClickActivity : AppCompatActivity(), OnDismissHandler {
collapsed.remove(group)
}
widgetPreferences.collapsed = collapsed
refreshBroadcaster.broadcastRefresh()
localBroadcastManager.broadcastRefresh()
finish()
}
else -> {
Timber.e("Unknown action $action")
finish()
}
}
}
val task: Task
get() = intent.getParcelableExtra(EXTRA_TASK)!!
override fun onDismiss() {
finish()
}
@ -144,9 +125,8 @@ class WidgetClickActivity : AppCompatActivity(), OnDismissHandler {
const val RESCHEDULE_TASK = "RESCHEDULE_TASK"
const val TOGGLE_GROUP = "TOGGLE_GROUP"
const val EXTRA_FILTER = "extra_filter"
const val EXTRA_TASK_ID = "extra_task_id"
const val EXTRA_TASK = "extra_task" // $NON-NLS-1$
const val EXTRA_COLLAPSED = "extra_collapsed"
const val EXTRA_COMPLETED = "extra_completed"
const val EXTRA_GROUP = "extra_group"
const val EXTRA_WIDGET = "extra_widget"
private const val FRAG_TAG_DATE_TIME_PICKER = "frag_tag_date_time_picker"

@ -1,118 +0,0 @@
package org.tasks.widget
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import android.os.ParcelFileDescriptor
import androidx.core.graphics.createBitmap
import androidx.core.net.toUri
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.utils.sizeDp
import org.tasks.BuildConfig
import org.tasks.icons.OutlinedGoogleMaterial
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
class WidgetIconProvider : ContentProvider() {
override fun onCreate() = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
if (mode != "r") {
throw SecurityException("Only read access allowed")
}
return try {
val segments = uri.pathSegments
if (segments.size != 2) return null
val iconName = segments[1]
if (!iconName.matches(Regex("^[a-zA-Z0-9_]+$"))) return null
val cacheFile = getCacheFile(iconName)
if (!cacheFile.exists()) {
generateIcon(cacheFile, iconName)
}
if (cacheFile.exists()) {
ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY)
} else {
null
}
} catch (e: Exception) {
Timber.e(e, "Failed to open icon file for URI: $uri")
null
}
}
private fun generateIcon(file: File, iconName: String) {
try {
val icon = OutlinedGoogleMaterial.getIcon("gmo_$iconName")
val context = context ?: return
val drawable = IconicsDrawable(context, icon).apply {
this.sizeDp = 24
}
val bitmap = createBitmap(
drawable.intrinsicWidth.coerceAtLeast(1),
drawable.intrinsicHeight.coerceAtLeast(1)
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
file.parentFile?.mkdirs()
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
bitmap.recycle()
} catch (e: Exception) {
Timber.e(e, "Failed to generate icon: $iconName")
file.delete()
}
}
private fun getCacheFile(iconName: String): File {
val context = context ?: throw IllegalStateException("Context is null")
val cacheDir = File(context.cacheDir, "widget_icons")
cacheDir.mkdirs()
return File(cacheDir, "${iconName}.png")
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int = 0
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun getType(uri: Uri): String = "image/png"
companion object {
const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.widgeticons"
fun getIconUri(iconName: String): Uri {
return "content://$AUTHORITY/icon/$iconName".toUri()
}
}
}

@ -68,13 +68,6 @@
</LinearLayout>
<View
android:id="@+id/systemBarScrim"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_gravity="bottom"
android:visibility="visible" />
<com.google.android.material.bottomappbar.BottomAppBar
android:id="@+id/bottomAppBar"
style="@style/Widget.Material3.BottomAppBar"

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

@ -89,7 +89,12 @@
<string name="language">Idioma</string>
<string name="settings_localization">Localització</string>
<string name="export_toast">Recolzades %1$s de %2$s.</string>
<string name="import_summary_message">El fitxer %1$s contenia %2$s. \n \n %3$s importades \n %4$s ja existien \n %5$s tenien errors</string>
<string name="import_summary_message">El fitxer %1$s contenia %2$s.
\n
\n %3$s importades
\n %4$s ja existien
\n %5$s tenien errors
\n</string>
<string name="discard_confirmation">Esteu segur que voleu descartar els canvis\?</string>
<string name="keep_editing">Continua l\'edició</string>
<string name="TLA_no_items">No hi ha cap tasca aquí.</string>
@ -132,7 +137,7 @@
<string name="start_date">Data d\'inici</string>
<string name="sort_created">Per data de creació</string>
<string name="SSD_sort_start">Per data d\'inici</string>
<string name="astrid_sort_order_summary">Activa el mode d\'ordenació manual d\'Astrid per a «Les meves tasques», «Avui» i «etiquetes». Aquest mode d\'ordenació serà substituït per «El meu ordre» en una actualització futura</string>
<string name="astrid_sort_order_summary">Activa el mode d\'ordenació manual d\'Astrid per a les etiquetes «Les meves tasques», «Avui» i «etiquetes». Aquest mode d\'ordenació serà substituït per «El meu ordre» en una actualització futura</string>
<string name="astrid_sort_order">Ordenació manual d\'Astrid</string>
<string name="cancel">Cancel·la</string>
<string name="ok">OK</string>
@ -177,7 +182,7 @@
<item quantity="many">%d tasques</item>
<item quantity="other">%d tasques</item>
</plurals>
<string name="gtasks_GLA_errorIOAuth">Perdoneu, hem tingut un problema connectant-nos als servidors de Google. Si us plau proveu un altre cop més tard.</string>
<string name="gtasks_GLA_errorIOAuth">Ho sentim, hem tingut problemes de comunicació amb els servidors de Google. Per favor torna-ho a intentar.</string>
<string name="gtasks_GPr_header">Google Tasks</string>
<string name="gtasks_error_accountNotFound">Compte %s no trobat-si us plau desconnecta i torna a connectar-te des de la configuració de Google Tasks.</string>
<string name="premium_record_audio">Grava una nota de veu</string>
@ -286,7 +291,7 @@
<string name="repeat_type_due">data de venciment</string>
<string name="repeat_type_completion">data de finalització</string>
<string name="TEA_creation_date">Data de creació</string>
<string name="new_list">Creeu una nova llista</string>
<string name="new_list">Crea una nova llista</string>
<string name="voice_create_prompt">Parlar per crear una tasca</string>
<string name="widget_show_header">Mostra la capçalera</string>
<string name="now">Ara</string>
@ -303,8 +308,8 @@
<string name="next_thursday">Proper dj</string>
<string name="EPr_voiceInputEnabled_title">Entrada de veu</string>
<string name="EPr_voiceRemindersEnabled_title">Recordatori de veu</string>
<string name="new_tag">Creeu una nova etiqueta</string>
<string name="delete_tag_confirmation">Esborreu %s?</string>
<string name="new_tag">Crea una nova etiqueta</string>
<string name="delete_tag_confirmation">Esborra %s?</string>
<string name="next_saturday">Proper ds</string>
<string name="TEA_timer_controls">Temporitzador</string>
<string name="customize_drawer">Personalitza el calaix</string>
@ -336,7 +341,7 @@
<string name="repeat_monthly_fifth_week">cinquè</string>
<string name="button_subscribe">Subscriu-te</string>
<string name="refresh_purchases">Actualitza les compres</string>
<string name="button_unsubscribe">Cancel·leu la subscripció</string>
<string name="button_unsubscribe">Cancel·la la subscripció</string>
<string name="missing_permissions">Falten permisos</string>
<string name="choose_new_location">Escollir una nova ubicació</string>
<string name="location_permission_required_location">Es necessiten els permisos de ubicació per trobar la teva ubicació actual</string>
@ -509,239 +514,4 @@
<string name="add_account">Afegeiu un compte</string>
<string name="continue_without_sync">Continua sense sincronitzar</string>
<string name="help_me_choose">Ajuda\'m a triar</string>
<string name="bundle_notifications">Notificacions del paquet</string>
<string name="default_reminder">Recordatori predeterminat</string>
<string name="badges">Insígnies</string>
<string name="repeats_from">Repeteix des de</string>
<string name="repeats_single">Repeteix %s</string>
<string name="repeats_single_on">Repeteix %1$s el %2$s</string>
<string name="repeats_single_until">Repeteix %1$s, acaba el %2$s</string>
<string name="repeats_single_number_of_times">Repeteix %1$s, es produeix %2$d %3$s</string>
<string name="repeats_single_on_until">Repeteix %1$s el %2$s, acaba el %3$s</string>
<string name="repeats_single_on_number_of_times">Repeteix %1$s el %2$s, es produeix %3$d %4$s</string>
<string name="repeats_custom_recurrence">Recurrència personalitzada</string>
<string name="repeats_weekly_on">Repeteix en</string>
<string name="repeats_on">En</string>
<string name="repeats_after">Després</string>
<string name="dont_add_to_calendar">No afegiu al calendari</string>
<string name="rmd_time_description">Mostreu les notificacions per a les tasques sense temps de venciment</string>
<string name="badges_description">Mostreu un recompte de tasques a la icona del llançador de tasques. No tots els llançadors suporten insígnies.</string>
<string name="caldav_home_set_not_found">No s\'ha trobat la configuració d\'inici</string>
<string name="pro_dashclock_extension">Extensió Dashclock</string>
<string name="requires_pro_subscription">Requereix una subscripció pro</string>
<string name="this_feature_requires_a_subscription">Aquesta característica requereix una subscripció</string>
<string name="logout">Surt</string>
<string name="delete_tasks_warning">%s serà eliminat. Això no es pot desfer!</string>
<string name="logout_warning">Totes les dades d\'aquest compte s\'eliminaran del vostre dispositiu</string>
<string name="cannot_access_account">No es pot accedir al compte</string>
<string name="reinitialize_account">Reinicialitzeu</string>
<string name="banner_app_updated_title">S\'ha actualitzat l\'aplicació</string>
<string name="banner_app_updated_description">Les tasques s\'han actualitzat a %s. Voleu veure les notes de la versió?</string>
<string name="subtasks_multilevel_google_task">Subtasques multinivell no suportades per Google Tasks</string>
<string name="subtasks_multilevel_microsoft">Les subtasques multinivell no són compatibles amb Microsoft To Do</string>
<string name="enter_tag_name">Introduïu el nom de l\'etiqueta</string>
<string name="choose_synchronization_service">Seleccioneu una plataforma</string>
<string name="tasks_org_description">Sincronitzeu les tasques amb Tasks.org</string>
<string name="google_tasks_selection_description">Servei bàsic que es sincronitza amb el vostre compte de Google</string>
<string name="caldav_selection_description">Sincronització basada en estàndards oberts d\'Internet</string>
<string name="etesync_selection_description">Sincronització xifrada d\'extrem a extrem</string>
<string name="decsync_selection_description">Sincronització per fitxers</string>
<string name="microsoft_selection_description">Sincronitza amb el teu compte personal de Microsoft</string>
<string name="davx5_selection_description">Sincronitza les teves tasques amb l\'aplicació DAVx5</string>
<string name="show_advanced_settings">Mostreu la configuració avançada</string>
<string name="caldav_account_description">Requereix un compte amb un proveïdor de servei CalDAV o un servidor allotjat per si mateix. Cerqueu un proveïdor de serveis visitant tasks.org/caldav</string>
<string name="etesync_account_description">Requereix un compte amb EteSync.com o un servidor allotjat</string>
<string name="preferences_look_and_feel">Aspecte i comportament</string>
<string name="preferences_advanced">Avançat</string>
<string name="documentation">Documentació</string>
<string name="wearable_notifications">Notificacions de rellotge intel·ligent</string>
<string name="wearable_notifications_summary">Mostreu les notificacions al vostre rellotge intel·ligent</string>
<string name="troubleshooting">Resolució de problemes</string>
<string name="notification_troubleshooting_summary">Toqueu aquí si teniu problemes amb les notificacions</string>
<string name="disable_battery_optimizations">Desactiveu les optimitzacions de la bateria</string>
<string name="more_settings">Més configuracions</string>
<string name="more_notification_settings_summary">To de trucada, vibracions i més</string>
<string name="invalid_username_or_password">Nom d\'usuari o contrasenya no vàlids</string>
<string name="color_wheel">Roda de color</string>
<string name="upgrade_blurb_1">Hola! Em dic Alex. Soc el desenvolupador independent darrere de les tasques</string>
<string name="upgrade_blurb_2">He passat milers d\'hores treballant en tasques, i publico tot el codi font en internet de forma gratuïta. Per tal de donar suport al meu treball algunes funcions requereixen una subscripció</string>
<string name="back">Enrere</string>
<string name="chips">Distintius</string>
<string name="chip_appearance">Aparença del distintiu</string>
<string name="places">Llocs</string>
<string name="place_settings">Paràmetres del lloc</string>
<string name="navigation_drawer">Calaix de navegació</string>
<string name="hide_unused_tags">Amagueu les etiquetes no utilitzades</string>
<string name="hide_unused_places">Amagueu els llocs no utilitzats</string>
<string name="chip_appearance_text_and_icon">Text i icona</string>
<string name="chip_appearance_text_only">Només text</string>
<string name="chip_appearance_icon_only">Només icones</string>
<string name="shortcut_pick_time">Trieu el temps</string>
<string name="auto_dismiss_datetime">Tanqueu automàticament el selector de data i hora</string>
<string name="auto_dismiss_datetime_list">Llista de tasques</string>
<string name="auto_dismiss_datetime_list_summary">Tanqueu automàticament en triar de la llista de tasques</string>
<string name="auto_dismiss_datetime_edit">Edició de tasques</string>
<string name="auto_dismiss_datetime_edit_summary">Tanqueu automàticament en triar de l\'edició de la tasca</string>
<string name="auto_dismiss_datetime_widget">Giny</string>
<string name="auto_dismiss_datetime_widget_summary">Tanqueu automàticament en seleccionar des del giny</string>
<string name="auto_dismiss_datetime_summary">Tanqueu el selector de data i hora després de seleccionar una data o hora</string>
<string name="calendar_event_created">S\'ha creat un esdeveniment de calendari per a %s</string>
<string name="select_all">Seleccioneu-ho tot</string>
<string name="share">Compartiu</string>
<string name="widget_id">ID del giny: %d</string>
<string name="settings_default">Per defecte</string>
<string name="compact">Compacte</string>
<string name="custom_filter_criteria">Criteris de filtratge</string>
<string name="filter_overdue">Vençuda</string>
<string name="filter_today_only">Només avui</string>
<string name="filter_any_due_date">Qualsevol data de venciment</string>
<string name="filter_after_today">Després d\'avui</string>
<string name="filter_no_tags">Sense etiquetes</string>
<string name="add_tags">Afegiu etiquetes</string>
<string name="filter_high_priority">Prioritat alta</string>
<string name="filter_medium_priority">Prioritat mitjana</string>
<string name="filter_low_priority">Prioritat baixa</string>
<string name="filter_no_priority">Sense prioritat</string>
<string name="filter_eisenhower_box_1">Important i urgent</string>
<string name="filter_eisenhower_box_2">Important i no urgent</string>
<string name="filter_eisenhower_box_3">No important i urgent</string>
<string name="filter_eisenhower_box_4">No important i no urgent</string>
<string name="enjoying_tasks">Gaudint Tasks?</string>
<string name="donate_nag">Considereu mostrar el vostre suport amb una donació!</string>
<string name="donate_today">Doneu avui</string>
<string name="donate_maybe_later">Potser més tard</string>
<string name="support_development_subscribe">Desbloquegeu funcionalitats addicionals i suporteu programari de codi obert</string>
<string name="got_it">Ja ho tinc!</string>
<string name="sort_start_group">Inici %s</string>
<string name="sort_due_group">Venciment %s</string>
<string name="sort_created_group">S\'ha creat %s</string>
<string name="sort_modified_group">S\'ha modificat %s</string>
<string name="sort_completion_group">Finalització %s</string>
<string name="on_launch">En iniciar</string>
<string name="open_last_viewed_list">Obriu la darrera llista vista</string>
<string name="local_lists">Llistes locals</string>
<string name="lists">Llistes</string>
<string name="reset_sort_order">Restabliu l\'ordre d\'ordenació</string>
<string name="permission_read_tasks">Accés complet a la base de dades de Tasks</string>
<string name="automatic_backups">Còpies de seguretat automàtiques</string>
<string name="android_auto_backup">Servei de còpia de seguretat d\'Android</string>
<string name="android_auto_backup_device_summary">També heu d\'activar el servei de còpia de seguretat a través de la configuració del vostre dispositiu. No tots els dispositius proporcionen servei de còpia de seguretat.</string>
<string name="last_backup">Última còpia de seguretat: %s</string>
<string name="last_backup_never">mai</string>
<string name="device_settings">Configuració del dispositiu</string>
<string name="account">Compte</string>
<string name="foreground_location">Ubicació en primer pla</string>
<string name="background_location">Ubicació en el fons</string>
<string name="backups_ignore_warnings">Ignoreu els avisos</string>
<string name="backups_ignore_warnings_summary">Ignoreu els avisos de còpia de seguretat si no necessiteu còpies de seguretat o teniu la vostra pròpia solució de còpia de seguretat</string>
<string name="backup_location_warning">AVÍS: els fitxers ubicats a %s s\'eliminaran si Tasks és desinstal·lat! Trieu una ubicació personalitzada per evitar que Android suprimeixi els vostres fitxers.</string>
<string name="multi_select_reschedule">Torneu a planificar</string>
<string name="date_picker_multiple">Multiple</string>
<string name="custom_filter_has_subtask">Hi ha subtasques</string>
<string name="custom_filter_is_subtask">És una subtasca</string>
<string name="custom_filter_has_reminder">Hi ha recordatori</string>
<string name="your_subscription_expired">La vostra subscripció ha caducat. Subscriu-te ara per reprendre el servei.</string>
<string name="insufficient_subscription">Nivell de subscripció insuficient. Actualitzeu la vostra subscripció per reprendre el servei.</string>
<string name="insufficient_sponsorship">No s\'ha trobat cap patrocini elegible de GitHub</string>
<string name="no_google_play_subscription">No s\'ha trobat cap subscripció de Google Play elegible</string>
<string name="price_per_year">$%s/any</string>
<string name="price_per_month">$%s/mes</string>
<string name="price_per_month_with_currency">%s/mes</string>
<string name="price_per_year_with_currency">%s/any</string>
<string name="current_subscription">Subscripció actual: %s</string>
<string name="follow_reddit">Uniu-vos a r/tasques</string>
<string name="follow_twitter">Seguiu a @tasks_org</string>
<string name="social">Social</string>
<string name="support">Suport</string>
<string name="issue_tracker">Seguidor d\'incidències</string>
<string name="open_source">Codi obert</string>
<string name="privacy">Privacitat</string>
<string name="authorization_cancelled">Autorització cancel·lada</string>
<string name="google_play_subscribers">Subscriptors de Google Play</string>
<string name="github_sponsors">Patrocinadors de GitHub</string>
<string name="sign_in_with_google">Inicieu sessió amb Google</string>
<string name="sign_in_with_github">Inicieu sessió amb GitHub</string>
<string name="authentication_required">Es requereix autenticació</string>
<string name="github_sponsor">Patrocinador</string>
<string name="migrate">Migreu</string>
<string name="migrating_tasks">Migració de tasques</string>
<string name="migrate_count">Mou %s a Tasks.org</string>
<string name="above_average">Per sobre de la mitjana</string>
<string name="save_percent">Desa %d%%</string>
<string name="sign_in_to_tasks">Inicieu la sessió a Tasks.org</string>
<string name="sign_in_to_tasks_disclosure">La vostra adreça de correu electrònic i l\'ID del compte es transmetran i s\'emmagatzemaran a Tasks.org. Aquesta informació s\'utilitzarà per a l\'autenticació i per a proporcionar-vos anuncis relacionats amb serveis importants. Aquesta informació no es compartirà amb ningú.</string>
<string name="app_password">Contrasenya de l\'aplicació</string>
<string name="app_passwords">Contrasenyes d\'aplicacions</string>
<string name="app_passwords_more_info">Sincronitzeu les vostres tasques i calendaris amb l\'escriptori de tercers i les aplicacions mòbils. Fes clic aquí per a més informació</string>
<string name="generate_new_password">Genereu una contrasenya nova</string>
<string name="app_password_enter_description">Doneu un nom a la vostra contrasenya (opcional)</string>
<string name="app_password_created_at">Creat: %s</string>
<string name="app_password_last_access">Darrera utilització: %s</string>
<string name="app_password_delete_confirmation">Qualsevol aplicació que utilitzi aquesta contrasenya serà eliminada</string>
<string name="app_password_save">Utilitzeu aquestes credencials per configurar una aplicació de tercers. Atorguen accés complet al vostre compte de Tasks.org, no els escriguis ni els comparteixis amb ningú!</string>
<string name="copied_to_clipboard">%s copiat al porta-retalls</string>
<string name="tasks_org_account">Compte Tasks.org</string>
<string name="tasks_org_account_required">Cal un compte de Tasks.org</string>
<string name="account_not_included">No inclòs amb les subscripcions \'Preu flexible\'</string>
<string name="list_members">Llista de membres</string>
<string name="remove_user">Voleu suprimir l\'usuari?</string>
<string name="remove_user_confirmation">%1$s ja no tindrà accés a %2$s</string>
<string name="share_list">Compartiu la llista</string>
<string name="invite">Convideu</string>
<string name="invite_declined">Invitació rebutjada</string>
<string name="invite_awaiting_response">Convideu a esperar resposta</string>
<string name="invite_invalid">Convida no vàlida</string>
<string name="pro_free_trial">Els nous abonats reben una prova gratuïta de 7 dies. Cancel·leu en qualsevol moment</string>
<string name="upgrade_more_customization">Més personalització</string>
<string name="upgrade_more_customization_description">Desbloquegeu tots els temes, colors i icones</string>
<string name="upgrade_tasks_org_account_description">Sincronitzeu amb Tasks.org i col·labora amb altres usuaris</string>
<string name="upgrade_desktop_access">Accés a l\'escriptori</string>
<string name="upgrade_desktop_access_description">Sincronitzeu amb clients de tercers com Outlook i Apple Reminders</string>
<string name="upgrade_open_source_description">La vostra subscripció suporta el desenvolupament continu</string>
<string name="more_options">Més opcions</string>
<string name="markdown">Markdown</string>
<string name="markdown_description">Habiliteu el Markdown en el títol i la descripció</string>
<string name="completion_sound">Reproduïu el so de finalització</string>
<string name="completed">Completat</string>
<string name="snackbar_task_completed">Tasca completada</string>
<string name="completed_tasks_at_bottom">Mou les tasques completades a la part inferior</string>
<string name="snackbar_tasks_completed">%d tasques completades</string>
<string name="alarm_before_start">%s abans d\'iniciar</string>
<string name="alarm_after_start">%s després d\'iniciar</string>
<string name="alarm_before_due">%s abans de venciment</string>
<string name="alarm_after_due">%s després de venciment</string>
<string name="snoozed_until">Posposat fins a %s</string>
<string name="custom_notification">Notificació personalitzada</string>
<string name="caldav_server_unknown">Desconegut</string>
<string name="caldav_server_other">Altres</string>
<string name="caldav_server_type">Tipus de servidor</string>
<string name="dismiss">Descarteu</string>
<string name="hint_customize_edit_title">Massa informació?</string>
<string name="hint_customize_edit_body">Podeu personalitzar aquesta pantalla reordenant o eliminant camps</string>
<string name="enable_reminders">Habiliteu els recordatoris</string>
<string name="enable_reminders_description">Els recordatoris estan desactivats a la configuració de l\'Android</string>
<string name="enable_alarms">Notifiqueu-vos en el moment adequat</string>
<string name="enable_alarms_description">Per assegurar-vos que us notifiqueu en el moment adequat, concediu permís per establir alarmes i recordatoris a la configuració</string>
<string name="sign_in">Inicieu la sessió</string>
<string name="consent_agree">D\'acord</string>
<string name="consent_deny">No ara</string>
<string name="sort_sorting">Ordenació</string>
<string name="sort_grouping">Agrupament</string>
<string name="sort_ascending">Ascendent</string>
<string name="sort_descending">Descendent</string>
<string name="sort_not_available">No disponible per a etiquetes, filtres o llocs</string>
<string name="add_shortcut_to_home_screen">Afegiu una drecera a la pantalla d\'inici</string>
<string name="add_widget_to_home_screen">Afegiu un giny a la pantalla d\'inici</string>
<string name="cost_free">Cost: gratuït</string>
<string name="cost_money">Cost: $</string>
<string name="cost_more_money">Cost: $$$</string>
<string name="multiline_title">Permeteu títols multilínies</string>
<string name="multiline_title_on">Premeu la tecla Retorn per a afegir salts de línia</string>
<string name="multiline_title_off">Premeu Fet per a desar la tasca</string>
<string name="sync_warning_microsoft_title">Sobre la sincronització del Microsoft To Do</string>
<string name="sync_warning_microsoft">No tots els detalls de la tasca se sincronitzen amb Microsoft To Do.</string>
<string name="sync_warning_google_tasks_title">Sobre la sincronització de Google Tasks</string>
<string name="sync_warning_google_tasks">No tots els detalls de la tasca es sincronitzen amb Google Tasks</string>
<string name="button_learn_more">Més informació</string>
<string name="widget_view_more_tasks">Mostreu més tasques</string>
</resources>

@ -721,5 +721,4 @@
<string name="continue_without_sync">Fortsæt uden synkronisering</string>
<string name="help_me_choose">Hjælp mig med at vælge</string>
<string name="delete_tasks_warning">%s bliver slettet. Dette kan ikke fortrydes!</string>
<string name="widget_view_more_tasks">Vis flere opgaver</string>
</resources>

@ -355,8 +355,8 @@
<string name="location_remind_arrival">Bei Ankunft erinnern</string>
<string name="location_remind_departure">Bei Abreise erinnern</string>
<string name="visit_website">Website öffnen</string>
<string name="location_arrived">%s erreicht</string>
<string name="location_departed">%s verlassen</string>
<string name="location_arrived">Angekommen um %s</string>
<string name="location_departed">Abgereist um %s</string>
<string name="building_notifications">Benachrichtigungen generieren</string>
<string name="choose_a_location">Ort auswählen</string>
<string name="pick_this_location">Diesen Ort auswählen</string>

@ -721,5 +721,4 @@
<string name="delete_tasks_warning">Forigas %s. Ne eblas malfari tion!</string>
<string name="continue_without_sync">Daŭrigi sen sinkronigo</string>
<string name="help_me_choose">Helpu min elekti</string>
<string name="widget_view_more_tasks">Vidi pli da taskoj</string>
</resources>

@ -7,7 +7,7 @@
<string name="backup_BAc_export">Respaldar ahora</string>
<string name="export_toast">Respaldadas %1$s de %2$s.</string>
<string name="import_summary_title">Resumen de restauración</string>
<string name="import_summary_message">El fichero %1$s contenía %2$s. \n \n %3$s importadas \n %4$s ya existían \n %5$s tenían errores</string>
<string name="import_summary_message">El fichero %1$s contenía %2$s.\n\n %3$s importadas\n %4$s ya existían\n %5$s tenían errores</string>
<string name="import_progress_read">Leyendo tarea %d…</string>
<string name="read_permission_label">Permisos de Tasks</string>
<string name="discard_confirmation">¿Confirme que desea descartar los cambios\?</string>
@ -359,7 +359,7 @@
<string name="about">Sobre</string>
<string name="license_summary">Tasks es software libre de código abierto, licenciado bajo la GNU General Public License v3.0</string>
<string name="pro_dashclock_extension">Extensión Dashclock</string>
<string name="requires_pro_subscription">Requiere suscripción Pro</string>
<string name="requires_pro_subscription">Requiere suscripción profesional</string>
<string name="logout">Cerrar sesión</string>
<string name="logout_warning">Todos los datos de esta cuenta se borrarán del dispositivo</string>
<string name="cannot_access_account">No se puede acceder a la cuenta</string>
@ -723,7 +723,7 @@
<string name="price_per_year_with_currency">%s/año</string>
<string name="cost_free">Precio: Gratis</string>
<string name="app_settings">Ajustes de la aplicación</string>
<string name="delete_comment">comentario</string>
<string name="delete_comment">Anotación</string>
<string name="yesterday">Ayer</string>
<string name="banner_app_updated_title">Actualización de la aplicación</string>
<string name="banner_app_updated_description">Tasks se ha actualizado a la versión %s. ¿Deseas ver las notas de esta versión?</string>
@ -731,17 +731,16 @@
<string name="add_shortcut_to_home_screen">Agregar acceso directo a la pantalla de inicio</string>
<string name="subtasks_multilevel_microsoft">Microsoft To Do no admite subtareas de múltiples niveles</string>
<string name="cost_money">Precio: $</string>
<string name="sync_warning_google_tasks_title">Acerca de la sincronización de Google Tasks</string>
<string name="sync_warning_google_tasks_title">Acerca de la sincronización de Google Task</string>
<string name="cost_more_money">Precio: $$$</string>
<string name="sync_warning_google_tasks">No todos los detalles de las tareas se sincronizan con Google Tasks</string>
<string name="button_learn_more">Más información</string>
<string name="sync_warning_google_tasks">Algunos detalles de las tareas no se sincronizan con Google Tasks</string>
<string name="button_learn_more">Obtén más información</string>
<string name="sync_warning_microsoft_title">Acerca de la sincronización de Microsoft To Do</string>
<string name="sync_warning_microsoft">No todos los detalles de las tareas se sincronizan con Microsoft To Do.</string>
<string name="price_per_month_with_currency">%s/mes</string>
<string name="price_per_month_with_currency">mes</string>
<string name="multiline_title_off">Presione en Hecho para guardar la tarea</string>
<string name="comment">Comentario</string>
<string name="comment">Anotación.</string>
<string name="continue_without_sync">Continua sin sincronizar</string>
<string name="help_me_choose">Ayudame a escoger</string>
<string name="delete_tasks_warning">%s será eliminado. ¡Esto no se puede deshacer!</string>
<string name="widget_view_more_tasks">Ver más tareas</string>
</resources>

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

@ -4,10 +4,15 @@
<string name="TVA_add_comment">Engadir un comentario…</string>
<string name="backup_BPr_header">Copias de seguranza</string>
<string name="backup_BAc_import">Importar tarefas</string>
<string name="backup_BAc_export">Facer copia de seguridade</string>
<string name="backup_BAc_export">Facer copia de seguranza</string>
<string name="export_toast">Respaldadas %1$s de %2$s.</string>
<string name="import_summary_title">Resumo de restauración</string>
<string name="import_summary_message">O ficheiro %1$s contiña %2$s. \n \n %3$s importadas \n %4$s xa existían \n %5$s tiñan erros</string>
<string name="import_summary_message">O ficheiro %1$s contiña %2$s.
\n
\n %3$s importadas
\n %4$s xa existían
\n %5$s tiñan erros
\n</string>
<string name="import_progress_read">Lendo a tarefa %d…</string>
<string name="read_permission_label">Permisos de Tasks</string>
<string name="discard_confirmation">Queres descartar os cambios\?</string>
@ -111,7 +116,7 @@
<string name="rmd_NoA_snooze">Pospoñer</string>
<string name="rmd_EPr_quiet_hours_start_title">Comeza o tempo en silencio</string>
<string name="rmd_EPr_quiet_hours_end_title">Rematou o tempo en silencio</string>
<string name="rmd_EPr_rmd_time_title">Hora de recordatorio</string>
<string name="rmd_EPr_rmd_time_title">Aviso por defecto</string>
<string name="rmd_EPr_rmd_time_desc">As notificacións de tarefas sen vencemento aparecerán ás %s</string>
<string name="persistent_notifications">Notificacións persistentes</string>
<string name="persistent_notifications_description">As notificacións persistentes non se poden borrar</string>
@ -228,7 +233,7 @@
<string name="filter_medium_priority">Prioridade intermedia</string>
<string name="filter_no_priority">Sen prioridade</string>
<string name="repeat_type_due">data de fin</string>
<string name="geofence_radius">Radio da xeocerca</string>
<string name="geofence_radius">Radio</string>
<string name="add_account">Engadir conta</string>
<string name="error_adding_account">Erro: %s</string>
<string name="repeats_yearly">por ano</string>
@ -260,11 +265,11 @@
<string name="list">Lista</string>
<string name="repeats_from">Repetir desde</string>
<string name="repeats_single_on">Repetir %1$s o %2$s</string>
<string name="repeats_single_until">Repítese %1$s, remata o %2$s</string>
<string name="repeats_single_until">Repetir %1$s ata o %2$s</string>
<string name="repeats_single_on_number_of_times">Repetir %1$s en %2$s, ocorre que %3$d %4$s</string>
<string name="repeats_monthly">por mes</string>
<string name="repeats_plural">Repetir cada %s</string>
<string name="repeats_plural_until">Repítese cada %1$s, remata o %2$s</string>
<string name="repeats_plural_until">Repetir cada %1$s ata %2$s</string>
<string name="repeats_plural_number_of_times">Repetir cada %1$s, ocorre que %2$d %3$s</string>
<string name="list_separator_with_space">", "</string>
<string name="repeat_monthly_every_day_of_nth_week">cada %1$s %2$s</string>
@ -373,10 +378,10 @@
<string name="widget_open_list">Abrir lista</string>
<string name="user">Usuario/a</string>
<string name="repeats_hourly">por hora</string>
<string name="repeats_plural_on_until">Repítese cada %1$s o %2$s, remata o %3$s</string>
<string name="repeats_plural_on_until">Repetir cada %1$s en %2$s ata %3$s</string>
<string name="repeats_plural_on_number_of_times">Repetir cada %1$s o %2$s, ocorre que %3$d %4$s</string>
<string name="license_summary">Tasks é software de código aberto, con licenza baixo a GNU General Public License v3.0</string>
<string name="requires_pro_subscription">Precísase dunha subscrición pro</string>
<string name="requires_pro_subscription">Precísase dunha suscrición pro</string>
<string name="this_feature_requires_a_subscription">Esta característica necesita dunha suscrición</string>
<string name="enter_tag_name">Introduce nome de etiqueta</string>
<string name="show_advanced_settings">Amosar configuración avanzada</string>
@ -408,7 +413,7 @@
<string name="gtasks_GPr_header">Google Tasks</string>
<string name="TEA_timer_est">Est. %s</string>
<string name="sort_created">Por data de creación</string>
<string name="action_call">Chamar</string>
<string name="action_call">Chamada</string>
<string name="display_name">Mostrar o nome</string>
<string name="next_monday">Próx Luns</string>
<string name="next_friday">Próx Ven</string>
@ -463,7 +468,7 @@
<string name="launcher_icon">Icona de escritorio</string>
<string name="theme_system_default">Predefinido do sistema</string>
<string name="repeats_single_number_of_times">Repetir %1$s, ocorre que %2$d %3$s</string>
<string name="repeats_single_on_until">Repítese %1$s o %2$s, remata o %3$s</string>
<string name="repeats_single_on_until">Repetir %1$s o %2$s ata %3$s</string>
<string name="dont_add_to_calendar">Non engadir ao calendario</string>
<string name="background_location_permission_required">Tasks recolle información de posicións para ser quen de amosar os recordatorios con información de posicións, incluído cando o aplicativo non estea aberto.</string>
<string name="location_permission_required_location">Non se precisa de permisos de posición para facer a busca da posición actual</string>
@ -535,191 +540,4 @@
<string name="filter_any_start_date">Calquera data de inicio</string>
<string name="repeat_type_completion">data de finalización</string>
<string name="repeat_type_completion_capitalized">Data de finalización</string>
<string name="app_settings">Configuración da aplicación</string>
<string name="customize_drawer">Personalizar caixón</string>
<string name="customize_drawer_summary">Arrastrar e soltar para reorganizar os elementos do menú</string>
<string name="astrid_sort_order">Ordenación manual de Astrid</string>
<string name="astrid_sort_order_summary">Activar o modo de ordenación manual de Astrid para \'As miñas tarefas\', \'Hoxe\' e as etiquetas. Este modo de ordenación será substituído por \'O meu pedido\' nunha actualización futura</string>
<string name="sort_list">Por lista</string>
<string name="sort_completed">Por hora de finalización</string>
<string name="TEA_creation_date">Data de creación</string>
<string name="swipe_to_snooze_title">Deslizar para pospoñer</string>
<string name="swipe_to_snooze_description">Hora de posposición</string>
<string name="swipe_to_snooze_time_description">Unha notificación borrada pospoñerase e volverase crear %s</string>
<string name="swipe_to_snooze_time_immediately">inmediatamente</string>
<string name="swipe_to_snooze_time_15_minutes">despois de 15 minutos</string>
<string name="swipe_to_snooze_time_30_minutes">despois de 30 minutos</string>
<string name="swipe_to_snooze_time_1_hour">despois de 1 hora</string>
<string name="swipe_to_snooze_time_24_hours">despois de 24 horas</string>
<string name="delete_comment">comentario</string>
<string name="comment">Comentario</string>
<string name="yesterday">Onte</string>
<string name="quiet_hours_in_effect">As horas de silencio están en vigor</string>
<string name="customize_edit_screen_summary">Reorganizar ou eliminar campos</string>
<string name="send_application_logs">Enviar rexistros de aplicacións</string>
<string name="change_priority">Cambiar prioridade</string>
<string name="show_edit_screen_without_unlock">Mostrar pantalla de edición sen desbloquear</string>
<string name="show_edit_screen_without_unlock_summary">Activa o uso do mosaico Configuración rápida sen desbloquear o dispositivo</string>
<string name="theme_dynamic">Dinámico</string>
<string name="continue_without_sync">Continuar sen sincronización</string>
<string name="help_me_choose">Axúdame a escoller</string>
<string name="default_reminder">Recordatorio predeterminado</string>
<string name="repeats_custom_recurrence">Recorrencia personalizada</string>
<string name="repeats_every">Repítese cada</string>
<string name="repeats_weekly_on">Repítese o</string>
<string name="repeats_never">Nunca</string>
<string name="repeats_on">Aceso</string>
<string name="repeats_after">Despois de</string>
<string name="repeats_ends">Remata</string>
<string name="rmd_time_description">Mostrar notificacións para tarefas sen data de vencemento</string>
<string name="repeat_monthly_on_day_number">Mensualmente o día %1d</string>
<string name="repeat_monthly_on_the_nth_weekday">Mensualmente o %1$s %2$s</string>
<string name="delete_tasks_warning">%s será eliminado. Isto non se pode desfacer!</string>
<string name="banner_app_updated_title">Aplicación actualizada</string>
<string name="banner_app_updated_description">Tarefas acaba de actualizarse a %s. Queres ver as notas da versión?</string>
<string name="name_your_price">Prezo flexible</string>
<string name="subtasks_multilevel_microsoft">Subtarefas multinivel non compatibles con Microsoft To Do</string>
<string name="microsoft_selection_description">Sincronizar coa túa conta persoal de Microsoft</string>
<string name="caldav_account_description">Require unha conta cun provedor de servizos CalDAV ou un servidor autoaloxado. Atopa un provedor de servizos visitando tasks.org/caldav</string>
<string name="etesync_account_description">Require unha conta con EteSync.com ou un servidor autoaloxado</string>
<string name="wearable_notifications">Notificacións de reloxo intelixente</string>
<string name="wearable_notifications_summary">Amosar notificacións no teu reloxo intelixente</string>
<string name="notification_troubleshooting_summary">Toca aquí se tes problemas coas notificacións</string>
<string name="disable_battery_optimizations">Desactivar optimizacións da batería</string>
<string name="more_notification_settings_summary">Tono de chamada, vibracións e máis</string>
<string name="upgrade_blurb_1">Ola! Chámome Alex. Son o desenvolvedor independente detrás de Tasks</string>
<string name="upgrade_blurb_2">Levo miles de horas traballando en Tasks e publico todo o código fonte en liña de balde. Para apoiar o meu traballo, algunhas funcións requiren unha subscrición</string>
<string name="chips">Distintivos</string>
<string name="chip_appearance">Apariencia dos distintivos</string>
<string name="widget_id">ID do Widget: %d</string>
<string name="filter_overdue">Atrasada</string>
<string name="enjoying_tasks">Gústache Tasks?</string>
<string name="donate_nag">Considera mostrar o teu apoio cunha doazón!</string>
<string name="donate_today">Doa hoxe</string>
<string name="donate_maybe_later">Quizais máis tarde</string>
<string name="support_development_subscribe">Desbloquea funcións adicionais e apoia software de código aberto</string>
<string name="got_it">Entendido!</string>
<string name="sort_completion_group">Completado %s</string>
<string name="on_launch">Ao iniciar</string>
<string name="permission_read_tasks">Acceso completo á base de datos de Tasks</string>
<string name="android_auto_backup">Servicio de Copias de Seguridade de Android</string>
<string name="android_auto_backup_device_summary">Tamén debes activar o servizo de copia de seguridade a través da configuración do teu dispositivo. Non todos os dispositivos ofrecen servizo de copia de seguridade.</string>
<string name="backups_ignore_warnings_summary">Ignora os avisos de copia de seguridade se non necesitas copias de seguridade ou tes a túa propia solución de copia de seguridade</string>
<string name="backup_location_warning">ATENCIÓN: Eliminaranse os arquivos situados en %s se se desinstala Tasks! Por favor escolla unha ubicación personalizada para evitar que Android elimine os seus arquivos.</string>
<string name="your_subscription_expired">A túa subscrición caducou. Subscríbete agora para retomar o servizo.</string>
<string name="insufficient_subscription">Nivel de subscrición insuficiente. Por favor actualiza a túa subscrición para reanudar o servizo.</string>
<string name="insufficient_sponsorship">Non se atopou ningún patrocinio de GitHub elixible</string>
<string name="no_google_play_subscription">Non se atopou ningunha subscrición de Google Play elixible</string>
<string name="price_per_month_with_currency">%s/mes</string>
<string name="price_per_year_with_currency">%s/ano</string>
<string name="current_subscription">Subscrición actual: %s</string>
<string name="follow_reddit">Únete a r/tasks</string>
<string name="follow_twitter">Seguir a @tasks_org</string>
<string name="social">Social</string>
<string name="support">Asistencia</string>
<string name="issue_tracker">Seguidor de problemas</string>
<string name="open_source">Código aberto</string>
<string name="privacy">Privacidade</string>
<string name="google_play_subscribers">Subscritores de Google Play</string>
<string name="github_sponsors">Patrocinadores de GitHub</string>
<string name="github_sponsor">Patrocinador</string>
<string name="sign_in_to_tasks_disclosure">O teu enderezo de correo electrónico e o ID da túa conta serán transmitidos e almacenados por Tasks.org. Esta información usarase para a autenticación e para proporcionarche anuncios importantes relacionados co servizo. Esta información non se compartirá con ninguén.</string>
<string name="app_password_delete_confirmation">Calquera aplicación que use este contrasinal pechará sesión</string>
<string name="app_password_save">Usa estas credenciais para configurar unha aplicación de terceiros. Conceden acceso completo á túa conta de Tasks.org, non as escribas nin as compartas con ninguén!</string>
<string name="account_not_included">Non incluído nas subscricións de \'Prezo flexible\'</string>
<string name="remove_user_confirmation">%1$s xa non terá acceso a %2$s</string>
<string name="pro_free_trial">Os novos subscritores reciben unha proba gratuíta de 7 días. Cancela en calquera momento</string>
<string name="upgrade_tasks_org_account_description">Sincroniza con Tasks.org e colabora con outros usuarios</string>
<string name="upgrade_open_source_description">A túa subscrición apoia o desenvolvemento continuo</string>
<string name="markdown_description">Activa Markdown no título e na descrición</string>
<string name="completion_sound">Reproduce son de finalización</string>
<string name="dismiss">Descartar</string>
<string name="hint_customize_edit_title">Demasiada información?</string>
<string name="hint_customize_edit_body">Podes personalizar esta pantalla reorganizando ou eliminando campos</string>
<string name="enable_reminders">Activar recordatorios</string>
<string name="enable_reminders_description">Os recordatorios están desactivados na Configuración de Android</string>
<string name="enable_alarms">Recibir notificacións no momento adecuado</string>
<string name="enable_alarms_description">Para asegurarte de que recibes notificacións no momento adecuado, concede permiso para configurar alarmas e recordatorios na Configuración</string>
<string name="sign_in">Iniciar sesión</string>
<string name="consent_agree">De acordo</string>
<string name="consent_deny">Agora non</string>
<string name="sort_sorting">Ordenación</string>
<string name="sort_grouping">Agrupación</string>
<string name="sort_ascending">Ascendente</string>
<string name="sort_descending">Descendente</string>
<string name="sort_not_available">Non dispoñible para etiquetas, filtros ou lugares</string>
<string name="add_shortcut_to_home_screen">Engadir acceso directo á pantalla de inicio</string>
<string name="add_widget_to_home_screen">Engadir widget á pantalla de inicio</string>
<string name="cost_free">Custo: Gratis</string>
<string name="cost_money">Custo: $</string>
<string name="cost_more_money">Custo: $$$</string>
<string name="multiline_title">Permitir títulos de varias liñas</string>
<string name="multiline_title_on">Preme a tecla Intro para engadir saltos de liña</string>
<string name="multiline_title_off">Preme Feito para gardar a tarefa</string>
<string name="sync_warning_microsoft_title">Acerca da sincronización de Microsoft To Do</string>
<string name="sync_warning_microsoft">Non todos os detalles da tarefa se sincronizan con Microsoft To Do.</string>
<string name="sync_warning_google_tasks_title">Acerca da sincronización de Google Task</string>
<string name="sync_warning_google_tasks">Non todos os detalles da tarefa se sincronizan con Google Tasks</string>
<string name="button_learn_more">Máis información</string>
<string name="widget_view_more_tasks">Ver máis tarefas</string>
<plurals name="repeat_occurrence">
<item quantity="one">ocorrencia</item>
<item quantity="other">ocorrencias</item>
</plurals>
<plurals name="repeat_n_minutes">
<item quantity="one">%d minuto</item>
<item quantity="other">%d minutos</item>
</plurals>
<plurals name="repeat_hours">
<item quantity="one">hora</item>
<item quantity="other">horas</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">Hora</item>
<item quantity="other">Horas</item>
</plurals>
<plurals name="repeat_n_hours">
<item quantity="one">%d hora</item>
<item quantity="other">%d horas</item>
</plurals>
<plurals name="repeat_days">
<item quantity="one">día</item>
<item quantity="other">días</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">Día</item>
<item quantity="other">Días</item>
</plurals>
<plurals name="repeat_n_days">
<item quantity="one">%d día</item>
<item quantity="other">%d días</item>
</plurals>
<plurals name="repeat_weeks">
<item quantity="one">semana</item>
<item quantity="other">semanas</item>
</plurals>
<plurals name="reminder_week">
<item quantity="one">Semana</item>
<item quantity="other">Semanas</item>
</plurals>
<plurals name="repeat_n_weeks">
<item quantity="one">%d semana</item>
<item quantity="other">%d semanas</item>
</plurals>
<plurals name="repeat_months">
<item quantity="one">mes</item>
<item quantity="other">meses</item>
</plurals>
<plurals name="repeat_n_months">
<item quantity="one">%d mes</item>
<item quantity="other">%d meses</item>
</plurals>
<plurals name="repeat_years">
<item quantity="one">ano</item>
<item quantity="other">anos</item>
</plurals>
<plurals name="repeat_n_years">
<item quantity="one">%d ano</item>
<item quantity="other">%d anos</item>
</plurals>
</resources>
</resources>

@ -1,3 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
</resources>

@ -743,5 +743,4 @@
<string name="banner_app_updated_title">Aplikacija je aktualizirana</string>
<string name="banner_app_updated_description">Aplikacija Tasks je upravo aktualizirana na %s. Želiš li vidjeti bilješke o izdanju?</string>
<string name="multiline_title_off">Pritisni „Gotovo” za spremanje zadatka</string>
<string name="widget_view_more_tasks">Prikaži više zadataka</string>
</resources>

@ -721,5 +721,4 @@
<string name="continue_without_sync">Folytatás szinkronizálás nélkül</string>
<string name="help_me_choose">Segítséget kérek a választásban</string>
<string name="multiline_title_off">Kattints a Kész gombra a feladat elmentéséhez</string>
<string name="widget_view_more_tasks">További feladatok megtekintése</string>
</resources>

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

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save