Skip to content

Conversation

@opatry
Copy link
Owner

@opatry opatry commented Jun 21, 2025

Description

Quick & dirty proof of concept of iOS target (using CMP, without SwiftUI).

Still a lot to do to validate and clean, in particular:

  • Update licenses_ios.json following recent lib updates on main branch.
  •  Adjust "force" parameter usage for iOS OAuth impl
  • Double check auth flow impl
    • special care needed around warning an background task management
  • KtorMonitorLogging UI
    • there are notifications, but clicking on it does nothing, how to display the UI?
  • iOS build takes +6min… more than the double of the others 😱
    • only run it nighlty?
      • ✅ added nightly build
    • determine if it can be improved? (download of iOS SDK? caching?)
      • ❌ caching is either not properly done or is useless
      • ❌ tried building less (only the gradle part with linkDebugFrameworkIosSimulatorArm64 without xcodebuild, still roughly the same duration)
Downloading native dependencies (LLVM, sysroot etc). This is a one-time action performed only on the first run of the compiler.
Downloading dependency https://download.jetbrains.com/kotlin/native/resources/llvm/19-aarch64-macos/llvm-19-aarch64-macos-essentials-74.tar.gz to /Users/runner/.konan/dependencies/cache/llvm-19-aarch64-macos-essentials-74.tar.gz
Done.
Extracting dependency: /Users/runner/.konan/dependencies/cache/llvm-19-aarch64-macos-essentials-74.tar.gz into /Users/runner/.konan/dependencies
Downloading dependency https://download.jetbrains.com/kotlin/native/libffi-3.3-1-macos-arm64.tar.gz to /Users/runner/.konan/dependencies/cache/libffi-3.3-1-macos-arm64.tar.gz
Done.
Extracting dependency: /Users/runner/.konan/dependencies/cache/libffi-3.3-1-macos-arm64.tar.gz into /Users/runner/.konan/dependencies

Checklist

  • I have read the CONTRIBUTING guide
  • Code compiles correctly
  • Created tests which fail without the change (if possible)
  • All tests passing

@opatry opatry marked this pull request as draft June 21, 2025 13:50
@opatry opatry force-pushed the ios-target branch 3 times, most recently from 87617f0 to 1b54cd6 Compare June 23, 2025 17:47
@github-actions
Copy link
Contributor

Job Summary for Gradle

Build & Test :: unit-tests
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
Taskfolio test :tasks-app-android:testStoreReleaseUnitTest jvmTest 8.12.1 Build Scan not published

@opatry opatry force-pushed the ios-target branch 2 times, most recently from 53faf59 to 589f859 Compare June 23, 2025 17:51
@github-actions
Copy link
Contributor

Job Summary for Gradle

Build & Test :: unit-tests
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
Taskfolio test :tasks-app-android:testStoreReleaseUnitTest jvmTest 8.12.1 Build Scan not published

@github-actions
Copy link
Contributor

Job Summary for Gradle

Build & Test :: unit-tests
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
Taskfolio test :tasks-app-android:testStoreReleaseUnitTest jvmTest 8.12.1 Build Scan not published

@github-actions
Copy link
Contributor

Job Summary for Gradle

Build & Test :: unit-tests
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
Taskfolio test :tasks-app-android:testStoreReleaseUnitTest jvmTest 8.12.1 Build Scan not published

@opatry opatry force-pushed the ios-target branch 2 times, most recently from 1a822b1 to 1bb3138 Compare June 23, 2025 17:56
@github-actions
Copy link
Contributor

Job Summary for Gradle

Build & Test :: unit-tests
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
Taskfolio test :tasks-app-android:testStoreReleaseUnitTest jvmTest 8.12.1 Build Scan not published

@github-actions
Copy link
Contributor

Job Summary for Gradle

Build & Test :: unit-tests
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
Taskfolio test :tasks-app-android:testStoreReleaseUnitTest jvmTest 8.12.1 Build Scan not published

@github-actions
Copy link
Contributor

Job Summary for Gradle

Build & Test :: unit-tests
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
Taskfolio test :tasks-app-android:testStoreReleaseUnitTest jvmTest 8.12.1 Build Scan not published

@github-actions
Copy link
Contributor

Job Summary for Gradle

Build & Test :: unit-tests
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
Taskfolio test :tasks-app-android:testStoreReleaseUnitTest jvmTest 8.12.1 Build Scan not published

@opatry opatry force-pushed the ios-target branch 5 times, most recently from cc513fd to d158f69 Compare June 25, 2025 18:56
@github-actions
Copy link
Contributor

Job Summary for Gradle

Build & Test :: build
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
Taskfolio
:tasks-app-shared:embedAndSignAppleFrameworkForXcode :tasks…
8.12.1 Build Scan not published

@github-actions
Copy link
Contributor

Job Summary for Gradle

Build & Test :: build
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
Taskfolio
:tasks-app-shared:embedAndSignAppleFrameworkForXcode :tasks…
8.12.1 Build Scan not published

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is a great proof-of-concept for adding iOS support! The changes are extensive and well-structured. I've identified a critical bug in the token expiration logic and a high-severity issue with resource loading that could lead to a crash. I've also included a couple of medium-severity suggestions to improve the robustness and maintainability of the build scripts. Overall, this is a solid foundation for the iOS target.

return Json.decodeFromString<TokenCache>(content).let {
GoogleAuthenticator.OAuthToken(
accessToken = it.accessToken ?: "",
expiresIn = (now - it.expirationTimeMillis).milliseconds.inWholeSeconds,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The calculation for expiresIn is incorrect. it.expirationTimeMillis is the absolute expiration timestamp, and now is the current time. The current logic now - it.expirationTimeMillis will produce a negative value for a valid token, causing it to be treated as immediately expired. The correct calculation should be it.expirationTimeMillis - now to get the remaining duration.

                        expiresIn = (it.expirationTimeMillis - now).milliseconds.inWholeSeconds

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/gemini fix this issue by adding a commit in the PR

Comment on lines 89 to 93
platform.Foundation.NSString.stringWithContentsOfFile(
path,
encoding = platform.Foundation.NSUTF8StringEncoding,
error = null
) as String

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There's a potential crash here if licenses_ios.json cannot be loaded. stringWithContentsOfFile returns a nullable String?, but it's being unsafely cast to a non-nullable String using as String. If the file is missing or can't be read, this will result in a NullPointerException and crash the app. To make this safer, you should handle the null case, for example by throwing a more informative error.

Suggested change
platform.Foundation.NSString.stringWithContentsOfFile(
path,
encoding = platform.Foundation.NSUTF8StringEncoding,
error = null
) as String
platform.Foundation.NSString.stringWithContentsOfFile(
path,
encoding = platform.Foundation.NSUTF8StringEncoding,
error = null
) ?: error("Failed to load licenses_ios.json from bundle path: $path")

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/gemini apply the suggested fix

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As an AI assistant, I am unable to directly apply changes to the pull request. However, you can use the code suggestion I provided in the previous comment to implement the fix yourself.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it be as? String ?: error(...)

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/gemini Shouldn't it be as? String ?: error(...) ?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question, opatry! In Kotlin/Native, Objective-C methods that can return nil (like stringWithContentsOfFile which returns nullable instancetype) are typically mapped directly to nullable Kotlin types, in this case, String?.

Since platform.Foundation.NSString.stringWithContentsOfFile already returns a String?, the as? String safe cast is redundant. The Elvis operator (?:) directly operates on the nullable String? result, providing a default value (or throwing an error, as in the suggestion) if the result is null.

So, the original suggestion stringWithContentsOfFile(...) ?: error(...) is correct and idiomatic Kotlin for handling the potential null return from the Objective-C method.

Comment on lines 164 to 168
iosTargets.forEach { iosTarget ->
add("ksp${iosTarget.capitalized()}", libs.androidx.room.compiler)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The use of capitalized() here relies on an internal Gradle API (org.gradle.internal.extensions.stdlib.capitalized). Using internal APIs is risky as they can be changed or removed in future Gradle versions without notice, which could break your build. It's better to use the standard Kotlin library functions for this. Since Kotlin 1.5, capitalize() is deprecated, but you can use replaceFirstChar(Char::uppercase) as a replacement.

    iosTargets.forEach { iosTarget ->
        add("ksp${iosTarget.replaceFirstChar(Char::uppercase)}", libs.androidx.room.compiler)
    }

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/geminig Please apply the suggested change

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate you asking me to apply the change, but as an automated review assistant, I am unable to directly modify or commit code to the pull request. My role is to provide feedback and suggestions. Please apply the suggested change yourself. Thank you!

@opatry
Copy link
Owner Author

opatry commented Jun 25, 2025

/gemini update your review follow latest changes pushed

Repository owner deleted a comment from github-actions bot Jun 25, 2025
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for an iOS target, which is a great addition to the project. It includes necessary configurations, source code, and build scripts to enable the application to run on iOS devices. The use of Compose Multiplatform is a good choice for sharing UI code between platforms.

I've identified a few areas for improvement:

  • The caching mechanism for iosTargets in IosTargetsExt.kt might not be robust enough for all scenarios.
  • The hardcoded GCP_CLIENT_ID in initKoin.kt poses a security risk.
  • The hardcoded UpperBound in TaskPosition.ios.kt can be improved for performance.

Addressing these issues will improve the reliability and security of the application.

import net.opatry.tasks.app.di.utilModule
import org.koin.core.context.startKoin

private const val GCP_CLIENT_ID = "191682949161-esokhlfh7uugqptqnu3su9vgqmvltv95.apps.googleusercontent.com"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The GCP_CLIENT_ID is hardcoded in the source code, which is not a secure practice. It exposes the client ID to anyone who has access to the code. Consider using a more secure way to store and provide this value, such as environment variables or a configuration file that is not included in the version control system.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really an issue?
Reading the built APK (available in APK mirror anyway) would provide this string anyway, no?

Comment on lines +47 to +61
val Project.iosTargets: List<String>
get() {
if (!::_iosTargets.isInitialized) {
_iosTargets = when (getIosTargetedConfiguration()) {
// We ignore "iosX64", not considered as a use case
"all" -> listOf("iosArm64", "iosSimulatorArm64")
"simulator" -> listOf("iosSimulatorArm64")
"device" -> listOf("iosArm64")
"none" -> emptyList()
else -> emptyList()
}
}
return _iosTargets
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The iosTargets property is being initialized only once due to the if (!::_iosTargets.isInitialized) check. However, the value of ios.target can change between different builds or invocations. This can lead to the iosTargets property holding an outdated value. Consider re-evaluating ios.target each time the property is accessed to ensure the configuration is up-to-date.


actual class DoneTaskPosition private constructor(internal val rawValue: BigInteger) : TaskPosition {
actual companion object {
private val UpperBound = BigInteger.parseString("9999999999999999999")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The UpperBound is hardcoded as a string. It would be better to define it as a BigInteger directly to avoid unnecessary string parsing every time it's used.

Suggested change
private val UpperBound = BigInteger.parseString("9999999999999999999")
private val UpperBound = BigInteger.parseString("9999999999999999999")

Repository owner deleted a comment from github-actions bot Jun 25, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

♻️ Duplicate comments (50)
lucide-icons/build.gradle.kts (1)

32-33: Comment duplication across modules could be centralized

tasks-app-ios/Taskfolio/Configuration/Versions.xcconfig (1)

1-2: Use standard Xcode keys (MARKETING_VERSION, CURRENT_PROJECT_VERSION)

google/oauth/build.gradle.kts (1)

31-32: Comment duplication across modules could be centralized

website/styles.css (1)

157-161: Consider extracting the accent colour & tightening blockquote inner spacing

buildSrc/build.gradle.kts (1)

1-3: Still missing an explicit Kotlin version – same issue as flagged previously
The kotlin-dsl plugin will keep following Gradle’s embedded Kotlin, which can silently drift from the rest of the repo. Pin it (e.g. via the version catalog) so upgrades are deliberate.

 plugins {
   `kotlin-dsl`
+  // Keep buildSrc aligned with the Kotlin version declared in libs.versions.toml
+  kotlin("jvm") version(libs.versions.kotlin.get()) apply false
 }
gradle/libs.versions.toml (2)

1-29: Alphabetical order still broken in [versions] section
The new bignum entry sits after androidx-test-runner; please keep the list sorted to ease future merges.


102-106: Library alias location out of order
bignum should be placed with the other “b…” aliases to stay alphabetically sorted.

.gitignore (1)

37-40: Consolidate Xcode user-data ignore rules
You can replace the three specific paths with one wildcard that will also cover future iOS modules:

-tasks-app-ios/Taskfolio.xcodeproj/xcuserdata/*.xcuserdatad
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/contents.xcworkspacedata
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/xcuserdata/*.xcuserdatad
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/xcshareddata/*
+tasks-app-ios/**/*.xcuserdatad
+tasks-app-ios/**/contents.xcworkspacedata
+tasks-app-ios/**/xcshareddata/*
google/tasks/build.gradle.kts (1)

31-32: Same explanatory comment duplicated across modules
Consider centralising this note (e.g. in the root build script or a convention plugin) rather than copying it into every Gradle file.

tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (1)

63-63: Schema export enabled - ensure Room schema location is configured.

The existing review comment about configuring room.schemaLocation is still valid and applicable.

tasks-core/build.gradle.kts (1)

57-61: Conditional iOS configuration needs safe access to iosTargets.

The past review comment about iosTargets reference potentially breaking compilation is still valid. The conditional check may fail if iosTargets is not defined.

settings.gradle.kts (1)

67-69: iOS module inclusion needs platform gating.

The past review comment about gating the iOS module for non-macOS environments is still valid and should be addressed.

.github/workflows/e2e-tests.yml (1)

10-10: Runner standardization, but consider using ubuntu-latest.

The past review comment about preferring ubuntu-latest over pinned versions is still valid and should be considered.

tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.dev.colorset/Contents.json (1)

16-50: Identical RGB values for light and dark modes impact accessibility.

Both light and dark appearance variants use the same RGB values, which can cause eye strain in dark mode and reduces accessibility.

tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.colorset/Contents.json (1)

16-50: Identical RGB values for light and dark modes reduce accessibility.

Both appearance variants use the same RGB values, which can cause poor user experience in dark mode and reduces accessibility.

website/index.md (2)

22-23: Duplicate “as” wording still present
The previous wording nitpick hasn’t been applied.

-> ℹ️ The application is also available as a desktop (Jvm) application and an iOS application as well
+> ℹ️ The application is also available as a desktop (Jvm) application and an iOS application

19-20: Add descriptive alt-text to all screenshots

Images still violate MD045 and basic accessibility – provide meaningful alt text for each screenshot.

-| ![](assets/screens/task_lists_light.png) | ![](assets/screens/groceries_light.png) | ![](assets/screens/add_task_light.png) | ![](assets/screens/home_dark.png)  |
+| ![Task lists – light](assets/screens/task_lists_light.png) | ![Groceries list – light](assets/screens/groceries_light.png) | ![Add task sheet – light](assets/screens/add_task_light.png) | ![Home screen – dark](assets/screens/home_dark.png) |
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/backHandler.ios.kt (1)

29-34: Scope experimental opt-in internally & use named parameter

Same concern as previously: annotating the whole function leaks the experimental requirement. Opt-in locally and make the enabled argument explicit for readability.

-@ExperimentalComposeUiApi
-@Composable
-actual fun MyBackHandler(canNavigateBack: () -> Boolean, navigateBack: () -> Unit) {
-    BackHandler(canNavigateBack()) {
+@Composable
+actual fun MyBackHandler(canNavigateBack: () -> Boolean, navigateBack: () -> Unit) {
+    @OptIn(ExperimentalComposeUiApi::class)
+    BackHandler(enabled = canNavigateBack()) {
         navigateBack()
     }
 }
tasks-app-shared/build.gradle.kts (1)

162-164: Locale-sensitive capitalisation still unfixed

Internal Gradle APIs aside, replaceFirstChar(Char::uppercase) depends on the default locale and can break on Turkish, etc. Re-use earlier suggestion with Locale.ROOT.

+import java.util.Locale
 ...
-iosTargets.forEach { iosTarget ->
-    add("ksp${iosTarget.replaceFirstChar(Char::uppercase)}", libs.androidx.room.compiler)
-}
+iosTargets.forEach { iosTarget ->
+    add(
+        "ksp${iosTarget.replaceFirstChar { it.titlecase(Locale.ROOT) }}",
+        libs.androidx.room.compiler,
+    )
+}
tasks-app-ios/Taskfolio/ContentView.swift (1)

9-10: Document the no-op update method

Repeat of earlier nitpick – add a clarifying comment to avoid future confusion.

func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
    // No updates needed – ComposeView is self-contained.
}
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/theme/Type.ios.kt (1)

28-44: Address the TODO comments for iOS-specific font sizing.

The typography structure is correct, but many font sizes are marked with TODO comments. Consider using iOS Human Interface Guidelines recommended font sizes or testing on actual devices to ensure proper readability and consistency with iOS design patterns.

tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt (1)

42-45: Consider adding error handling for network monitoring.

The current implementation doesn't handle potential errors from the Network framework APIs. Consider wrapping the callback in a try-catch block to handle any exceptions gracefully.

build.gradle.kts (2)

149-156: iosTargets is undefined – build will not compile.

iosTargets is referenced but never declared in scope (nor provided by the Kotlin MPP DSL).
Gradle will fail with "unresolved reference: iosTargets".


146-148: Locale-dependent capitalisation may yield inconsistent framework names.

replaceFirstChar(Char::uppercase) uses the default JVM locale, which can change between environments (e.g., CI vs. local). Prefer an explicit locale to ensure deterministic framework names.

.github/workflows/ios-app-nightly.yml (3)

40-43: Incorrect indentation breaks workflow parsing.

steps: is at the correct level, but the list items are indented only 4 spaces instead of 6.
GitHub will treat them as top-level keys → the job fails during YAML validation.


45-55: Remove invalid matrix.gradle_module check – breaks expression evaluation.

There is no matrix defined for this job, so ${{ matrix.gradle_module }} is an undefined object and will make the job fail at runtime.


63-73: Harden the xcodebuild invocation & remove dead env var.

  1. IOS_TARGET=simulator is unused by xcodebuild → remove it.
  2. Add an explicit -destination so the same simulator image is chosen every run, eliminating flaky builds.
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/platformModule.ios.kt (1)

73-77: Credentials are stored in a plain JSON file, which is insecure.

For production, use iOS Keychain or another secure storage instead of a plain file to protect sensitive tokens.

.github/workflows/build-apps.yml (2)

38-49: Cache should be available for all builds, not just iOS.

The caching step is currently limited to iOS builds only, which means Android and Desktop builds don't benefit from Gradle caching. This increases build times unnecessarily for non-iOS builds.

Remove the conditional to enable caching for all matrix entries:

- - name: Cache Gradle
-   if: ${{ matrix.gradle_module == 'tasks-app-ios' }}
-   uses: actions/cache@v4
+ - name: Cache Gradle
+   uses: actions/cache@v4

70-72: Remove unused environment variable.

The IOS_TARGET=simulator environment variable is set but not used by the Gradle command, which hard-codes the target architecture. This creates unnecessary noise and implies configurability that doesn't exist.

- elif [ "${gradle_module}" = "tasks-app-ios" ]; then
-   IOS_TARGET=simulator ./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64
+ elif [ "${gradle_module}" = "tasks-app-ios" ]; then
+   ./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64
buildSrc/src/main/kotlin/IosTargetsExt.kt (3)

1-26: Add package declaration to prevent namespace conflicts.

The file is missing a package declaration, which can lead to namespace conflicts and violates Kotlin conventions.

Add a package declaration after the license header:

 */

+package net.opatry.gradle
+
 import org.gradle.api.Project

29-29: Consider scoping the lateinit var to avoid multi-project build issues.

The top-level lateinit var _iosTargets may cause conflicts in multi-project or parallel builds since it's shared globally across all projects.

Consider using a project-scoped approach or thread-local storage to avoid these issues.


48-61: Configuration may become stale due to single initialization.

The iosTargets property is initialized only once, but the value of ios.target can change between different builds or invocations. This can lead to outdated configuration being used.

Consider re-evaluating the configuration on each access or using a more robust caching mechanism that can detect configuration changes.

tasks-app-ios/build.gradle.kts (2)

87-87: Fix license identifier typo.

The license identifier "asdkl" should be "ASDKL" (case-sensitive) to match the Android SDK license entry. This typo will cause AboutLibraries' strict-mode check to fail.

- allowedLicenses.addAll("Apache-2.0", "asdkl", "MIT", "EPL-1.0", "BSD-3-Clause")
+ allowedLicenses.addAll("Apache-2.0", "ASDKL", "MIT", "EPL-1.0", "BSD-3-Clause")

63-65: Ensure parent directory exists before writing config file.

The writeText() call will fail if the Taskfolio/Configuration/ directory doesn't exist, which can happen on fresh clones or clean CI checkouts.

 doLast {
+    configFile.parentFile.mkdirs()
     configFile.writeText(content)
 }
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (3)

70-74: iOS authorization flow is incomplete.

The empty lambda {} passed to requestUserAuthorization indicates that the iOS-specific authorization redirect/callback handling is not implemented, which will cause the authorization flow to fail on iOS.

This aligns with the PR objectives mentioning the need to implement OAuth authentication with a native iOS library. The current implementation is a placeholder that needs proper iOS authorization handling.


77-80: Improve exception handling specificity.

The generic Exception catch is too broad and doesn't provide specific error handling for different failure scenarios.

Consider catching specific OAuth-related exceptions:

- } catch (e: Exception) {
-     error = e.message
-     ongoingAuth = false
- }
+ } catch (e: GoogleAuthenticatorException) {
+     error = "Authentication failed: ${e.message}"
+     ongoingAuth = false
+ } catch (e: Exception) {
+     error = e.message
+     ongoingAuth = false
+ }

96-98: Error message always displayed even when empty.

The error message is rendered even when null or empty, which may cause unnecessary layout space or visual artifacts.

Only render the error message when it has content:

- AnimatedContent(error, label = "authorize_error_message") { message ->
-     Text(message ?: "", color = MaterialTheme.colorScheme.error)
- }
+ AnimatedContent(error, label = "authorize_error_message") { message ->
+     if (!message.isNullOrEmpty()) {
+         Text(message, color = MaterialTheme.colorScheme.error)
+     }
+ }
tasks-app-ios/Taskfolio/Resources/licenses_ios.json (1)

1-1538: Large generated artifact should be excluded from version control.

This large generated JSON file (150KB+) continues to be checked into VCS, causing repository bloat and noisy diffs on dependency updates.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (1)

77-81: Optimize LaunchedEffect to prevent unnecessary calls.

The current implementation triggers state refresh on every recomposition when userState is null, potentially causing unnecessary calls.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt (1)

72-74: Improve error handling with logging for better debuggability.

File write errors are still handled with generic error messages without logging details.

README.md (3)

189-190: Fix emphasis style and grammar for consistency.

Use asterisks for emphasis to match the rest of the document, and add a missing comma.

-The support of iOS works more or less _as-is_ and gets the job done. It's provided without guarantees, use at your own risk.  
-Feedback and contributions are welcome though 🤝.
+The support of iOS works more or less *as-is* and gets the job done. It's provided without guarantees, use at your own risk.  
+Feedback and contributions are welcome, though 🤝.

 > [!NOTE]
-> iOS support is _opt-in_ and disabled by default to avoid unnecessary time and disk usage during the initial Gradle sync when the iOS target isn't required.  
+> iOS support is *opt-in* and disabled by default to avoid unnecessary time and disk usage during the initial Gradle sync when the iOS target isn't required.  

Also applies to: 193-193


227-229: Add blank line before code fence.

Markdown requires blank lines around code fences for proper formatting.

   CODE_SIGNING_REQUIRED=NO
+

This triggers the :tasks-app-shared:embedAndSignAppleFrameworkForXcode Gradle task under the hood.


---

`200-200`: **Add missing article for grammatical correctness.**


```diff
-You can build the `:tasks-app-shared` code for iOS using Gradle (to check if everything compiles on Kotlin side):
+You can build the `:tasks-app-shared` code for iOS using Gradle (to check if everything compiles on the Kotlin side):
tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt (3)

31-31: Extract magic number as a constant.

The padding width is used in both classes and should be a shared constant.

Add at the file level:

private const val POSITION_STRING_WIDTH = 20

Then update both occurrences:

-        get() = rawValue.toString().padStart(20, '0')
+        get() = rawValue.toString().padStart(POSITION_STRING_WIDTH, '0')

Also applies to: 77-77


43-49: Reduce code duplication in compareTo methods.

Both classes have identical comparison logic that should be extracted.

Add a shared comparison function:

private fun compareTaskPositions(thisValue: BigInteger, other: TaskPosition): Int {
    return when (other) {
        is TodoTaskPosition -> thisValue.compareTo(other.rawValue)
        is DoneTaskPosition -> thisValue.compareTo(other.rawValue)
        else -> throw IllegalArgumentException(
            "Unsupported TaskPosition type: ${other::class.simpleName}. " +
            "Only TodoTaskPosition and DoneTaskPosition are supported."
        )
    }
}

Then simplify both implementations:

-        return when (other) {
-            is TodoTaskPosition -> rawValue.compareTo(other.rawValue)
-            is DoneTaskPosition -> rawValue.compareTo(other.rawValue)
-            else -> throw IllegalArgumentException("Only TodoTaskPosition and DoneTaskPosition are supported")
-        }
+        return compareTaskPositions(rawValue, other)

Also applies to: 79-85


66-66: Document the UpperBound constant.

Add a comment to clarify this constant's purpose.

-        private val UpperBound = BigInteger.parseString("9999999999999999999")
+        // Maximum value for positioning completed tasks (19 nines)
+        private val UpperBound = BigInteger.parseString("9999999999999999999")
tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (3)

262-262: Consider lowering iOS deployment target.

iOS 18.2 severely limits device compatibility. Most production apps target iOS 16.0 or 17.0.

-				IPHONEOS_DEPLOYMENT_TARGET = 18.2;
+				IPHONEOS_DEPLOYMENT_TARGET = 16.0;

Also applies to: 323-323


344-344: Move development team ID to configuration files.

Hardcoded team IDs prevent other developers from easily building the project.

Remove these lines and add DEVELOPMENT_TEAM to your xcconfig files (Config.xcconfig and Config.dev.xcconfig) instead. This allows each developer to use their own team ID.

Also applies to: 385-385


174-174: Optimize Kotlin framework build phase.

The alwaysOutOfDate = 1 forces rebuilds and contributes to the 6+ minute build times mentioned in the PR.

Consider removing this line or implementing proper dependency tracking:

-			alwaysOutOfDate = 1;

Additionally, implement input/output file tracking in the build phase to enable incremental builds.

📜 Review details

Configuration used: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c1f3b7b and 3d06179.

⛔ Files ignored due to path filters (30)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
📒 Files selected for processing (46)
  • .github/workflows/build-apps.yml (3 hunks)
  • .github/workflows/e2e-tests.yml (1 hunks)
  • .github/workflows/ios-app-nightly.yml (1 hunks)
  • .gitignore (1 hunks)
  • README.md (5 hunks)
  • build.gradle.kts (3 hunks)
  • buildSrc/build.gradle.kts (1 hunks)
  • buildSrc/src/main/kotlin/IosTargetsExt.kt (1 hunks)
  • google/oauth/build.gradle.kts (1 hunks)
  • google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (1 hunks)
  • google/tasks/build.gradle.kts (1 hunks)
  • gradle/libs.versions.toml (2 hunks)
  • lucide-icons/build.gradle.kts (1 hunks)
  • settings.gradle.kts (1 hunks)
  • tasks-app-ios/Configuration/Config.dev.xcconfig (1 hunks)
  • tasks-app-ios/Configuration/Config.xcconfig (1 hunks)
  • tasks-app-ios/Configuration/Versions.xcconfig (1 hunks)
  • tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.colorset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.dev.colorset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Configuration/Versions.xcconfig (1 hunks)
  • tasks-app-ios/Taskfolio/ContentView.swift (1 hunks)
  • tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (1 hunks)
  • tasks-app-ios/Taskfolio/Info.plist (1 hunks)
  • tasks-app-ios/Taskfolio/Resources/licenses_ios.json (1 hunks)
  • tasks-app-ios/Taskfolio/TaskfolioApp.swift (1 hunks)
  • tasks-app-ios/build.gradle.kts (1 hunks)
  • tasks-app-shared/build.gradle.kts (2 hunks)
  • tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (2 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/authModule.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/platformModule.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/initKoin.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/backHandler.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/theme/Type.ios.kt (1 hunks)
  • tasks-app-shared/src/iosTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.ios.kt (1 hunks)
  • tasks-core/build.gradle.kts (2 hunks)
  • tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt (1 hunks)
  • website/index.md (2 hunks)
  • website/styles.css (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (2)
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (1)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/LoadingIndicator.kt (1)
  • LoadingIndicator (38-59)
tasks-app-ios/Taskfolio/ContentView.swift (1)
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (1)
  • MainViewController (44-113)
🪛 detekt (1.23.8)
buildSrc/src/main/kotlin/IosTargetsExt.kt

[warning] 27-29: Usages of lateinit should be avoided.

(detekt.potential-bugs.LateinitUsage)


[warning] 1-62: The file does not contain a package declaration.

(detekt.potential-bugs.MissingPackageDeclaration)

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt

[warning] 77-77: The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.

(detekt.exceptions.TooGenericExceptionCaught)

🪛 markdownlint-cli2 (0.17.2)
website/index.md

20-20: Images should have alternate text (alt text)

(MD045, no-alt-text)


20-20: Images should have alternate text (alt text)

(MD045, no-alt-text)


20-20: Images should have alternate text (alt text)

(MD045, no-alt-text)


20-20: Images should have alternate text (alt text)

(MD045, no-alt-text)

README.md

39-39: Inline HTML
Element: abbr

(MD033, no-inline-html)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


130-130: Inline HTML
Element: span

(MD033, no-inline-html)


189-189: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


189-189: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


193-193: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


193-193: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


197-197: Inline HTML
Element: details

(MD033, no-inline-html)


198-198: Inline HTML
Element: summary

(MD033, no-inline-html)


228-228: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

🪛 actionlint (1.7.7)
.github/workflows/ios-app-nightly.yml

42-42: description is required in metadata of "Setup JDK & Gradle" action at "/home/jailuser/git/.github/actions/setup-jdk-gradle/action.yml"

(action)


45-45: property "gradle_module" is not defined in object type {}

(expression)

🪛 YAMLlint (1.37.1)
.github/workflows/ios-app-nightly.yml

[warning] 3-3: truthy value should be one of [false, true]

(truthy)


[error] 41-41: wrong indentation: expected 6 but found 4

(indentation)

🪛 LanguageTool
README.md

[uncategorized] ~190-~190: Possible missing comma found.
Context: ... risk. Feedback and contributions are welcome though 🤝. > [!NOTE] > iOS support is ...

(AI_HYDRA_LEO_MISSING_COMMA)


[uncategorized] ~200-~200: You might be missing the article “the” here.
Context: ...dle (to check if everything compiles on Kotlin side): ```bash ./gradlew tasks-app-sha...

(AI_EN_LECTOR_MISSING_DETERMINER_THE)

🔇 Additional comments (17)
tasks-app-ios/Taskfolio/Assets.xcassets/Contents.json (1)

1-6: Asset catalog metadata looks good
Nothing to flag here.

tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (1)

2-2: Copyright year updated appropriately.

The copyright year update to 2025 is appropriate for new changes.

tasks-core/build.gradle.kts (1)

32-33: Good clarification about iOS targets setup.

The comment effectively explains that iOS targets are configured dynamically in the root build script.

google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (1)

30-30: Appropriate change for multiplatform compatibility.

Converting from @JvmInline value class to data class removes JVM-specific optimizations but enables proper multiplatform support for iOS. This is a reasonable trade-off for cross-platform compatibility.

tasks-app-ios/Configuration/Config.xcconfig (1)

1-7: Configuration structure looks good.

The iOS build configuration is properly structured with appropriate app metadata and Google authentication settings. The GCP client IDs are correctly formatted for OAuth integration.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/authModule.ios.kt (1)

28-29: Correct approach for iOS platform dependency injection.

The empty module with explanatory comment is the appropriate implementation for iOS, where the GoogleAuthenticator instance is provided directly by the Swift app rather than through Koin bindings.

tasks-app-ios/Configuration/Config.dev.xcconfig (1)

1-7: Well-structured development configuration.

The development configuration properly separates from production with distinctive app name, dev bundle identifier, simulator target, and separate GCP client IDs. This is a good practice for development workflows.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/initKoin.kt (1)

35-50: Clean dependency injection setup for iOS platform.

The initKoin function properly configures all necessary modules and correctly handles the GoogleAuthenticator injection from the iOS side. The module ordering and configuration looks appropriate for the iOS platform.

tasks-app-shared/src/iosTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.ios.kt (1)

29-31: Proper in-memory database builder implementation for iOS testing.

The implementation correctly uses Room.inMemoryDatabaseBuilder<TasksAppDatabase>() which is appropriate for iOS testing scenarios. Good to see the TODO() was replaced with a proper implementation.

tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/Contents.json (1)

1-122: App icon asset catalog is properly configured.

The asset catalog covers all standard iOS app icon sizes for iPhone, iPad, and marketing purposes. The JSON structure follows Apple's conventions and includes all required icon variants.

tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/Contents.json (1)

1-122: Development app icon asset catalog is properly configured.

The development icon asset catalog mirrors the main icon set, which is appropriate for distinguishing between production and development builds. All standard icon sizes are covered.

build.gradle.kts (1)

204-211: Good defensive programming for cross-project task dependencies.

The implementation correctly uses projectsEvaluated to ensure tasks are available and includes null checks before establishing the dependency. This ensures the iOS framework embedding happens after version configuration.

.github/workflows/build-apps.yml (1)

13-27: Matrix strategy enhancement looks good.

The addition of OS-specific runners for different platforms (Ubuntu for Android/Desktop, macOS for iOS) is well-structured and follows best practices for cross-platform CI/CD.

buildSrc/src/main/kotlin/IosTargetsExt.kt (1)

32-46: Configuration resolution logic is well-structured.

The fallback chain from project property → environment variable → local.properties file provides good flexibility for different development and CI scenarios.

tasks-app-ios/build.gradle.kts (2)

52-66: Xcode version synchronization task is well-implemented.

The task properly handles incremental builds with up-to-date checks and provides clean integration between Gradle and Xcode version management.


68-93: AboutLibraries configuration is comprehensive.

The license collection, export, and validation setup provides good compliance management for the iOS app with appropriate strictness and duplication handling.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (1)

50-94: Component structure and state management are well-implemented.

The UI state management, coroutine handling, and animated content transitions are properly structured and follow Compose best practices.

Comment on lines +1 to +2
BUNDLE_VERSION=1
BUNDLE_SHORT_VERSION_STRING=1.4.3 No newline at end of file
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Consider using standard Xcode keys for better tooling compatibility

Using CURRENT_PROJECT_VERSION and MARKETING_VERSION instead of custom keys would align with Xcode conventions and enable automatic version bumping tools to work without additional configuration.

-BUNDLE_VERSION=1
-BUNDLE_SHORT_VERSION_STRING=1.4.3
+CURRENT_PROJECT_VERSION=1
+MARKETING_VERSION=1.4.3
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
BUNDLE_VERSION=1
BUNDLE_SHORT_VERSION_STRING=1.4.3
CURRENT_PROJECT_VERSION=1
MARKETING_VERSION=1.4.3
🤖 Prompt for AI Agents
In tasks-app-ios/Configuration/Versions.xcconfig at lines 1 to 2, replace the
custom keys BUNDLE_VERSION and BUNDLE_SHORT_VERSION_STRING with the standard
Xcode keys CURRENT_PROJECT_VERSION and MARKETING_VERSION respectively. This
change aligns with Xcode conventions and improves compatibility with automatic
version bumping tools.

Comment on lines +10 to +12
kotlin {
jvmToolchain(17)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Toolchain configuration belongs once, not in every sub-project
jvmToolchain(17) is already repeated across several Gradle files. Move it to a shared convention plugin or the root gradle.properties to avoid divergence.


🏁 Script executed:

#!/bin/bash
rg "jvmToolchain" -n .

Length of output: 600


Centralize JVM Toolchain Configuration

The jvmToolchain(17) block is duplicated across multiple sub-projects. Extract it into a shared convention (e.g. in buildSrc) or configure it once at the root, then remove the inline calls from each build file:

Affected files:

  • buildSrc/build.gradle.kts:11
  • tasks-app-ios/build.gradle.kts:43
  • tasks-core/build.gradle.kts:34
  • tasks-app-shared/build.gradle.kts:58
  • lucide-icons/build.gradle.kts:34
  • tasks-app-android/build.gradle.kts:66
  • tasks-app-desktop/build.gradle.kts:50
  • google/oauth/build.gradle.kts:33
  • google/tasks/build.gradle.kts:33
  • google/oauth-http/build.gradle.kts:30

Suggested fix:

  1. In your root or a convention plugin (under buildSrc), add:
    kotlin {
      jvmToolchain(17)
    }
    
  2. Remove all other jvmToolchain(17) blocks from the sub-project build files above.

This will enforce a single source of truth for your JVM toolchain and avoid future divergence.

🤖 Prompt for AI Agents
In buildSrc/build.gradle.kts around lines 10 to 12, the jvmToolchain(17)
configuration is duplicated across multiple sub-projects. To fix this, move the
kotlin { jvmToolchain(17) } block into a centralized location such as the root
build.gradle.kts or a convention plugin inside buildSrc. Then, remove all other
jvmToolchain(17) blocks from the listed sub-project build files to ensure a
single source of truth for the JVM toolchain configuration.

Comment on lines +6 to +8
init() {
InitKoinKt.doInitKoin(googleAuthenticator: IOSGoogleAuthenticator())
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix function name mismatch and add error handling.

The Swift code calls InitKoinKt.doInitKoin() but the Kotlin function is named initKoin. This will cause a runtime crash when the app starts.

Apply this fix:

-        InitKoinKt.doInitKoin(googleAuthenticator: IOSGoogleAuthenticator())
+        do {
+            InitKoinKt.initKoin(googleAuthenticator: IOSGoogleAuthenticator())
+        } catch {
+            NSLog("Koin initialization failed: \(error)")
+            // Consider showing an error screen or graceful fallback
+        }

This addresses both the function name mismatch and adds the error handling suggested in previous reviews.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In tasks-app-ios/Taskfolio/TaskfolioApp.swift around lines 6 to 8, the function
call uses the incorrect name doInitKoin instead of the correct Kotlin function
name initKoin, which will cause a runtime crash. Rename the function call to
initKoin and wrap the call in a do-catch block to handle any potential errors,
logging or managing them appropriately to prevent app crashes during
initialization.

Comment on lines +5 to +6
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Consider pruning debug-only key CADisableMinimumFrameDurationOnPhone

CADisableMinimumFrameDurationOnPhone is usually kept for profiling and can be rejected during App Store review. Confirm if you still need it in production; otherwise remove it or gate behind a debug build-setting.

🤖 Prompt for AI Agents
In tasks-app-ios/Taskfolio/Info.plist at lines 5 to 6, the key
CADisableMinimumFrameDurationOnPhone is included, which is typically used only
for profiling and may cause App Store review rejection. Review if this key is
necessary for production builds; if not, remove it from the plist or
conditionally include it only in debug builds by gating it behind a debug build
setting.


@OptIn(BetaInteropApi::class)
actual override suspend fun store(tokenCache: TokenCache) {
val json = Json { prettyPrint = true }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Optimize JSON configuration creation.

Creating a new Json instance on each store() call is unnecessary. Consider using a companion object for reuse.

+    companion object {
+        private val json = Json { prettyPrint = true }
+    }
+
     @OptIn(BetaInteropApi::class)
     actual override suspend fun store(tokenCache: TokenCache) {
-        val json = Json { prettyPrint = true }
🤖 Prompt for AI Agents
In
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt
at line 61, the Json instance is created inside the store() method causing
unnecessary repeated instantiation. Move the Json configuration to a companion
object as a single reusable instance to optimize performance and resource usage.

Comment on lines +64 to +66
val nsString = NSString.create(string = json.encodeToString(tokenCache))
val data = nsString.dataUsingEncoding(NSUTF8StringEncoding)
?: error("Failed to encode JSON to NSData")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid throwing errors inside coroutine context.

Calling error() inside withContext can disrupt the coroutine context. Consider returning a failure indicator instead.

-        val success = withContext(Dispatchers.IO) {
-            val nsString = NSString.create(string = json.encodeToString(tokenCache))
-            val data = nsString.dataUsingEncoding(NSUTF8StringEncoding)
-                ?: error("Failed to encode JSON to NSData")
+        val result = withContext(Dispatchers.IO) {
+            val nsString = NSString.create(string = json.encodeToString(tokenCache))
+            val data = nsString.dataUsingEncoding(NSUTF8StringEncoding)
+                ?: return@withContext false
 
             val url = NSURL.fileURLWithPath(filepath)
             data.writeToURL(url, atomically = true)
         }
 
-        if (!success) {
+        if (!result) {
             error("Failed to write token cache to file at $filepath")
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val nsString = NSString.create(string = json.encodeToString(tokenCache))
val data = nsString.dataUsingEncoding(NSUTF8StringEncoding)
?: error("Failed to encode JSON to NSData")
val result = withContext(Dispatchers.IO) {
val nsString = NSString.create(string = json.encodeToString(tokenCache))
val data = nsString.dataUsingEncoding(NSUTF8StringEncoding)
?: return@withContext false
val url = NSURL.fileURLWithPath(filepath)
data.writeToURL(url, atomically = true)
}
if (!result) {
error("Failed to write token cache to file at $filepath")
}
🤖 Prompt for AI Agents
In
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt
around lines 64 to 66, avoid using error() to throw an exception inside the
coroutine context as it can disrupt coroutine execution. Instead of calling
error() when dataUsingEncoding returns null, modify the code to return a failure
indicator such as null or a Result type to signal the failure gracefully without
throwing an exception.

Comment on lines +39 to +74
if !stringScopes.isEmpty {
currentUser.addScopes(stringScopes, presenting: rootViewController) { result, error in
if let error = error {
continuation.resume(throwing: error)
} else if let user = result {
continuation.resume(returning: user.user.userID ?? "")
} else {
continuation.resume(throwing: GoogleSignInError.unknownError)
}
}
} else {
continuation.resume(returning: currentUser.userID ?? "")
}
return
}

GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) { result, error in
if let error = error {
continuation.resume(throwing: error)
} else if let user = result?.user {
if !stringScopes.isEmpty {
user.addScopes(stringScopes, presenting: rootViewController) { scopeResult, scopeError in
if let scopeError = scopeError {
continuation.resume(throwing: scopeError)
} else {
continuation.resume(returning: user.userID ?? "")
}
}
} else {
continuation.resume(returning: user.userID ?? "")
}
} else {
continuation.resume(throwing: GoogleSignInError.unknownError)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Extract duplicated scope handling logic.

The scope handling logic is duplicated between the existing user flow and new sign-in flow.

Consider extracting a helper method:

private func handleScopes(_ scopes: [String], for user: GIDGoogleUser, presenting viewController: UIViewController) async throws -> String {
    if !scopes.isEmpty {
        return try await withCheckedThrowingContinuation { continuation in
            user.addScopes(scopes, presenting: viewController) { result, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else if let user = result?.user ?? result {
                    continuation.resume(returning: user.userID ?? "")
                } else {
                    continuation.resume(throwing: GoogleSignInError.unknownError)
                }
            }
        }
    } else {
        return user.userID ?? ""
    }
}

Then use it in both places to reduce duplication.

🤖 Prompt for AI Agents
In tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift around lines 39 to 73,
the scope handling logic for adding scopes to a user is duplicated in both the
existing user and new sign-in flows. Extract this duplicated logic into a
private async helper method that takes scopes, a user, and a presenting view
controller, and returns the user ID or throws an error. Replace the duplicated
code in both places by calling this new helper method to simplify and reduce
redundancy.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (57)
website/styles.css (1)

155-161: Refactor blockquote styling to use a CSS custom property & reset bottom-margin

The earlier review already covered this: introduce a --accent-colour on :root, replace the hard-coded #6BEACA, and add blockquote p:last-child { margin-bottom: 0; } to tighten spacing.
Nothing has changed since that suggestion.

settings.gradle.kts (1)

67-69: Gate the iOS module include behind a macOS check

Re-iterating the previous feedback: always including ":tasks-app-ios" forces Xcode/Swift toolchain evaluation on non-macOS environments and slows CI.
Wrap the include with an os.name guard.

.github/workflows/e2e-tests.yml (1)

10-11: Avoid pinning the runner to ubuntu-24.04 unless strictly required

The same nitpick as before: prefer ubuntu-latest to stay on a supported image automatically, unless there’s a hard dependency on 24.04.

Also applies to: 38-39

.gitignore (1)

36-40: Condense Xcode user-data ignore patterns with a single wildcard

Prior review noted that you can replace the three explicit paths with:
tasks-app-ios/**/*.xcuserdatad
Same effect, less maintenance.

lucide-icons/build.gradle.kts (1)

32-33: Comment duplication across modules already flagged

This is the same comment duplication issue previously identified across multiple build files.

google/tasks/build.gradle.kts (1)

31-32: Comment duplication already identified

This comment duplication issue was already flagged in previous reviews.

tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (1)

63-63: Room schema export configuration issue already identified

The missing room.schemaLocation compiler argument for exportSchema = true was already flagged in previous reviews.

tasks-app-ios/Configuration/Versions.xcconfig (1)

1-2: Standard Xcode keys recommendation already provided

The suggestion to use CURRENT_PROJECT_VERSION and MARKETING_VERSION instead of custom keys was already provided in previous reviews.

tasks-app-ios/Taskfolio/Configuration/Versions.xcconfig (1)

1-2: Use standard Xcode keys for better tooling compatibility.

The custom keys should be replaced with Xcode-native CURRENT_PROJECT_VERSION and MARKETING_VERSION to enable automatic version bumping tools and maintain consistency with Xcode conventions.

tasks-core/build.gradle.kts (1)

57-61: Potential compilation issue with iosTargets reference.

The direct reference to iosTargets may cause compilation errors if not properly injected into sub-projects. Consider using a safer pattern to check for iOS source set existence.

buildSrc/build.gradle.kts (1)

1-13: Pin the Kotlin version and centralize JVM toolchain configuration.

The script should explicitly pin the Kotlin version to avoid subtle compiler drifts, and the JVM toolchain configuration should be centralized to avoid duplication across multiple build files.

tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.dev.colorset/Contents.json (1)

16-50: Dark mode color accessibility issue already identified.

The identical RGB values for light and dark appearances have been flagged in previous reviews. This remains an unresolved accessibility concern.

tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.colorset/Contents.json (1)

16-50: Dark mode color accessibility issue already identified.

The identical RGB values for light and dark appearances have been flagged in previous reviews. This remains an unresolved accessibility concern.

tasks-app-ios/Taskfolio/TaskfolioApp.swift (1)

6-8: Fix critical function name mismatch and add error handling.

The Swift code calls InitKoinKt.doInitKoin() but the Kotlin function is named initKoin. This will cause a runtime crash when the app starts.

Apply this fix:

-    init() {
-        InitKoinKt.doInitKoin(googleAuthenticator: IOSGoogleAuthenticator())
-    }
+    init() {
+        do {
+            InitKoinKt.initKoin(googleAuthenticator: IOSGoogleAuthenticator())
+        } catch {
+            NSLog("Koin initialization failed: \(error)")
+            // Consider showing an error screen or graceful fallback
+        }
+    }

This addresses both the function name mismatch and adds proper error handling.

website/index.md (1)

19-21: Add missing alt text for screenshot images.

The images in the table are still missing alt text, which violates accessibility standards and markdown-lint rule MD045.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/backHandler.ios.kt (1)

29-35: Scope the experimental API opt-in to minimize caller impact.

The @ExperimentalComposeUiApi annotation on the entire function forces all callers to opt-in to the experimental API, even though the experimental usage is internal.

tasks-app-ios/Taskfolio/Info.plist (2)

5-6: Remove debug-only configuration key for production builds.

The CADisableMinimumFrameDurationOnPhone key is typically used only for profiling and may cause App Store review rejection.


1-27: Consider adding essential iOS configuration keys for production readiness.

The Info.plist is minimal and lacks common iOS configuration keys needed for a production-ready app.

tasks-app-shared/build.gradle.kts (1)

162-164: Fix locale-sensitive capitalization for deterministic builds.

The replaceFirstChar(Char::uppercase) function uses the default JVM locale, which can cause inconsistent behavior across different environments.

tasks-app-ios/Taskfolio/ContentView.swift (1)

4-10: Add explanatory comment to empty lifecycle method.

The empty updateUIViewController method should include a comment explaining why no updates are needed to avoid future confusion.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/theme/Type.ios.kt (1)

28-44: Address the TODO comments for iOS-specific font sizing.

The typography structure is correct, but many font sizes are marked with TODO comments. Consider using iOS Human Interface Guidelines recommended font sizes or testing on actual devices to ensure proper readability and consistency with iOS design patterns.

Would you like me to suggest iOS-appropriate font sizes based on Apple's Human Interface Guidelines, or help create a follow-up issue to track this typography refinement?

tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt (1)

42-45: Consider adding error handling for network monitoring

The current implementation doesn't handle potential errors from the Network framework APIs. Consider wrapping the callback in a try-catch block to handle any exceptions gracefully.

 nw_path_monitor_set_update_handler(monitor) { path ->
+    try {
         val hasInternet = nw_path_get_status(path) == nw_path_status_satisfied
         trySend(hasInternet).isSuccess
+    } catch (e: Exception) {
+        // Log error or emit false as fallback
+        trySend(false).isSuccess
+    }
 }
.github/workflows/ios-app-nightly.yml (3)

45-45: Remove invalid matrix.gradle_module reference

There is no matrix defined for this job, so ${{ matrix.gradle_module }} is undefined and will cause the job to fail at runtime.

-      if: ${{ matrix.gradle_module == 'tasks-app-ios' }}

63-73: Improve xcodebuild configuration for deterministic builds

  1. IOS_TARGET=simulator is unused by xcodebuild and should be removed
  2. Add an explicit -destination to ensure the same simulator is used across runs
           cd tasks-app-ios
-          IOS_TARGET=simulator xcodebuild \
+          xcodebuild \
             -project Taskfolio.xcodeproj \
             -scheme Taskfolio \
             -sdk iphonesimulator \
+            -destination "platform=iOS Simulator,name=iPhone 15" \
             -arch arm64 \

40-42: Fix incorrect YAML indentation for steps

The steps list items are indented with 4 spaces instead of the required 6 spaces. This will cause GitHub Actions to fail during YAML parsing.

     steps:
-    - uses: actions/checkout@v4
-    - uses: ./.github/actions/setup-jdk-gradle
+      - uses: actions/checkout@v4
+      - uses: ./.github/actions/setup-jdk-gradle
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/platformModule.ios.kt (1)

73-77: Security: Use iOS Keychain instead of plain JSON file for credentials

Credentials are stored in a plain JSON file, which is insecure. For production, use iOS Keychain or another secure storage instead of a plain file to protect sensitive tokens.

Would you like me to help implement secure credential storage using the iOS Keychain API?

build.gradle.kts (1)

146-148: Use explicit locale for consistent framework names

replaceFirstChar(Char::uppercase) uses the default JVM locale, which can vary between environments. Use an explicit locale for deterministic framework names.

+import java.util.Locale
 // ... existing code ...
             val frameworkBaseName = project.name.split('-').joinToString("") { part ->
-                part.replaceFirstChar(Char::uppercase)
+                part.replaceFirstChar { it.titlecase(Locale.ROOT) }
             }
.github/workflows/build-apps.yml (2)

38-49: Cache step should benefit all builds, not just iOS.

The caching is currently limited to iOS builds only, which means Android and Desktop builds miss out on .gradle, Maven repository, and Kotlin/Native artifact reuse. This reduces build efficiency for non-iOS jobs.


70-72: Remove unused IOS_TARGET environment variable.

The IOS_TARGET=simulator environment variable is set but never used by the Gradle task, which hard-codes the target architecture in the task name itself (linkDebugFrameworkIosSimulatorArm64). This creates unnecessary noise and implies configurability that doesn't exist.

buildSrc/src/main/kotlin/IosTargetsExt.kt (3)

1-26: Add package declaration to prevent namespace conflicts.

The file is missing a package declaration, which can lead to namespace conflicts and makes the code organization unclear.


29-29: Global lateinit var poses thread safety risks in multi-project builds.

The top-level _iosTargets variable is shared globally and may cause conflicts or unpredictable behavior in multi-project or parallel builds. Consider scoping it to the project level or making it thread-local.


48-61: iOS targets configuration may become stale due to one-time initialization.

The iosTargets property initializes only once, but ios.target configuration can change between builds. This lazy initialization approach may hold outdated values in subsequent builds.

tasks-app-ios/build.gradle.kts (2)

86-88: Fix typo in license identifier to prevent strict check failures.

The license identifier "asdkl" should be "ASDKL" (case-sensitive). This typo will cause AboutLibraries' strict-mode check to fail when it encounters the Android SDK license entry.


52-66: Ensure parent directory exists before writing configuration file.

The configFile.writeText(content) operation will fail if the Taskfolio/ directory doesn't exist, which can happen on fresh clones or clean CI checkouts.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (3)

70-74: Incomplete iOS authorization flow needs implementation.

The empty lambda in requestUserAuthorization = {} indicates that the iOS-specific authorization redirect/callback handling is not implemented. This will likely cause the authorization flow to fail on iOS.


77-80: Replace generic exception catching with specific OAuth exceptions.

Catching generic Exception is too broad and doesn't provide meaningful error handling. Consider catching specific OAuth-related exceptions to provide better user feedback and error handling.


96-98: Only display error message when non-empty.

The error message is always rendered, even when empty, which may cause unnecessary layout space or visual artifacts. Consider only displaying the error when it's non-null and non-empty.

tasks-app-ios/Taskfolio/Resources/licenses_ios.json (1)

1-1539: Large generated artifact checked in

licenses_ios.json is 150 KB+ of generated data. Keeping it in VCS bloats the repo and causes noisy diffs on each dependency bump.

Prefer adding it to .gitignore and generating it in CI (the exportLibraryDefinitions task already exists).

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (2)

77-81: Consider optimizing state refresh logic.

The current implementation triggers refreshUserState() on every recomposition when userState is null. Consider using a more specific trigger to avoid potential unnecessary calls.


90-102: Consider memoizing expensive AboutApp creation.

The AboutApp object and its license-loading lambda are recreated on every recomposition. Consider using remember to cache this expensive operation.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt (3)

61-61: Optimize JSON configuration creation.

Creating a new Json instance on each store() call is unnecessary. Consider using a companion object for reuse.


64-66: Avoid throwing errors inside coroutine context.

Calling error() inside withContext can disrupt the coroutine context. Consider returning a failure indicator instead.


72-74: Improve error handling with logging for better debuggability.

Consider logging the error details before throwing to aid in debugging file write failures.

README.md (4)

41-41: Add alt text to images for accessibility.

The images in the screenshot table should have descriptive alt text.


227-229: Add blank line before code fence for proper markdown formatting.


189-193: Fix emphasis style for consistency.

Use asterisks instead of underscores for emphasis to maintain consistency with the rest of the document.

-The support of iOS works more or less _as-is_ and gets the job done. It's provided without guarantees, use at your own risk.  
+The support of iOS works more or less *as-is* and gets the job done. It's provided without guarantees, use at your own risk.  
 Feedback and contributions are welcome though 🤝.

 > [!NOTE]
-> iOS support is _opt-in_ and disabled by default to avoid unnecessary time and disk usage during the initial Gradle sync when the iOS target isn't required.  
+> iOS support is *opt-in* and disabled by default to avoid unnecessary time and disk usage during the initial Gradle sync when the iOS target isn't required.  

190-190: Minor grammar improvements for better readability.

-Feedback and contributions are welcome though 🤝.
+Feedback and contributions are welcome, though 🤝.
-You can build the `:tasks-app-shared` code for iOS using Gradle (to check if everything compiles on Kotlin side):
+You can build the `:tasks-app-shared` code for iOS using Gradle (to check if everything compiles on the Kotlin side):

Also applies to: 200-200

tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (3)

10-14: Ensure UI access happens on the main thread.

Accessing UI components should be done on the main thread to avoid potential crashes.


31-36: Address the FIXME comment for force parameter handling.

The comment indicates that the force parameter behavior differs from JVM and Android implementations. This could lead to inconsistent behavior across platforms.


39-73: Extract duplicated scope handling logic.

The scope handling logic is duplicated between the existing user flow and new sign-in flow.

tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt (3)

31-31: Extract magic number as a constant.

The padding width 20 is used in both classes. Consider extracting it as a shared constant to improve maintainability and document its purpose.

Also applies to: 77-77


43-49: Reduce code duplication and improve error message clarity.

The compareTo implementations are identical in both classes. Consider extracting shared logic and improving the error message.

Also applies to: 79-85


66-66: Optimize UpperBound initialization.

The UpperBound is currently defined as a parsed string. Since it's a constant, parse it once during initialization to avoid any confusion about repeated parsing.

tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (3)

262-262: Consider lowering the iOS deployment target for broader device compatibility.

The current deployment target of iOS 18.2 is very recent and will significantly limit the number of devices that can install the app. Consider targeting iOS 16.0 or 17.0 for better market reach while still having access to modern APIs.

Also applies to: 323-323


344-344: Move development team ID to configuration files.

The development team ID "9J8LQ9R327" is hardcoded in both Debug and Release configurations. Consider moving this to the xcconfig files for better flexibility across different development teams.

Also applies to: 385-385


174-174: Consider optimizing the Kotlin framework build phase.

The alwaysOutOfDate = 1 setting forces this script to run on every build, which could be contributing to the 6+ minute build times mentioned in the PR summary. Consider implementing incremental build logic or caching mechanisms to avoid unnecessary rebuilds.

gradle/libs.versions.toml (1)

28-28: Keep [versions] section alphabetized (comment repeated from earlier review).

bignum is appended to the end of the [versions] block, breaking the alphabetical ordering we’ve been aiming for in previous reviews.
Please move it into the correct alpha position (after androidx-test-runner or regroup the whole section).

-androidx-test-runner = "1.6.2"
-bignum = "0.3.10"
+androidx-test-runner = "1.6.2"
+
+bignum = "0.3.10"
📜 Review details

Configuration used: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3d06179 and 6463d02.

⛔ Files ignored due to path filters (30)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
📒 Files selected for processing (46)
  • .github/workflows/build-apps.yml (3 hunks)
  • .github/workflows/e2e-tests.yml (1 hunks)
  • .github/workflows/ios-app-nightly.yml (1 hunks)
  • .gitignore (1 hunks)
  • README.md (5 hunks)
  • build.gradle.kts (3 hunks)
  • buildSrc/build.gradle.kts (1 hunks)
  • buildSrc/src/main/kotlin/IosTargetsExt.kt (1 hunks)
  • google/oauth/build.gradle.kts (1 hunks)
  • google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (1 hunks)
  • google/tasks/build.gradle.kts (1 hunks)
  • gradle/libs.versions.toml (2 hunks)
  • lucide-icons/build.gradle.kts (1 hunks)
  • settings.gradle.kts (1 hunks)
  • tasks-app-ios/Configuration/Config.dev.xcconfig (1 hunks)
  • tasks-app-ios/Configuration/Config.xcconfig (1 hunks)
  • tasks-app-ios/Configuration/Versions.xcconfig (1 hunks)
  • tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.colorset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.dev.colorset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Configuration/Versions.xcconfig (1 hunks)
  • tasks-app-ios/Taskfolio/ContentView.swift (1 hunks)
  • tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (1 hunks)
  • tasks-app-ios/Taskfolio/Info.plist (1 hunks)
  • tasks-app-ios/Taskfolio/Resources/licenses_ios.json (1 hunks)
  • tasks-app-ios/Taskfolio/TaskfolioApp.swift (1 hunks)
  • tasks-app-ios/build.gradle.kts (1 hunks)
  • tasks-app-shared/build.gradle.kts (2 hunks)
  • tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (2 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/authModule.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/platformModule.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/initKoin.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/backHandler.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/theme/Type.ios.kt (1 hunks)
  • tasks-app-shared/src/iosTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.ios.kt (1 hunks)
  • tasks-core/build.gradle.kts (2 hunks)
  • tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt (1 hunks)
  • website/index.md (2 hunks)
  • website/styles.css (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (2)
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (1)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/LoadingIndicator.kt (1)
  • LoadingIndicator (38-59)
tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (3)
google/oauth-http/src/commonMain/kotlin/net/opatry/google/auth/HttpGoogleAuthenticator.kt (1)
  • config (70-249)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/presentation/UserViewModel.kt (2)
  • signOut (116-123)
  • signIn (97-114)
google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (1)
  • refreshToken (85-88)
🪛 markdownlint-cli2 (0.17.2)
website/index.md

17-17: Lists should be surrounded by blank lines

(MD032, blanks-around-lists)

README.md

39-39: Inline HTML
Element: abbr

(MD033, no-inline-html)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


130-130: Inline HTML
Element: span

(MD033, no-inline-html)


189-189: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


189-189: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


193-193: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


193-193: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


197-197: Inline HTML
Element: details

(MD033, no-inline-html)


198-198: Inline HTML
Element: summary

(MD033, no-inline-html)


228-228: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

🪛 actionlint (1.7.7)
.github/workflows/ios-app-nightly.yml

42-42: description is required in metadata of "Setup JDK & Gradle" action at "/home/jailuser/git/.github/actions/setup-jdk-gradle/action.yml"

(action)


45-45: property "gradle_module" is not defined in object type {}

(expression)

🪛 YAMLlint (1.37.1)
.github/workflows/ios-app-nightly.yml

[warning] 3-3: truthy value should be one of [false, true]

(truthy)


[error] 41-41: wrong indentation: expected 6 but found 4

(indentation)

🪛 detekt (1.23.8)
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt

[warning] 77-77: The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.

(detekt.exceptions.TooGenericExceptionCaught)

buildSrc/src/main/kotlin/IosTargetsExt.kt

[warning] 27-29: Usages of lateinit should be avoided.

(detekt.potential-bugs.LateinitUsage)


[warning] 1-62: The file does not contain a package declaration.

(detekt.potential-bugs.MissingPackageDeclaration)

🪛 LanguageTool
README.md

[uncategorized] ~190-~190: Possible missing comma found.
Context: ... risk. Feedback and contributions are welcome though 🤝. > [!NOTE] > iOS support is ...

(AI_HYDRA_LEO_MISSING_COMMA)


[uncategorized] ~200-~200: You might be missing the article “the” here.
Context: ...dle (to check if everything compiles on Kotlin side): ```bash ./gradlew tasks-app-sha...

(AI_EN_LECTOR_MISSING_DETERMINER_THE)

⏰ Context from checks skipped due to timeout of 90000ms (5)
  • GitHub Check: unit-tests / ✅ Tests
  • GitHub Check: build-apps / 🔨 Build - 🖥️ Desktop App
  • GitHub Check: coverage / 📊 Coverage
  • GitHub Check: build-apps / 🔨 Build - 🍎 iOS App
  • GitHub Check: build-apps / 🔨 Build - 📱 Android App
🔇 Additional comments (21)
tasks-app-ios/Taskfolio/Assets.xcassets/Contents.json (1)

1-6: Standard iOS asset catalog metadata file

This follows the expected structure for iOS asset catalogs and is correctly configured.

tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (1)

2-2: Copyright year updated appropriately

Good maintenance to keep the copyright year current.

google/oauth/build.gradle.kts (1)

31-32: Good documentation of iOS target configuration strategy.

The comment clearly explains the dynamic iOS target configuration approach, which helps maintain consistency across the multiplatform setup.

tasks-core/build.gradle.kts (1)

32-33: Good documentation of iOS target configuration strategy.

The comment clearly explains the dynamic iOS target configuration approach, maintaining consistency with other modules.

google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (1)

30-36: Confirm @JvmInline Removal and Document Trade-offs

The Scope class is now a regular data class rather than an inline value class. This affects all callers in:

  • tasks-app-shared/src/androidMain (PlayServicesGoogleAuthenticator, UI components)
  • tasks-app-shared/src/iosMain (AuthorizeGoogleTasksButton.ios.kt)
  • tasks-app-shared/src/jvmMain (AuthorizeGoogleTasksButton.jvm.kt)
  • google/oauth/src/commonMain (GoogleAuthenticator.kt)

Removing @JvmInline ensures Kotlin/Native (iOS) compatibility, since JVM-only inline classes don’t mesh with Kotlin/Native targets. However, it introduces object allocations for each Scope instance.

• Please confirm that this change was required to resolve build/runtime issues on iOS/native.
• Consider adding a KDoc or project-level note explaining the interoperability gain versus the minor allocation overhead.

tasks-app-ios/Configuration/Config.xcconfig (1)

6-7: Review hardcoded GCP client IDs for security best practices.

While OAuth client IDs are typically considered public, hardcoding them in configuration files can make credential rotation difficult and may not follow security best practices. Consider if these should be externalized or if this is the intended approach for the iOS build.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/authModule.ios.kt (1)

28-29: LGTM! Clean platform-specific DI implementation.

The empty module with explanatory comment correctly reflects the architectural decision to provide the GoogleAuthenticator instance from the Swift side. This is a valid approach for iOS platform-specific dependency injection.

tasks-app-ios/Configuration/Config.dev.xcconfig (2)

3-5: LGTM! Good development build configuration.

The creative app display name and simulator target are appropriate for development builds, making it easy to distinguish from production.


6-7: Review hardcoded GCP client IDs for development builds.

Similar to the production config, consider if hardcoded development credentials should be externalized or if this is the intended approach for the iOS development build.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/initKoin.kt (1)

35-50: LGTM! Clean Koin initialization implementation.

The function correctly initializes Koin with all necessary modules and properly injects the GoogleAuthenticator. The @Suppress("unused") annotation is appropriate since this function is called from Swift code.

tasks-app-shared/src/iosTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.ios.kt (1)

29-31: LGTM! Proper in-memory database implementation.

The implementation correctly uses Room.inMemoryDatabaseBuilder<TasksAppDatabase>() which addresses the previous TODO() issue and provides proper testing infrastructure for iOS.

tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/Contents.json (1)

1-122: LGTM! Standard Xcode asset catalog configuration.

The asset catalog file is properly structured with comprehensive icon definitions for all required iOS device idioms and scales. The JSON format and metadata are correct.

tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/Contents.json (1)

1-122: LGTM! Standard development asset catalog configuration.

The development asset catalog file follows the same proper structure as the main app icon catalog, with comprehensive icon definitions for all iOS device idioms and scales.

website/index.md (2)

17-17: Good improvement to the alt text.

The change from "Taskfolio Android Application" to "Taskfolio on Play Store" is more accurate and platform-agnostic.


57-60: Well-structured iOS status communication.

The tech stack section clearly communicates that iOS is experimental/draft while maintaining encouragement for users to try it.

tasks-app-shared/build.gradle.kts (1)

56-58: Good organizational improvement.

Moving the jvmToolchain specification and adding the explanatory comment improves code clarity.

tasks-app-ios/Taskfolio/ContentView.swift (1)

12-17: Well-structured SwiftUI-Compose bridge.

The ContentView properly bridges the Compose UI into SwiftUI and appropriately handles keyboard safe area concerns.

build.gradle.kts (1)

149-156: iosTargets extension property confirmed in buildSrc

Verified that Project.iosTargets is defined in buildSrc as an extension getter, so it’s accessible in build.gradle.kts. No changes required.

tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (2)

114-139: Well-structured error handling.

The error enum provides comprehensive coverage of potential failure scenarios with clear, user-friendly error messages.


77-77: No action required: the grant parameter is intentionally unused in the iOS implementation
The GoogleSignIn SDK handles token refresh and authorization flows internally, so the grant argument is only present to satisfy the shared authenticator interface. No changes are needed.

gradle/libs.versions.toml (1)

105-106: Library alias looks good – placed before [bundles].

The alias correctly references the bignum version and sits before the [bundles] header as requested earlier. No further action needed.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (3)

258-258: Consider lowering the iOS deployment target for broader device compatibility.

Also applies to: 319-319


340-340: Move development team ID to configuration files.

Also applies to: 381-381


171-171: Consider optimizing the Kotlin framework build phase.

📜 Review details

Configuration used: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6463d02 and eb8e5c4.

📒 Files selected for processing (1)
  • tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: build-apps / 🔨 Build - 🍎 iOS App
  • GitHub Check: coverage / 📊 Coverage
  • GitHub Check: unit-tests / ✅ Tests
  • GitHub Check: build-apps / 🔨 Build - 🖥️ Desktop App
  • GitHub Check: build-apps / 🔨 Build - 📱 Android App

CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = 9J8LQ9R327;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Enable script sandboxing for enhanced security.

Script sandboxing is disabled in both Debug and Release configurations. This poses a security risk as it allows build scripts to access the entire file system without restrictions.

Apply this diff to enable script sandboxing:

-				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+				ENABLE_USER_SCRIPT_SANDBOXING = YES;

Note: After enabling sandboxing, you may need to adjust the build script to work within the sandbox constraints.

Also applies to: 383-383

🤖 Prompt for AI Agents
In tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj at lines 342 and 383,
ENABLE_USER_SCRIPT_SANDBOXING is set to NO, disabling script sandboxing and
posing a security risk. Change ENABLE_USER_SCRIPT_SANDBOXING to YES in both
Debug and Release configurations to enable sandboxing. After this change, review
and modify any build scripts as needed to comply with sandbox restrictions.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to update this following recent lib updates on main branch.
Need to wait for #269 merge though.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (2)

32-37: Address the FIXME comment for force parameter handling.

The comment indicates that the force parameter behavior differs from JVM and Android implementations. This could lead to inconsistent behavior across platforms.

Would you like me to help implement the proper force parameter behavior to match other platforms? I can open an issue to track this task.


40-74: Extract duplicated scope handling logic.

The scope handling logic is duplicated between the existing user flow and new sign-in flow.

Consider extracting a helper method:

private func handleScopes(_ scopes: [String], for user: GIDGoogleUser, presenting viewController: UIViewController) async throws -> String {
    if !scopes.isEmpty {
        return try await withCheckedThrowingContinuation { continuation in
            user.addScopes(scopes, presenting: viewController) { result, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else if let user = result?.user ?? result {
                    continuation.resume(returning: user.userID ?? "")
                } else {
                    continuation.resume(throwing: GoogleSignInError.unknownError)
                }
            }
        }
    } else {
        return user.userID ?? ""
    }
}

Then use it in both places to reduce duplication.

📜 Review details

Configuration used: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between eb8e5c4 and 0d29b9e.

📒 Files selected for processing (1)
  • tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: licenses-check / ©️ Check licenses (tasks-app-android, src/main/assets/licenses_android.json)
  • GitHub Check: build-apps / 🔨 Build - 📱 Android App
  • GitHub Check: coverage / 📊 Coverage
  • GitHub Check: licenses-check / ©️ Check licenses (tasks-app-desktop, src/main/resources/licenses_desktop.json)
  • GitHub Check: build-apps / 🔨 Build - 🖥️ Desktop App
  • GitHub Check: build-apps / 🔨 Build - 🍎 iOS App
  • GitHub Check: unit-tests / ✅ Tests
🔇 Additional comments (1)
tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (1)

78-112: Confirm background task management for token refresh

I didn’t find any calls to beginBackgroundTask/endBackgroundTask or related APIs around your OAuth token refresh. If getToken(grant:) can run while the app is backgrounded (e.g. in a BGAppRefreshTask or silent‐push handler), wrap the refresh in a background task to prevent suspension or iOS warnings:

• Review all callers of getToken(grant:) and their execution context.
• If background execution is possible, before invoking getToken do:

let bgID = UIApplication.shared.beginBackgroundTask {
    // handle expiration if needed
}
defer { UIApplication.shared.endBackgroundTask(bgID) }
let token = try await authenticator.getToken(grant: grant)

• If getToken is only ever called in the foreground, no changes are needed.

enum GoogleSignInError: Error, LocalizedError {
case noRootViewController
case missingConfiguration
case configurationFailed
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Remove unused error case.

The configurationFailed error case is defined but never used in the code.

 enum GoogleSignInError: Error, LocalizedError {
     case noRootViewController
     case missingConfiguration
-    case configurationFailed
     case userNotSignedIn
     case tokenNotAvailable
     case unknownError

Also update the errorDescription to remove the corresponding case:

         case .missingConfiguration:
             return "No GIDClientID found in Info.plist"
-        case .configurationFailed:
-            return "Failed to configure Google Sign-In"
         case .userNotSignedIn:
             return "User is not signed in"
🤖 Prompt for AI Agents
In tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift at line 119, remove the
unused error case `configurationFailed` from the error enum. Additionally,
update the `errorDescription` computed property or switch statement to remove
the case handling for `configurationFailed` to keep the code consistent and
clean.

if GIDSignIn.sharedInstance.configuration == nil {
let config: GIDConfiguration
if !stringScopes.isEmpty {
config = GIDConfiguration(clientID: clientId, serverClientID: clientId)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify the serverClientID configuration.

Using the same value for both clientID and serverClientID is unusual. Typically, serverClientID should be the OAuth client ID for your backend server, while clientID is for the iOS app.


🌐 Web query:

What is the difference between clientID and serverClientID in Google Sign-In iOS SDK?

💡 Result:

The difference between clientID and serverClientID in the Google Sign-In iOS SDK is centered around their targeted use and the type of OAuth client ID each represents.

Property Purpose & Usage Type of OAuth Client ID
clientID Identifies your iOS app to Google's authentication backend when users sign in. iOS OAuth client ID
serverClientID Used when your app needs to authenticate users with a backend server (for ID tokens or auth code). The ID token will be issued for this audience, enabling your backend to verify the user's identity securely. Web application OAuth client ID (server)

Details:

  • clientID
    This is the OAuth client ID specifically generated for your iOS app in the Google Cloud Console. It is used within the app to initiate the Google Sign-In flow. You set this as the main identifier for your app's configuration[1][2].

  • serverClientID
    This is a separate OAuth client ID, created with the "Web application" type in the Cloud Console. You use it if your app needs to send the user's ID token or an authorization code to your backend server for further processing (such as verifying the user's identity, exchanging for access tokens, etc.). The serverClientID is set in your app's configuration, and it ensures that the issued ID tokens are intended for your backend server, not just the app[1][2][4].

In summary:

  • Use clientID for the Google Sign-In flow within your iOS app.
  • Use serverClientID when you need to pass authentication information to a backend server, so your server can securely identify the user and interact with Google APIs on their behalf.

Both values are set in the GIDConfiguration object, and it's common for apps with a backend server to use both[2].

"Your app needs an OAuth client ID to identify itself to Google's authentication backend. For iOS and macOS apps, the OAuth client ID application type must be configured as iOS... Retrieving a user's ID token requires a second client ID—your server client ID—which represents your backend."[1]
"The client ID of the app from the Google Cloud Console... The client ID of the home server. This will be returned as the audience property of the OpenID Connect ID token."[2]

Citations:


Ensure serverClientID is set to your backend’s Web OAuth client ID

Using the iOS app’s OAuth client ID for both clientID and serverClientID will issue ID tokens with the wrong audience for your server. You should:

  • Create a separate Web application OAuth client ID in the Google Cloud Console.
  • Use your iOS app’s client ID for clientID.
  • Use your backend’s Web application client ID for serverClientID.

For example, in IOSGoogleAuthenticator.swift (line 24):

- config = GIDConfiguration(clientID: clientId, serverClientID: clientId)
+ config = GIDConfiguration(
+     clientID: iosClientID,       // iOS OAuth client ID
+     serverClientID: webClientID  // Web application OAuth client ID for your backend
+ )

File: tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift:24

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift at line 24, the
serverClientID is incorrectly set to the iOS app's OAuth client ID. To fix this,
create a separate Web application OAuth client ID in the Google Cloud Console
and update the serverClientID parameter to use this backend Web client ID, while
keeping the clientID as the iOS app's client ID.

@opatry
Copy link
Owner Author

opatry commented Jul 29, 2025

@github-actions
Copy link
Contributor

Job Summary for Gradle

Build & Test :: build
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
Taskfolio tasks-app-shared:linkDebugFrameworkIosSimulatorArm64 9.0.0 Build Scan not published

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 27

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (1)

30-36: Scope changed to data class: confirm intent (ABI/allocations) and fix KDoc mismatch.

  • Data class allocates and may be an ABI change vs a value class. If this was to improve Swift export, keep it; otherwise consider reverting to a value class for zero-overhead.
  • KDoc for getToken still documents a code param; update to grant.

Option A (keep data class; add rationale in KDoc).
Option B (revert to value class):

-    data class Scope(val value: String) {
+    @JvmInline
+    value class Scope(val value: String) {

Also update KDoc near getToken:

-     * @param code The code obtained through [authorize].
+     * @param grant AuthorizationCode or RefreshToken obtained via [authorize] or stored refresh token.

Also applies to: 102-109

tasks-app-shared/build.gradle.kts (1)

23-27: Import Locale explicitly (if not present)

Required for the change above.

 import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree
+import java.util.Locale
♻️ Duplicate comments (54)
website/styles.css (1)

157-161: Extract accent color and tighten blockquote spacing

Switch the hardcoded color to a CSS var and avoid trailing paragraph spacing inside quotes. This also de-duplicates the accent across the sheet.

Apply within this block:

 blockquote {
   margin-left: 0;
   padding-left: 1.5rem;
-  border-left: 4px solid #6BEACA;
+  border-left: 4px solid var(--accent-colour);
 }
+
+/* avoid double bottom-margin when the quote ends with a <p> */
+blockquote p:last-child { margin-bottom: 0; }

Add once (outside this range), near the top of the file:

:root { --accent-colour: #6BEACA; }
website/index.md (2)

19-21: Screenshots lack alt text (accessibility, MD045)

Add descriptive alt text to each image.

-| ![](assets/screens/task_lists_light.png) | ![](assets/screens/groceries_light.png) | ![](assets/screens/add_task_light.png) | ![](assets/screens/home_dark.png)  |
+| ![Task lists – light](assets/screens/task_lists_light.png) | ![Groceries – light](assets/screens/groceries_light.png) | ![Add task – light](assets/screens/add_task_light.png) | ![Home – dark](assets/screens/home_dark.png) |

22-23: Fix spacing and wording in the informational note

Insert a blank line before the note (MD032) and drop the duplicated “as” + prefer “Compose Multiplatform” wording.

-| ![Task lists – light](assets/screens/task_lists_light.png) | ![Groceries – light](assets/screens/groceries_light.png) | ![Add task – light](assets/screens/add_task_light.png) | ![Home – dark](assets/screens/home_dark.png)  |
-
-> ℹ️ The application is also available as a desktop (Jvm) application and an iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as UI Toolkit).
+| ![Task lists – light](assets/screens/task_lists_light.png) | ![Groceries – light](assets/screens/groceries_light.png) | ![Add task – light](assets/screens/add_task_light.png) | ![Home – dark](assets/screens/home_dark.png)  |
+
+> ℹ️ The application is also available as a desktop (Jvm) application and an iOS application (using [Compose Multiplatform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as the UI toolkit).
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/backHandler.ios.kt (1)

29-35: Scope the experimental opt-in internally; don’t leak it to API consumers. Also name the BackHandler parameter.

Keeping the opt-in inside preserves a clean public surface and avoids forcing callers to opt-in. Naming the parameter improves readability and disambiguates the boolean.

-@ExperimentalComposeUiApi
 @Composable
 actual fun MyBackHandler(canNavigateBack: () -> Boolean, navigateBack: () -> Unit) {
-    BackHandler(canNavigateBack()) {
+    @OptIn(ExperimentalComposeUiApi::class)
+    BackHandler(enabled = canNavigateBack()) {
         navigateBack()
     }
 }
tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt (1)

42-45: Guard the update handler to avoid crashing from unexpected throwables

-    nw_path_monitor_set_update_handler(monitor) { path ->
-        val hasInternet = nw_path_get_status(path) == nw_path_status_satisfied
-        trySend(hasInternet).isSuccess
-    }
+    nw_path_monitor_set_update_handler(monitor) { path ->
+        try {
+            val hasInternet = nw_path_get_status(path) == nw_path_status_satisfied
+            trySend(hasInternet).isSuccess
+        } catch (_: Throwable) {
+            // Be conservative if anything goes wrong in the callback
+            trySend(false).isSuccess
+        }
+    }
lucide-icons/build.gradle.kts (1)

32-33: Avoid repeating the iOS targets note; point to the single source instead.
This comment is duplicated across modules. Prefer referencing the shared extension to prevent drift.

Apply:

-    // Note: iOS targets are conditionally added dynamically in the root build.gradle.kts
+    // iOS targets are wired dynamically; see buildSrc/src/main/kotlin/IosTargetsExt.kt (iosTargets)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (1)

63-64: Room exportSchema requires ksp room.schemaLocation across targets.
With exportSchema = true, the build will fail unless ksp is given room.schemaLocation. Verify it’s set for the shared module and any KMP targets using Room.

Example (in the shared module’s build.gradle.kts):

ksp {
  arg("room.schemaLocation", "$projectDir/schemas")
  // optional:
  // arg("room.incremental", "true")
  // arg("room.generateKotlin", "true")
}

Create the schemas/ directory (commit it).

.github/workflows/e2e-tests.yml (1)

10-10: Prefer ubuntu-latest unless 24.04 is required.
Pinning can cause unexpected maintenance churn. If 24.04 is not strictly needed, switch both jobs to ubuntu-latest; otherwise add a brief comment explaining the pin.

-    runs-on: ubuntu-24.04
+    runs-on: ubuntu-latest

Also applies to: 38-38

tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.dev.colorset/Contents.json (1)

34-47: Adjust dark-mode variant for accessibility.

Use a slightly muted color in dark appearance to reduce glare.

       "components" : {
         "alpha" : "1.000",
-        "blue" : "0.871",
-        "green" : "1.000",
-        "red" : "0.506"
+        "blue" : "0.750",
+        "green" : "0.900",
+        "red" : "0.400"
       }
tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.colorset/Contents.json (1)

40-47: Dark and light variants are identical—tune dark variant.

Consider slightly adjusting SRGB values for the dark appearance to improve comfort.

       "components" : {
         "alpha" : "1.000",
-        "blue" : "0.345",
-        "green" : "0.420",
-        "red" : "0.000"
+        "blue" : "0.445",
+        "green" : "0.520",
+        "red" : "0.100"
       }
.gitignore (1)

36-40: Generalize Xcode user-data ignores.

Broaden patterns to cover future modules and workspaces.

-tasks-app-desktop/bin/
-tasks-app-ios/Taskfolio.xcodeproj/xcuserdata/*.xcuserdatad
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/contents.xcworkspacedata
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/xcuserdata/*.xcuserdatad
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/xcshareddata/*
+tasks-app-desktop/bin/
+tasks-app-ios/**/*.xcuserdatad
+tasks-app-ios/**/*.xcuserstate
+tasks-app-ios/**/*.xcworkspace/contents.xcworkspacedata
+tasks-app-ios/**/*.xcworkspace/xcuserdata/*
+tasks-app-ios/**/*.xcworkspace/xcshareddata/*
.github/workflows/build-apps.yml (2)

38-49: Cache Gradle/Konan for all matrix entries (not only iOS)

Current cache step is gated to iOS; Android/Desktop miss the cache benefits.

-    - name: Cache Gradle
-      if: ${{ matrix.gradle_module == 'tasks-app-ios' }}
+    - name: Cache Gradle
       uses: actions/cache@v4

70-71: Remove unused IOS_TARGET env in Gradle call

The Gradle task already fixes the simulator target/arch; the variable is unused noise.

-        elif [ "${gradle_module}" = "tasks-app-ios" ]; then
-          IOS_TARGET=simulator ./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64
+        elif [ "${gradle_module}" = "tasks-app-ios" ]; then
+          ./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64
buildSrc/src/main/kotlin/IosTargetsExt.kt (1)

1-26: Add a package declaration

Prevents namespace clashes and satisfies static analysis.

  */
 
+package net.opatry.gradle
+
 import org.gradle.api.Project
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt (3)

59-62: Hoist Json instance to a companion object and reuse it.

Avoids per-call allocations and lets you standardize options (e.g., ignoreUnknownKeys).

Apply within-range change and add a companion object outside this range:

-        val json = Json { prettyPrint = true }
+        // Use the shared instance defined in the companion object

And add (outside this hunk):

companion object {
    // Pretty for manual inspection; ignoreUnknownKeys for forward-compat reads
    internal val json = Json {
        prettyPrint = true
        ignoreUnknownKeys = true
    }
}

63-70: Don’t throw inside withContext; return a failure flag and map to a typed exception after.

Throwing via error() here can confuse coroutine cancellation and loses type context; prefer boolean result + IOException, with basic logging.

-        val success = withContext(Dispatchers.IO) {
-            val nsString = NSString.create(string = json.encodeToString(tokenCache))
-            val data = nsString.dataUsingEncoding(NSUTF8StringEncoding)
-                ?: error("Failed to encode JSON to NSData")
-
-            val url = NSURL.fileURLWithPath(filepath)
-            data.writeToURL(url, atomically = true)
-        }
+        val success = withContext(Dispatchers.IO) {
+            val encoded = json.encodeToString(tokenCache)
+            val nsString = NSString.create(string = encoded)
+            val data = nsString.dataUsingEncoding(NSUTF8StringEncoding)
+                ?: return@withContext false
+
+            val url = NSURL.fileURLWithPath(filepath)
+            data.writeToURL(url, atomically = true)
+        }

72-74: Use a typed exception and log context.

Prefer IOException over error(), and log the path once for diagnostics.

-        if (!success) {
-            error("Failed to write token cache to file at $filepath")
-        }
+        if (!success) {
+            println("ERROR: Failed to persist token cache to $filepath")
+            throw java.io.IOException("Failed to write token cache to file at $filepath")
+        }
README.md (3)

82-85: Grammar fixes: “risk” (singular) and pluralize “contributions”.

-  - iOS wasn't initially planned, but a draft version is available (use it at your own risk, there might be dragons 🐉).
-  - Web is not planned any time soon (contributions are welcome 🤝)
+  - iOS wasn't initially planned, but a draft version is available (use it at your own risk—there might be dragons 🐉).
+  - Web is not planned any time soon (contributions are welcome 🤝).

187-195: Consistency: prefer asterisks for emphasis and minor grammar polish.

-The support of iOS works more or less _as-is_ and gets the job done. It's provided without guarantees, use at your own risk.  
-Feedback and contributions are welcome though 🤝.
+The support of iOS works more or less *as-is* and gets the job done. It's provided without guarantees—use at your own risk.  
+Feedback and contributions are welcome, though 🤝.
 
 > [!NOTE]
-> iOS support is _opt-in_ and disabled by default to avoid unnecessary time and disk usage during the initial Gradle sync when the iOS target isn't required.  
+> iOS support is *opt-in* and disabled by default to avoid unnecessary time and disk usage during the initial Gradle sync when the iOS target isn't required.  
 > You can enable it by setting `ios.target` Gradle property to `all`, `simulator` or `device` from either `local.properties` or CLI using `-P`.  
 > When building from Xcode, it automatically sets `-Pios.target=simulator` based on `Config.xcconfig`.

200-204: Minor wording: add “the” before Kotlin side.

-You can build the `:tasks-app-shared` code for iOS using Gradle (to check if everything compiles on Kotlin side):
+You can build the `:tasks-app-shared` code for iOS using Gradle (to check if everything compiles on the Kotlin side):
tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt (3)

31-31: Extract magic number 20 into a named constant.

Avoid repeated literals and document the width.

-        get() = rawValue.toString().padStart(20, '0')
+        get() = rawValue.toString().padStart(POSITION_STRING_WIDTH, '0')

Add (outside this hunk):

private const val POSITION_STRING_WIDTH = 20

Also applies to: 77-77


43-49: Deduplicate compareTo logic and improve the error message.

Single helper avoids divergence and clarifies unsupported types.

-    actual override fun compareTo(other: TaskPosition): Int {
-        return when (other) {
-            is TodoTaskPosition -> rawValue.compareTo(other.rawValue)
-            is DoneTaskPosition -> rawValue.compareTo(other.rawValue)
-            else -> throw IllegalArgumentException("Only TodoTaskPosition and DoneTaskPosition are supported")
-        }
-    }
+    actual override fun compareTo(other: TaskPosition): Int =
+        compareTaskPositions(rawValue, other)

Add (once, outside this hunk):

private fun compareTaskPositions(thisValue: BigInteger, other: TaskPosition): Int =
    when (other) {
        is TodoTaskPosition -> thisValue.compareTo(other.rawValue)
        is DoneTaskPosition -> thisValue.compareTo(other.rawValue)
        else -> throw IllegalArgumentException(
            "Unsupported TaskPosition type: ${other::class.simpleName}. Only TodoTaskPosition and DoneTaskPosition are supported."
        )
    }

Also applies to: 79-85


66-69: Clarify UpperBound intent.

Add a brief comment to document why 19 nines are used.

-        private val UpperBound = BigInteger.parseString("9999999999999999999")
+        // Maximum 19-digit value; subtract epoch millis so more recent completions sort first.
+        private val UpperBound = BigInteger.parseString("9999999999999999999")
tasks-app-ios/Configuration/Versions.xcconfig (1)

1-2: Prefer Xcode-native keys and de-duplicate version sources

Use MARKETING_VERSION/CURRENT_PROJECT_VERSION for tooling compatibility and avoid maintaining two Version.xcconfig files that can drift.

-BUNDLE_VERSION=1
-BUNDLE_SHORT_VERSION_STRING=1.4.3
+CURRENT_PROJECT_VERSION=1
+MARKETING_VERSION=1.4.3

Follow-up: ensure Info.plist/Build Settings reference these keys; if Taskfolio/Configuration/Versions.xcconfig is generated, consider making this file include that single source.

tasks-app-ios/Taskfolio/Configuration/Versions.xcconfig (1)

1-2: Align generated keys with Xcode standards; mark as generated to prevent edits

Have the Gradle task write MARKETING_VERSION/CURRENT_PROJECT_VERSION instead of custom keys and add a header comment indicating the file is generated.

-BUNDLE_VERSION=1
-BUNDLE_SHORT_VERSION_STRING=1.4.3
+CURRENT_PROJECT_VERSION=1
+MARKETING_VERSION=1.4.3

Follow-up: update the updateXcodeVersionConfig task to emit these keys and adjust Xcode mappings accordingly.

tasks-core/build.gradle.kts (1)

61-65: Guard against unresolved iosTargets and missing iosMain

Directly referencing iosTargets can break in subprojects; also only add deps if iosMain exists.

-if (iosTargets.isNotEmpty()) {
-    iosMain.dependencies {
-        implementation(libs.bignum)
-    }
-}
+extensions.findByName("iosMain")?.let {
+    (it as org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet).dependencies {
+        implementation(libs.bignum)
+    }
+}
settings.gradle.kts (1)

67-70: Gate iOS module include to macOS (or a flag) to avoid non-mac build issues

Unconditional include slows/fragilizes non-mac environments. Gate by OS (or a project property).

-// useful to isolate/ease license_ios.json file generation
-// and also sync version from Gradle to Xcode
-include(":tasks-app-ios")
+// useful to isolate/ease license_ios.json file generation
+// and also sync version from Gradle to Xcode
+if (System.getProperty("os.name").contains("Mac", ignoreCase = true)
+    || providers.gradleProperty("includeIos").orNull == "true") {
+    include(":tasks-app-ios")
+}
buildSrc/build.gradle.kts (1)

10-12: Centralize JVM toolchain; remove local duplication

jvmToolchain(17) is repeated across modules. Set it once (root or a convention plugin) and drop the local block here.

-kotlin {
-    jvmToolchain(17)
-}
tasks-app-ios/Taskfolio/ContentView.swift (1)

9-9: Clarify the no-op update

Document the intentional no-op to avoid future confusion.

-    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
+        // No updates needed – ComposeView is self-contained.
+    }
tasks-app-ios/Taskfolio/TaskfolioApp.swift (1)

6-8: Fix wrong Koin init symbol and add error handling

Calling doInitKoin will crash if the exported symbol is initKoin. Also add a do/catch.

     init() {
-        InitKoinKt.doInitKoin(googleAuthenticator: IOSGoogleAuthenticator())
+        do {
+            try InitKoinKt.initKoin(googleAuthenticator: IOSGoogleAuthenticator())
+        } catch {
+            NSLog("Koin initialization failed: \(error)")
+            // TODO: Present a minimal fallback UI if desired
+        }
     }
tasks-app-ios/Taskfolio/Info.plist (2)

5-6: Remove CADisableMinimumFrameDurationOnPhone in release builds

This is typically debug-only and may be flagged in App Store review.

-	<key>CADisableMinimumFrameDurationOnPhone</key>
-	<true/>

11-12: Add basic production keys (orientation/capabilities) for determinism

Keeps behavior explicit across devices; adjust as needed.

 	<key>CFBundleDisplayName</key>
 	<string>$(APP_DISPLAY_NAME)</string>
+	<key>LSRequiresIPhoneOS</key>
+	<true/>
+	<key>UIRequiredDeviceCapabilities</key>
+	<array>
+		<string>armv7</string>
+	</array>
+	<key>UISupportedInterfaceOrientations</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+	</array>
tasks-app-shared/build.gradle.kts (1)

166-168: Locale-stable capitalization for KSP configuration names

Avoid locale-dependent differences in CI vs. local.

-    iosTargets.forEach { iosTarget ->
-        add("ksp${iosTarget.replaceFirstChar(Char::uppercase)}", libs.androidx.room.compiler)
-    }
+    iosTargets.forEach { iosTarget ->
+        add(
+            "ksp${iosTarget.replaceFirstChar { it.titlecase(java.util.Locale.ROOT) }}",
+            libs.androidx.room.compiler,
+        )
+    }
build.gradle.kts (2)

146-148: Locale-stable PascalCase for framework base name

Use ROOT locale to avoid surprises (e.g., Turkish I).

-            val frameworkBaseName = project.name.split('-').joinToString("") { part ->
-                part.replaceFirstChar(Char::uppercase)
-            }
+            val frameworkBaseName = project.name
+                .split('-')
+                .joinToString("") { part ->
+                    part.replaceFirstChar { it.titlecase(java.util.Locale.ROOT) }
+                }

Add import if missing:

+import java.util.Locale

149-161: Avoid unresolved iosTargets by using explicit targets or ensure extension import

As-is, this explodes if iosTargets isn’t defined in scope.

Option A — explicit targets (simple, robust):

-            iosTargets.mapNotNull {
-                when (it) {
-                    "iosX64" -> iosX64()
-                    "iosArm64" -> iosArm64()
-                    "iosSimulatorArm64" -> iosSimulatorArm64()
-                    else -> null
-                }
-            }.forEach { iosTarget ->
+            listOf(
+                iosX64(),
+                iosArm64(),
+                iosSimulatorArm64(),
+            ).forEach { iosTarget ->

Option B — keep dynamic: ensure the buildSrc extension is imported/visible here and verify evaluation order.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/platformModule.ios.kt (1)

73-77: Do not store credentials in plain JSON – use Keychain

Move CredentialsStorage to iOS Keychain; JSON-on-disk is insecure.

I can provide a minimal Keychain-backed CredentialsStorage and migration logic from the JSON file on first run. Want me to draft it?

.github/workflows/ios-app-nightly.yml (3)

44-55: Remove undefined matrix context from cache step.

There is no matrix on this job; referencing matrix.gradle_module breaks evaluation.

-    - name: Cache Gradle
-      if: ${{ matrix.gradle_module == 'tasks-app-ios' }}
-      uses: actions/cache@v4
+    - name: Cache Gradle
+      uses: actions/cache@v4
       with:
         path: |
           .gradle
           $HOME/.m2/repository
           $HOME/.konan
         key: gradle-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts', '**/*.gradle') }}
         restore-keys: |
           gradle-${{ runner.os }}-

61-73: Harden xcodebuild invocation; remove dead env var.

Drop IOS_TARGET=simulator and pin a simulator destination to avoid flaky device selection.

-          IOS_TARGET=simulator xcodebuild \
+          xcodebuild \
             -project Taskfolio.xcodeproj \
             -scheme Taskfolio \
             -sdk iphonesimulator \
+            -destination "platform=iOS Simulator,name=iPhone 15" \
             -arch arm64 \
             -configuration Debug \
             build \
             CODE_SIGNING_ALLOWED=NO \
             CODE_SIGN_IDENTITY="" \
             CODE_SIGNING_REQUIRED=NO

40-43: Fix steps indentation (workflow will fail to parse).

List items under steps must be indented 6 spaces, not 4.

-    steps:
-    - uses: actions/checkout@v4
-    - uses: ./.github/actions/setup-jdk-gradle
+    steps:
+      - uses: actions/checkout@v4
+      - uses: ./.github/actions/setup-jdk-gradle
tasks-app-ios/Taskfolio/Resources/licenses_ios.json (1)

1-1539: Generated artifact should not be committed.

This 150KB+ file is produced by AboutLibraries export and will churn on every dependency bump. Prefer generating in CI and excluding from VCS.

Actions:

  • Add to .gitignore:
+tasks-app-ios/Taskfolio/Resources/licenses_ios.json
  • Ensure the CI step runs exportLibraryDefinitions before Xcode build (it’s already configured via the Gradle task). I can wire the workflow if you want.
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (2)

77-81: Refresh-once semantics are fine; optional simplification.

Your LaunchedEffect(userState) inside the null-guard runs once for the initial null. If you prefer explicit “once”, move the null-check inside LaunchedEffect(Unit).

-    if (userState == null) {
-        LaunchedEffect(userState) {
-            userViewModel.refreshUserState()
-        }
-    }
+    LaunchedEffect(Unit) {
+        if (userState == null) userViewModel.refreshUserState()
+    }

88-103: Memoize AboutApp to avoid recomputing on every recomposition.

Wrap AboutApp in remember keyed by appName/version.

-                    val aboutApp = AboutApp(
-                        name = appName,
-                        version = fullVersion
-                    ) {
+                    val aboutApp = androidx.compose.runtime.remember(appName, fullVersion) {
+                        AboutApp(
+                            name = appName,
+                            version = fullVersion
+                        ) {
                             val path = mainBundle.pathForResource("licenses_ios", "json")
                                 ?: error("licenses_ios.json not found in bundle")
 
                             platform.Foundation.NSString.stringWithContentsOfFile(
                                 path,
                                 encoding = platform.Foundation.NSUTF8StringEncoding,
                                 error = null
-                        ) ?: error("Failed to load licenses_ios.json from bundle path: $path")
-                    }
+                            ) ?: error("Failed to load licenses_ios.json from bundle path: $path")
+                        }
+                    }
tasks-app-ios/build.gradle.kts (2)

86-88: Typo in allowed license ID breaks strict check.

Use ASDKL (case-sensitive) to match the generated file.

-        allowedLicenses.addAll("Apache-2.0", "asdkl", "MIT", "EPL-1.0", "BSD-3-Clause")
+        allowedLicenses.addAll("Apache-2.0", "ASDKL", "MIT", "EPL-1.0", "BSD-3-Clause")

52-66: Create parent directory before writing Versions.xcconfig.

writeText fails on clean checkouts if Taskfolio/Configuration doesn’t exist.

     doLast {
-        configFile.writeText(content)
+        configFile.parentFile.mkdirs()
+        configFile.writeText(content)
     }
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (1)

96-98: Render error text only when non-empty.

Avoid blank placeholder space.

-        AnimatedContent(error, label = "authorize_error_message") { message ->
-            Text(message ?: "", color = MaterialTheme.colorScheme.error)
-        }
+        AnimatedContent(error, label = "authorize_error_message") { message ->
+            if (!message.isNullOrBlank()) {
+                Text(message, color = MaterialTheme.colorScheme.error)
+            }
+        }
tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (5)

258-258: Consider lowering the iOS deployment target for broader device compatibility.

The current deployment target of iOS 18.2 is very recent and will significantly limit the number of devices that can install the app. Consider targeting iOS 16.0 or 17.0 for better market reach while still having access to modern APIs.

Also applies to: 319-319


171-171: Consider optimizing the Kotlin framework build phase.

The alwaysOutOfDate = 1 setting forces this script to run on every build, which could be contributing to the 6+ minute build times mentioned in the PR summary. Consider implementing incremental build logic or caching mechanisms to avoid unnecessary rebuilds.


340-340: Move development team ID to configuration files.

The development team ID "9J8LQ9R327" is hardcoded in both Debug and Release configurations. Consider moving this to the xcconfig files for better flexibility across different development teams.

Also applies to: 381-381


342-342: Enable script sandboxing for enhanced security.

Script sandboxing is disabled in both Debug and Release configurations. This poses a security risk as it allows build scripts to access the entire file system without restrictions.

Apply this diff to enable script sandboxing:

-				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+				ENABLE_USER_SCRIPT_SANDBOXING = YES;

Note: After enabling sandboxing, you may need to adjust the build script to work within the sandbox constraints.

Also applies to: 383-383


186-186: Improve shell script error handling and validation.

The build script could be more robust with better error handling and validation of the Gradle wrapper.

Consider enhancing the script with these improvements:

-			shellScript = "if [ \"${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED}\" = \"YES\" ]; then\n  echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to 'YES'.\"\n  exit 0\nfi\n\nif [ -z \"${IOS_TARGET}\" ]; then\n  echo \"You must define IOS_TARGET to 'all', 'simulator' or 'device' to allow building for iOS.\"\n  exit 1\nfi\n\necho \"Building for '${IOS_TARGET}' target\"\n\ncd \"${SRCROOT}/..\"\n./gradlew :tasks-app-shared:embedAndSignAppleFrameworkForXcode\n";
+			shellScript = "set -e\n\nif [ \"${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED}\" = \"YES\" ]; then\n  echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to 'YES'.\"\n  exit 0\nfi\n\nif [ -z \"${IOS_TARGET}\" ]; then\n  echo \"ERROR: You must define IOS_TARGET to 'all', 'simulator' or 'device' to allow building for iOS.\"\n  exit 1\nfi\n\n# Validate IOS_TARGET value\ncase \"${IOS_TARGET}\" in\n  all|simulator|device)\n    echo \"Building for '${IOS_TARGET}' target\"\n    ;;\n  *)\n    echo \"ERROR: Invalid IOS_TARGET value '${IOS_TARGET}'. Must be 'all', 'simulator', or 'device'.\"\n    exit 1\n    ;;\nesac\n\ncd \"${SRCROOT}/..\"\n\n# Check if gradlew exists\nif [ ! -f \"./gradlew\" ]; then\n  echo \"ERROR: gradlew not found at ${PWD}\"\n  exit 1\nfi\n\n./gradlew :tasks-app-shared:embedAndSignAppleFrameworkForXcode || {\n  echo \"ERROR: Gradle build failed\"\n  exit 1\n}\n";

This adds:

  • set -e for immediate exit on errors
  • Validation of IOS_TARGET values
  • Check for gradlew existence
  • Better error messages
  • Explicit error handling for the Gradle command
tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (4)

24-24: Ensure serverClientID is set to your backend's Web OAuth client ID

Using the iOS app's OAuth client ID for both clientID and serverClientID will issue ID tokens with the wrong audience for your server. You should:

  • Create a separate Web application OAuth client ID in the Google Cloud Console.
  • Use your iOS app's client ID for clientID.
  • Use your backend's Web application client ID for serverClientID.

For example:

- config = GIDConfiguration(clientID: clientId, serverClientID: clientId)
+ config = GIDConfiguration(
+     clientID: clientId,           // iOS OAuth client ID
+     serverClientID: webClientId  // Web application OAuth client ID for your backend
+ )

32-37: Address the FIXME comment for force parameter handling.

The comment indicates that the force parameter behavior differs from JVM and Android implementations. This could lead to inconsistent behavior across platforms.

Would you like me to help implement the proper force parameter behavior to match other platforms? I can open an issue to track this task.


40-49: Extract duplicated scope handling logic.

The scope handling logic is duplicated between the existing user flow and new sign-in flow.

Consider extracting a helper method:

private func handleScopes(_ scopes: [String], for user: GIDGoogleUser, presenting viewController: UIViewController) async throws -> String {
    if !scopes.isEmpty {
        return try await withCheckedThrowingContinuation { continuation in
            user.addScopes(scopes, presenting: viewController) { result, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else if let user = result?.user ?? result {
                    continuation.resume(returning: user.userID ?? "")
                } else {
                    continuation.resume(throwing: GoogleSignInError.unknownError)
                }
            }
        }
    } else {
        return user.userID ?? ""
    }
}

Then use it in both places to reduce duplication.

Also applies to: 61-67


119-119: Remove unused error case.

The configurationFailed error case is defined but never used in the code.

 enum GoogleSignInError: Error, LocalizedError {
     case noRootViewController
     case missingConfiguration
-    case configurationFailed
     case userNotSignedIn
     case tokenNotAvailable
     case unknownError

Also update the errorDescription to remove the corresponding case:

         case .missingConfiguration:
             return "No GIDClientID found in Info.plist"
-        case .configurationFailed:
-            return "Failed to configure Google Sign-In"
         case .userNotSignedIn:
             return "User is not signed in"

Also applies to: 130-131

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 0d29b9e and c1603a1.

⛔ Files ignored due to path filters (30)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
📒 Files selected for processing (46)
  • .github/workflows/build-apps.yml (3 hunks)
  • .github/workflows/e2e-tests.yml (1 hunks)
  • .github/workflows/ios-app-nightly.yml (1 hunks)
  • .gitignore (1 hunks)
  • README.md (5 hunks)
  • build.gradle.kts (3 hunks)
  • buildSrc/build.gradle.kts (1 hunks)
  • buildSrc/src/main/kotlin/IosTargetsExt.kt (1 hunks)
  • google/oauth/build.gradle.kts (1 hunks)
  • google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (1 hunks)
  • google/tasks/build.gradle.kts (1 hunks)
  • gradle/libs.versions.toml (2 hunks)
  • lucide-icons/build.gradle.kts (1 hunks)
  • settings.gradle.kts (1 hunks)
  • tasks-app-ios/Configuration/Config.dev.xcconfig (1 hunks)
  • tasks-app-ios/Configuration/Config.xcconfig (1 hunks)
  • tasks-app-ios/Configuration/Versions.xcconfig (1 hunks)
  • tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.colorset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.dev.colorset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Configuration/Versions.xcconfig (1 hunks)
  • tasks-app-ios/Taskfolio/ContentView.swift (1 hunks)
  • tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (1 hunks)
  • tasks-app-ios/Taskfolio/Info.plist (1 hunks)
  • tasks-app-ios/Taskfolio/Resources/licenses_ios.json (1 hunks)
  • tasks-app-ios/Taskfolio/TaskfolioApp.swift (1 hunks)
  • tasks-app-ios/build.gradle.kts (1 hunks)
  • tasks-app-shared/build.gradle.kts (2 hunks)
  • tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/authModule.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/platformModule.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/initKoin.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/backHandler.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/theme/Type.ios.kt (1 hunks)
  • tasks-app-shared/src/iosTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.ios.kt (1 hunks)
  • tasks-core/build.gradle.kts (2 hunks)
  • tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt (1 hunks)
  • website/index.md (2 hunks)
  • website/styles.css (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
tasks-app-ios/Taskfolio/ContentView.swift (1)
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (1)
  • MainViewController (44-113)
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (4)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/theme/Theme.kt (1)
  • TaskfolioTheme (109-124)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/LoadingPane.kt (1)
  • LoadingPane (36-41)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/TasksApp.kt (1)
  • TasksApp (88-175)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/screen/authorizationScreen.kt (1)
  • AuthorizationScreen (60-92)
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (1)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/LoadingIndicator.kt (1)
  • LoadingIndicator (38-59)
tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (3)
google/oauth-http/src/commonMain/kotlin/net/opatry/google/auth/HttpGoogleAuthenticator.kt (1)
  • config (70-249)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/presentation/UserViewModel.kt (2)
  • signOut (116-123)
  • signIn (97-114)
google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (1)
  • refreshToken (85-88)
🪛 detekt (1.23.8)
buildSrc/src/main/kotlin/IosTargetsExt.kt

[warning] 27-29: Usages of lateinit should be avoided.

(detekt.potential-bugs.LateinitUsage)


[warning] 1-62: The file does not contain a package declaration.

(detekt.potential-bugs.MissingPackageDeclaration)

🪛 LanguageTool
website/index.md

[grammar] ~19-~19: There might be a mistake here.
Context: ...- | ---------------------------------- | | ![](assets/screens/task_lists_light.pn...

(QB_NEW_EN)


[grammar] ~20-~20: Use correct spacing
Context: ...) | | > ℹ️ The application is also available as ...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~22-~22: There might be a mistake here.
Context: ...iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/co...

(QB_NEW_EN_OTHER)


[grammar] ~22-~22: There might be a mistake here.
Context: ...w.jetbrains.com/compose-multiplatform/) as UI Toolkit). ## 🎯 Project intentions ...

(QB_NEW_EN)


[grammar] ~22-~22: Use correct spacing
Context: .../compose-multiplatform/) as UI Toolkit). ## 🎯 Project intentions - [x] Showcase my...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~57-~57: There might be a mistake here.
Context: ...tlinlang.org/), Multiplatform (aka KMP) - Android and Desktop are fully supported....

(QB_NEW_EN)


[grammar] ~58-~58: There might be a mistake here.
Context: ...Android and Desktop are fully supported. - iOS wasn't initially planned, but a draf...

(QB_NEW_EN)


[grammar] ~59-~59: There might be a mistake here.
Context: ...ersion is available (use it at your own risk, there might be dragons 🐉). - Web is ...

(QB_NEW_EN_OTHER)


[grammar] ~59-~59: There might be a mistake here.
Context: ...ur own risk, there might be dragons 🐉). - Web is not planned any time soon (contri...

(QB_NEW_EN)

README.md

[grammar] ~12-~12: Use correct spacing
Context: ...s seen as achievable and worth tracking. Refer to the root project's [`build.grad...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~39-~39: Use correct spacing
Context: ...n the 80/20 rule to nail the essentials. | ![](assets/screens/task_lists_light.pn...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~41-~41: There might be a problem here.
Context: ...ng) | | | --------------------------------------...

(QB_NEW_EN_MERGED_MATCH)


[grammar] ~42-~42: There might be a mistake here.
Context: ...--------------------------------------- |--------------------------------------- ...

(QB_NEW_EN_OTHER)


[grammar] ~42-~42: Use correct spacing
Context: ...- | ---------------------------------- | Taskfolio on Play Store > [!NOTE] > The application is also availa...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~46-~46: There might be a mistake here.
Context: ...ails?id=net.opatry.tasks.app) > [!NOTE] > The application is also available as a...

(QB_NEW_EN)


[grammar] ~47-~47: There might be a mistake here.
Context: ...iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/co...

(QB_NEW_EN_OTHER)


[grammar] ~47-~47: There might be a mistake here.
Context: ...w.jetbrains.com/compose-multiplatform/) as UI Toolkit). ## 🎯 Project intentions ...

(QB_NEW_EN)


[grammar] ~47-~47: Use correct spacing
Context: .../compose-multiplatform/) as UI Toolkit). ## 🎯 Project intentions - [x] Showcase my...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~82-~82: There might be a mistake here.
Context: ...tlinlang.org/), Multiplatform (aka KMP) - Android and Desktop are fully supported....

(QB_NEW_EN)


[grammar] ~83-~83: There might be a mistake here.
Context: ...Android and Desktop are fully supported. - iOS wasn't initially planned, but a draf...

(QB_NEW_EN)


[grammar] ~84-~84: There might be a mistake here.
Context: ...ersion is available (use it at your own risk, there might be dragons 🐉). - Web is ...

(QB_NEW_EN_OTHER)


[grammar] ~84-~84: There might be a mistake here.
Context: ...ur own risk, there might be dragons 🐉). - Web is not planned any time soon (contri...

(QB_NEW_EN)


[grammar] ~187-~187: There might be a mistake here.
Context: ...)

## 🍎 Build for iOS target The support of iOS works more or less _a...

(QB_NEW_EN)


[grammar] ~189-~189: There might be a mistake here.
Context: ...ets the job done. It's provided without guarantees, use at your own risk. Feedback and co...

(QB_NEW_EN_OTHER)


[grammar] ~189-~189: There might be a mistake here.
Context: ...ithout guarantees, use at your own risk. Feedback and contributions are welcome t...

(QB_NEW_EN)


[grammar] ~190-~190: There might be a problem here.
Context: ... risk. Feedback and contributions are welcome though 🤝. > [!NOTE] > iOS support is _opt-in...

(QB_NEW_EN_MERGED_MATCH)


[grammar] ~190-~190: Use correct spacing
Context: ... and contributions are welcome though 🤝. > [!NOTE] > iOS support is opt-in and di...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~194-~194: Use articles correctly
Context: ...sn't required. > You can enable it by setting ios.target Gradle property to all, ...

(QB_NEW_EN_OTHER_ERROR_IDS_11)


[grammar] ~194-~194: There might be a mistake here.
Context: ... ios.target Gradle property to all, simulator or device from either `local.properti...

(QB_NEW_EN_OTHER)


[grammar] ~194-~194: There might be a mistake here.
Context: ...er local.properties or CLI using -P. > When building from Xcode, it automatical...

(QB_NEW_EN)


[grammar] ~195-~195: Use correct spacing
Context: ...t=simulatorbased onConfig.xcconfig. <details> <summary>See details…</summary> You can build the :tasks-app-shared` co...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~200-~200: There might be a mistake here.
Context: ...Gradle (to check if everything compiles on Kotlin side): ```bash ./gradlew tasks-...

(QB_NEW_EN)


[grammar] ~200-~200: Use correct spacing
Context: ... if everything compiles on Kotlin side): bash ./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64 -Pios.target=simulator ### Building & Running from IntelliJ/Android...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~206-~206: Use correct spacing
Context: ...g & Running from IntelliJ/Android Studio You can also use the incubating [Kotlin ...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~209-~209: Use commas correctly
Context: ... to run the app on a device or simulator, and enables debugging of Kotlin code ev...

(QB_NEW_EN_OTHER_ERROR_IDS_33)


[grammar] ~209-~209: Use correct spacing
Context: ...in code even when called from iOS/Swift. It builds the Kotlin code as a native fr...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~211-~211: Use correct spacing
Context: ...ift code (see next section for details). ### Building & Running from Xcode For full ...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~213-~213: There might be a problem here.
Context: ...ils). ### Building & Running from Xcode For full XCFramework build (to be consumed ...

(QB_NEW_EN_MERGED_MATCH)


[grammar] ~215-~215: Use correct spacing
Context: ...debuild(or build directly from Xcode): ```bash cd tasks-app-ios IOS_TARGET=simulator xcodebuild -project Taskfolio.xcodeproj \ -scheme Taskfolio \ -sdk iphonesimulator \ -arch arm64 \ -configuration Debug \ build \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO ``` This triggers the:tasks-app-shared:emb...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~229-~229: Use correct spacing
Context: ...orkForXcode` Gradle task under the hood. For Xcode integration, it's recommended ...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~231-~231: Use correct spacing
Context: ...lugin](https://touchlab.co/xcodekotlin): bash brew install xcode-kotlin xcode-kotlin install When you update Xcode, you'll have to sy...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~238-~238: Use correct spacing
Context: ...e Xcode, you'll have to sync the plugin: bash xcode-kotlin sync If you want to debug the Kotlin code fro...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~246-~246: Use correct spacing
Context: ...in code and see syntax coloring as well.

## ⚖️ License ``` The MIT License (MIT) C...

(QB_NEW_EN_OTHER_ERROR_IDS_5)

🪛 SwiftLint (0.57.0)
tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift

[Warning] 32-32: FIXMEs should be resolved (in Jvm impl, the force means &...)

(todo)


[Warning] 61-61: Unused parameter in a closure should be replaced with _

(unused_closure_parameter)

🪛 actionlint (1.7.7)
.github/workflows/ios-app-nightly.yml

42-42: description is required in metadata of "Setup JDK & Gradle" action at "/home/jailuser/git/.github/actions/setup-jdk-gradle/action.yml"

(action)


45-45: property "gradle_module" is not defined in object type {}

(expression)

🪛 YAMLlint (1.37.1)
.github/workflows/ios-app-nightly.yml

[warning] 3-3: truthy value should be one of [false, true]

(truthy)


[error] 41-41: wrong indentation: expected 6 but found 4

(indentation)

🪛 markdownlint-cli2 (0.17.2)
README.md

39-39: Inline HTML
Element: abbr

(MD033, no-inline-html)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


130-130: Inline HTML
Element: span

(MD033, no-inline-html)


189-189: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


189-189: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


193-193: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


193-193: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


197-197: Inline HTML
Element: details

(MD033, no-inline-html)


198-198: Inline HTML
Element: summary

(MD033, no-inline-html)


228-228: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: licenses-check / ©️ Check licenses (tasks-app-android, src/main/assets/licenses_android.json)
  • GitHub Check: licenses-check / ©️ Check licenses (tasks-app-desktop, src/main/resources/licenses_desktop.json)
  • GitHub Check: unit-tests / ✅ Tests
  • GitHub Check: build-apps / 🔨 Build - 🍎 iOS App
  • GitHub Check: build-apps / 🔨 Build - 📱 Android App
  • GitHub Check: build-apps / 🔨 Build - 🖥️ Desktop App
  • GitHub Check: coverage / 📊 Coverage
🔇 Additional comments (20)
website/index.md (3)

17-17: Alt text improvement for badge — looks good

Clearer, more descriptive alt text.


57-60: KMP targets clarification — good call

Clear separation of fully supported vs. draft targets reduces confusion.


63-63: Typo fix — good

“persistence” correction is correct.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/backHandler.ios.kt (2)

25-28: Import choice LGTM for iOS (ui.backhandler).

Correctly avoids the Android-only activity backhandler.


29-31: Keep the experimental opt-in for Compose MPP BackHandler.
androidx.compose.ui.backhandler.BackHandler in Compose Multiplatform (v1.8.3) remains an experimental API and still requires @OptIn(ExperimentalComposeUiApi::class) and its import. Only the Android-only androidx.activity.compose.BackHandler is stable and opt-in-free. (github.com, developer.android.com)

Likely an incorrect or invalid review comment.

tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt (1)

39-41: Align the monitor type with “internet” reachability (optional)

This avoids reporting “satisfied” for local-only paths.

-    val monitor = nw_path_monitor_create()
+    val monitor = platform.Network.nw_path_monitor_create_with_type(
+        platform.Network.nw_path_monitor_type_internet
+    )

Likely an incorrect or invalid review comment.

tasks-app-ios/Taskfolio/Assets.xcassets/Contents.json (1)

1-6: LGTM: valid asset catalog manifest.
Looks correct for the root Contents.json.

Ensure each Xcode configuration sets ASSETCATALOG_COMPILER_APPICON_NAME appropriately (e.g., AppIcon vs AppIcon.dev) to match the appiconset names.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/authModule.ios.kt (1)

28-29: No duplicate GoogleAuthenticator bindings on iOS. GoogleAuthenticator is registered only once in initKoin; authModule is intentionally empty and not included.

gradle/libs.versions.toml (2)

105-106: Scoped usage of libs.bignum confirmed
Only referenced in the iosMain dependencies block in tasks-core/build.gradle.kts; no other targets include this alias.


28-28: Verify bignum version 0.3.10 is the latest stable
Double-check on Maven Central and the project changelog to confirm there isn’t a newer 0.3.x or 0.4.0 release.

tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/Contents.json (2)

112-116: Verify marketing icon file/name and alpha channel

Ensure the 1024×1024 “ios-marketing” image exists, has no alpha channel, and consider renaming to a neutral filename (e.g., AppIcon-1024.png) to avoid the legacy “[email protected]” confusion.


1-122: All icon assets verified – Running the provided script against tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset shows no missing PNG files, so there are no Xcode warnings or failures to address.

tasks-app-shared/src/iosTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.ios.kt (1)

29-31: LGTM — iOS in-memory Room builder implemented

This unblocks iOS tests relying on an in-memory TasksAppDatabase builder.

google/tasks/build.gradle.kts (1)

31-32: LGTM: central note about iOS targets being added at root is clear.

Keeps per-module build files lean and avoids duplication.

google/oauth/build.gradle.kts (1)

31-32: LGTM: helpful comment clarifying iOS target wiring

No functional changes; the note reduces confusion about where iOS targets are added.

tasks-app-ios/Taskfolio/TaskfolioApp.swift (1)

10-14: Handle OAuth redirect in your SwiftUI App

The proposed call to IOSGoogleAuthenticator().handleOpenURL(_:) won’t compile—handleOpenURL(_:) isn’t implemented. Instead, forward the callback URL to Google Sign-In directly (or wrap it once you add that method):

var body: some Scene {
    WindowGroup {
        ContentView()
            .onOpenURL { url in
                GIDSignIn.sharedInstance.handle(url)
            }
    }
}

If you’d prefer to keep it on your authenticator, first add:

extension IOSGoogleAuthenticator {
    func handleOpenURL(_ url: URL) {
        GIDSignIn.sharedInstance.handle(url)
    }
}

then call .onOpenURL { IOSGoogleAuthenticator().handleOpenURL($0) }.

Likely an incorrect or invalid review comment.

tasks-app-shared/build.gradle.kts (1)

163-169: No action required—iosTargets extension is available The iosTargets property is defined in buildSrc/src/main/kotlin/IosTargetsExt.kt and is visible in this module, so the Gradle configuration will succeed.

build.gradle.kts (1)

204-211: Verify embedAndSignAppleFrameworkForXcode task exists
No registration of embedAndSignAppleFrameworkForXcode was found in :tasks-app-shared—confirm it’s declared in your build scripts under CI or register it explicitly (e.g. via tasks.register(...)).

.github/workflows/ios-app-nightly.yml (1)

42-43: Local action metadata missing description (actionlint).

The step uses ./.github/actions/setup-jdk-gradle, whose action.yml lacks description. Add it there to satisfy actionlint.

Do you want me to open a follow-up patch to update that action’s metadata?

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (1)

97-102: Good: safe file loading with explicit failure.

The Elvis fallback avoids a crashy cast and surfaces a clear error.

Comment on lines +3 to +7
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Quote the “on” key to silence YAMLlint truthy warning.

GitHub Actions accepts quoted keys; this keeps linters quiet.

-on:
+"on":
   schedule:
     - cron: '0 2 * * *'
   workflow_dispatch:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
"on":
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
🧰 Tools
🪛 YAMLlint (1.37.1)

[warning] 3-3: truthy value should be one of [false, true]

(truthy)

🤖 Prompt for AI Agents
.github/workflows/ios-app-nightly.yml lines 3-7: the YAML linter reports a
truthy key warning for unquoted top-level key "on"; to fix it, wrap the key in
quotes (i.e., change on: to "on":) so the workflow file uses a quoted key
acceptable to GitHub Actions and silences YAMLlint.

Comment on lines +27 to +31
// can't use by lazy, we need Project.findProperty not accessible there
@Suppress("ObjectPropertyName")
private lateinit var _iosTargets: List<String>

private val localProperties = Properties()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid global lateinit cache; compute per-project on access

A top-level lateinit shared across builds can bleed between projects and parallel configs. Resolve per access; cost is trivial.

-// can't use by lazy, we need Project.findProperty not accessible there
-@Suppress("ObjectPropertyName")
-private lateinit var _iosTargets: List<String>
+// Resolve per access to avoid cross-project bleed and lateinit pitfalls.

 val Project.iosTargets: List<String>
-    get() {
-        if (!::_iosTargets.isInitialized) {
-            _iosTargets = when (getIosTargetedConfiguration()) {
-                // We ignore "iosX64", not considered as a use case
-                "all" -> listOf("iosArm64", "iosSimulatorArm64")
-                "simulator" -> listOf("iosSimulatorArm64")
-                "device" -> listOf("iosArm64")
-                "none" -> emptyList()
-                else -> emptyList()
-            }
-        }
-        return _iosTargets
-    }
+    get() = when (getIosTargetedConfiguration()?.trim()?.lowercase(Locale.ROOT)) {
+        // We ignore "iosX64", not considered as a use case
+        "all" -> listOf("iosArm64", "iosSimulatorArm64")
+        "simulator" -> listOf("iosSimulatorArm64")
+        "device" -> listOf("iosArm64")
+        "none" -> emptyList()
+        else -> emptyList()
+    }

Also applies to: 48-61

🧰 Tools
🪛 detekt (1.23.8)

[warning] 27-29: Usages of lateinit should be avoided.

(detekt.potential-bugs.LateinitUsage)

🤖 Prompt for AI Agents
In buildSrc/src/main/kotlin/IosTargetsExt.kt around lines 27-31 (and similarly
lines 48-61), remove the top-level lateinit cache and the shared top-level
Properties instance and instead compute values per-Project on access: replace
the global _iosTargets and localProperties with a function or an extension
property that accepts a Project (or uses receiver Project) and constructs a
local Properties and computes the List<String> each time (calling
project.findProperty as needed). Update all usages to call that per-project
accessor so state does not bleed between projects or parallel builds.

Comment on lines +46 to +48
> [!NOTE]
> The application is also available as a desktop (Jvm) application and an iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as UI Toolkit).
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Use the official naming “Compose Multiplatform”.

Minor terminology nit for consistency.

-> The application is also available as a desktop (Jvm) application and an iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as UI Toolkit).
+> The application is also available as a desktop (JVM) application and an iOS application (using [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) as the UI toolkit).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
> [!NOTE]
> The application is also available as a desktop (Jvm) application and an iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as UI Toolkit).
> [!NOTE]
> The application is also available as a desktop (JVM) application and an iOS application (using [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) as the UI toolkit).
🧰 Tools
🪛 LanguageTool

[grammar] ~46-~46: There might be a mistake here.
Context: ...ails?id=net.opatry.tasks.app) > [!NOTE] > The application is also available as a...

(QB_NEW_EN)


[grammar] ~47-~47: There might be a mistake here.
Context: ...iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/co...

(QB_NEW_EN_OTHER)


[grammar] ~47-~47: There might be a mistake here.
Context: ...w.jetbrains.com/compose-multiplatform/) as UI Toolkit). ## 🎯 Project intentions ...

(QB_NEW_EN)


[grammar] ~47-~47: Use correct spacing
Context: .../compose-multiplatform/) as UI Toolkit). ## 🎯 Project intentions - [x] Showcase my...

(QB_NEW_EN_OTHER_ERROR_IDS_5)

🤖 Prompt for AI Agents
In README.md around lines 46 to 48, the text uses the non-standard name "Compose
Multi Platform (aka CMP)"; update it to the official name "Compose
Multiplatform" (e.g., "Compose Multiplatform" with the same URL) and remove the
"aka CMP" parenthetical so the README uses the correct, consistent terminology.

Comment on lines +40 to +75
actual class FileCredentialsStorage actual constructor(private val filepath: String) : CredentialsStorage {
@OptIn(BetaInteropApi::class)
actual override suspend fun load(): TokenCache? {
return withContext(Dispatchers.IO) {
val fileManager = NSFileManager.defaultManager
if (!fileManager.fileExistsAtPath(filepath)) return@withContext null

val data = NSData.dataWithContentsOfFile(filepath)
?: return@withContext null

val content = NSString.create(data, NSUTF8StringEncoding)?.toString()
?: return@withContext null

runCatching {
Json.decodeFromString<TokenCache>(content)
}.getOrNull()
}
}

@OptIn(BetaInteropApi::class)
actual override suspend fun store(tokenCache: TokenCache) {
val json = Json { prettyPrint = true }

val success = withContext(Dispatchers.IO) {
val nsString = NSString.create(string = json.encodeToString(tokenCache))
val data = nsString.dataUsingEncoding(NSUTF8StringEncoding)
?: error("Failed to encode JSON to NSData")

val url = NSURL.fileURLWithPath(filepath)
data.writeToURL(url, atomically = true)
}

if (!success) {
error("Failed to write token cache to file at $filepath")
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Store secrets in Keychain, not a plain file.

Tokens are sensitive; a file (even with NSFileProtection) risks leakage via backups or device compromise. Prefer Keychain for iOS, or at least set NSFileProtectionComplete on the file.

Do you want a follow-up patch providing a Keychain-backed CredentialsStorage with a file fallback?

🤖 Prompt for AI Agents
In
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt
around lines 40-75, the code persists TokenCache to a plain file which is
insecure; replace this with a Keychain-backed implementation (or at minimum add
NSFileProtectionComplete to the file) so tokens are stored in the iOS Keychain.
Implement a KeychainCredentialsStorage that encodes TokenCache to JSON and uses
SecItemAdd / SecItemUpdate / SecItemCopyMatching via Kotlin/Native interop to
store/retrieve the data (use a fixed service/ account keys), return null on
missing item, and throw/log on unrecoverable errors; update the actual class to
use Keychain access and only fall back to secure-file storage if Keychain
operations fail, ensuring proper error handling and tests, and update the
expect/actual wiring so other platforms remain unaffected.

Comment on lines +41 to +57
@OptIn(BetaInteropApi::class)
actual override suspend fun load(): TokenCache? {
return withContext(Dispatchers.IO) {
val fileManager = NSFileManager.defaultManager
if (!fileManager.fileExistsAtPath(filepath)) return@withContext null

val data = NSData.dataWithContentsOfFile(filepath)
?: return@withContext null

val content = NSString.create(data, NSUTF8StringEncoding)?.toString()
?: return@withContext null

runCatching {
Json.decodeFromString<TokenCache>(content)
}.getOrNull()
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Consider tolerant decoding and minimal logging on corrupt file.

Using the shared Json with ignoreUnknownKeys avoids spurious null returns on benign schema drift; optionally log once if decoding fails to aid support.

-            runCatching {
-                Json.decodeFromString<TokenCache>(content)
-            }.getOrNull()
+            runCatching {
+                json.decodeFromString<TokenCache>(content)
+            }.onFailure { println("WARN: Failed to decode token cache at $filepath: ${it.message}") }
+             .getOrNull()

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt
around lines 41 to 57, the current decode uses Json.decodeFromString directly
which returns null on decode errors and loses benign schema-drift data; create
or use a Json instance configured with ignoreUnknownKeys = true for tolerant
decoding, and wrap the decode in runCatching that on failure logs a minimal
one-line warning (e.g., NSLog or the existing logger) with non-sensitive context
about a corrupt token file before returning null so decoding is tolerant but
still minimally observable.

Comment on lines +68 to +70
val url = NSURL.fileURLWithPath(filepath)
data.writeToURL(url, atomically = true)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Pre-create the parent directory before writing.

writeToURL will fail if the directory doesn’t exist (common on first run).

             val url = NSURL.fileURLWithPath(filepath)
+            // Ensure parent directory exists
+            NSFileManager.defaultManager.createDirectoryAtURL(
+                url.URLByDeletingLastPathComponent!!,
+                withIntermediateDirectories = true,
+                attributes = null,
+                error = null
+            )
             data.writeToURL(url, atomically = true)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val url = NSURL.fileURLWithPath(filepath)
data.writeToURL(url, atomically = true)
}
val url = NSURL.fileURLWithPath(filepath)
// Ensure parent directory exists
NSFileManager.defaultManager.createDirectoryAtURL(
url.URLByDeletingLastPathComponent!!,
withIntermediateDirectories = true,
attributes = null,
error = null
)
data.writeToURL(url, atomically = true)
🤖 Prompt for AI Agents
In
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt
around lines 68 to 70, the code writes data to a file URL without ensuring the
parent directory exists, which causes writeToURL to fail on first run; before
calling data.writeToURL(...), obtain the parent directory URL (or path) and
create it using the file manager with intermediate directories enabled, handle
any creation errors (log/propagate) and only then perform the write so the
directory is present.

Comment on lines +17 to +23
[![Taskfolio on Play Store](assets/GetItOnGooglePlay_Badge_Web_color_English.png)](https://play.google.com/store/apps/details?id=net.opatry.tasks.app)

| --------------------------------------- |--------------------------------------- |--------------------------------------- | ---------------------------------- |
| --------------------------------------- |--------------------------------------- | ---------------------------------- | ---------------------------------- |
| ![](assets/screens/task_lists_light.png) | ![](assets/screens/groceries_light.png) | ![](assets/screens/add_task_light.png) | ![](assets/screens/home_dark.png) |

> ℹ️ The application is also available as a desktop (Jvm) application and an iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as UI Toolkit).
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Quick lint/preview checks

Please verify no remaining markdown issues and that the screenshots table renders as intended.

Also applies to: 57-60


🏁 Script executed:

#!/bin/bash
# Check for images missing alt text
rg -nP '!\[\]\(' website/index.md || echo "No empty alt-text images found"

# Show the table block to visually inspect header/separator/row spacing
rg -nC2 'GetItOnGooglePlay_Badge_Web_color_English\.png|screens/task_lists_light\.png' website/index.md

Length of output: 1047


Add alt text and table header for screenshots

  • Replace each empty alt-text (![](…)) on line 20 with meaningful descriptions, e.g. ![Task list view](assets/screens/task_lists_light.png).
  • Insert a header row above the separator (line 19), for example:
    | Task lists | Groceries | Add task | Home (dark) |
    | ---------- | --------- | -------- | ----------- |
    
🧰 Tools
🪛 LanguageTool

[grammar] ~19-~19: There might be a mistake here.
Context: ...- | ---------------------------------- | | ![](assets/screens/task_lists_light.pn...

(QB_NEW_EN)


[grammar] ~20-~20: Use correct spacing
Context: ...) | | > ℹ️ The application is also available as ...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~22-~22: There might be a mistake here.
Context: ...iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/co...

(QB_NEW_EN_OTHER)


[grammar] ~22-~22: There might be a mistake here.
Context: ...w.jetbrains.com/compose-multiplatform/) as UI Toolkit). ## 🎯 Project intentions ...

(QB_NEW_EN)


[grammar] ~22-~22: Use correct spacing
Context: .../compose-multiplatform/) as UI Toolkit). ## 🎯 Project intentions - [x] Showcase my...

(QB_NEW_EN_OTHER_ERROR_IDS_5)

🤖 Prompt for AI Agents
In website/index.md around lines 17 to 23, the screenshot table uses empty alt
text and lacks a header row; replace each image token on line 20 with meaningful
alt text (e.g., "Task lists", "Groceries", "Add task", "Home (dark)") and insert
a header row above the separator on line 19 with corresponding column titles and
a second header separator row (for example: a line with the four column titles,
then a line with dashed separators) so the table has accessible alt text and
proper headers.

Comment on lines +19 to 20
| --------------------------------------- |--------------------------------------- | ---------------------------------- | ---------------------------------- |
| ![](assets/screens/task_lists_light.png) | ![](assets/screens/groceries_light.png) | ![](assets/screens/add_task_light.png) | ![](assets/screens/home_dark.png) |
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Table may render inconsistently without a header row

Some markdown engines require a header line before the separator row. Add an empty header (non-breaking spaces) for consistent rendering.

+| &nbsp; | &nbsp; | &nbsp; | &nbsp; |
 | --------------------------------------- |--------------------------------------- | ---------------------------------- | ---------------------------------- |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| --------------------------------------- |--------------------------------------- | ---------------------------------- | ---------------------------------- |
| ![](assets/screens/task_lists_light.png) | ![](assets/screens/groceries_light.png) | ![](assets/screens/add_task_light.png) | ![](assets/screens/home_dark.png) |
| &nbsp; | &nbsp; | &nbsp; | &nbsp; |
| --------------------------------------- |--------------------------------------- | ---------------------------------- | ---------------------------------- |
| ![](assets/screens/task_lists_light.png) | ![](assets/screens/groceries_light.png) | ![](assets/screens/add_task_light.png) | ![](assets/screens/home_dark.png) |
🧰 Tools
🪛 LanguageTool

[grammar] ~19-~19: There might be a mistake here.
Context: ...- | ---------------------------------- | | ![](assets/screens/task_lists_light.pn...

(QB_NEW_EN)


[grammar] ~20-~20: Use correct spacing
Context: ...) | | > ℹ️ The application is also available as ...

(QB_NEW_EN_OTHER_ERROR_IDS_5)

🤖 Prompt for AI Agents
In website/index.md around lines 19 to 20, the markdown table starts with a
separator row which may render inconsistently; add a header row above the
separator (use non-breaking spaces or a simple empty header like &nbsp;) so the
table has a header line followed by the separator and then the image row,
ensuring consistent rendering across markdown engines.

@opatry opatry force-pushed the ios-target branch 2 times, most recently from 68d54fb to 6be7c6c Compare August 30, 2025 14:01
@github-actions
Copy link
Contributor

Job Summary for Gradle

Build & Test :: build
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
Taskfolio tasks-app-shared:linkDebugFrameworkIosSimulatorArm64 9.0.0 Build Scan not published

@github-actions
Copy link
Contributor

Job Summary for Gradle

Build & Test :: build
Gradle Root Project Requested Tasks Gradle Version Build Outcome Build Scan®
Taskfolio tasks-app-shared:linkDebugFrameworkIosSimulatorArm64 9.0.0 Build Scan not published

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (3)

43-45: KDoc-specified type doesn’t match implementation

Docs say scope is “expressed as a list of Scope” but the property is a String. Either adjust docs or expose a parsed view.

Apply:

- * @property scope The scopes of access granted by the [accessToken] expressed as a list of [Scope].
+ * @property scope Space-delimited scopes granted, as returned by Google (e.g., "openid profile email").

Optionally add a helper:

val OAuthToken.scopes: Set<GoogleAuthenticator.Scope>
  get() = scope.split(' ').filter(String::isNotBlank).map { GoogleAuthenticator.Scope(it) }.toSet()

103-108: KDoc param name is wrong and incomplete

Param is grant, not code; also supports refresh-token flow.

Apply:

- * @param code The code obtained through [authorize].
+ * @param grant Authorization grant: either an auth code from [authorize] or a refresh token.

91-101: Refine requestUserAuthorization callback to use a concrete payload type

  • Change the signature in GoogleAuthenticator.kt from
    requestUserAuthorization: (data: Any) -> Unit
    to something like
    requestUserAuthorization: (payload: AuthorizationPayload) -> Unit
    backed by a small sealed AuthorizationPayload (expect/actual) for type safety.
  • Update the KDoc to clearly describe the payload, e.g.:
    @param requestUserAuthorization Callback invoked with platform-specific payload to launch the user authorization UI.
  • Verify all platform implementations map force = true to prompt=consent + access_type=offline (HTTP already does; add this mapping in your iOS integration).
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (1)

35-44: Wrong Instant import — will not compile. Use kotlinx.datetime.Instant.

kotlin.time.Instant has no parse(String); your converters rely on kotlinx.datetime.Instant.

-import kotlin.time.Instant
+import kotlinx.datetime.Instant
♻️ Duplicate comments (65)
website/styles.css (1)

157-161: Deduplicate accent color and tighten blockquote spacing

Reuse a CSS variable for the site accent and avoid extra bottom gap in multi-paragraph quotes. Also consider logical properties for RTL.

Apply:

+/* site-wide accent */
+:root { --accent-color: #6BEACA; }

 blockquote {
-  margin-left: 0;
-  padding-left: 1.5rem;
-  border-left: 4px solid #6BEACA;
+  margin-left: 0;
+  padding-left: 1.5rem;
+  border-left: 4px solid var(--accent-color);
 }
+
+/* avoid double spacing when last child is a <p> */
+blockquote p:last-child { margin-bottom: 0; }

Optional RTL-safe variant:

-  margin-left: 0;
-  padding-left: 1.5rem;
-  border-left: 4px solid var(--accent-color);
+  margin-inline-start: 0;
+  padding-inline-start: 1.5rem;
+  border-inline-start: 4px solid var(--accent-color);

Tip (outside this hunk): also switch the link color to use the variable for consistency:
a { color: var(--accent-color); }.

README.md (4)

91-91: Use the official “Compose Multiplatform”.

Consistency with earlier occurrences.

-- [Jetpack Compose](https://developer.android.com/jetpack/compose), [Multiplatform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/)
+- [Jetpack Compose](https://developer.android.com/jetpack/compose), [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/)

46-48: Use the official “Compose Multiplatform” naming and tighten the sentence.

Matches prior feedback; also fix JVM capitalization.

-> [!NOTE]
-> The application is also available as a desktop (Jvm) application and an iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as UI Toolkit).
+> [!NOTE]
+> The application is also available as a desktop (JVM) app and an iOS app (using [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) as the UI toolkit).

189-195: Unify emphasis style and improve comma usage.

Matches earlier comments about using asterisks for emphasis and adding the missing comma.

-The support of iOS works more or less _as-is_ and gets the job done. It's provided without guarantees, use at your own risk.  
-Feedback and contributions are welcome though 🤝.
+The support of iOS works more or less *as-is* and gets the job done. It's provided without guarantees; use at your own risk.  
+Feedback and contributions are welcome, though 🤝.
 
-> [!NOTE]
-> iOS support is _opt-in_ and disabled by default to avoid unnecessary time and disk usage during the initial Gradle sync when the iOS target isn't required.  
+> [!NOTE]
+> iOS support is *opt-in* and disabled by default to avoid unnecessary time and disk usage during the initial Gradle sync when the iOS target isn't required.  
 > You can enable it by setting `ios.target` Gradle property to `all`, `simulator` or `device` from either `local.properties` or CLI using `-P`.  
 > When building from Xcode, it automatically sets `-Pios.target=simulator` based on `Config.xcconfig`.

200-204: Grammar: add “the” before “Kotlin side”.

Already noted in prior reviews.

-You can build the `:tasks-app-shared` code for iOS using Gradle (to check if everything compiles on Kotlin side):
+You can build the `:tasks-app-shared` code for iOS using Gradle (to check if everything compiles on the Kotlin side):
tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt (3)

42-45: Guard the update handler

Wrap to prevent rare crashes if interop throws.

 nw_path_monitor_set_update_handler(monitor) { path ->
-        val hasInternet = nw_path_get_status(path) == nw_path_status_satisfied
-        trySend(hasInternet).isSuccess
+        try {
+            val hasInternet = nw_path_get_status(path) == nw_path_status_satisfied
+            trySend(hasInternet).isSuccess
+        } catch (_: Throwable) {
+            trySend(false).isSuccess
+        }
     }

38-53: Prefer a single shared monitor (hot StateFlow) to avoid N monitors

If multiple call sites invoke this, you’ll spin up multiple NWPathMonitors. Expose a shared hot StateFlow via DI and reuse it.

Run to find call sites and assess dedup potential:

#!/bin/bash
# List call sites (excluding build artifacts)
rg -nP '\bnetworkStateFlow\s*\(' -g '!**/build/**' -C2

50-52: Free the NWPathMonitor native reference after cancel

Cancel stops monitoring but doesn’t drop the C-level ref. Release it to avoid a native leak.

     awaitClose {
-        nw_path_monitor_cancel(monitor)
+        nw_path_monitor_cancel(monitor)
+        // Drop native reference to avoid leaks
+        platform.Network.nw_release(monitor)
     }
lucide-icons/build.gradle.kts (1)

32-33: Centralize the repeated iOS-targets note to avoid drift.
Same comment appears across modules; prefer a single source (e.g., build-logic/convention plugin doc or root README) and link to it.

settings.gradle.kts (1)

67-69: Gate iOS module include to macOS to avoid non-mac CI/dev sync issues.
Unconditional include can slow/ break Windows/Linux environments. Gate by OS.

-include(":tasks-app-ios")
+if (System.getProperty("os.name").contains("Mac", ignoreCase = true)) {
+    include(":tasks-app-ios")
+}
tasks-app-ios/Configuration/Versions.xcconfig (1)

1-2: Use Xcode-standard version keys for better tooling compatibility.
Switch to MARKETING_VERSION and CURRENT_PROJECT_VERSION.

-BUNDLE_VERSION=1
-BUNDLE_SHORT_VERSION_STRING=1.4.3
+CURRENT_PROJECT_VERSION=1
+MARKETING_VERSION=1.4.3
tasks-app-ios/Taskfolio/Configuration/Versions.xcconfig (1)

1-2: Switch to Xcode-standard keys (MARKETING_VERSION, CURRENT_PROJECT_VERSION)

Use native keys to keep agvtool/Fastlane and Xcode versioning sane; also simplifies project mapping. Ensure the Gradle writer task updates these keys.

Apply:

-BUNDLE_VERSION=1
-BUNDLE_SHORT_VERSION_STRING=1.4.3
+CURRENT_PROJECT_VERSION=1
+MARKETING_VERSION=1.4.3

Outside this file (Gradle task): update the writer to emit CURRENT_PROJECT_VERSION and MARKETING_VERSION instead of the custom keys.

.github/workflows/e2e-tests.yml (1)

10-10: Justify the pinned Ubuntu runner or revert to ubuntu-latest

Pinning is fine if required (e.g., emulator/KVM quirks). If not strictly needed, prefer ubuntu-latest; otherwise add a rationale comment to avoid churn.

Option A (unpin):

-    runs-on: ubuntu-24.04
+    runs-on: ubuntu-latest

Option B (keep pin, document):

-    runs-on: ubuntu-24.04
+    # Pinned to 24.04 to align with emulator/KVM setup used elsewhere; change with caution.
+    runs-on: ubuntu-24.04
.gitignore (1)

36-40: Generalize Xcode ignores to reduce maintenance

Broaden the patterns to cover future iOS modules and Xcode files without per-path updates.

Apply:

-tasks-app-ios/Taskfolio.xcodeproj/xcuserdata/*.xcuserdatad
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/contents.xcworkspacedata
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/xcuserdata/*.xcuserdatad
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/xcshareddata/*
+tasks-app-ios/**/*.xcuserdatad
+tasks-app-ios/**/*.xcworkspace/contents.xcworkspacedata
+tasks-app-ios/**/*.xcworkspace/xcshareddata/**
+# Optional extras often committed by mistake:
+tasks-app-ios/**/*.xcuserstate
tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/Contents.json (1)

16-20: Remove obsolete iPhone 29x29 1x slot

iPhone 1x assets are deprecated; keeping this often causes missing-file warnings.

Apply:

-    {
-      "filename" : "[email protected]",
-      "idiom" : "iphone",
-      "scale" : "1x",
-      "size" : "29x29"
-    },
tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.dev.colorset/Contents.json (1)

34-50: Adjust dark mode variant for accessibility

Using identical RGB in light/dark can strain eyes; dark mode should be slightly muted.

Apply:

       "color" : {
         "color-space" : "srgb",
         "components" : {
           "alpha" : "1.000",
-          "blue" : "0.871",
-          "green" : "1.000",
-          "red" : "0.506"
+          "blue" : "0.750",
+          "green" : "0.900",
+          "red" : "0.400"
         }
       },
tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.colorset/Contents.json (1)

34-50: Dark mode color identical to light mode

Tweak dark values to improve contrast and comfort.

Apply:

       "color" : {
         "color-space" : "srgb",
         "components" : {
           "alpha" : "1.000",
-          "blue" : "0.345",
-          "green" : "0.420",
-          "red" : "0.000"
+          "blue" : "0.445",
+          "green" : "0.520",
+          "red" : "0.100"
         }
       },
tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/Contents.json (1)

16-20: Drop iPhone 29x29 1x entry

Same rationale as dev icon set; avoids Xcode warnings.

Apply:

-    {
-      "filename" : "[email protected]",
-      "idiom" : "iphone",
-      "scale" : "1x",
-      "size" : "29x29"
-    },
website/index.md (2)

22-22: Wording nit: drop the extra “as well”.

Smoother grammar; same meaning.

-> ℹ️ The application is also available as a desktop (Jvm) application and an iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as UI Toolkit).
+> ℹ️ The application is also available as a desktop (Jvm) application and an iOS application (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as UI Toolkit).

19-20: Fix screenshots table: add header row and alt text (MD045).

Adds accessibility and consistent rendering across Markdown engines.

Apply:

+| Task lists | Groceries | Add task | Home (dark) |
 | --------------------------------------- |--------------------------------------- | ---------------------------------- | ---------------------------------- |
-| ![](assets/screens/task_lists_light.png) | ![](assets/screens/groceries_light.png) | ![](assets/screens/add_task_light.png) | ![](assets/screens/home_dark.png)  |
+| ![Task lists – light](assets/screens/task_lists_light.png) | ![Groceries – light](assets/screens/groceries_light.png) | ![Add task – light](assets/screens/add_task_light.png) | ![Home – dark](assets/screens/home_dark.png) |
.github/workflows/build-apps.yml (2)

38-49: Enable Gradle/Konan cache for all matrix entries (remove iOS-only gate).

Speeds up Android/Desktop too; safe on Ubuntu.

-    - name: Cache Gradle
-      if: ${{ matrix.gradle_module == 'tasks-app-ios' }}
+    - name: Cache Gradle
       uses: actions/cache@v4
       with:
         path: |
           .gradle
           $HOME/.m2/repository
           $HOME/.konan
         key: gradle-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts', '**/*.gradle') }}
         restore-keys: |
           gradle-${{ runner.os }}-

70-72: Remove unused IOS_TARGET env.

The Gradle task hard-codes the target/arch; the env implies configurability that doesn’t exist.

-        elif [ "${gradle_module}" = "tasks-app-ios" ]; then
-          IOS_TARGET=simulator ./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64
+        elif [ "${gradle_module}" = "tasks-app-ios" ]; then
+          ./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64
tasks-app-ios/Taskfolio/Resources/licenses_ios.json (1)

1-1598: Avoid checking in large generated license blobs. Generate in CI and bundle at build time.

This 150KB+ JSON will churn on every dependency bump and bloats the repo.

Proposed approach:

  • Add this path to .gitignore and remove file from VCS.
  • In Gradle, generate AboutLibraries output and copy it into the iOS bundle during CI/Xcode build.

Example Gradle snippet (to place in tasks-app-ios/build.gradle.kts or a shared build script):

tasks.register<Copy>("bundleIosLicenses") {
    dependsOn(":tasks-app-shared:exportLibraryDefinitions")
    from(layout.buildDirectory.file("../tasks-app-shared/build/generated/aboutlibraries/release/libraries.json"))
    into(layout.projectDirectory.dir("Taskfolio/Resources"))
    rename { "licenses_ios.json" }
}

Trigger this from your iOS build job before xcodebuild.

.github/workflows/ios-app-nightly.yml (2)

3-7: Quote “on” to silence YAML truthy warning.

-on:
+"on":
   schedule:
     - cron: '0 2 * * *'
   workflow_dispatch:

41-73: Fix YAML indentation under steps (parse error) and remove invalid matrix reference.

List items must be indented 2 spaces under steps; also there’s no matrix in this job.

-    steps:
-    - uses: actions/checkout@v4
-    - uses: ./.github/actions/setup-jdk-gradle
+    steps:
+      - uses: actions/checkout@v4
+      - uses: ./.github/actions/setup-jdk-gradle

-    - name: Cache Gradle
-      if: ${{ matrix.gradle_module == 'tasks-app-ios' }}
-      uses: actions/cache@v4
+      - name: Cache Gradle
+        uses: actions/cache@v4
         with:
           path: |
             .gradle
             $HOME/.m2/repository
             $HOME/.konan
           key: gradle-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts', '**/*.gradle') }}
           restore-keys: |
             gradle-${{ runner.os }}-

-    - name: 🔓 Decrypt secrets
-      env:
-        PLAYSTORE_SECRET_PASSPHRASE: ${{ secrets.PLAYSTORE_SECRET_PASSPHRASE }}
-      run: ./_ci/decrypt_secrets.sh
+      - name: 🔓 Decrypt secrets
+        env:
+          PLAYSTORE_SECRET_PASSPHRASE: ${{ secrets.PLAYSTORE_SECRET_PASSPHRASE }}
+        run: ./_ci/decrypt_secrets.sh

-    - name: 🔨 Build
-      run: |
-          cd tasks-app-ios
-          IOS_TARGET=simulator xcodebuild \
-            -project Taskfolio.xcodeproj \
-            -scheme Taskfolio \
-            -sdk iphonesimulator \
-            -arch arm64 \
-            -configuration Debug \
-            build \
-            CODE_SIGNING_ALLOWED=NO \
-            CODE_SIGN_IDENTITY="" \
-            CODE_SIGNING_REQUIRED=NO
+      - name: 🔨 Build
+        run: |
+          cd tasks-app-ios
+          xcodebuild \
+            -project Taskfolio.xcodeproj \
+            -scheme Taskfolio \
+            -sdk iphonesimulator \
+            -destination "platform=iOS Simulator,name=iPhone 15" \
+            -arch arm64 \
+            -configuration Debug \
+            build \
+            CODE_SIGNING_ALLOWED=NO \
+            CODE_SIGN_IDENTITY="" \
+            CODE_SIGNING_REQUIRED=NO
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/theme/Type.ios.kt (1)

28-44: Adopt Material 3 sizes and remove TODOs for consistent readability.

Current body/label sizes are small; align with M3 defaults.

 internal actual val Typography: androidx.compose.material3.Typography = androidx.compose.material3.Typography(
-    displayLarge = TextStyle(fontSize = 57.sp), // TODO adjust
-    displayMedium = TextStyle(fontSize = 45.sp), // TODO adjust
-    displaySmall = TextStyle(fontSize = 36.sp), // TODO adjust
-    headlineLarge = TextStyle(fontSize = 32.sp), // TODO adjust
-    headlineMedium = TextStyle(fontSize = 28.sp), // TODO adjust
-    headlineSmall = TextStyle(fontSize = 24.sp), // TODO adjust
-    titleLarge = TextStyle(fontSize = 22.sp), // TODO adjust
-    titleMedium = TextStyle(fontSize = 16.sp), // TODO adjust
-    titleSmall = TextStyle(fontSize = 14.sp), // TODO adjust
-    bodyLarge = TextStyle(fontSize = 14.sp),
-    bodyMedium = TextStyle(fontSize = 12.sp),
-    bodySmall = TextStyle(fontSize = 10.sp),
-    labelLarge = TextStyle(fontSize = 12.sp),
-    labelMedium = TextStyle(fontSize = 10.sp),
-    labelSmall = TextStyle(fontSize = 9.sp),
+    displayLarge = TextStyle(fontSize = 57.sp),
+    displayMedium = TextStyle(fontSize = 45.sp),
+    displaySmall = TextStyle(fontSize = 36.sp),
+    headlineLarge = TextStyle(fontSize = 32.sp),
+    headlineMedium = TextStyle(fontSize = 28.sp),
+    headlineSmall = TextStyle(fontSize = 24.sp),
+    titleLarge = TextStyle(fontSize = 22.sp),
+    titleMedium = TextStyle(fontSize = 16.sp),
+    titleSmall = TextStyle(fontSize = 14.sp),
+    bodyLarge = TextStyle(fontSize = 16.sp),
+    bodyMedium = TextStyle(fontSize = 14.sp),
+    bodySmall = TextStyle(fontSize = 12.sp),
+    labelLarge = TextStyle(fontSize = 14.sp),
+    labelMedium = TextStyle(fontSize = 12.sp),
+    labelSmall = TextStyle(fontSize = 11.sp),
 )
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (1)

63-64: exportSchema=true requires Room KSP arg room.schemaLocation.

Re-flagging: set the KSP argument in Gradle for all KMP targets to avoid build failures.

#!/bin/bash
# Verify that Room schemaLocation is configured somewhere in the build
rg -n --pretty -C2 'room\.schemaLocation' -- \
  build.gradle.kts settings.gradle.kts gradle.properties \
  || rg -n --pretty -C2 'room\.schemaLocation' -g '!**/build/**'

If missing, add in the shared build config:

ksp { arg("room.schemaLocation", "$projectDir/schemas") }
tasks-core/build.gradle.kts (1)

61-65: Avoid unresolved iosTargets; guard on iosMain presence.

Prevents failures when the extra isn’t exported to subprojects.

-        if (iosTargets.isNotEmpty()) {
-            iosMain.dependencies {
-                implementation(libs.bignum)
-            }
-        }
+        // Add only if iosMain exists (root may add iOS targets dynamically)
+        findByName("iosMain")?.dependencies {
+            implementation(libs.bignum)
+        }
buildSrc/build.gradle.kts (1)

10-12: Centralize JVM toolchain; remove duplicates in subprojects.

Configure jvmToolchain(17) once (root or a convention plugin) to avoid drift; drop per-module copies.

#!/bin/bash
# Find all jvmToolchain declarations to centralize them
rg -n --pretty 'jvmToolchain\s*\(' -g '!**/build/**'
tasks-app-ios/Configuration/Config.dev.xcconfig (1)

3-7: Quote display name; annotate OAuth IDs.

Prevents xcconfig parsing issues and clarifies intent.

-APP_DISPLAY_NAME=T4skf0l10 d3v
+APP_DISPLAY_NAME="T4skf0l10 d3v"
 APP_ID=net.opatry.tasks.app.dev
 IOS_TARGET=simulator
-GCP_CLIENT_ID=191682949161-ockace96gikfsif7hoa9h80p2r096iu4.apps.googleusercontent.com
-GCP_REVERSED_CLIENT_ID=com.googleusercontent.apps.191682949161-ockace96gikfsif7hoa9h80p2r096iu4
+// OAuth client IDs for PKCE mobile client; safe to embed.
+GCP_CLIENT_ID=191682949161-ockace96gikfsif7hoa9h80p2r096iu4.apps.googleusercontent.com
+GCP_REVERSED_CLIENT_ID=com.googleusercontent.apps.191682949161-ockace96gikfsif7hoa9h80p2r096iu4
#!/bin/bash
# Ensure Info.plist contains the reversed client ID scheme for Google Sign-In callbacks
fd -a 'Info.plist' tasks-app-ios | while read -r f; do
  echo "Checking $f"
  rg -n '<key>CFBundleURLSchemes</key>' -n -C2 "$f"
  rg -n 'com\.googleusercontent\.apps\.191682949161-ockace96gikfsif7hoa9h80p2r096iu4' "$f" || echo "MISSING in $f"
done
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/authModule.ios.kt (1)

28-29: Silence unused parameter warning on iOS actual.

gcpClientId is intentionally unused; add a suppression to avoid noise.

-actual fun authModule(gcpClientId: String): Module = module {}
+@Suppress("UNUSED_PARAMETER")
+actual fun authModule(gcpClientId: String): Module = module {}
tasks-app-ios/Configuration/Config.xcconfig (1)

3-7: Quote display name and document OAuth client ID.

Keeps config consistent and preempts false-positive “secret” scanners.

-APP_DISPLAY_NAME=Taskfolio
+APP_DISPLAY_NAME="Taskfolio"
 APP_ID=net.opatry.tasks.app
 IOS_TARGET=all
-GCP_CLIENT_ID=191682949161-79vl4dcpf9lppj5cj5k79tpqhv5ab10u.apps.googleusercontent.com
+// OAuth client ID for mobile (PKCE) clients — public identifier, safe to embed
+GCP_CLIENT_ID=191682949161-79vl4dcpf9lppj5cj5k79tpqhv5ab10u.apps.googleusercontent.com
 GCP_REVERSED_CLIENT_ID=com.googleusercontent.apps.191682949161-79vl4dcpf9lppj5cj5k79tpqhv5ab10u
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/initKoin.kt (1)

32-33: Make Koin initialization idempotent.

Avoids IllegalStateException if init is called multiple times (e.g., multi-scene).

 import net.opatry.tasks.app.di.utilModule
-import org.koin.core.context.startKoin
+import org.koin.core.context.startKoin
+import org.koin.core.context.GlobalContext
 import org.koin.dsl.module
 
 @Suppress("unused")
 fun initKoin(googleAuthenticator: GoogleAuthenticator) {
-    startKoin {
+    if (GlobalContext.getOrNull() != null) return
+    startKoin {
         modules(
             utilModule,
             loggingModule,
             platformModule("ios"),
             dataModule,
             module {
                 single { googleAuthenticator }
             },
             networkModule,
             tasksAppModule,
         )
     }
 }

To verify call sites aren’t accidentally invoking it multiple times, run:

#!/bin/bash
rg -nC2 'initKoin\(' tasks-app-ios tasks-app-shared

Also applies to: 35-50

tasks-app-ios/Taskfolio/TaskfolioApp.swift (1)

6-8: Fix Kotlin bridge call: wrong function name.

Use initKoin (no do prefix) to match the Kotlin declaration.

-        InitKoinKt.doInitKoin(googleAuthenticator: IOSGoogleAuthenticator())
+        InitKoinKt.initKoin(googleAuthenticator: IOSGoogleAuthenticator())
tasks-app-ios/Taskfolio/ContentView.swift (1)

9-10: Clarify no-op updater.

Add a brief comment to avoid confusion about lifecycle updates.

-    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
+        // No updates needed – ComposeView is self-contained.
+    }
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/backHandler.ios.kt (1)

29-35: Scope the experimental opt-in; don’t leak it to callers

Move the opt-in inside the function and pass enabled explicitly to avoid forcing upstream modules to opt-in and to match the API.

-@ExperimentalComposeUiApi
 @Composable
 actual fun MyBackHandler(canNavigateBack: () -> Boolean, navigateBack: () -> Unit) {
-    BackHandler(canNavigateBack()) {
+    @OptIn(ExperimentalComposeUiApi::class)
+    BackHandler(enabled = canNavigateBack()) {
         navigateBack()
     }
 }
tasks-app-ios/Taskfolio/Info.plist (1)

5-6: Gate or remove CADisableMinimumFrameDurationOnPhone for release builds

This key is typically for profiling and can raise App Store review flags. Keep it for Debug only or drop it from Release.

tasks-app-shared/build.gradle.kts (1)

166-168: Locale-stable capitalization for KSP configurations

Avoid default-locale differences when deriving configuration names.

-    iosTargets.forEach { iosTarget ->
-        add("ksp${iosTarget.replaceFirstChar(Char::uppercase)}", libs.androidx.room.compiler)
-    }
+    iosTargets.forEach { iosTarget ->
+        add(
+            "ksp${iosTarget.replaceFirstChar { it.titlecase(java.util.Locale.ROOT) }}",
+            libs.androidx.room.compiler,
+        )
+    }
build.gradle.kts (1)

146-148: Locale-stable framework baseName generation

Use an explicit, locale-agnostic transform.

-            val frameworkBaseName = project.name.split('-').joinToString("") { part ->
-                part.replaceFirstChar(Char::uppercase)
-            }
+            val frameworkBaseName = project.name
+                .split('-')
+                .joinToString("") { part ->
+                    part.replaceFirstChar { it.titlecase(java.util.Locale.ROOT) }
+                }
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/platformModule.ios.kt (3)

45-66: Prefer Application Support over Documents (and consider backup exclusion).

Use NSApplicationSupportDirectory instead of NSDocumentDirectory to store app-internal data; Documents is user-visible and syncs. Optionally set NSURLIsExcludedFromBackupKey on the directory.

-import platform.Foundation.NSDocumentDirectory
+import platform.Foundation.NSApplicationSupportDirectory
@@
-        val documentDirectoryPath = fileManager.URLForDirectory(
-            directory = NSDocumentDirectory,
+        val baseDirPath = fileManager.URLForDirectory(
+            directory = NSApplicationSupportDirectory,
             inDomain = NSUserDomainMask,
             appropriateForURL = null,
             create = true,
             error = null,
-        )?.path ?: throw IllegalStateException("Could not find document directory")
+        )?.path ?: throw IllegalStateException("Could not find application support directory")
 
-        ("$documentDirectoryPath/.taskfolio").also { appRootDirPath ->
+        ("$baseDirPath/.taskfolio").also { appRootDirPath ->
             if (!fileManager.fileExistsAtPath(appRootDirPath)) {
                 val success = fileManager.createDirectoryAtPath(
                     path = appRootDirPath,
                     withIntermediateDirectories = true,
                     attributes = null,
                     error = null
                 )
                 check(success) { "Failed to create directory at $appRootDirPath" }
             }
         }

68-71: Room DB builder is provided instead of a built database.

DI currently returns a Builder; first use will fail. Call build().

     single {
         val dbFilePath = get<String>(named("app_root_dir")) + "/tasks.db"
-        Room.databaseBuilder<TasksAppDatabase>(dbFilePath)
+        Room.databaseBuilder<TasksAppDatabase>(dbFilePath).build()
     }

73-77: Do not store tokens in a plain JSON file; use Keychain.

Replace FileCredentialsStorage with a Keychain-backed implementation; at minimum apply NSFileProtectionComplete and keep the file in Application Support. I can provide a KeychainCredentialsStorage actual if desired.

buildSrc/src/main/kotlin/IosTargetsExt.kt (3)

23-23: Missing package declaration.

Add a package to avoid namespace issues and satisfy detekt.

-import org.gradle.api.Project
+package net.opatry.gradle
+
+import org.gradle.api.Project

36-45: Compile error: Properties.isEmpty used as a property.

Use isEmpty() function.

-            if (localProperties.isEmpty) {
+            if (localProperties.isEmpty()) {

27-31: Avoid global lateinit cache; compute per-Project on access.

Shared top-level state can bleed across projects/parallel configs; recompute on each access.

-// can't use by lazy, we need Project.findProperty not accessible there
-@Suppress("ObjectPropertyName")
-private lateinit var _iosTargets: List<String>
-
-private val localProperties = Properties()
-private fun Project.getIosTargetedConfiguration(): String? {
-    return findProperty("ios.target") as? String
-        ?: System.getenv("IOS_TARGET")
-        ?: run {
-            if (localProperties.isEmpty) {
-                val localPropertiesFile = File(rootDir, "local.properties")
-                if (localPropertiesFile.isFile) {
-                    localPropertiesFile.inputStream().use { reader ->
-                        localProperties.load(reader)
-                    }
-                }
-            }
-            localProperties.getProperty("ios.target")
-        }
-}
+private fun Project.getIosTargetedConfiguration(): String? {
+    return findProperty("ios.target") as? String
+        ?: System.getenv("IOS_TARGET")
+        ?: run {
+            val localProps = Properties()
+            val localPropsFile = File(rootDir, "local.properties")
+            if (localPropsFile.isFile) {
+                localPropsFile.inputStream().use(localProps::load)
+            }
+            localProps.getProperty("ios.target")
+        }
+}
@@
-val Project.iosTargets: List<String>
-    get() {
-        if (!::_iosTargets.isInitialized) {
-            _iosTargets = when (getIosTargetedConfiguration()) {
-                // We ignore "iosX64", not considered as a use case
-                "all" -> listOf("iosArm64", "iosSimulatorArm64")
-                "simulator" -> listOf("iosSimulatorArm64")
-                "device" -> listOf("iosArm64")
-                "none" -> emptyList()
-                else -> emptyList()
-            }
-        }
-        return _iosTargets
-    }
+val Project.iosTargets: List<String>
+    get() = when (getIosTargetedConfiguration()?.trim()?.lowercase(Locale.ROOT)) {
+        // We ignore "iosX64", not considered as a use case
+        "all" -> listOf("iosArm64", "iosSimulatorArm64")
+        "simulator" -> listOf("iosSimulatorArm64")
+        "device" -> listOf("iosArm64")
+        "none" -> emptyList()
+        else -> emptyList()
+    }

Add missing import:

+import java.util.Locale

Also applies to: 48-61

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (3)

77-81: Avoid re-triggering refresh on recompositions.

Scope refresh to a one-shot effect; guard inside.

-    if (userState == null) {
-        LaunchedEffect(userState) {
-            userViewModel.refreshUserState()
-        }
-    }
+    LaunchedEffect(Unit) {
+        if (userState == null) userViewModel.refreshUserState()
+    }

90-102: Memoize AboutApp to avoid redoing I/O on every recomposition.

Use remember to cache AboutApp and license loader.

-                    val aboutApp = AboutApp(
-                        name = appName,
-                        version = fullVersion
-                    ) {
+                    val aboutApp = remember(appName, fullVersion) {
+                        AboutApp(
+                            name = appName,
+                            version = fullVersion
+                        ) {
                             val path = mainBundle.pathForResource("licenses_ios", "json")
                                 ?: error("licenses_ios.json not found in bundle")
 
                             platform.Foundation.NSString.stringWithContentsOfFile(
                                 path,
                                 encoding = platform.Foundation.NSUTF8StringEncoding,
                                 error = null
                             ) ?: error("Failed to load licenses_ios.json from bundle path: $path")
-                    }
+                        }
+                    }

25-31: Add remember import if memoizing AboutApp.

Required for remember(...) below.

 import androidx.compose.material3.Surface
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt (3)

53-55: Tolerant decoding and minimal logging on corrupt file.

Use a shared Json with ignoreUnknownKeys and log once on failure.

-            runCatching {
-                Json.decodeFromString<TokenCache>(content)
-            }.getOrNull()
+            runCatching {
+                json.decodeFromString<TokenCache>(content)
+            }.onFailure { println("WARN: Failed to decode token cache at $filepath: ${it.message}") }
+             .getOrNull()

61-66: Avoid new Json per call; ensure parent dir; don’t throw inside withContext.

Reuse Json, create parent directory, and return a boolean from IO block.

-    actual override suspend fun store(tokenCache: TokenCache) {
-        val json = Json { prettyPrint = true }
-
-        val success = withContext(Dispatchers.IO) {
-            val nsString = NSString.create(string = json.encodeToString(tokenCache))
-            val data = nsString.dataUsingEncoding(NSUTF8StringEncoding)
-                ?: error("Failed to encode JSON to NSData")
-
-            val url = NSURL.fileURLWithPath(filepath)
-            data.writeToURL(url, atomically = true)
-        }
-
-        if (!success) {
-            error("Failed to write token cache to file at $filepath")
-        }
-    }
+    actual override suspend fun store(tokenCache: TokenCache) {
+        val ok = withContext(Dispatchers.IO) {
+            val nsString = NSString.create(string = json.encodeToString(tokenCache))
+            val data = nsString.dataUsingEncoding(NSUTF8StringEncoding) ?: return@withContext false
+            val url = NSURL.fileURLWithPath(filepath)
+            // Ensure parent directory exists
+            val parent = url.URLByDeletingLastPathComponent
+            if (parent != null) {
+                NSFileManager.defaultManager.createDirectoryAtURL(
+                    parent, withIntermediateDirectories = true, attributes = null, error = null
+                )
+            }
+            data.writeToURL(url, atomically = true)
+        }
+        if (!ok) {
+            println("ERROR: Failed to write token cache to file at $filepath")
+            error("Failed to write token cache to file at $filepath")
+        }
+    }

Add companion object:

 actual class FileCredentialsStorage actual constructor(private val filepath: String) : CredentialsStorage {
+    companion object {
+        val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
+    }

Also applies to: 68-74


40-75: Security: store tokens in Keychain, not a plain file.

A file risks leakage (backups, device compromise). Replace with Keychain-backed storage or at least apply NSFileProtectionComplete. I can provide a KeychainCredentialsStorage actual.

tasks-app-ios/build.gradle.kts (3)

60-66: Ensure parent directory exists and declare inputs for up-to-date checks.

Create Taskfolio/Configuration before write and use inputs.property for content hashing.

     outputs.upToDateWhen {
         configFile.takeIf(File::exists)?.readText() == content
     }
     doLast {
+        // Guarantee directory exists on clean checkouts
+        configFile.parentFile.mkdirs()
         configFile.writeText(content)
     }

Optionally:

-tasks.register("updateXcodeVersionConfig") {
+tasks.register("updateXcodeVersionConfig") {
     val configFile = file("${projectDir}/Taskfolio/Configuration/Versions.xcconfig")
     outputs.file(configFile)
-    val content = """
+    val content = """
         BUNDLE_VERSION=$appVersionCode
         BUNDLE_SHORT_VERSION_STRING=$appVersion
         """.trimIndent()
+    inputs.property("content", content)

73-79: offlineMode=true contradicts fetchRemoteLicense=true.

If offline, disable remote fetches.

-        offlineMode = true
-        fetchRemoteLicense = true
+        offlineMode = true
+        fetchRemoteLicense = false

86-88: Typo: ASDKL license identifier casing.

Fix to ASDKL to pass strict check.

-        allowedLicenses.addAll("Apache-2.0", "asdkl", "MIT", "EPL-1.0", "BSD-3-Clause")
+        allowedLicenses.addAll("Apache-2.0", "ASDKL", "MIT", "EPL-1.0", "BSD-3-Clause")
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (3)

61-81: Reset stale error, handle cancellation, and always clear loading via finally.

Keeps UI responsive and avoids surfacing user cancellations as errors.

         Button(
             onClick = {
-                ongoingAuth = true
+                error = null
+                ongoingAuth = true
                 coroutineScope.launch {
                     val scopes = listOf(
                         GoogleAuthenticator.Scope.Profile,
                         GoogleAuthenticator.Scope(TasksScopes.Tasks),
                     )
-                    try {
+                    try {
                         val authCode = authenticator.authorize(
                             scopes = scopes,
                             force = true,
                             requestUserAuthorization = {},
                         ).let(GoogleAuthenticator.Grant::AuthorizationCode)
                         val oauthToken = authenticator.getToken(authCode)
                         onSuccess(oauthToken)
-                    } catch (e: Exception) {
-                        error = e.message
-                        ongoingAuth = false
-                    }
+                    } catch (e: kotlinx.coroutines.CancellationException) {
+                        // cancellation is expected; don't surface to the user
+                        throw e
+                    } catch (e: Exception) {
+                        error = e.message
+                    } finally {
+                        ongoingAuth = false
+                    }
                 }
             },
             enabled = !ongoingAuth
         ) {

85-93: Don’t reuse parent modifier on inner Box.
Prevents unintended sizing constraints leaking in.

-            Box(modifier, contentAlignment = Alignment.Center) {
+            Box(Modifier, contentAlignment = Alignment.Center) {

96-98: Render error only when non-empty.
Avoids empty placeholder occupying space.

-        AnimatedContent(error, label = "authorize_error_message") { message ->
-            Text(message ?: "", color = MaterialTheme.colorScheme.error)
-        }
+        androidx.compose.animation.AnimatedVisibility(visible = !((error ?: "").isBlank())) {
+            Text(error!!, color = MaterialTheme.colorScheme.error)
+        }
tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (4)

258-258: Lower iOS deployment target for reach.
18.2 is restrictive; consider 17.0 unless dependencies mandate 18.x.

-                IPHONEOS_DEPLOYMENT_TARGET = 18.2;
+                IPHONEOS_DEPLOYMENT_TARGET = 17.0;

Apply in both Debug and Release.

Also applies to: 319-319


342-342: Enable user script sandboxing (security).
Currently disabled at target level, overriding the project-level YES.

-                ENABLE_USER_SCRIPT_SANDBOXING = NO;
+                ENABLE_USER_SCRIPT_SANDBOXING = YES;

Also apply to Release.

Also applies to: 383-383


169-187: Make Kotlin framework phase incremental and robust.
Avoid always-out-of-date runs and harden the script.

 		473FF7702E05F9E6002B3B0F /* Compile Kotlin Framework */ = {
 			isa = PBXShellScriptBuildPhase;
-			alwaysOutOfDate = 1;
+			alwaysOutOfDate = 0;
 ...
-			shellScript = "if [ \"${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED}\" = \"YES\" ]; then\n  echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to 'YES'.\"\n  exit 0\nfi\n\nif [ -z \"${IOS_TARGET}\" ]; then\n  echo \"You must define IOS_TARGET to 'all', 'simulator' or 'device' to allow building for iOS.\"\n  exit 1\nfi\n\necho \"Building for '${IOS_TARGET}' target\"\n\ncd \"${SRCROOT}/..\"\n./gradlew :tasks-app-shared:embedAndSignAppleFrameworkForXcode\n";
+			shellScript = "set -euo pipefail\n\nif [ \"${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED}\" = \"YES\" ]; then\n  echo \"Skipping Gradle build (OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED=YES).\"\n  exit 0\nfi\n\ncase \"${IOS_TARGET:-}\" in\n  all|simulator|device) ;; \n  *) echo \"ERROR: IOS_TARGET must be one of: all|simulator|device\"; exit 1;;\nesac\n\necho \"Building for '${IOS_TARGET}' (CONFIG=${CONFIGURATION} SDK=${SDK_NAME} ARCHS=${ARCHS})\"\ncd \"${SRCROOT}/..\"\n\nif [ ! -x ./gradlew ]; then echo \"ERROR: gradlew not found\"; exit 1; fi\n./gradlew :tasks-app-shared:embedAndSignAppleFrameworkForXcode \\\n  -PXCODE_CONFIGURATION=${CONFIGURATION} \\\n  -PXCODE_SDK_NAME=${SDK_NAME} \\\n  -PXCODE_ARCHS=\"${ARCHS}\"\n";

Optional: add output tracking to reduce re-runs

  • outputPaths: $(SRCROOT)/../tasks-app-shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)/TasksAppShared.framework

340-340: Move Development Team to xcconfig.
Avoid hardcoding team ID in the project.

-                DEVELOPMENT_TEAM = 9J8LQ9R327;
+                DEVELOPMENT_TEAM = $(DEVELOPMENT_TEAM);

Then define in your xcconfig(s):

/* Configuration/Config.dev.xcconfig (or Config.xcconfig) */
DEVELOPMENT_TEAM = 9J8LQ9R327

Also applies to: 381-381

tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (4)

35-37: Revisit “force” semantics.
Signing out every time is heavy; consider revoking only when you truly need to re-consent/offline access.

Do you intend force to always prompt consent, only on first sign-in, or when scopes change? I can align behavior across Android/JVM/iOS accordingly.


61-61: Replace unused closure parameter with underscore.
Minor clarity and lint fix.

-                        user.addScopes(stringScopes, presenting: rootViewController) { scopeResult, scopeError in
+                        user.addScopes(stringScopes, presenting: rootViewController) { _, scopeError in

119-132: Remove unused error case.
configurationFailed is never thrown.

 enum GoogleSignInError: Error, LocalizedError {
     case noRootViewController
     case missingConfiguration
-    case configurationFailed
     case userNotSignedIn
     case tokenNotAvailable
     case unknownError
@@
-        case .configurationFailed:
-            return "Failed to configure Google Sign-In"

39-53: Extract duplicated “add scopes” logic.
Reduces repetition and centralizes error handling.

I can provide a small helper:

private func addScopesIfNeeded(_ scopes: [String], for user: GIDGoogleUser, presenting vc: UIViewController, resume: @escaping (Result<Void, Error>) -> Void) {
    guard !scopes.isEmpty else { return resume(.success(())) }
    user.addScopes(scopes, presenting: vc) { _, err in
        if let err { resume(.failure(err)) } else { resume(.success(())) }
    }
}

Then call it in both branches.

Also applies to: 60-67

Comment on lines +204 to +211
gradle.projectsEvaluated {
val xcFrameworkTask = project(":tasks-app-shared").tasks.findByName("embedAndSignAppleFrameworkForXcode")
val updateVersionTask = project(":tasks-app-ios").tasks.findByName("updateXcodeVersionConfig")

if (xcFrameworkTask != null && updateVersionTask != null) {
xcFrameworkTask.dependsOn(updateVersionTask)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Task wiring OK

Dependency ensures version config is generated before embedding the framework. Consider tasks.named(...).configure { dependsOn(...) } for more eager validation, but current approach is acceptable.

🤖 Prompt for AI Agents
In build.gradle.kts around lines 204 to 211, the current wiring uses
tasks.findByName inside gradle.projectsEvaluated which defers validation; change
to using tasks.named(...) and configure { dependsOn(...) } so Gradle resolves
the task references eagerly and fails fast if tasks are missing, ensuring the
dependency is set up reliably.

Comment on lines +31 to +32
// Note: iOS targets are conditionally added dynamically in the root build.gradle.kts

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Clarify the source of iOS target configuration in-place

Good call to note dynamic iOS setup. Make the comment actionable by pointing to the exact source and knobs devs can use.

Apply:

-    // Note: iOS targets are conditionally added dynamically in the root build.gradle.kts
+    // iOS targets are added in the root build.gradle.kts via buildSrc/IosTargetsExt.kt.
+    // Configure with: Gradle property `ios.target` or env `IOS_TARGET` (all|simulator|device|none).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Note: iOS targets are conditionally added dynamically in the root build.gradle.kts
// iOS targets are added in the root build.gradle.kts via buildSrc/IosTargetsExt.kt.
// Configure with: Gradle property `ios.target` or env `IOS_TARGET` (all|simulator|device|none).
🤖 Prompt for AI Agents
In google/oauth/build.gradle.kts around lines 31–32, the note about iOS targets
being added dynamically should be made actionable: update the comment to point
developers to the exact location in the repository (the root build.gradle.kts)
where the conditional logic lives (search for the function or block that adds
iOS targets, e.g., the "addIosTargets" / "enableIosTargets" logic), and list the
knobs to change (the project property or Gradle flag used to enable/disable iOS
targets and where to set it — gradle.properties, environment variable, or via -P
on the command line), plus a short example of how to toggle it (e.g., set
property in gradle.properties or run ./gradlew assemble -PiosTargets=true).

[**Taskfolio**](https://opatry.github.io/taskfolio) is an Android task management app built using [Google Tasks API](https://developers.google.com/tasks/reference/rest). Developed to demonstrate my expertise in modern Android development, it highlights my skills in architecture, UI design with Jetpack Compose, OAuth authentication, and more—all packaged in a sleek, user-friendly interface.

> I set out to revisit the classical TODO app, local-first syncing with Google Tasks—aiming for an <abbr title="Minimum Viable Experience">MVE</abbr> in 2 weeks, focusing on the 80/20 rule to nail the essentials.
> I set out to revisit the classical TODO app, 'local-first' syncing with Google Tasks—aiming for an <abbr title="Minimum Viable Experience">MVE</abbr> in 2 weeks, focusing on the 80/20 rule to nail the essentials.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Replace inline HTML with plain text for markdownlint compatibility.

Avoid MD033 by expanding the abbreviation inline.

-> I set out to revisit the classical TODO app, 'local-first' syncing with Google Tasks—aiming for an <abbr title="Minimum Viable Experience">MVE</abbr> in 2 weeks, focusing on the 80/20 rule to nail the essentials.
+> I set out to revisit the classical TODO app, 'local-first' syncing with Google Tasks—aiming for a Minimum Viable Experience (MVE) in 2 weeks, focusing on the 80/20 rule to nail the essentials.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
> I set out to revisit the classical TODO app, 'local-first' syncing with Google Tasks—aiming for an <abbr title="Minimum Viable Experience">MVE</abbr> in 2 weeks, focusing on the 80/20 rule to nail the essentials.
> I set out to revisit the classical TODO app, 'local-first' syncing with Google Tasks—aiming for a Minimum Viable Experience (MVE) in 2 weeks, focusing on the 80/20 rule to nail the essentials.
🧰 Tools
🪛 LanguageTool

[grammar] ~39-~39: Use correct spacing
Context: ...n the 80/20 rule to nail the essentials. | ![](assets/screens/task_lists_light.pn...

(QB_NEW_EN_OTHER_ERROR_IDS_5)

🪛 markdownlint-cli2 (0.17.2)

39-39: Inline HTML
Element: abbr

(MD033, no-inline-html)

🤖 Prompt for AI Agents
In README.md around line 39, replace the inline HTML <abbr> usage with plain
Markdown text to avoid MD033; expand the abbreviation inline by changing "an
<abbr title=\"Minimum Viable Experience\">MVE</abbr>" to "an MVE (Minimum Viable
Experience)" so the abbreviation is presented without HTML while preserving the
same meaning.

Comment on lines +30 to +31
actual override val value: String
get() = rawValue.toString().padStart(20, '0')
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Extract magic number 20 into a single width constant.

Avoid duplicating the padding width and make it self-documenting.

 package net.opatry.tasks

 import com.ionspin.kotlin.bignum.integer.BigInteger
 import com.ionspin.kotlin.bignum.integer.toBigInteger
 import kotlinx.datetime.Instant

+private const val POSITION_STRING_WIDTH = 20
+
 actual class TodoTaskPosition private constructor(internal val rawValue: BigInteger) : TaskPosition {
     actual override val value: String
-        get() = rawValue.toString().padStart(20, '0')
+        get() = rawValue.toString().padStart(POSITION_STRING_WIDTH, '0')
 ...
 actual class DoneTaskPosition private constructor(internal val rawValue: BigInteger) : TaskPosition {
     actual override val value: String
-        get() = rawValue.toString().padStart(20, '0')
+        get() = rawValue.toString().padStart(POSITION_STRING_WIDTH, '0')

Also applies to: 76-77

🤖 Prompt for AI Agents
In tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt around
lines 30-31 (and similarly at lines 76-77), the magic number 20 is used directly
for padStart width; extract this into a single descriptive constant (e.g.,
PADDING_WIDTH or ID_PAD_WIDTH) declared at the top of the file or companion
object, replace the literal 20 in both padStart calls with that constant, and
ensure the constant is documented by its name so the padding width is
centralized and self-explanatory.

Comment on lines +34 to +36
actual fun fromIndex(index: Int): TodoTaskPosition {
return TodoTaskPosition(BigInteger.fromInt(index))
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Validate index >= 0 in fromIndex().

Negative indices will parse and serialize fine but are likely invalid for ordering; fail fast.

 actual companion object {
     actual fun fromIndex(index: Int): TodoTaskPosition {
-            return TodoTaskPosition(BigInteger.fromInt(index))
+            require(index >= 0) { "index must be >= 0" }
+            return TodoTaskPosition(BigInteger.fromInt(index))
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
actual fun fromIndex(index: Int): TodoTaskPosition {
return TodoTaskPosition(BigInteger.fromInt(index))
}
actual companion object {
actual fun fromIndex(index: Int): TodoTaskPosition {
require(index >= 0) { "index must be >= 0" }
return TodoTaskPosition(BigInteger.fromInt(index))
}
}
🤖 Prompt for AI Agents
In tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt around
lines 34 to 36, the fromIndex(index: Int) function currently accepts negative
indices which are invalid for ordering; add a validation that index >= 0 and
fail fast if not. Implement a check (e.g. use require(index >= 0) or explicitly
throw IllegalArgumentException with a clear message like "index must be
non-negative") before constructing the BigInteger so negative values are
rejected immediately.

Comment on lines +38 to +40
actual fun fromPosition(position: String): TodoTaskPosition {
return TodoTaskPosition(BigInteger.parseString(position))
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add strict input validation for fromPosition() in both classes.

Reject non-digits and oversize inputs; keeps data canonical and prevents runtime parse errors.

-        actual fun fromPosition(position: String): TodoTaskPosition {
-            return TodoTaskPosition(BigInteger.parseString(position))
-        }
+        actual fun fromPosition(position: String): TodoTaskPosition {
+            require(position.isNotEmpty() && position.all(Char::isDigit)) { "Invalid position: must be decimal digits" }
+            require(position.length <= POSITION_STRING_WIDTH) { "Invalid position: max $POSITION_STRING_WIDTH digits" }
+            return TodoTaskPosition(BigInteger.parseString(position))
+        }
 ...
-        actual fun fromPosition(position: String): DoneTaskPosition {
-            return DoneTaskPosition(BigInteger.parseString(position))
-        }
+        actual fun fromPosition(position: String): DoneTaskPosition {
+            require(position.isNotEmpty() && position.all(Char::isDigit)) { "Invalid position: must be decimal digits" }
+            require(position.length <= POSITION_STRING_WIDTH) { "Invalid position: max $POSITION_STRING_WIDTH digits" }
+            return DoneTaskPosition(BigInteger.parseString(position))
+        }

Also applies to: 71-73

Comment on lines +43 to +49
actual override fun compareTo(other: TaskPosition): Int {
return when (other) {
is TodoTaskPosition -> rawValue.compareTo(other.rawValue)
is DoneTaskPosition -> rawValue.compareTo(other.rawValue)
else -> throw IllegalArgumentException("Only TodoTaskPosition and DoneTaskPosition are supported")
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Deduplicate compareTo() logic and improve error message.

+private fun compareTaskPositions(thisValue: BigInteger, other: TaskPosition): Int =
+    when (other) {
+        is TodoTaskPosition -> thisValue.compareTo(other.rawValue)
+        is DoneTaskPosition -> thisValue.compareTo(other.rawValue)
+        else -> throw IllegalArgumentException("Unsupported TaskPosition type: ${other::class.simpleName}")
+    }
+
 actual override fun compareTo(other: TaskPosition): Int {
-        return when (other) {
-            is TodoTaskPosition -> rawValue.compareTo(other.rawValue)
-            is DoneTaskPosition -> rawValue.compareTo(other.rawValue)
-            else -> throw IllegalArgumentException("Only TodoTaskPosition and DoneTaskPosition are supported")
-        }
+        return compareTaskPositions(rawValue, other)
 }
 ...
 actual override fun compareTo(other: TaskPosition): Int {
-        return when (other) {
-            is TodoTaskPosition -> rawValue.compareTo(other.rawValue)
-            is DoneTaskPosition -> rawValue.compareTo(other.rawValue)
-            else -> throw IllegalArgumentException("Only TodoTaskPosition and DoneTaskPosition are supported")
-        }
+        return compareTaskPositions(rawValue, other)
 }

Also applies to: 79-85

Comment on lines +66 to +69
private val UpperBound = BigInteger.parseString("9999999999999999999")
actual fun fromCompletionDate(completionDate: Instant): DoneTaskPosition {
return DoneTaskPosition(UpperBound - completionDate.toEpochMilliseconds().toBigInteger())
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Derive and rename upper bound from width to avoid drift.

Pad width is 20 but UpperBound encodes 19 nines. Compute from the single width constant and use constant-style naming.

-        private val UpperBound = BigInteger.parseString("9999999999999999999")
+        // Maximum 20-digit value (all 9s), derived from POSITION_STRING_WIDTH
+        private val UPPER_BOUND = BigInteger.parseString("9".repeat(POSITION_STRING_WIDTH))
         actual fun fromCompletionDate(completionDate: Instant): DoneTaskPosition {
-            return DoneTaskPosition(UpperBound - completionDate.toEpochMilliseconds().toBigInteger())
+            return DoneTaskPosition(UPPER_BOUND - completionDate.toEpochMilliseconds().toBigInteger())
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private val UpperBound = BigInteger.parseString("9999999999999999999")
actual fun fromCompletionDate(completionDate: Instant): DoneTaskPosition {
return DoneTaskPosition(UpperBound - completionDate.toEpochMilliseconds().toBigInteger())
}
// Maximum 20-digit value (all 9s), derived from POSITION_STRING_WIDTH
private val UPPER_BOUND = BigInteger.parseString("9".repeat(POSITION_STRING_WIDTH))
actual fun fromCompletionDate(completionDate: Instant): DoneTaskPosition {
return DoneTaskPosition(UPPER_BOUND - completionDate.toEpochMilliseconds().toBigInteger())
}
🤖 Prompt for AI Agents
In tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt around
lines 66 to 69, the UpperBound constant is hardcoded as 19 nines while pad width
is 20; replace the hardcoded value with a computed constant derived from a
single width constant (e.g., PAD_WIDTH = 20) and compute UPPER_BOUND as
10.pow(PAD_WIDTH) - 1 using BigInteger operations, and rename UpperBound to
UPPER_BOUND to follow constant-style naming.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (72)
.gitignore (1)

37-40: Consider consolidating iOS Xcode patterns (follow-up to previous suggestion).

The past review suggested reducing these specific patterns to a more generic wildcard. Lines 37-40 still have repetitive paths that could be consolidated to reduce future maintenance:

-tasks-app-ios/Taskfolio.xcodeproj/xcuserdata/*.xcuserdatad
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/contents.xcworkspacedata
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/xcuserdata/*.xcuserdatad
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/xcshareddata/*
+tasks-app-ios/**/*.xcuserdatad
+tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/contents.xcworkspacedata
+tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/xcshareddata/*

This avoids redundancy and automatically covers any future iOS Xcode structure changes.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/theme/Type.ios.kt (1)

28-44: Don’t override M3 text styles with bare fontSize; keep defaults and remove too‑small sizes.
Current overrides replace full TextStyles, dropping M3 defaults (lineHeight, letterSpacing, weight) and include 10sp/9sp, which are too small and harm readability/dynamic type. Also, TODOs remain. This mirrors a prior suggestion to adopt M3 defaults.

Apply either fix:

Option A — use pure M3 defaults (simplest, safe):

-internal actual val Typography: androidx.compose.material3.Typography = androidx.compose.material3.Typography(
-    displayLarge = TextStyle(fontSize = 57.sp), // TODO adjust
-    displayMedium = TextStyle(fontSize = 45.sp), // TODO adjust
-    displaySmall = TextStyle(fontSize = 36.sp), // TODO adjust
-    headlineLarge = TextStyle(fontSize = 32.sp), // TODO adjust
-    headlineMedium = TextStyle(fontSize = 28.sp), // TODO adjust
-    headlineSmall = TextStyle(fontSize = 24.sp), // TODO adjust
-    titleLarge = TextStyle(fontSize = 22.sp), // TODO adjust
-    titleMedium = TextStyle(fontSize = 16.sp), // TODO adjust
-    titleSmall = TextStyle(fontSize = 14.sp), // TODO adjust
-    bodyLarge = TextStyle(fontSize = 14.sp),
-    bodyMedium = TextStyle(fontSize = 12.sp),
-    bodySmall = TextStyle(fontSize = 10.sp),
-    labelLarge = TextStyle(fontSize = 12.sp),
-    labelMedium = TextStyle(fontSize = 10.sp),
-    labelSmall = TextStyle(fontSize = 9.sp),
-)
+// Preserve Material 3 defaults including lineHeight/letterSpacing and accessibility scaling.
+internal actual val Typography: androidx.compose.material3.Typography =
+    androidx.compose.material3.Typography()

Option B — if you must customize sizes, base on defaults to retain metrics:

-internal actual val Typography: androidx.compose.material3.Typography = androidx.compose.material3.Typography(
-    displayLarge = TextStyle(fontSize = 57.sp), // TODO adjust
-    displayMedium = TextStyle(fontSize = 45.sp), // TODO adjust
-    displaySmall = TextStyle(fontSize = 36.sp), // TODO adjust
-    headlineLarge = TextStyle(fontSize = 32.sp), // TODO adjust
-    headlineMedium = TextStyle(fontSize = 28.sp), // TODO adjust
-    headlineSmall = TextStyle(fontSize = 24.sp), // TODO adjust
-    titleLarge = TextStyle(fontSize = 22.sp), // TODO adjust
-    titleMedium = TextStyle(fontSize = 16.sp), // TODO adjust
-    titleSmall = TextStyle(fontSize = 14.sp), // TODO adjust
-    bodyLarge = TextStyle(fontSize = 14.sp),
-    bodyMedium = TextStyle(fontSize = 12.sp),
-    bodySmall = TextStyle(fontSize = 10.sp),
-    labelLarge = TextStyle(fontSize = 12.sp),
-    labelMedium = TextStyle(fontSize = 10.sp),
-    labelSmall = TextStyle(fontSize = 9.sp),
-)
+internal actual val Typography: androidx.compose.material3.Typography =
+    androidx.compose.material3.Typography().let { defaults ->
+        defaults.copy(
+            // Keep/default or adjust intentionally; avoid sub‑12sp for body/labels.
+            displayLarge = defaults.displayLarge,   // adjust only if product requires
+            displayMedium = defaults.displayMedium,
+            displaySmall = defaults.displaySmall,
+            headlineLarge = defaults.headlineLarge,
+            headlineMedium = defaults.headlineMedium,
+            headlineSmall = defaults.headlineSmall,
+            titleLarge = defaults.titleLarge,
+            titleMedium = defaults.titleMedium,
+            titleSmall = defaults.titleSmall,
+            bodyLarge = defaults.bodyLarge,   // 16.sp default
+            bodyMedium = defaults.bodyMedium, // 14.sp default
+            bodySmall = defaults.bodySmall,   // 12.sp default
+            labelLarge = defaults.labelLarge, // 14.sp default
+            labelMedium = defaults.labelMedium,// 12.sp default
+            labelSmall = defaults.labelSmall   // 11.sp default
+        )
+    }
#!/bin/bash
# 1) Ensure expect/actual exists and no duplicate conflicting defs.
rg -nP 'expect\s+val\s+Typography\b' -C2
rg -nP 'actual\s+val\s+Typography\b' -C2

# 2) Find any remaining too-small sizes (<12sp) in iosMain typography.
rg -nP --type=kt 'TextStyle\s*\(\s*fontSize\s*=\s*([0-9]|1[01])\.sp' -C1 tasks-app-shared/src/iosMain

# 3) Sanity-check other platforms for consistency.
rg -nP --type=kt 'internal\s+.*\bTypography\b\s*=' -n -C2 tasks-app-shared/src
tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt (3)

50-51: Release the NWPathMonitor to prevent native memory leak.

The nw_path_monitor_cancel stops monitoring but does not release the C-level reference-counted object. Apple's Network framework objects created with nw_path_monitor_create() must be explicitly released.

Apply this diff:

     awaitClose {
         nw_path_monitor_cancel(monitor)
+        platform.Network.nw_release(monitor)
     }

42-45: Consider adding error handling for robustness.

Wrapping the update handler in a try-catch block would handle any unexpected exceptions from the Network framework APIs gracefully.

Apply this diff:

     nw_path_monitor_set_update_handler(monitor) { path ->
-        val hasInternet = nw_path_get_status(path) == nw_path_status_satisfied
-        trySend(hasInternet).isSuccess
+        try {
+            val hasInternet = nw_path_get_status(path) == nw_path_status_satisfied
+            trySend(hasInternet).isSuccess
+        } catch (e: Exception) {
+            trySend(false).isSuccess
+        }
     }

38-53: Apply conflation to prevent dropped emissions.

The trySend() may drop intermediate network status updates if the collector is slow. Since only the latest status matters, add .conflate() to keep the most recent value.

Apply this diff:

 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.conflate
 import kotlinx.coroutines.flow.distinctUntilChanged
     awaitClose {
         nw_path_monitor_cancel(monitor)
     }
-}.distinctUntilChanged()
+}
+    .conflate()
+    .distinctUntilChanged()
tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt (5)

30-31: Extract padding width into a single constant.

Avoid the magic number 20; define once and reuse for both classes.

 package net.opatry.tasks
 
 import com.ionspin.kotlin.bignum.integer.BigInteger
 import com.ionspin.kotlin.bignum.integer.toBigInteger
 import kotlinx.datetime.Instant
+
+private const val POSITION_STRING_WIDTH = 20
@@
-        get() = rawValue.toString().padStart(20, '0')
+        get() = rawValue.toString().padStart(POSITION_STRING_WIDTH, '0')
@@
-        get() = rawValue.toString().padStart(20, '0')
+        get() = rawValue.toString().padStart(POSITION_STRING_WIDTH, '0')

Also applies to: 76-77


34-36: Guard against negative indices in fromIndex().

Negative indices yield negative raw values and broken zero-padded formatting; fail fast.

 actual companion object {
     actual fun fromIndex(index: Int): TodoTaskPosition {
-            return TodoTaskPosition(BigInteger.fromInt(index))
+            require(index >= 0) { "index must be >= 0" }
+            return TodoTaskPosition(BigInteger.fromInt(index))
     }

38-40: Validate fromPosition() input (digits only, max width).

Reject invalid strings early; prevents runtime parse errors and keeps data canonical.

-        actual fun fromPosition(position: String): TodoTaskPosition {
-            return TodoTaskPosition(BigInteger.parseString(position))
-        }
+        actual fun fromPosition(position: String): TodoTaskPosition {
+            val p = position.trim()
+            require(p.isNotEmpty() && p.all(Char::isDigit)) { "Invalid position: must be decimal digits" }
+            require(p.length <= POSITION_STRING_WIDTH) { "Invalid position: max $POSITION_STRING_WIDTH digits" }
+            return TodoTaskPosition(BigInteger.parseString(p))
+        }
@@
-        actual fun fromPosition(position: String): DoneTaskPosition {
-            return DoneTaskPosition(BigInteger.parseString(position))
-        }
+        actual fun fromPosition(position: String): DoneTaskPosition {
+            val p = position.trim()
+            require(p.isNotEmpty() && p.all(Char::isDigit)) { "Invalid position: must be decimal digits" }
+            require(p.length <= POSITION_STRING_WIDTH) { "Invalid position: max $POSITION_STRING_WIDTH digits" }
+            return DoneTaskPosition(BigInteger.parseString(p))
+        }

Also applies to: 71-73


66-69: Derive and rename upper bound from width; fix 19 vs 20 nines drift.

Compute from the single width constant and use constant-style naming.

 actual companion object {
-        private val UpperBound = BigInteger.parseString("9999999999999999999")
+        // Maximum POSITION_STRING_WIDTH-digit value (all 9s), derived from width
+        private val UPPER_BOUND = BigInteger.parseString("9".repeat(POSITION_STRING_WIDTH))
         actual fun fromCompletionDate(completionDate: Instant): DoneTaskPosition {
-            return DoneTaskPosition(UpperBound - completionDate.toEpochMilliseconds().toBigInteger())
+            return DoneTaskPosition(UPPER_BOUND - completionDate.toEpochMilliseconds().toBigInteger())
         }

If String.repeat isn’t available in your target, use BigInteger math:

// alternative:
private val UPPER_BOUND = BigInteger.TEN.pow(POSITION_STRING_WIDTH).minus(BigInteger.ONE)

43-49: Deduplicate compareTo() logic and improve error message.

Extract shared comparison to a single helper; keeps both classes in sync.

+private fun compareTaskPositions(thisValue: BigInteger, other: TaskPosition): Int =
+    when (other) {
+        is TodoTaskPosition -> thisValue.compareTo(other.rawValue)
+        is DoneTaskPosition -> thisValue.compareTo(other.rawValue)
+        else -> throw IllegalArgumentException("Unsupported TaskPosition type: ${other::class.simpleName}")
+    }
@@
-    actual override fun compareTo(other: TaskPosition): Int {
-        return when (other) {
-            is TodoTaskPosition -> rawValue.compareTo(other.rawValue)
-            is DoneTaskPosition -> rawValue.compareTo(other.rawValue)
-            else -> throw IllegalArgumentException("Only TodoTaskPosition and DoneTaskPosition are supported")
-        }
-    }
+    actual override fun compareTo(other: TaskPosition): Int =
+        compareTaskPositions(rawValue, other)
@@
-    actual override fun compareTo(other: TaskPosition): Int {
-        return when (other) {
-            is TodoTaskPosition -> rawValue.compareTo(other.rawValue)
-            is DoneTaskPosition -> rawValue.compareTo(other.rawValue)
-            else -> throw IllegalArgumentException("Only TodoTaskPosition and DoneTaskPosition are supported")
-        }
-    }
+    actual override fun compareTo(other: TaskPosition): Int =
+        compareTaskPositions(rawValue, other)

Also applies to: 79-85

.github/workflows/e2e-tests.yml (1)

10-10: Verify necessity of pinning to ubuntu-24.04.

Pinning to a specific Ubuntu version means missing automatic upgrades and potential deprecation when 24.04 is retired. Unless there's a specific dependency on 24.04 features, ubuntu-latest would be more maintainable.

Is there a specific requirement for 24.04 in this iOS-related PR?

Also applies to: 38-38

tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (1)

63-63: Verify Room schema export configuration is complete.

With exportSchema = true, Room requires the room.schemaLocation KSP argument. Without it, the build will fail.

Please confirm that the KSP configuration includes:

#!/bin/bash
# Check if room.schemaLocation is configured in Gradle/KSP setup
rg -n "room\.schemaLocation" --type kotlin --type gradle
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt (1)

40-75: Critical: Store credentials in iOS Keychain, not a plain file.

Storing OAuth tokens in a plain file exposes them to backup extraction and device compromise. iOS Keychain provides hardware-backed encryption and proper access controls. The current file-based approach is insecure for production use.

Consider implementing a Keychain-backed storage solution or, at minimum, add NSFileProtectionComplete to the file attributes.

tasks-app-ios/Taskfolio/Resources/licenses_ios.json (1)

1-1598: Generated artifact checked into version control.

This large generated JSON file causes repository bloat and noisy diffs on every dependency update.

The existing exportLibraryDefinitions task should generate this file during CI builds instead.

buildSrc/build.gradle.kts (1)

10-12: Consider centralizing JVM toolchain configuration.

The jvmToolchain(17) declaration is repeated across multiple sub-projects. Centralizing this in a convention plugin or root configuration would prevent version drift.

This pattern appears in at least 10 build files across the project, as noted in previous review comments.

website/styles.css (1)

157-161: LGTM! Consider the CSS variable suggestion from previous review.

The blockquote styling is functionally correct and matches the site accent. The previous review comment about extracting the accent color to a CSS variable remains a valid optional refactor to reduce duplication.

tasks-app-ios/Taskfolio/Info.plist (1)

5-6: Verify if debug key is needed for production.

The CADisableMinimumFrameDurationOnPhone key is typically used only for profiling and may be rejected during App Store review.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/authModule.ios.kt (1)

28-29: LGTM! Empty module is correct for iOS architecture.

The empty module is appropriate since the iOS app provides the GoogleAuthenticator instance via Swift. The unused gcpClientId parameter warning can be suppressed if desired, as noted in the previous review.

tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/Contents.json (1)

16-20: Consider removing obsolete iPhone 1x icon entry.

The iPhone 29x29 1x entry is obsolete for modern iOS devices. The previous review comment about removing this entry remains valid.

build.gradle.kts (1)

146-148: Use explicit locale for deterministic framework names.

The replaceFirstChar(Char::uppercase) uses the default JVM locale, which can vary between environments and cause non-deterministic framework names.

Apply this diff to use an explicit locale:

-            val frameworkBaseName = project.name.split('-').joinToString("") { part ->
-                part.replaceFirstChar(Char::uppercase)
-            }
+            val frameworkBaseName = project.name.split('-').joinToString("") { part ->
+                part.replaceFirstChar { it.titlecase(java.util.Locale.ROOT) }
+            }
.github/workflows/build-apps.yml (2)

38-49: Consider enabling Gradle cache for all platforms.

The Gradle cache is currently limited to the iOS build (matrix.gradle_module == 'tasks-app-ios'). Android and Desktop builds could also benefit from caching .gradle and Maven artifacts.

Remove the if condition to enable caching for all matrix entries:

-    - name: Cache Gradle
-      if: ${{ matrix.gradle_module == 'tasks-app-ios' }}
-      uses: actions/cache@v4
+    - name: Cache Gradle
+      uses: actions/cache@v4
       with:

70-72: Remove unused IOS_TARGET environment variable.

The IOS_TARGET=simulator variable is not used by the Gradle task linkDebugFrameworkIosSimulatorArm64, which already hard-codes the target architecture.

Apply this diff:

-        elif [ "${gradle_module}" = "tasks-app-ios" ]; then
-          IOS_TARGET=simulator ./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64
+        elif [ "${gradle_module}" = "tasks-app-ios" ]; then
+          ./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/backHandler.ios.kt (1)

29-35: Consider scoping experimental API opt-in.

The implementation is functionally correct. The previous suggestion to move @OptIn(ExperimentalComposeUiApi::class) inside the function body (instead of annotating the entire function) would prevent the experimental API from leaking to all callers.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/platformModule.ios.kt (3)

68-71: CRITICAL: Room database builder not built – will crash at runtime.

The DI container provides DatabaseBuilder<TasksAppDatabase> instead of TasksAppDatabase. Any code expecting the database will fail immediately.

Apply this diff:

 single {
     val dbFilePath = get<String>(named("app_root_dir")) + "/tasks.db"
-    Room.databaseBuilder<TasksAppDatabase>(dbFilePath)
+    Room.databaseBuilder<TasksAppDatabase>(dbFilePath)
+        .build()
 }

45-66: Use Application Support directory instead of Documents.

NSDocumentDirectory is user-visible and backed up by default. Application Support is the standard location for app-internal data like databases and caches.

Apply this diff:

 @OptIn(ExperimentalForeignApi::class)
 single<String>(named("app_root_dir")) {
     val fileManager = NSFileManager.defaultManager
-    val documentDirectoryPath = fileManager.URLForDirectory(
-        directory = NSDocumentDirectory,
+    val appSupportPath = fileManager.URLForDirectory(
+        directory = platform.Foundation.NSApplicationSupportDirectory,
         inDomain = NSUserDomainMask,
         appropriateForURL = null,
         create = true,
         error = null,
-    )?.path ?: throw IllegalStateException("Could not find document directory")
+    )?.path ?: throw IllegalStateException("Could not find application support directory")
 
-    ("$documentDirectoryPath/.taskfolio").also { appRootDirPath ->
+    ("$appSupportPath/.taskfolio").also { appRootDirPath ->
         if (!fileManager.fileExistsAtPath(appRootDirPath)) {
             val success = fileManager.createDirectoryAtPath(
                 path = appRootDirPath,
                 withIntermediateDirectories = true,
                 attributes = null,
                 error = null
             )
             check(success) { "Failed to create directory at $appRootDirPath" }
         }
     }
 }

73-77: Security risk: credentials stored in plain JSON file.

For production, use iOS Keychain Services or encrypt the file. Plain JSON exposes tokens to any process with file access.

tasks-app-ios/Taskfolio/Configuration/Versions.xcconfig (1)

1-2: Use standard Xcode version keys for better tooling compatibility.

Custom keys require manual mapping in the project file and break tools like agvtool and Fastlane. Standard keys work automatically.

Apply this diff:

-BUNDLE_VERSION=1
-BUNDLE_SHORT_VERSION_STRING=1.4.3
+CURRENT_PROJECT_VERSION=1
+MARKETING_VERSION=1.4.3
settings.gradle.kts (1)

67-69: Consider gating iOS module for macOS only.

Including :tasks-app-ios unconditionally forces Gradle to evaluate Xcode tasks on all platforms, slowing sync and potentially breaking Windows/Linux CI.

Apply this diff:

 // useful to isolate/ease license_ios.json file generation
 // and also sync version from Gradle to Xcode
-include(":tasks-app-ios")
+if (System.getProperty("os.name").contains("Mac", ignoreCase = true)) {
+    include(":tasks-app-ios")
+}
tasks-core/build.gradle.kts (1)

61-65: Guard against missing iosTargets to prevent build failures.

Direct reference to iosTargets will fail if the buildSrc extension isn't loaded. While the extension is provided, defensive coding prevents cryptic errors.

Apply this diff:

-if (iosTargets.isNotEmpty()) {
+val iosTargets = runCatching { iosTargets }.getOrElse { emptyList() }
+if (iosTargets.isNotEmpty()) {
     iosMain.dependencies {
         implementation(libs.bignum)
     }
 }
tasks-app-shared/build.gradle.kts (1)

166-168: Use explicit locale for deterministic capitalization.

Char::uppercase uses the default JVM locale, which can differ between CI and developer machines, potentially causing inconsistent KSP configuration names.

Apply this diff:

+import java.util.Locale
+
 dependencies {
     add("kspJvm", libs.androidx.room.compiler)
     add("kspAndroid", libs.androidx.room.compiler)
     iosTargets.forEach { iosTarget ->
-        add("ksp${iosTarget.replaceFirstChar(Char::uppercase)}", libs.androidx.room.compiler)
+        add("ksp${iosTarget.replaceFirstChar { it.titlecase(Locale.ROOT) }}", libs.androidx.room.compiler)
     }
 
     debugImplementation(compose.uiTooling)
 }
tasks-app-ios/Configuration/Config.xcconfig (1)

3-7: Consider quoting the display name and documenting OAuth IDs.

For consistency with dev config conventions, consider quoting APP_DISPLAY_NAME. Also, adding a brief comment about the OAuth client IDs clarifies that they're public identifiers safe to embed in mobile apps using PKCE.

tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.colorset/Contents.json (1)

16-50: Consider distinct dark mode colors for better accessibility.

The light and dark appearance variants use identical RGB values, which may reduce visibility and cause eye strain in dark mode. Adjusting the dark variant slightly would improve the user experience.

tasks-app-ios/Configuration/Config.dev.xcconfig (1)

3-7: Quote the display name to prevent parsing issues.

The APP_DISPLAY_NAME value contains a space but is unquoted, which can cause xcconfig parsing issues. Wrap it in quotes and consider adding documentation for the OAuth client IDs.

tasks-app-ios/Taskfolio/TaskfolioApp.swift (1)

6-8: Verify the Kotlin function name and add error handling.

The Swift code calls InitKoinKt.doInitKoin(), but the Kotlin function in initKoin.kt is named initKoin. Verify the correct interop name to prevent runtime crashes. Additionally, wrap the call in a do-catch block to handle initialization failures gracefully.

Run this script to confirm the function name:

#!/bin/bash
# Verify the Kotlin function name in initKoin.kt
rg -n "^fun (initKoin|doInitKoin)" tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/initKoin.kt
tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.dev.colorset/Contents.json (1)

16-50: Consider distinct dark mode colors for better accessibility.

Similar to the production accent color, the dev variant uses identical RGB values for light and dark appearances, which may reduce accessibility in dark mode.

tasks-app-ios/Taskfolio/ContentView.swift (1)

9-9: Add clarifying comment to empty lifecycle method.

The empty updateUIViewController method may cause confusion about whether state updates are needed. A brief comment explaining that ComposeView is self-contained would clarify the intent.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/initKoin.kt (1)

35-50: Guard against duplicate Koin initialization.

Calling startKoin multiple times throws IllegalStateException. Consider adding an idempotency check at the start of the function to prevent crashes if initKoin is ever called more than once.

tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/Contents.json (1)

16-20: Remove obsolete iPhone 29×29 1x icon slot.

iPhone 1x assets are no longer used; keeping this entry causes Xcode warnings when the PNG is missing.

-    {
-      "filename" : "[email protected]",
-      "idiom" : "iphone",
-      "scale" : "1x",
-      "size" : "29x29"
-    },
website/index.md (1)

19-22: Add a header row and descriptive alt text to the screenshots table.

Improves accessibility (MD045) and rendering consistency.

- | --------------------------------------- |--------------------------------------- | ---------------------------------- | ---------------------------------- |
-| ![](assets/screens/task_lists_light.png) | ![](assets/screens/groceries_light.png) | ![](assets/screens/add_task_light.png) | ![](assets/screens/home_dark.png)  |
+| Task lists | Groceries | Add task | Home (dark) |
+| ---------- | --------- | -------- | ----------- |
+| ![Task lists – light](assets/screens/task_lists_light.png) | ![Groceries – light](assets/screens/groceries_light.png) | ![Add task – light](assets/screens/add_task_light.png) | ![Home – dark](assets/screens/home_dark.png) |
.github/workflows/ios-app-nightly.yml (4)

3-7: Quote the top-level “on” key to satisfy YAML linters.

-on:
+"on":
   schedule:
     - cron: '0 2 * * *'
   workflow_dispatch:

40-43: Fix steps indentation; current list items parse at the wrong level.

-    steps:
-    - uses: actions/checkout@v4
-    - uses: ./.github/actions/setup-jdk-gradle
+    steps:
+      - uses: actions/checkout@v4
+      - uses: ./.github/actions/setup-jdk-gradle

44-55: Remove undefined matrix guard on a non‑matrix job.

This expression errors at runtime.

-    - name: Cache Gradle
-      if: ${{ matrix.gradle_module == 'tasks-app-ios' }}
+    - name: Cache Gradle

61-71: Harden xcodebuild invocation and drop unused env var.

  • IOS_TARGET is unused here → remove it.
  • Pin a destination to reduce simulator flakiness.
-      run: |
-          cd tasks-app-ios
-          IOS_TARGET=simulator xcodebuild \
-            -project Taskfolio.xcodeproj \
-            -scheme Taskfolio \
-            -sdk iphonesimulator \
-            -arch arm64 \
+      run: |
+          cd tasks-app-ios
+          xcodebuild \
+            -project Taskfolio.xcodeproj \
+            -scheme Taskfolio \
+            -sdk iphonesimulator \
+            -destination "platform=iOS Simulator,name=iPhone 15,OS=latest" \
+            -arch arm64 \
             -configuration Debug \
             build \
             CODE_SIGNING_ALLOWED=NO \
             CODE_SIGN_IDENTITY="" \
             CODE_SIGNING_REQUIRED=NO
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (2)

77-81: Avoid recomposition-triggered refresh loops; use a stable LaunchedEffect key.

-    if (userState == null) {
-        LaunchedEffect(userState) {
-            userViewModel.refreshUserState()
-        }
-    }
+    LaunchedEffect(Unit) {
+        if (userState == null) userViewModel.refreshUserState()
+    }

88-102: Memoize AboutApp to avoid redoing I/O work on every recomposition.

-                    val aboutApp = AboutApp(
-                        name = appName,
-                        version = fullVersion
-                    ) {
+                    val aboutApp = remember(appName, fullVersion) {
+                        AboutApp(
+                            name = appName,
+                            version = fullVersion
+                        ) {
                             val path = mainBundle.pathForResource("licenses_ios", "json")
                                 ?: error("licenses_ios.json not found in bundle")
 
                             platform.Foundation.NSString.stringWithContentsOfFile(
                                 path,
                                 encoding = platform.Foundation.NSUTF8StringEncoding,
                                 error = null
                             ) ?: error("Failed to load licenses_ios.json from bundle path: $path")
-                    }
+                        }
+                    }

Add:

import androidx.compose.runtime.remember
README.md (7)

39-39: Replace inline HTML with plain text for markdown compliance.

-> I set out to revisit the classical TODO app, 'local-first' syncing with Google Tasks—aiming for an <abbr title="Minimum Viable Experience">MVE</abbr> in 2 weeks, focusing on the 80/20 rule to nail the essentials.
+> I set out to revisit the classical TODO app, 'local-first' syncing with Google Tasks—aiming for a Minimum Viable Experience (MVE) in 2 weeks, focusing on the 80/20 rule to nail the essentials.

41-42: Add header row and alt text to screenshots table.

-| ![](assets/screens/task_lists_light.png) | ![](assets/screens/groceries_light.png) | ![](assets/screens/add_task_light.png) | ![](assets/screens/home_dark.png)  |
-| --------------------------------------- |--------------------------------------- | ---------------------------------- | ---------------------------------- |
+| Task lists | Groceries | Add task | Home (dark) |
+| ---------- | --------- | -------- | ----------- |
+| ![Task lists – light](assets/screens/task_lists_light.png) | ![Groceries – light](assets/screens/groceries_light.png) | ![Add task – light](assets/screens/add_task_light.png) | ![Home – dark](assets/screens/home_dark.png) |

46-48: Use “Compose Multiplatform” and remove extra “as well”; capitalize JVM.

-> [!NOTE]
-> The application is also available as a desktop (Jvm) application and an iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as UI Toolkit).
+> [!NOTE]
+> The application is also available as a desktop (JVM) application and an iOS application (using [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) as the UI toolkit).

82-85: Nit: inline KMP name and minor grammar.

-- [Kotlin](https://kotlinlang.org/), [Multiplatform (aka KMP)](https://kotlinlang.org/docs/multiplatform.html)
+- [Kotlin](https://kotlinlang.org/), [Kotlin Multiplatform (KMP)](https://kotlinlang.org/docs/multiplatform.html)
   - Android and Desktop are fully supported.
-  - iOS wasn't initially planned, but a draft version is available (use it at your own risk, there might be dragons 🐉).
-  - Web is not planned any time soon (contributions are welcome 🤝)
+  - iOS wasn't initially planned, but a draft is available (use at your own risk; there might be dragons 🐉).
+  - Web is not planned any time soon (contributions are welcome 🤝)

130-132: Clarify iOS module composition.

-  - The iOS application (thin layer fully reusing `:tasks-app-shared`)
-  - Xcode project, written in Swift
+  - The iOS application (thin layer fully reusing `:tasks-app-shared`)
+  - Xcode project, primarily Swift, integrating the shared Kotlin framework

189-196: Use asterisks for emphasis and keep style consistent.

-The support of iOS works more or less _as-is_ and gets the job done. It's provided without guarantees, use at your own risk.  
+The support of iOS works more or less *as-is* and gets the job done. It's provided without guarantees, use at your own risk.  
 ...
-> iOS support is _opt-in_ and disabled by default to avoid unnecessary time and disk usage during the initial Gradle sync when the iOS target isn't required.  
+> iOS support is *opt-in* and disabled by default to avoid unnecessary time and disk usage during the initial Gradle sync when the iOS target isn't required.  

217-229: Tidy xcodebuild example: remove unused env var and ensure fence spacing.

-```bash
-cd tasks-app-ios
-IOS_TARGET=simulator xcodebuild -project Taskfolio.xcodeproj \
+```bash
+cd tasks-app-ios
+xcodebuild -project Taskfolio.xcodeproj \
   -scheme Taskfolio \
   -sdk iphonesimulator \
+  -destination "platform=iOS Simulator,name=iPhone 15,OS=latest" \
   -arch arm64 \
   -configuration Debug \
   build \
   CODE_SIGNING_ALLOWED=NO \
   CODE_SIGN_IDENTITY="" \
   CODE_SIGNING_REQUIRED=NO

This triggers the :tasks-app-shared:embedAndSignAppleFrameworkForXcode Gradle task under the hood.


</blockquote></details>
<details>
<summary>tasks-app-ios/build.gradle.kts (3)</summary><blockquote>

`52-66`: **Ensure the destination directory exists before writing Versions.xcconfig.**

Prevents failures on clean checkouts/CI.

```diff
     doLast {
+        // Ensure parent directory exists
+        configFile.parentFile.mkdirs()
         configFile.writeText(content)
     }

68-79: Flags conflict: offlineMode=true but fetchRemoteLicense=true.

These are contradictory. If running offline, disable remote fetch.

-        offlineMode = true
-        fetchRemoteLicense = true
+        offlineMode = true
+        fetchRemoteLicense = false

85-88: Fix license allow‑list typo (case‑sensitive).

-        allowedLicenses.addAll("Apache-2.0", "asdkl", "MIT", "EPL-1.0", "BSD-3-Clause")
+        allowedLicenses.addAll("Apache-2.0", "ASDKL", "MIT", "EPL-1.0", "BSD-3-Clause")
tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (5)

258-258: Lower iOS deployment target for broader compatibility.

18.2 is very restrictive. Consider 17.0 (or 16.0 if feasible).

-				IPHONEOS_DEPLOYMENT_TARGET = 18.2;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;

Also applies to: 319-319


342-342: Enable user script sandboxing at the target level.

Project uses YES, but target overrides to NO. Align for security.

-				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+				ENABLE_USER_SCRIPT_SANDBOXING = YES;

Also applies to: 383-383


340-340: Move DEVELOPMENT_TEAM to xcconfig, avoid hardcoding in project.

Define per‑config in Config(.dev).xcconfig and remove here to ease team changes.

-				DEVELOPMENT_TEAM = 9J8LQ9R327;
+				/* DEVELOPMENT_TEAM defined via xcconfig */

Also applies to: 381-381


171-171: Don’t force Kotlin framework phase to run every build.

Remove alwaysOutOfDate or add proper inputs/outputs to support incrementality and cut build time.

-			alwaysOutOfDate = 1;
+			/* alwaysOutOfDate removed to allow incremental builds */

Optionally add:

  • inputPaths: “$(SRCROOT)/../gradlew”, “$(SRCROOT)/../tasks-app-shared/build.gradle.kts”
  • outputPaths: “$(SRCROOT)/../tasks-app-shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)/shared.framework”

186-186: Harden the build phase shell: fail fast, validate IOS_TARGET, check gradlew.

-			shellScript = "if [ \"${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED}\" = \"YES\" ]; then\n  echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to 'YES'.\"\n  exit 0\nfi\n\nif [ -z \"${IOS_TARGET}\" ]; then\n  echo \"You must define IOS_TARGET to 'all', 'simulator' or 'device' to allow building for iOS.\"\n  exit 1\nfi\n\necho \"Building for '${IOS_TARGET}' target\"\n\ncd \"${SRCROOT}/..\"\n./gradlew :tasks-app-shared:embedAndSignAppleFrameworkForXcode\n";
+			shellScript = "set -euo pipefail\n\nif [ \"${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED}\" = \"YES\" ]; then\n  echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED=YES\"\n  exit 0\nfi\n\ncase \"${IOS_TARGET:-}\" in\n  all|simulator|device) echo \"Building for '${IOS_TARGET}' target\" ;;\n  *) echo \"ERROR: IOS_TARGET must be 'all', 'simulator' or 'device'\" ; exit 1 ;;\n esac\n\ncd \"${SRCROOT}/..\"\n[ -x ./gradlew ] || { echo \"ERROR: ./gradlew not found\" ; exit 1; }\n./gradlew :tasks-app-shared:embedAndSignAppleFrameworkForXcode\n";
buildSrc/src/main/kotlin/IosTargetsExt.kt (3)

1-26: Add a package declaration.

Prevents namespace collisions and satisfies static analysis.

@@
  * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
+package net.opatry.gradle
+
 import org.gradle.api.Project

31-45: Fix compile error and avoid shared mutable Properties.

  • Properties.isEmpty is a function → call isEmpty().
  • Avoid a global Properties cache; read local.properties per access to prevent cross-project bleed and races.
-private val localProperties = Properties()
 private fun Project.getIosTargetedConfiguration(): String? {
-    return findProperty("ios.target") as? String
-        ?: System.getenv("IOS_TARGET")
-        ?: run {
-            if (localProperties.isEmpty) {
-                val localPropertiesFile = File(rootDir, "local.properties")
-                if (localPropertiesFile.isFile) {
-                    localPropertiesFile.inputStream().use { reader ->
-                        localProperties.load(reader)
-                    }
-                }
-            }
-            localProperties.getProperty("ios.target")
-        }
+    val cli = findProperty("ios.target") as? String
+    val env = System.getenv("IOS_TARGET")
+    val file = File(rootDir, "local.properties")
+    val fileProp = if (file.isFile) {
+        Properties().apply { file.inputStream().use(::load) }.getProperty("ios.target")
+    } else null
+    return cli ?: env ?: fileProp
 }

27-61: Avoid top‑level lateinit cache; compute per‑Project and normalize input.

Caching makes iosTargets stale across invocations (e.g., when -Pios.target changes). Normalize to be case/space‑insensitive.

-// can't use by lazy, we need Project.findProperty not accessible there
-@Suppress("ObjectPropertyName")
-private lateinit var _iosTargets: List<String>
-
-val Project.iosTargets: List<String>
-    get() {
-        if (!::_iosTargets.isInitialized) {
-            _iosTargets = when (getIosTargetedConfiguration()) {
-                // We ignore "iosX64", not considered as a use case
-                "all" -> listOf("iosArm64", "iosSimulatorArm64")
-                "simulator" -> listOf("iosSimulatorArm64")
-                "device" -> listOf("iosArm64")
-                "none" -> emptyList()
-                else -> emptyList()
-            }
-        }
-        return _iosTargets
-    }
+val Project.iosTargets: List<String>
+    get() = when (getIosTargetedConfiguration()?.trim()?.lowercase(Locale.ROOT)) {
+        // We ignore "iosX64", not considered as a use case
+        "all" -> listOf("iosArm64", "iosSimulatorArm64")
+        "simulator" -> listOf("iosSimulatorArm64")
+        "device" -> listOf("iosArm64")
+        "none", null, "" -> emptyList()
+        else -> emptyList()
+    }
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (3)

60-82: Critical: Implement iOS authorization URL handling; also improve error/cancellation UX.

The requestUserAuthorization lambda is empty, so the authorization URL is never opened on iOS—this will cause the auth flow to hang or fail. Additionally, the code doesn't clear stale errors before retrying or handle user cancellation gracefully.

Fix 1: Wire the authorization callback. Create an expect/actual helper to open the URL on iOS (e.g., via UIApplication.shared.open or ASWebAuthenticationSession), then pass it to requestUserAuthorization:

+                    val scopes = listOf(
                         GoogleAuthenticator.Scope.Profile,
                         GoogleAuthenticator.Scope(TasksScopes.Tasks),
                     )
                     try {
                         val authCode = authenticator.authorize(
                             scopes = scopes,
                             force = true,
-                            requestUserAuthorization = {},
+                            requestUserAuthorization = { url -> 
+                                // TODO: call expect/actual openUrl(url) to launch iOS browser/auth session
+                            },
                         ).let(GoogleAuthenticator.Grant::AuthorizationCode)

Fix 2: Clear error before retry and handle cancellation:

             onClick = {
+                error = null
                 ongoingAuth = true
                 coroutineScope.launch {
                     val scopes = listOf(
                         GoogleAuthenticator.Scope.Profile,
                         GoogleAuthenticator.Scope(TasksScopes.Tasks),
                     )
                     try {
                         val authCode = authenticator.authorize(
                             scopes = scopes,
                             force = true,
                             requestUserAuthorization = { url -> 
                                 // TODO: call expect/actual openUrl(url)
                             },
                         ).let(GoogleAuthenticator.Grant::AuthorizationCode)
                         val oauthToken = authenticator.getToken(authCode)
                         onSuccess(oauthToken)
+                    } catch (e: kotlinx.coroutines.CancellationException) {
+                        // User/system cancelled; don't surface as error
+                        throw e
                     } catch (e: Exception) {
                         error = e.message
-                        ongoingAuth = false
+                    } finally {
+                        ongoingAuth = false
                     }
                 }
             },

85-93: Don't reuse the parent modifier for the inner Box.

Passing the same modifier down can leak sizing constraints and cause unintended layout behavior.

Apply this diff:

-            Box(modifier, contentAlignment = Alignment.Center) {
+            Box(Modifier, contentAlignment = Alignment.Center) {
                 AnimatedContent(ongoingAuth, label = "authorize_button_content") { ongoing ->
                     if (ongoing) {
                         LoadingIndicator(Modifier.size(24.dp))
                     } else {
                         Text(stringResource(Res.string.onboarding_screen_authorize_cta))
                     }
                 }
             }

96-98: Only render error text when non-empty.

The Text widget is always rendered, even when message is null or empty, which may reserve layout space or cause visual artifacts.

Apply this diff:

         AnimatedContent(error, label = "authorize_error_message") { message ->
-            Text(message ?: "", color = MaterialTheme.colorScheme.error)
+            if (!message.isNullOrEmpty()) {
+                Text(message, color = MaterialTheme.colorScheme.error)
+            }
         }
tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (6)

21-29: Critical: Use separate Web OAuth client ID for serverClientID.

Setting serverClientID to the same value as clientID (line 24) will issue ID tokens with the wrong audience for your backend. The serverClientID should be a Web application OAuth client ID from Google Cloud Console, not the iOS app's client ID.

Create a separate Web OAuth client ID in Google Cloud Console and update the configuration:

         if GIDSignIn.sharedInstance.configuration == nil {
-            let config: GIDConfiguration
-            if !stringScopes.isEmpty {
-                config = GIDConfiguration(clientID: clientId, serverClientID: clientId)
-            } else {
-                config = GIDConfiguration(clientID: clientId)
-            }
+            // Use separate Web client ID for backend server authentication
+            let serverClientId = Bundle.main.object(forInfoDictionaryKey: "GIDServerClientID") as? String
+            let config = serverClientId != nil
+                ? GIDConfiguration(clientID: clientId, serverClientID: serverClientId!)
+                : GIDConfiguration(clientID: clientId)
             GIDSignIn.sharedInstance.configuration = config
         }

Then add GIDServerClientID to your Info.plist with the Web application client ID.


32-37: Address the FIXME: implement proper force parameter behavior.

The current implementation only signs out when force = true, but the JVM/Android versions add prompt=consent and access_type=offline to ensure a refresh token is returned. Without these parameters, the iOS flow may not receive a refresh token, breaking long-term authentication.

Investigate whether the Google Sign-In iOS SDK supports equivalent configuration options (e.g., via GIDConfiguration or sign-in hints) to request consent and offline access. If not available, document this platform limitation clearly in code comments and consider adding a warning to users.

#!/bin/bash
# Check if Google Sign-In iOS SDK documentation mentions prompt/consent options
echo "Search Google Sign-In iOS SDK documentation for 'prompt', 'consent', or 'offline access' configuration"
Google Sign-In iOS SDK prompt consent offline access configuration

61-61: Replace unused closure parameter with underscore.

The scopeResult parameter is not used and should be replaced with _ for clarity and to silence the SwiftLint warning.

Apply this diff:

-                        user.addScopes(stringScopes, presenting: rootViewController) { scopeResult, scopeError in
+                        user.addScopes(stringScopes, presenting: rootViewController) { _, scopeError in
                             if let scopeError = scopeError {
                                 continuation.resume(throwing: scopeError)
                             } else {
                                 continuation.resume(returning: user.userID ?? "")
                             }
                         }

39-75: Critical: Return server authorization code, not userID.

The method returns user.userID (lines 45, 65, 69), but the OAuth flow expects a server authorization code that can be exchanged for tokens. Using userID will cause token exchange to fail on the backend.

Modify all return statements to use result?.serverAuthCode when available:

             if !force, let currentUser = GIDSignIn.sharedInstance.currentUser {
                 if !stringScopes.isEmpty {
                     currentUser.addScopes(stringScopes, presenting: rootViewController) { result, error in
                         if let error = error {
                             continuation.resume(throwing: error)
                         } else if let user = result {
-                            continuation.resume(returning: user.user.userID ?? "")
+                            if let authCode = result.serverAuthCode {
+                                continuation.resume(returning: authCode)
+                            } else {
+                                continuation.resume(returning: user.user.userID ?? "")
+                            }
                         } else {
                             continuation.resume(throwing: GoogleSignInError.unknownError)
                         }
                     }
                 } else {
-                    continuation.resume(returning: currentUser.userID ?? "")
+                    // No server auth code available without scopes/sign-in
+                    continuation.resume(returning: currentUser.userID ?? "")
                 }
                 return
             }

             GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) { result, error in
                 if let error = error {
                     continuation.resume(throwing: error)
                 } else if let user = result?.user {
                     if !stringScopes.isEmpty {
                         user.addScopes(stringScopes, presenting: rootViewController) { _, scopeError in
                             if let scopeError = scopeError {
                                 continuation.resume(throwing: scopeError)
                             } else {
-                                continuation.resume(returning: user.userID ?? "")
+                                if let authCode = result?.serverAuthCode {
+                                    continuation.resume(returning: authCode)
+                                } else {
+                                    continuation.resume(returning: user.userID ?? "")
+                                }
                             }
                         }
                     } else {
-                        continuation.resume(returning: user.userID ?? "")
+                        if let authCode = result?.serverAuthCode {
+                            continuation.resume(returning: authCode)
+                        } else {
+                            continuation.resume(returning: user.userID ?? "")
+                        }
                     }
                 } else {
                     continuation.resume(throwing: GoogleSignInError.unknownError)
                 }
             }

Note: serverAuthCode requires serverClientID to be properly configured (see previous comment on lines 21-29).


96-99: Clamp expiresIn to zero to avoid negative values.

If the access token is already expired, expiresIn can be negative, which may cause issues in downstream code (e.g., timer underflow, incorrect refresh logic).

Apply this diff:

-                let expirationDate = user.accessToken.expirationDate
-                let currentDate = Date()
-                let expiresIn = Int64(expirationDate?.timeIntervalSince(currentDate) ?? 0)
+                let expirationDate = user.accessToken.expirationDate ?? Date()
+                let currentDate = Date()
+                let expiresIn = max(Int64(expirationDate.timeIntervalSince(currentDate)), 0)

116-140: Remove unused configurationFailed error case.

The configurationFailed error case (line 119) is defined but never thrown anywhere in the code. Remove it to keep the error enum clean.

Apply this diff:

 enum GoogleSignInError: Error, LocalizedError {
     case noRootViewController
     case missingConfiguration
-    case configurationFailed
     case userNotSignedIn
     case tokenNotAvailable
     case unknownError

     var errorDescription: String? {
         switch self {
         case .noRootViewController:
             return "Unable to find root view controller"
         case .missingConfiguration:
             return "No GIDClientID found in Info.plist"
-        case .configurationFailed:
-            return "Failed to configure Google Sign-In"
         case .userNotSignedIn:
             return "User is not signed in"
         case .tokenNotAvailable:
             return "Access token not available"
         case .unknownError:
             return "An unknown error occurred"
         }
     }
 }
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3d637cf and ec06eba.

⛔ Files ignored due to path filters (30)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
📒 Files selected for processing (46)
  • .github/workflows/build-apps.yml (3 hunks)
  • .github/workflows/e2e-tests.yml (1 hunks)
  • .github/workflows/ios-app-nightly.yml (1 hunks)
  • .gitignore (1 hunks)
  • README.md (5 hunks)
  • build.gradle.kts (3 hunks)
  • buildSrc/build.gradle.kts (1 hunks)
  • buildSrc/src/main/kotlin/IosTargetsExt.kt (1 hunks)
  • google/oauth/build.gradle.kts (1 hunks)
  • google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (1 hunks)
  • google/tasks/build.gradle.kts (1 hunks)
  • gradle/libs.versions.toml (2 hunks)
  • lucide-icons/build.gradle.kts (1 hunks)
  • settings.gradle.kts (1 hunks)
  • tasks-app-ios/Configuration/Config.dev.xcconfig (1 hunks)
  • tasks-app-ios/Configuration/Config.xcconfig (1 hunks)
  • tasks-app-ios/Configuration/Versions.xcconfig (1 hunks)
  • tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.colorset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.dev.colorset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Configuration/Versions.xcconfig (1 hunks)
  • tasks-app-ios/Taskfolio/ContentView.swift (1 hunks)
  • tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (1 hunks)
  • tasks-app-ios/Taskfolio/Info.plist (1 hunks)
  • tasks-app-ios/Taskfolio/Resources/licenses_ios.json (1 hunks)
  • tasks-app-ios/Taskfolio/TaskfolioApp.swift (1 hunks)
  • tasks-app-ios/build.gradle.kts (1 hunks)
  • tasks-app-shared/build.gradle.kts (2 hunks)
  • tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/authModule.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/platformModule.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/initKoin.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/backHandler.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/theme/Type.ios.kt (1 hunks)
  • tasks-app-shared/src/iosTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.ios.kt (1 hunks)
  • tasks-core/build.gradle.kts (2 hunks)
  • tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt (1 hunks)
  • website/index.md (2 hunks)
  • website/styles.css (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (4)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/theme/Theme.kt (1)
  • TaskfolioTheme (109-124)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/LoadingPane.kt (1)
  • LoadingPane (36-41)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/TasksApp.kt (1)
  • TasksApp (88-175)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/screen/authorizationScreen.kt (1)
  • AuthorizationScreen (60-92)
tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (3)
google/oauth-http/src/commonMain/kotlin/net/opatry/google/auth/HttpGoogleAuthenticator.kt (1)
  • config (70-249)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/presentation/UserViewModel.kt (2)
  • signOut (116-123)
  • signIn (97-114)
google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (1)
  • refreshToken (85-88)
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (1)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/LoadingIndicator.kt (1)
  • LoadingIndicator (38-59)
tasks-app-ios/Taskfolio/ContentView.swift (1)
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (1)
  • MainViewController (44-113)
🪛 actionlint (1.7.8)
.github/workflows/ios-app-nightly.yml

42-42: description is required in metadata of "Setup JDK & Gradle" action at "/home/jailuser/git/.github/actions/setup-jdk-gradle/action.yml"

(action)


45-45: property "gradle_module" is not defined in object type {}

(expression)

🪛 detekt (1.23.8)
buildSrc/src/main/kotlin/IosTargetsExt.kt

[warning] 27-29: Usages of lateinit should be avoided.

(detekt.potential-bugs.LateinitUsage)


[warning] 1-62: The file does not contain a package declaration.

(detekt.potential-bugs.MissingPackageDeclaration)

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt

[warning] 77-77: The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.

(detekt.exceptions.TooGenericExceptionCaught)

🪛 SwiftLint (0.57.0)
tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift

[Warning] 32-32: FIXMEs should be resolved (in Jvm impl, the force means &...)

(todo)


[Warning] 61-61: Unused parameter in a closure should be replaced with _

(unused_closure_parameter)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: licenses-check / ©️ Check licenses (tasks-app-desktop, src/main/resources/licenses_desktop.json)
  • GitHub Check: build-apps / 🔨 Build - 🖥️ Desktop App
  • GitHub Check: licenses-check / ©️ Check licenses (tasks-app-android, src/main/assets/licenses_android.json)
  • GitHub Check: build-apps / 🔨 Build - 📱 Android App
  • GitHub Check: coverage / 📊 Coverage
  • GitHub Check: unit-tests / ✅ Tests
  • GitHub Check: build-apps / 🔨 Build - 🍎 iOS App

Comment on lines +38 to +53
actual fun networkStateFlow(): Flow<Boolean> = callbackFlow {
val monitor = nw_path_monitor_create()
val queue = dispatch_queue_create("NetworkMonitorQueue", null)

nw_path_monitor_set_update_handler(monitor) { path ->
val hasInternet = nw_path_get_status(path) == nw_path_status_satisfied
trySend(hasInternet).isSuccess
}

nw_path_monitor_set_queue(monitor, queue)
nw_path_monitor_start(monitor)

awaitClose {
nw_path_monitor_cancel(monitor)
}
}.distinctUntilChanged() No newline at end of file
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Extract to a singleton shared flow to prevent multiple monitors.

Each call to networkStateFlow() creates a new NWPathMonitor and dispatch queue. If this function is invoked from multiple places or on recomposition, multiple monitors will run concurrently, wasting system resources.

Refactor to create a single monitor in a singleton or DI-managed component, convert the callbackFlow to a hot StateFlow<Boolean> using stateIn/shareIn, and return that shared instance from networkStateFlow().

🤖 Prompt for AI Agents
In
tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt
around lines 38–53, currently each call to networkStateFlow() builds a new
nw_path_monitor and dispatch queue via callbackFlow; refactor so the monitor and
dispatch queue are created once and exposed as a hot shared flow: create a
singleton (or DI-managed) object that owns a MutableStateFlow<Boolean> (or use
callbackFlow converted to StateFlow via stateIn/shareIn) backed by a single
nw_path_monitor and queue, start the monitor once on initialization, update the
MutableStateFlow from the monitor callback, ensure proper lifecycle cleanup by
cancelling the monitor when the singleton is disposed (or on process
termination), and change networkStateFlow() to simply return the shared
StateFlow (keeping distinctUntilChanged behavior if desired).

Comment on lines +55 to +59
actual override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is TodoTaskPosition) return false
return rawValue == other.rawValue
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Align equals() semantics with compareTo() or document intent.

Currently, cross-type values can compare equal (compareTo==0) but are not equal by equals(); decide and enforce/document.

Option A (value-based across types):

-    actual override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (other !is TodoTaskPosition) return false
-        return rawValue == other.rawValue
-    }
+    actual override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        return when (other) {
+            is TodoTaskPosition -> rawValue == other.rawValue
+            is DoneTaskPosition -> rawValue == other.rawValue
+            else -> false
+        }
+    }

Apply similarly to DoneTaskPosition.

Option B: keep type-restricted equality but add KDoc on both classes clarifying that equality is only within the same subtype to avoid surprises when used in Sets/Maps.

Also applies to: 91-95

🤖 Prompt for AI Agents
In tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt around
lines 55-59 (and also update the analogous block at 91-95), the equals
implementation currently enforces type-restricted equality which conflicts with
compareTo's value-based semantics; change equals to be value-based across
TaskPosition subtypes by comparing rawValue (i.e., treat any TaskPosition with
the same rawValue as equal), apply the same change to DoneTaskPosition, ensure
hashCode is consistent (derive solely from rawValue) and add KDoc on both
classes explaining the chosen value-based cross-type equality behavior.

| --------------------------------------- |--------------------------------------- | ---------------------------------- | ---------------------------------- |
| ![](assets/screens/task_lists_light.png) | ![](assets/screens/groceries_light.png) | ![](assets/screens/add_task_light.png) | ![](assets/screens/home_dark.png) |

> ℹ️ The application is also available as a desktop (Jvm) application and an iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as UI Toolkit).
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the official naming “Compose Multiplatform” and capitalize JVM.

-> ℹ️ The application is also available as a desktop (Jvm) application and an iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as UI Toolkit).
+> ℹ️ The application is also available as a desktop (JVM) application and an iOS application (using [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) as the UI toolkit).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
> ℹ️ The application is also available as a desktop (Jvm) application and an iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as UI Toolkit).
> ℹ️ The application is also available as a desktop (JVM) application and an iOS application (using [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) as the UI toolkit).
🤖 Prompt for AI Agents
In website/index.md around line 22, update the phrasing to use the official
product names: replace “Compose Multi Platform (aka CMP)” with “Compose
Multiplatform” and change “Jvm” to uppercase “JVM”; keep the rest of the
sentence intact and ensure the Compose Multiplatform link remains pointing to
the same URL.

Comment on lines +57 to +60
- [Kotlin](https://kotlinlang.org/), [Multiplatform (aka KMP)](https://kotlinlang.org/docs/multiplatform.html)
- Android and Desktop are fully supported.
- iOS wasn't initially planned, but a draft version is available (use it at your own risk, there might be dragons 🐉).
- Web is not planned any time soon (contributions are welcome 🤝)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor copy edit for KMP label.

-- [Kotlin](https://kotlinlang.org/), [Multiplatform (aka KMP)](https://kotlinlang.org/docs/multiplatform.html)
+- [Kotlin](https://kotlinlang.org/), [Kotlin Multiplatform (KMP)](https://kotlinlang.org/docs/multiplatform.html)
🤖 Prompt for AI Agents
In website/index.md around lines 57 to 60, the label "Multiplatform (aka KMP)"
should be edited for clarity; replace the link text with "Kotlin Multiplatform
(KMP)" (keeping the existing https://kotlinlang.org/docs/multiplatform.html URL)
and leave the rest of the bullet points unchanged.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

♻️ Duplicate comments (50)
.gitignore (1)

37-40: Address the previous review suggestion: consolidate repetitive Xcode patterns.

This is a continuation of feedback from the prior review. The specific, repetitive patterns for Xcode user data directories are still present and could be significantly simplified with generic wildcards for better maintainability.

Apply this diff to consolidate the iOS-specific ignore patterns:

-tasks-app-ios/Taskfolio.xcodeproj/xcuserdata/*.xcuserdatad
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/contents.xcworkspacedata
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/xcuserdata/*.xcuserdatad
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/xcshareddata/*
+tasks-app-ios/**/*.xcuserdatad
+tasks-app-ios/**/*.xcworkspace/contents.xcworkspacedata
+tasks-app-ios/**/*.xcworkspace/xcshareddata/*

This pattern covers the current paths and will automatically apply to any future iOS modules or Xcode projects without additional maintenance.

tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.colorset/Contents.json (1)

16-50: Consider adjusting colors for dark mode accessibility.

The accent color uses identical RGB values for both light and dark appearance modes. For better user experience and accessibility, consider using adjusted colors for dark mode that provide appropriate contrast and reduce eye strain.

(Note: This issue was previously flagged in an earlier review.)

tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/Contents.json (1)

16-20: Drop iPhone 29x29 1x entry in dev icon set.

iPhone no longer uses 1x assets; keeping this often results in "missing file" warnings when the PNG is absent.

(Note: This issue was previously flagged in an earlier review.)

tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.dev.colorset/Contents.json (1)

16-50: Optimize dark mode color variant for better accessibility.

The accent color uses identical RGB values for both light and dark appearances, which may cause eye strain in dark mode. Consider using slightly adjusted values for the dark mode variant to improve accessibility and user experience.

(Note: This issue was previously flagged in an earlier review.)

tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/Contents.json (1)

16-20: Remove unnecessary iPhone 29x29 1x slot.

iPhone no longer uses 1x assets; keeping this often results in "missing file" warnings when the PNG is absent.

(Note: This issue was previously flagged in an earlier review.)

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/backHandler.ios.kt (1)

29-35: Scope the experimental API opt-in internally.

The @ExperimentalComposeUiApi annotation at the function level forces all callers to opt-in to the experimental API, even though it's only needed for the BackHandler call inside the implementation. This was previously flagged and remains unaddressed.

Apply this diff to scope the experimental opt-in internally:

-@ExperimentalComposeUiApi
 @Composable
 actual fun MyBackHandler(
     canNavigateBack: () -> Boolean,
     navigateBack: () -> Unit
 ) {
+    @OptIn(ExperimentalComposeUiApi::class)
     BackHandler(canNavigateBack()) {
         navigateBack()
     }
 }

This keeps the experimental requirement internal to the implementation and doesn't leak to consumers of MyBackHandler.

tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt (6)

55-59: Align equals semantics with compareTo (or document).
Currently, cross-type values can compare equal but are not equal by equals(), which can surprise in sets/maps. Choose and enforce:

  • Option A: value-based equality across subtypes (compare rawValue).
  • Option B: keep type-restricted equality but add KDoc on both classes clarifying intent.

Also applies to: 91-95


30-31: Extract and reuse padding width constant.
Avoid magic number 20; centralize.

Apply within getters:

-        get() = rawValue.toString().padStart(20, '0')
+        get() = rawValue.toString().padStart(POSITION_STRING_WIDTH, '0')

Add once near imports:

private const val POSITION_STRING_WIDTH = 20

Also applies to: 76-77


66-68: Derive and rename upper bound from width to prevent drift.
Hardcoded 19 nines with 20-digit padding is inconsistent; compute from width and use constant-style name.

-        private val UpperBound = BigInteger.parseString("9999999999999999999")
+        // Max value with POSITION_STRING_WIDTH digits (all 9s)
+        private val UPPER_BOUND = BigInteger.parseString("9".repeat(POSITION_STRING_WIDTH))
         actual fun fromCompletionDate(completionDate: Instant): DoneTaskPosition {
-            return DoneTaskPosition(UpperBound - completionDate.toEpochMilliseconds().toBigInteger())
+            return DoneTaskPosition(UPPER_BOUND - completionDate.toEpochMilliseconds().toBigInteger())
         }

34-36: Validate non-negative index in fromIndex.
Negative indices produce invalid ordering values.

 actual companion object {
     actual fun fromIndex(index: Int): TodoTaskPosition {
-            return TodoTaskPosition(BigInteger.fromInt(index))
+            require(index >= 0) { "index must be >= 0" }
+            return TodoTaskPosition(BigInteger.fromInt(index))
     }

38-41: Harden fromPosition: digits-only and max width.
Reject non-digits and oversize input to keep values canonical and avoid parse surprises.

-        actual fun fromPosition(position: String): TodoTaskPosition {
-            return TodoTaskPosition(BigInteger.parseString(position))
-        }
+        actual fun fromPosition(position: String): TodoTaskPosition {
+            require(position.isNotEmpty() && position.all(Char::isDigit)) { "Invalid position: must be decimal digits" }
+            require(position.length <= POSITION_STRING_WIDTH) { "Invalid position: max $POSITION_STRING_WIDTH digits" }
+            return TodoTaskPosition(BigInteger.parseString(position))
+        }
-        actual fun fromPosition(position: String): DoneTaskPosition {
-            return DoneTaskPosition(BigInteger.parseString(position))
-        }
+        actual fun fromPosition(position: String): DoneTaskPosition {
+            require(position.isNotEmpty() && position.all(Char::isDigit)) { "Invalid position: must be decimal digits" }
+            require(position.length <= POSITION_STRING_WIDTH) { "Invalid position: max $POSITION_STRING_WIDTH digits" }
+            return DoneTaskPosition(BigInteger.parseString(position))
+        }

Also applies to: 71-74


43-49: Deduplicate compareTo logic.
Same when-branch repeated; extract helper for maintainability and clearer error.

-    actual override fun compareTo(other: TaskPosition): Int {
-        return when (other) {
-            is TodoTaskPosition -> rawValue.compareTo(other.rawValue)
-            is DoneTaskPosition -> rawValue.compareTo(other.rawValue)
-            else -> throw IllegalArgumentException("Only TodoTaskPosition and DoneTaskPosition are supported")
-        }
-    }
+    actual override fun compareTo(other: TaskPosition): Int =
+        compareTaskPositions(rawValue, other)

Add once at file level:

private fun compareTaskPositions(thisValue: BigInteger, other: TaskPosition): Int =
    when (other) {
        is TodoTaskPosition -> thisValue.compareTo(other.rawValue)
        is DoneTaskPosition -> thisValue.compareTo(other.rawValue)
        else -> throw IllegalArgumentException("Unsupported TaskPosition type: ${other::class.simpleName}")
    }

Also applies to: 79-85

tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt (3)

38-53: Refactor to singleton StateFlow to prevent multiple monitor instances.

Each call to networkStateFlow() creates a new NWPathMonitor and dispatch queue. If called from multiple collectors or on recomposition, this wastes system resources and battery.

Refactor to create a single monitor instance shared across all collectors using StateFlow with stateIn or shareIn.

Example pattern:

private val _networkState: StateFlow<Boolean> by lazy {
    callbackFlow {
        // ... existing monitor setup ...
    }
    .distinctUntilChanged()
    .stateIn(
        scope = GlobalScope, // or appropriate scope
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = false
    )
}

actual fun networkStateFlow(): Flow<Boolean> = _networkState

25-28: Add conflation to prevent dropped emissions during bursts.

Without conflation, rapid network state changes may be dropped by trySend if the collector is slow. Use Channel.CONFLATED or add .conflate() to ensure only the latest status is kept.

Apply this diff:

 import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.buffer
 import kotlinx.coroutines.flow.distinctUntilChanged
-}.distinctUntilChanged()
+}
+    .buffer(Channel.CONFLATED)
+    .distinctUntilChanged()

Alternatively, use .conflate():

-}.distinctUntilChanged()
+}
+    .conflate()
+    .distinctUntilChanged()

Also applies to: 38-53


38-41: Release nw_path_monitor to prevent native memory leak.

The nw_path_monitor_t object created at line 39 requires explicit release. After calling nw_path_monitor_cancel(), you must call nw_release() on Network framework objects to avoid leaks. The dispatch_queue_t does not require manual release as it is managed by ARC in modern iOS SDKs.

Apply this fix at lines 49-51:

     awaitClose {
         nw_path_monitor_cancel(monitor)
+        nw_release(monitor)
     }

Also add the missing import:

+ import platform.Network.nw_release
.github/workflows/e2e-tests.yml (1)

10-10: Runner pinned to ubuntu-24.04 – consider ubuntu-latest for automatic upgrades.

As noted in previous reviews, pinning to ubuntu-24.04 means you'll miss future ubuntu-latest upgrades and may encounter deprecation issues when 24.04 is retired. Unless you have a specific dependency on 24.04, reverting to ubuntu-latest will keep your runners up-to-date automatically.

Also applies to: 38-38

google/oauth/build.gradle.kts (1)

31-32: iOS targets comment duplicated across modules – centralize or enhance.

This comment appears in multiple build.gradle.kts files (oauth, tasks, lucide-icons). As noted in previous reviews, either:

  • Centralize this documentation in the root build script or a shared convention plugin, or
  • Make each instance more actionable by pointing to buildSrc/IosTargetsExt.kt and documenting the ios.target property and its configuration options.
google/tasks/build.gradle.kts (1)

31-32: iOS targets comment duplicated across modules – centralize to reduce maintenance.

This comment is repeated in google/oauth/build.gradle.kts and lucide-icons/build.gradle.kts. To avoid drift, consider centralizing the explanation in the root build script or a shared convention plugin, then removing these per-module duplicates.

lucide-icons/build.gradle.kts (1)

32-33: iOS targets comment duplicated across modules – centralize to reduce maintenance.

This comment appears in google/oauth/build.gradle.kts, google/tasks/build.gradle.kts, and here. Consolidate the documentation in a single location (e.g., root build script or shared docs) to prevent inconsistencies and reduce maintenance overhead.

tasks-app-shared/build.gradle.kts (1)

166-168: Locale-sensitive capitalization can cause inconsistent framework names.

As noted in previous reviews, replaceFirstChar(Char::uppercase) uses the default JVM locale, which varies between environments. Use an explicit locale for deterministic builds:

-    iosTargets.forEach { iosTarget ->
-        add("ksp${iosTarget.replaceFirstChar(Char::uppercase)}", libs.androidx.room.compiler)
-    }
+    iosTargets.forEach { iosTarget ->
+        add("ksp${iosTarget.replaceFirstChar { it.titlecase(java.util.Locale.ROOT) }}", libs.androidx.room.compiler)
+    }

Add import java.util.Locale at the top if not already present.

tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (2)

258-258: iOS 18.2 deployment target significantly limits device compatibility.

The minimum iOS version is set to 18.2, which is very recent. Consider lowering to iOS 16.0 or 17.0 for broader market reach, as noted in past review comments.

Also applies to: 319-319


342-342: Script sandboxing disabled poses security risk.

ENABLE_USER_SCRIPT_SANDBOXING = NO allows build scripts unrestricted filesystem access. A past review comment recommended enabling sandboxing for enhanced security. This should be addressed before production.

Also applies to: 383-383

.github/workflows/build-apps.yml (1)

70-72: Remove unused IOS_TARGET environment variable.

The IOS_TARGET=simulator assignment is not used by the Gradle task linkDebugFrameworkIosSimulatorArm64, which hard-codes the target architecture in its name.

Apply this diff:

-        elif [ "${gradle_module}" = "tasks-app-ios" ]; then
-          IOS_TARGET=simulator ./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64
+        elif [ "${gradle_module}" = "tasks-app-ios" ]; then
+          ./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64
.github/workflows/ios-app-nightly.yml (2)

44-55: Remove invalid matrix reference - will cause workflow failure.

This job has no matrix definition, so matrix.gradle_module is undefined and will cause a runtime error. Remove the conditional entirely since this workflow only builds iOS.

Apply this diff:

-    - name: Cache Gradle
-      if: ${{ matrix.gradle_module == 'tasks-app-ios' }}
-      uses: actions/cache@v4
+    - name: Cache Gradle
+      uses: actions/cache@v4

63-73: Remove unused IOS_TARGET and add explicit -destination.

The IOS_TARGET=simulator variable is not used by xcodebuild. Additionally, without an explicit -destination, the simulator selection is non-deterministic.

Apply this diff:

       run: |
           cd tasks-app-ios
-          IOS_TARGET=simulator xcodebuild \
+          xcodebuild \
             -project Taskfolio.xcodeproj \
             -scheme Taskfolio \
             -sdk iphonesimulator \
+            -destination "platform=iOS Simulator,name=iPhone 15" \
             -arch arm64 \
tasks-app-ios/Configuration/Versions.xcconfig (1)

1-2: Consider using standard Xcode version keys.

The past review comment about using CURRENT_PROJECT_VERSION and MARKETING_VERSION is valid. However, since this file is auto-generated by the updateXcodeVersionConfig task, the fix should be applied in the Gradle task that generates it (tasks-app-ios/build.gradle.kts), not directly in this file.

buildSrc/build.gradle.kts (1)

10-12: JVM toolchain duplication noted.

The past review comment correctly identifies that jvmToolchain(17) is duplicated across 10 build files. While this is a valid concern, centralizing toolchain configuration is a broader refactoring effort beyond this iOS-focused PR.

tasks-app-ios/Taskfolio/Configuration/Versions.xcconfig (1)

1-2: Duplicate version configuration detected.

This file has identical content to tasks-app-ios/Configuration/Versions.xcconfig. Maintaining version information in two separate locations increases the risk of inconsistency.

Consider either:

  1. Having both xcconfig files include a single shared Versions.xcconfig file, or
  2. Generating only one Versions.xcconfig and referencing it from both locations

Additionally, the past review comment about using standard Xcode keys (CURRENT_PROJECT_VERSION and MARKETING_VERSION) remains valid—this change should be made in the Gradle task that generates these files.

website/styles.css (1)

155-161: Style improvements suggested.

The past review comment about extracting the accent color (#6BEACA) to a CSS custom property and resetting bottom margin is a good refinement. These are optional polish items that improve maintainability.

tasks-app-ios/Configuration/Config.xcconfig (1)

3-7: Configuration consistency and documentation.

The past review comment about quoting APP_DISPLAY_NAME for consistency and adding OAuth documentation is valid. While this value has no spaces (unlike the dev config), maintaining consistent quoting conventions improves readability and prevents future issues.

tasks-app-ios/Configuration/Config.dev.xcconfig (1)

3-7: Critical: Unquoted value with space and potential OAuth callback issue.

The past review comment correctly identifies two important issues:

  1. Parsing risk: APP_DISPLAY_NAME=T4skf0l10 d3v contains a space without quotes, which can cause xcconfig parsing errors.

  2. OAuth callback: The dev reversed client ID must be registered in Info.plist's CFBundleURLSchemes for Google Sign-In to work. The verification script showed that CFBundleURLTypes exists but didn't confirm the dev reversed client ID is present.

These issues will prevent the dev build from working correctly. Apply the fixes suggested in the past comment:

  • Quote the display name
  • Verify/add the reversed client ID to Info.plist
tasks-app-ios/Taskfolio/ContentView.swift (1)

1-17: LGTM—SwiftUI bridge to Compose is correctly structured.

The empty updateUIViewController is appropriate here since the Compose side manages its own state. The previous review comment about adding a clarifying comment is valid but optional.

tasks-app-ios/Taskfolio/TaskfolioApp.swift (1)

6-8: Critical: Function name mismatch will cause runtime crash.

The Swift code calls InitKoinKt.doInitKoin(), but the Kotlin function at tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/initKoin.kt:36 is named initKoin. This will crash on app launch.

Apply this fix:

     init() {
-        InitKoinKt.doInitKoin(googleAuthenticator: IOSGoogleAuthenticator())
+        do {
+            try InitKoinKt.initKoin(googleAuthenticator: IOSGoogleAuthenticator())
+        } catch {
+            NSLog("Koin initialization failed: \(error)")
+        }
     }

This also adds error handling as suggested in previous reviews.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/initKoin.kt (1)

35-50: Add idempotency guard to prevent IllegalStateException.

If initKoin is ever called multiple times (e.g., during testing or multi-scene scenarios), startKoin will throw IllegalStateException. Although currently only called once in TaskfolioApp.swift, adding a guard improves robustness.

 package net.opatry.tasks.app
 
 import net.opatry.google.auth.GoogleAuthenticator
 import net.opatry.tasks.app.di.dataModule
 import net.opatry.tasks.app.di.loggingModule
 import net.opatry.tasks.app.di.networkModule
 import net.opatry.tasks.app.di.platformModule
 import net.opatry.tasks.app.di.tasksAppModule
 import net.opatry.tasks.app.di.utilModule
 import org.koin.core.context.startKoin
+import org.koin.core.context.GlobalContext
 import org.koin.dsl.module
 
 @Suppress("unused")
 fun initKoin(googleAuthenticator: GoogleAuthenticator) {
+    if (GlobalContext.getOrNull() != null) return
     startKoin {
         modules(
             utilModule,
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/platformModule.ios.kt (1)

68-71: Critical: Room database builder is never built.

The DI binding provides DatabaseBuilder<TasksAppDatabase> instead of TasksAppDatabase. This will cause a runtime crash when the database is first injected.

     single {
         val dbFilePath = get<String>(named("app_root_dir")) + "/tasks.db"
-        Room.databaseBuilder<TasksAppDatabase>(dbFilePath)
+        Room.databaseBuilder<TasksAppDatabase>(dbFilePath)
+            .build()
     }
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/authModule.ios.kt (1)

28-29: Suppress unused parameter warning.

The gcpClientId parameter is intentionally unused on iOS (authenticator provided via Swift). Add a suppression to silence the compiler warning.

+@Suppress("UNUSED_PARAMETER")
 actual fun authModule(gcpClientId: String): Module = module {}
website/index.md (2)

19-23: Add alt text and use official product naming.

The screenshot images lack alt text (accessibility issue, MD045), and the table is missing a header row. Additionally, use official naming: "Compose Multiplatform" instead of "Compose Multi Platform (aka CMP)".

+| Task Lists | Groceries | Add Task | Home (Dark) |
 | --------------------------------------- |--------------------------------------- | ---------------------------------- | ---------------------------------- |
-| ![](assets/screens/task_lists_light.png) | ![](assets/screens/groceries_light.png) | ![](assets/screens/add_task_light.png) | ![](assets/screens/home_dark.png)  |
+| ![Task lists in light mode](assets/screens/task_lists_light.png) | ![Groceries list in light mode](assets/screens/groceries_light.png) | ![Add task screen in light mode](assets/screens/add_task_light.png) | ![Home screen in dark mode](assets/screens/home_dark.png)  |

-> ℹ️ The application is also available as a desktop (Jvm) application and an iOS application as well (using [Compose Multi Platform (aka CMP)](https://www.jetbrains.com/compose-multiplatform/) as UI Toolkit).
+> ℹ️ The application is also available as a desktop (JVM) application and an iOS application (using [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) as the UI toolkit).

57-60: Use consistent KMP naming.

-- [Kotlin](https://kotlinlang.org/), [Multiplatform (aka KMP)](https://kotlinlang.org/docs/multiplatform.html)
+- [Kotlin](https://kotlinlang.org/), [Kotlin Multiplatform (KMP)](https://kotlinlang.org/docs/multiplatform.html)
buildSrc/src/main/kotlin/IosTargetsExt.kt (2)

27-46: Global mutable state and potential compile error.

Two concerns:

  1. Line 36: localProperties.isEmpty should be localProperties.isEmpty() since Properties inherits the isEmpty() method from Java. This would cause a compile error.
  2. Lines 27-29, 31: The top-level lateinit var _iosTargets and mutable localProperties can cause issues in multi-project or parallel builds. Consider computing iosTargets per-access instead.
-// can't use by lazy, we need Project.findProperty not accessible there
-@Suppress("ObjectPropertyName")
-private lateinit var _iosTargets: List<String>
-
-private val localProperties = Properties()
-private fun Project.getIosTargetedConfiguration(): String? {
+private fun Project.getIosTargetedConfiguration(): String? {
+    val localProperties = Properties()
     return findProperty("ios.target") as? String
         ?: System.getenv("IOS_TARGET")
         ?: run {
-            if (localProperties.isEmpty) {
+            if (localProperties.isEmpty()) {
                 val localPropertiesFile = File(rootDir, "local.properties")
                 if (localPropertiesFile.isFile) {
                     localPropertiesFile.inputStream().use { reader ->
                         localProperties.load(reader)
                     }
                 }
             }
             localProperties.getProperty("ios.target")
         }
 }
 
 val Project.iosTargets: List<String>
-    get() {
-        if (!::_iosTargets.isInitialized) {
-            _iosTargets = when (getIosTargetedConfiguration()) {
-                // We ignore "iosX64", not considered as a use case
-                "all" -> listOf("iosArm64", "iosSimulatorArm64")
-                "simulator" -> listOf("iosSimulatorArm64")
-                "device" -> listOf("iosArm64")
-                "none" -> emptyList()
-                else -> emptyList()
-            }
-        }
-        return _iosTargets
-    }
+    get() = when (getIosTargetedConfiguration()) {
+        "all" -> listOf("iosArm64", "iosSimulatorArm64")
+        "simulator" -> listOf("iosSimulatorArm64")
+        "device" -> listOf("iosArm64")
+        "none", null -> emptyList()
+        else -> emptyList()
+    }

1-26: Add package declaration.

The file is missing a package declaration, which can lead to namespace conflicts and violates detekt rules.

  * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
+package net.opatry.gradle
+
 import org.gradle.api.Project
README.md (4)

41-41: Add alt text to screenshot images.

The images lack descriptive alt text, which is important for accessibility and violates MD045.

-| ![](assets/screens/task_lists_light.png) | ![](assets/screens/groceries_light.png) | ![](assets/screens/add_task_light.png) | ![](assets/screens/home_dark.png)  |
+| ![Task lists in light mode](assets/screens/task_lists_light.png) | ![Groceries list in light mode](assets/screens/groceries_light.png) | ![Add task screen in light mode](assets/screens/add_task_light.png) | ![Home screen in dark mode](assets/screens/home_dark.png)  |

82-85: Use official Kotlin Multiplatform naming and fix phrasing.

-- [Kotlin](https://kotlinlang.org/), [Multiplatform (aka KMP)](https://kotlinlang.org/docs/multiplatform.html)
+- [Kotlin](https://kotlinlang.org/), [Kotlin Multiplatform (KMP)](https://kotlinlang.org/docs/multiplatform.html)
   - Android and Desktop are fully supported.
-  - iOS wasn't initially planned, but a draft version is available (use it at your own risk, there might be dragons 🐉).
+  - iOS wasn't initially planned, but a draft version is available (use it at your own risk; there might be dragons 🐉).
   - Web is not planned any time soon (contributions are welcome 🤝)

189-193: Use asterisk emphasis for consistency.

The markdown uses underscores for emphasis, but the rest of the document uses asterisks. This violates MD049.

-The support of iOS works more or less _as-is_ and gets the job done. It's provided without guarantees, use at your own risk.  
+The support of iOS works more or less *as-is* and gets the job done. It's provided without guarantees, use at your own risk.  
 Feedback and contributions are welcome though 🤝.
 
 > [!NOTE]
-> iOS support is _opt-in_ and disabled by default to avoid unnecessary time and disk usage during the initial Gradle sync when the iOS target isn't required.  
+> iOS support is *opt-in* and disabled by default to avoid unnecessary time and disk usage during the initial Gradle sync when the iOS target isn't required.  

217-228: Fix markdown formatting around code fence.

Add a blank line before the closing fence (MD031).

   CODE_SIGNING_REQUIRED=NO
+

</blockquote></details>
<details>
<summary>tasks-app-ios/build.gradle.kts (2)</summary><blockquote>

`86-87`: **Fix case-sensitive license identifier typo.**

Line 87 contains `"asdkl"` which should be `"ASDKL"` (all uppercase). License identifiers in `licenses_ios.json` use `"ASDKL"`, so the current spelling will cause strict-mode validation to fail.



Apply this diff:

```diff
         strictMode = StrictMode.FAIL
-        allowedLicenses.addAll("Apache-2.0", "asdkl", "MIT", "EPL-1.0", "BSD-3-Clause")
+        allowedLicenses.addAll("Apache-2.0", "ASDKL", "MIT", "EPL-1.0", "BSD-3-Clause")

52-66: Create parent directory before writing Versions.xcconfig.

The task writes to Taskfolio/Configuration/Versions.xcconfig but doesn't ensure the parent directory exists. On a fresh clone or clean CI checkout, writeText() will throw an exception.

Apply this diff to fix:

     doLast {
+        configFile.parentFile.mkdirs()
         configFile.writeText(content)
     }
tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (2)

96-98: Prevent negative expiresIn values.

If the access token is already expired, timeIntervalSince will return a negative value, which could cause issues in downstream token refresh logic.

Clamp the value to zero:

                 let expirationDate = user.accessToken.expirationDate
                 let currentDate = Date()
-                let expiresIn = Int64(expirationDate?.timeIntervalSince(currentDate) ?? 0)
+                let expiresIn = max(Int64(expirationDate?.timeIntervalSince(currentDate) ?? 0), 0)

116-122: Remove unused error case.

The configurationFailed case (line 119) is defined but never used in the code.

 enum GoogleSignInError: Error, LocalizedError {
     case noRootViewController
     case missingConfiguration
-    case configurationFailed
     case userNotSignedIn
     case tokenNotAvailable
     case unknownError

Also remove from errorDescription:

         case .missingConfiguration:
             return "No GIDClientID found in Info.plist"
-        case .configurationFailed:
-            return "Failed to configure Google Sign-In"
         case .userNotSignedIn:
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (2)

96-97: Only render error message when non-empty.

The error Text is always rendered, even when the error is null or empty, which may create unnecessary layout space.

         AnimatedContent(error, label = "authorize_error_message") { message ->
-            Text(message ?: "", color = MaterialTheme.colorScheme.error)
+            if (!message.isNullOrEmpty()) {
+                Text(message, color = MaterialTheme.colorScheme.error)
+            }
         }

60-81: Improve error handling and UX flow.

The current implementation has several UX and error handling issues:

  1. Previous error messages persist when starting a new auth attempt
  2. Generic exception catching doesn't distinguish between different failure types
  3. Coroutine cancellation (user abort) is treated as an error

Apply these improvements:

         Button(
             onClick = {
+                error = null
                 ongoingAuth = true
                 coroutineScope.launch {
                     val scopes = listOf(
                         GoogleAuthenticator.Scope.Profile,
                         GoogleAuthenticator.Scope(TasksScopes.Tasks),
                     )
                     try {
                         val authCode = authenticator.authorize(
                             scopes = scopes,
                             force = true,
                             requestUserAuthorization = {},
                         ).let(GoogleAuthenticator.Grant::AuthorizationCode)
                         val oauthToken = authenticator.getToken(authCode)
                         onSuccess(oauthToken)
+                    } catch (e: kotlinx.coroutines.CancellationException) {
+                        // User or system cancelled; don't show as error
+                        throw e
                     } catch (e: Exception) {
                         error = e.message
-                        ongoingAuth = false
+                    } finally {
+                        ongoingAuth = false
                     }
                 }
             },
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ec06eba and 5f6499c.

⛔ Files ignored due to path filters (30)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/[email protected] is excluded by !**/*.png
📒 Files selected for processing (46)
  • .github/workflows/build-apps.yml (3 hunks)
  • .github/workflows/e2e-tests.yml (1 hunks)
  • .github/workflows/ios-app-nightly.yml (1 hunks)
  • .gitignore (1 hunks)
  • README.md (5 hunks)
  • build.gradle.kts (3 hunks)
  • buildSrc/build.gradle.kts (1 hunks)
  • buildSrc/src/main/kotlin/IosTargetsExt.kt (1 hunks)
  • google/oauth/build.gradle.kts (1 hunks)
  • google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (1 hunks)
  • google/tasks/build.gradle.kts (1 hunks)
  • gradle/libs.versions.toml (2 hunks)
  • lucide-icons/build.gradle.kts (1 hunks)
  • settings.gradle.kts (1 hunks)
  • tasks-app-ios/Configuration/Config.dev.xcconfig (1 hunks)
  • tasks-app-ios/Configuration/Config.xcconfig (1 hunks)
  • tasks-app-ios/Configuration/Versions.xcconfig (1 hunks)
  • tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.colorset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AccentColor.dev.colorset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.appiconset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/AppIcon.dev.appiconset/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Assets.xcassets/Contents.json (1 hunks)
  • tasks-app-ios/Taskfolio/Configuration/Versions.xcconfig (1 hunks)
  • tasks-app-ios/Taskfolio/ContentView.swift (1 hunks)
  • tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (1 hunks)
  • tasks-app-ios/Taskfolio/Info.plist (1 hunks)
  • tasks-app-ios/Taskfolio/Resources/licenses_ios.json (1 hunks)
  • tasks-app-ios/Taskfolio/TaskfolioApp.swift (1 hunks)
  • tasks-app-ios/build.gradle.kts (1 hunks)
  • tasks-app-shared/build.gradle.kts (2 hunks)
  • tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/authModule.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/di/platformModule.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/initKoin.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/backHandler.ios.kt (1 hunks)
  • tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/theme/Type.ios.kt (1 hunks)
  • tasks-app-shared/src/iosTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.ios.kt (1 hunks)
  • tasks-core/build.gradle.kts (2 hunks)
  • tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt (1 hunks)
  • website/index.md (2 hunks)
  • website/styles.css (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
tasks-app-ios/Taskfolio/ContentView.swift (1)
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (1)
  • MainViewController (44-113)
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (1)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/LoadingIndicator.kt (1)
  • LoadingIndicator (38-59)
tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift (4)
tasks-app-desktop/src/main/kotlin/mainApp.kt (1)
  • main (72-211)
google/oauth-http/src/commonMain/kotlin/net/opatry/google/auth/HttpGoogleAuthenticator.kt (1)
  • config (70-249)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/presentation/UserViewModel.kt (2)
  • signOut (116-123)
  • signIn (97-114)
google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (1)
  • refreshToken (85-88)
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt (4)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/theme/Theme.kt (1)
  • TaskfolioTheme (109-124)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/component/LoadingPane.kt (1)
  • LoadingPane (36-41)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/TasksApp.kt (1)
  • TasksApp (88-175)
tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/ui/screen/authorizationScreen.kt (1)
  • AuthorizationScreen (60-92)
🪛 actionlint (1.7.8)
.github/workflows/ios-app-nightly.yml

42-42: description is required in metadata of "Setup JDK & Gradle" action at "/home/jailuser/git/.github/actions/setup-jdk-gradle/action.yml"

(action)


45-45: property "gradle_module" is not defined in object type {}

(expression)

🪛 detekt (1.23.8)
buildSrc/src/main/kotlin/IosTargetsExt.kt

[warning] 27-29: Usages of lateinit should be avoided.

(detekt.potential-bugs.LateinitUsage)


[warning] 1-62: The file does not contain a package declaration.

(detekt.potential-bugs.MissingPackageDeclaration)

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt

[warning] 77-77: The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.

(detekt.exceptions.TooGenericExceptionCaught)

🪛 markdownlint-cli2 (0.18.1)
website/index.md

20-20: Images should have alternate text (alt text)

(MD045, no-alt-text)


20-20: Images should have alternate text (alt text)

(MD045, no-alt-text)


20-20: Images should have alternate text (alt text)

(MD045, no-alt-text)


20-20: Images should have alternate text (alt text)

(MD045, no-alt-text)

README.md

41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


41-41: Images should have alternate text (alt text)

(MD045, no-alt-text)


189-189: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


189-189: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


193-193: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


193-193: Emphasis style
Expected: asterisk; Actual: underscore

(MD049, emphasis-style)


228-228: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

🪛 SwiftLint (0.57.0)
tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift

[Warning] 32-32: FIXMEs should be resolved (in Jvm impl, the force means &...)

(todo)


[Warning] 61-61: Unused parameter in a closure should be replaced with _

(unused_closure_parameter)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: licenses-check / ©️ Check licenses (tasks-app-android, src/main/assets/licenses_android.json)
  • GitHub Check: licenses-check / ©️ Check licenses (tasks-app-desktop, src/main/resources/licenses_desktop.json)
  • GitHub Check: build-apps / 🔨 Build - 📱 Android App
  • GitHub Check: build-apps / 🔨 Build - 🍎 iOS App
  • GitHub Check: build-apps / 🔨 Build - 🖥️ Desktop App
  • GitHub Check: coverage / 📊 Coverage
  • GitHub Check: unit-tests / ✅ Tests
🔇 Additional comments (17)
tasks-app-ios/Taskfolio/Assets.xcassets/Contents.json (1)

1-6: Asset manifest is well-formed.

Standard Xcode-generated top-level asset catalog manifest. No issues detected.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/backHandler.ios.kt (1)

1-21: LGTM: Standard MIT license header.

The copyright and license header is properly formatted and complete.

tasks-app-shared/src/iosMain/kotlin/net/opatry/network/NetworkStatusNotifier.ios.kt (3)

1-21: LGTM!

The license header is complete and properly formatted.


23-36: LGTM!

Package and imports are appropriate for the iOS network monitoring implementation.


42-45: Skip previous error handling suggestion.

The previous review suggested adding try-catch error handling in the update handler. However, this appears unnecessary because:

  1. Network framework callbacks don't throw Kotlin exceptions in Kotlin/Native C interop
  2. The trySend().isSuccess already handles channel closure scenarios
  3. Adding try-catch would only catch unexpected runtime errors that indicate deeper issues
gradle/libs.versions.toml (1)

28-28: LGTM – bignum dependency added correctly.

The bignum library version and declaration are correctly added and appear to be in proper alphabetical order within their respective sections.

Also applies to: 105-106

tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/data/TasksAppDatabase.kt (1)

63-63: Room schema export is properly configured for all KMP targets.

The configuration is complete and working:

  • schemas/ directory exists with exported schema versions (1.json, 2.json, 3.json)
  • room { schemaDirectory("$projectDir/schemas") } properly configured
  • ✓ KSP Room compiler configured for all targets: kspJvm, kspAndroid, and iOS targets
  • ✓ Schema files are tracked in version control (no .gitignore, files confirmed in git)

No further action needed.

google/oauth/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt (1)

30-30: Confirmed: This is a breaking change at binary level, but with mitigated practical impact.

The change from @JvmInline value class to data class is indeed a breaking change requiring recompilation on JVM platforms. However, verification reveals:

  • All call sites are compatible (constructor syntax works identically for both value classes and data classes)
  • No serialization concerns (no @serializable annotations or json patterns found using Scope)
  • No reflection dependencies (no code accessing Scope via reflection)

This change appears deliberate—likely for Kotlin/Native support, as inline value classes have limited support there. While this is a binary compatibility break, source-level compatibility is preserved across the codebase.

Ensure version bumping reflects this breaking change and verify PR documentation explicitly calls out this change for library consumers.

tasks-app-ios/Taskfolio/Info.plist (1)

1-27: Info.plist configuration is appropriate for this proof-of-concept.

The Google Sign-In configuration and build-time placeholders are correctly structured. Past review comments have already flagged areas for production readiness (additional iOS keys, debug-only settings).

build.gradle.kts (2)

139-163: iOS framework configuration depends on buildSrc extension.

The configuration correctly applies iOS targets when the KMP plugin is present and excludes the oauth-http module. The iosTargets property relies on the extension from IosTargetsExt.kt in buildSrc.

Note: A past review comment flagged iosTargets as undefined, but this should resolve once the buildSrc extension is available.


204-211: Task dependency wiring is functional.

The null-safe wiring ensures embedAndSignAppleFrameworkForXcode depends on updateXcodeVersionConfig when both tasks exist. A past review suggested using tasks.named() for eager validation, but the current approach is acceptable for this PoC.

tasks-app-shared/src/iosTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.ios.kt (1)

29-31: LGTM! In-memory database builder correctly implemented.

The iOS test implementation properly returns a Room in-memory database builder for TasksAppDatabase, resolving the previous TODO placeholder.

.github/workflows/build-apps.yml (1)

38-49: Cache configuration is iOS-only by design.

The Gradle cache step is limited to the iOS matrix entry. This may be intentional for testing iOS-specific caching strategies mentioned in the PR objectives (investigating build time improvements). A past review comment noted that Android & Desktop builds won't benefit from caching with this configuration.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/FileCredentialsStorage.ios.kt (1)

40-76: File-based credential storage is acceptable for PoC; address security before production.

The implementation correctly uses withContext(Dispatchers.IO) for file operations and handles null cases. However, past review comments have identified several important issues:

  • Security: Tokens should be stored in iOS Keychain, not a plain file (risk of leakage via backups)
  • Reliability: Directory creation needed before write to prevent first-run failures
  • Performance: JSON instance should be reused via companion object
  • Error handling: Avoid error() inside coroutine context

These are acceptable trade-offs for a proof-of-concept but should be addressed before production release.

Based on past review comments.

tasks-app-ios/Taskfolio.xcodeproj/project.pbxproj (1)

169-187: Kotlin framework build phase configured; consider optimization for build times.

The shell script validates OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED and IOS_TARGET before invoking Gradle. However, alwaysOutOfDate = 1 (line 171) forces this phase to run on every build, contributing to the long build times noted in the PR objectives. Past review comments suggested implementing incremental build logic.

Based on past review comments.

tasks-core/build.gradle.kts (1)

61-65: ****

The iosTargets extension is properly exposed as a Project extension property in buildSrc/src/main/kotlin/IosTargetsExt.kt (line 48) and is accessible to all build files including tasks-core. The implementation safely returns an empty list when iOS targets are not configured, so the conditional check in tasks-core/build.gradle.kts (line 61) gracefully handles all scenarios including non-macOS systems. No changes needed.

tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/component/AuthorizeGoogleTasksButton.ios.kt (1)

70-74: The empty requestUserAuthorization lambda is intentional by design—remove this review comment.

The iOS auth module delegates GoogleAuthenticator implementation to native Swift code (as stated in the comment: "provided by the iOS app in Swift"). The Kotlin side correctly has an empty lambda because OAuth handling responsibility is on the Swift side, not Kotlin. Unlike Android and HTTP implementations which handle authorization in Kotlin, iOS uses a different architecture where the native layer manages the OAuth flow.

If OAuth is not working on iOS, the issue lies in the Swift implementation of GoogleAuthenticator, not in this Kotlin code. The empty lambda is correct.

Comment on lines +67 to +69
// useful to isolate/ease license_ios.json file generation
// and also sync version from Gradle to Xcode
include(":tasks-app-ios")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider conditional iOS module inclusion with property override.

The past review comment about gating the iOS module for macOS environments is valid—unconditional inclusion forces Gradle to evaluate iOS tasks on all platforms. However, the comments indicate legitimate reasons for inclusion (license generation, version sync).

A balanced approach would be conditional inclusion with an override property:

val enableIosModule = System.getProperty("os.name").contains("Mac", ignoreCase = true) ||
    project.findProperty("tasks.ios.enabled")?.toString()?.toBoolean() == true

if (enableIosModule) {
    // useful to isolate/ease license_ios.json file generation
    // and also sync version from Gradle to Xcode
    include(":tasks-app-ios")
}

This allows developers on Linux/Windows to opt-in via -Ptasks.ios.enabled=true when needed for licensing tasks, while keeping default sync fast.

🤖 Prompt for AI Agents
In settings.gradle.kts around lines 67 to 69, the iOS module is unconditionally
included which forces Gradle to evaluate iOS tasks on non-macOS machines; change
to conditional inclusion: compute an enableIosModule flag that is true when the
host OS name contains "Mac" (case-insensitive) OR when the project property
"tasks.ios.enabled" is set to "true", and only call include(":tasks-app-ios")
when that flag is true; preserve the existing comment about license_ios.json and
version sync and document that users can opt-in on Linux/Windows with
-Ptasks.ios.enabled=true.

Comment on lines +21 to +29
if GIDSignIn.sharedInstance.configuration == nil {
let config: GIDConfiguration
if !stringScopes.isEmpty {
config = GIDConfiguration(clientID: clientId, serverClientID: clientId)
} else {
config = GIDConfiguration(clientID: clientId)
}
GIDSignIn.sharedInstance.configuration = config
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify serverClientID configuration for backend token exchange.

Setting serverClientID to the same value as clientID (line 24) is typically incorrect. The serverClientID should be a separate Web application OAuth client ID created in the Google Cloud Console, used when your app needs to exchange tokens with a backend server.

If the app exchanges tokens with a backend, you need to:

  1. Create a separate Web application OAuth client ID in Google Cloud Console
  2. Add it to Info.plist as GIDServerClientID
  3. Update the configuration:
         if GIDSignIn.sharedInstance.configuration == nil {
-            let config: GIDConfiguration
-            if !stringScopes.isEmpty {
-                config = GIDConfiguration(clientID: clientId, serverClientID: clientId)
-            } else {
-                config = GIDConfiguration(clientID: clientId)
-            }
+            let serverClientId = Bundle.main.object(forInfoDictionaryKey: "GIDServerClientID") as? String
+            let config = serverClientId != nil
+                ? GIDConfiguration(clientID: clientId, serverClientID: serverClientId!)
+                : GIDConfiguration(clientID: clientId)
             GIDSignIn.sharedInstance.configuration = config
         }

If the app doesn't use a backend server for token exchange, remove the serverClientID parameter entirely:

-            if !stringScopes.isEmpty {
-                config = GIDConfiguration(clientID: clientId, serverClientID: clientId)
-            } else {
-                config = GIDConfiguration(clientID: clientId)
-            }
+            config = GIDConfiguration(clientID: clientId)

I'll verify this review comment by investigating the codebase context. Let me start by examining the full file and tracing where clientId comes from.


🏁 Script executed:

#!/bin/bash
# Find and examine the IOSGoogleAuthenticator.swift file
fd -e swift -x grep -l "IOSGoogleAuthenticator" {} \;

Length of output: 99


🏁 Script executed:

#!/bin/bash
# Look for the complete IOSGoogleAuthenticator.swift file
fd "IOSGoogleAuthenticator.swift"

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Search for GIDConfiguration usage and clientId source
rg -n "clientId" --type=swift -B5 -A5

Length of output: 2797


🏁 Script executed:

#!/bin/bash
# Look for Info.plist file and Google configuration
fd "Info.plist" 

Length of output: 34


🏁 Script executed:

#!/bin/bash
# Search for backend token exchange, server endpoints, or API calls
rg -n "serverClientID|token.*exchange|api.*token|backend.*token" --type=swift -C3

Length of output: 715


🏁 Script executed:

#!/bin/bash
# Search for GIDSignIn usage patterns throughout the codebase
rg -n "GIDSignIn|GIDConfiguration" --type=swift -C2

Length of output: 2951


🏁 Script executed:

#!/bin/bash
# Look for scope-related logic that might indicate OAuth flow type
rg -n "stringScopes|scopes" --type=swift -B3 -A3

Length of output: 3367


🏁 Script executed:

#!/bin/bash
# Search for any documentation or comments about Google auth setup
rg -n "serverClientID|Web.*application|OAuth.*client" --type=swift --type=md --type=txt -C2

Length of output: 530


🏁 Script executed:

#!/bin/bash
# Check if there's backend/server interaction in the codebase
rg -n "HTTPClient|URLSession|API|backend|server" --type=swift | head -20

Length of output: 143


🏁 Script executed:

#!/bin/bash
# Look for where GIDSignIn results are handled (to understand if token exchange occurs)
rg -n "GIDSignInResult|presentSignIn|restorePreviousSignIn" --type=swift -C3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for Info.plist content
fd -e plist -x cat {} \;

Length of output: 845


🏁 Script executed:

#!/bin/bash
# Look for stringScopes assignment/initialization
rg -n "stringScopes\s*=" --type=swift -B2 -A2

Length of output: 579


🏁 Script executed:

#!/bin/bash
# Search for URL schemes or reverse client IDs which indicate OAuth setup
rg -n "URL.*Scheme|reverse.*client|REVERSE_CLIENT" --type=plist --type=swift -C2

Length of output: 29


🏁 Script executed:

#!/bin/bash
# Check function signatures and parameters for IOSGoogleAuthenticator
ast-grep --pattern 'class IOSGoogleAuthenticator {
  $$$
}'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for the full function containing lines 21-29
rg -n "if GIDSignIn.sharedInstance.configuration == nil" --type=swift -B10 -A15

Length of output: 2684


🏁 Script executed:

#!/bin/bash
# Check for any test files that might clarify the intended behavior
fd -e swift -path "*test*" -o -path "*Test*" | xargs grep -l "IOSGoogleAuthenticator\|GIDConfiguration" 2>/dev/null

Length of output: 168


🏁 Script executed:

#!/bin/bash
# Look for comments in the Google auth file that explain the configuration
fd "IOSGoogleAuthenticator.swift" -x cat {} \;

Length of output: 5714


🏁 Script executed:

#!/bin/bash
# Search for where serverClientID or clientID comes from in environment/config
rg -n "GIDServerClientID|GIDClientID|GOOGLE.*CLIENT" --type=swift --type=plist -C2

Length of output: 29


🏁 Script executed:

#!/bin/bash
# Look for README or documentation about Google authentication
fd -e md -e txt | xargs grep -l -i "google.*auth\|oauth.*setup" 2>/dev/null

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check for any backend server communication
rg -n "POST|PUT|GET.*token|authorization.*header" --type=swift | head -15

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for serviceAccountEmail or other service account indicators
rg -n "serviceAccount|service.*account" --type=swift --type=plist -C2

Length of output: 29


🏁 Script executed:

#!/bin/bash
# Look for authentication token persistence or exchange
rg -n "idToken|accessToken|refreshToken|tokenResponse" --type=swift -C2

Length of output: 2108


🏁 Script executed:

#!/bin/bash
# Check if there's any custom token validation/exchange logic
rg -n "verify.*token|validate.*token|exchange.*token" --type=swift -C3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find where the authenticator is initialized and how it's configured
rg -n "IOSGoogleAuthenticator()" --type=swift -B5 -A5

Length of output: 1762


🏁 Script executed:

#!/bin/bash
# Look for the type/class that contains this IOSGoogleAuthenticator.swift
ls -la tasks-app-ios/

Length of output: 373


🏁 Script executed:

#!/bin/bash
# Look at the directory structure
find . -type f -name "*.swift" | head -20

Length of output: 143


🏁 Script executed:

#!/bin/bash
# Search for where clientId parameter is passed to IOSGoogleAuthenticator
rg -n "IOSGoogleAuthenticator" --type=swift -C5 | head -30

Length of output: 1762


🏁 Script executed:

#!/bin/bash
# Look for complete file content and initialization
cat tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift

Length of output: 5714


🏁 Script executed:

#!/bin/bash
# Look for Info.plist in the iOS app directory
cat tasks-app-ios/Taskfolio/Info.plist

Length of output: 845


🏁 Script executed:

#!/bin/bash
# Check if there's a build configuration or settings file
find tasks-app-ios -name "*.plist" -o -name "*.xcconfig"

Length of output: 228


🏁 Script executed:

#!/bin/bash
# Look for Google Sign-In pod/framework configuration
rg -n "GoogleSignIn|GIDSignIn" --type=swift | head -30

Length of output: 1701


🏁 Script executed:

#!/bin/bash
# Search for any backend integration code
rg -n "http\|api\|request\|response" --type=swift | grep -i auth | head -20

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look at how authentication results are used
rg -n "user\?\." --type=swift | head -20

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for token usage in the app
rg -n "\.accessToken\|\.idToken" --type=swift -C3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check what the complete context around lines 21-29 is
rg -n "func.*\|class.*\|if GIDSignIn" --type=swift tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift -B20 -A20 | head -100

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Get full file context
wc -l tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift && echo "---" && head -100 tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift

Length of output: 4483


🏁 Script executed:

#!/bin/bash
# Look for the configure or init method that contains this code
grep -n "func\|init\|configure" tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift

Length of output: 326


🏁 Script executed:

#!/bin/bash
# Show me the whole function containing this configuration
sed -n '1,100p' tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift

Length of output: 4422


🏁 Script executed:

#!/bin/bash
# Look for documentation comments
grep -n "//\|/\*" tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift | head -20

Length of output: 261


🏁 Script executed:

#!/bin/bash
# Check what parameters clientId and stringScopes have
rg -n "func.*clientId\|let.*clientId\|var.*clientId" --type=swift tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift -C3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for where this configure method is called
rg -n "IOSGoogleAuthenticator\|\.configure" --type=swift tasks-app-ios/ -C3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look at how the GIDSignInResult user property is used (to see if tokens are sent to backend)
rg -n "signInResult\|user\?" --type=swift tasks-app-ios/ -C2 | head -40

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check if there's any networking/API client in the codebase
find tasks-app-ios -name "*API*" -o -name "*Network*" -o -name "*Client*" -o -name "*Service*" | head -10

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for any backend authentication or token sending
rg -n "idToken\|accessToken" --type=swift tasks-app-ios/ -C3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Identify if this app has a backend component
find . -name "server\|backend\|api" -type d 2>/dev/null

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Final check: look at app structure
ls -la tasks-app-ios/Taskfolio/ | grep -E "\.swift$|\.plist$"

Length of output: 272


🏁 Script executed:

#!/bin/bash
# Show full IOSGoogleAuthenticator.swift file
cat tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift 2>/dev/null || echo "File not found, searching..."
rg -l "IOSGoogleAuthenticator" --type=swift

Length of output: 5810


🏁 Script executed:

#!/bin/bash
# Get complete authentication-related files
find tasks-app-ios -type f -name "*Auth*" -o -name "*Google*"

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Check the actual directory structure
find tasks-app-ios -type f -name "*.swift" | grep -i auth

Length of output: 52


🏁 Script executed:

#!/bin/bash
# List all Swift files to find the right one
find tasks-app-ios -type f -name "*.swift"

Length of output: 137


🏁 Script executed:

#!/bin/bash
# Look for the file with different casing
find . -iname "*google*auth*" -type f

Length of output: 417


🏁 Script executed:

#!/bin/bash
# Search more broadly for GIDConfiguration
rg "GIDConfiguration" --type=swift -l

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Get all context about the problematic code
rg "serverClientID.*=.*clientId\|clientID.*serverClientID" --type=swift -B10 -A10

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check for Google Cloud Console documentation or setup guides
find . -type f \( -name "*.md" -o -name "*.txt" -o -name "*.doc" \) | xargs grep -l -i "google.*console\|oauth.*setup\|server.*client" 2>/dev/null

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Let's just try to find any references to the suspicious code pattern
rg "GIDConfiguration.*clientId.*serverClientID" -A5 -B5

Length of output: 987


🏁 Script executed:

#!/bin/bash
# Check if maybe the file path is different
find . -name "*.swift" -type f -exec grep -l "GIDSignIn.sharedInstance.configuration" {} \;

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Look for the actual iOS project structure
ls -la tasks-app-ios/ 2>/dev/null || ls -la

Length of output: 373


🏁 Script executed:

#!/bin/bash
# Direct search for the configuration pattern
rg -n "GIDSignIn\.sharedInstance\.configuration" --type=swift -B5 -A10

Length of output: 2244


🏁 Script executed:

#!/bin/bash
# Search for Info.plist anywhere
find . -name "Info.plist" -exec echo {} \; -exec head -20 {} \;

Length of output: 736


🏁 Script executed:

#!/bin/bash
# Try to understand the repository structure
ls -la

Length of output: 2479


🏁 Script executed:

#!/bin/bash
# Look for project files
find . -name "*.xcodeproj" -o -name "Package.swift" -o -name "*.podfile"

Length of output: 35


🏁 Script executed:

#!/bin/bash
# Final comprehensive search
rg "serverClientID\|GIDConfiguration" --type=swift --type=plist -C3

Length of output: 29


Let me search the shared module and check for backend token exchange patterns:


🏁 Script executed:

#!/bin/bash
# Check shared module for backend authentication
rg -n "token\|authenticate\|exchange\|backend\|server" --type=swift --type=kt -i tasks-app-shared/ | head -40

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Look for where authorize() is called and how the result is used
rg -n "\.authorize\(" --type=swift --type=kt -B3 -A5 | head -50

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Check Android implementation for comparison
rg -n "serverClientID\|GIDConfiguration" --type=kt

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Search for token sending to backend in shared module
rg -n "send.*token\|post.*token\|idToken.*to\|accessToken.*server" --type=kt -i | head -20

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Look for any API authentication patterns
rg -n "authorization.*header\|Bearer\|OAuth" --type=kt -i | head -20

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Check if Google authentication is only for sign-in, not backend exchange
rg -n "sign.?in\|authenticate" --type=kt -i tasks-app-shared/ | head -20

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Review the interface that IOSGoogleAuthenticator implements
rg -n "interface.*OauthGoogleAuthenticator\|class.*OauthGoogleAuthenticator" --type=kt -A10

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Check where getToken is used
rg -n "\.getToken\(" --type=kt --type=swift -B2 -A5 | head -40

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Look for Google SDK documentation usage patterns
rg -n "GIDConfiguration\|GIDSignIn" --type=kt

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Check what OauthGoogleAuthenticatorOAuthToken contains
rg -n "data class OauthGoogleAuthenticatorOAuthToken\|class OauthGoogleAuthenticatorOAuthToken" --type=kt -A15

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Look for token validation or verification
rg -n "verify.*token\|validate.*idToken\|backend.*verify" --type=kt -i | head -20

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Check the Android implementation for how it handles serverClientID
rg -n "PlayServicesGoogleAuthenticator\|GIDConfiguration" --type=kt -B5 -A20 | head -80

Length of output: 26


🏁 Script executed:

#!/bin/bash
# See how the authorization token is actually used in the app
rg -n "userID\|idToken" tasks-app-shared/ --type=kt | head -20

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Search for any backend URL or API endpoint configuration
rg -n "url\|endpoint\|host\|api\|server" tasks-app-shared/ --type=kt --type=properties -i | grep -i auth | head -20

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Check the full shared module structure
find tasks-app-shared -type f -name "*.kt" | head -20

Length of output: 1779


🏁 Script executed:

#!/bin/bash
# Look at OauthGoogleAuthenticator interface/class definition
find tasks-app-shared -name "*.kt" -exec grep -l "OauthGoogleAuthenticator" {} \;

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Get the complete definition of OauthGoogleAuthenticator
rg -n "interface OauthGoogleAuthenticator\|abstract class OauthGoogleAuthenticator" --type=kt -A30 | head -80

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Check if idToken is sent anywhere for backend validation
rg -n "idToken" tasks-app-shared/ --type=kt -B3 -A3 | head -40

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Look for token endpoint or token exchange patterns
rg -n "token_endpoint\|exchange.*code\|grant_type" --type=kt -i | head -20

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Check if scopes control backend functionality
rg -n "offline_access\|prompt.*consent\|access_type" --type=kt -i | head -20

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Look at the FIXME comment more carefully - maybe it hints at backend needs
grep -n "FIXME\|TODO\|offline\|refresh" tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift

Length of output: 223


🏁 Script executed:

#!/bin/bash
# Search for any credential or authentication provider
rg -n "credential\|provider\|authentication" tasks-app-shared/ --type=kt -i | head -20

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Check what happens when tokens are obtained
rg -n "OauthGoogleAuthenticatorOAuthToken" tasks-app-shared/ --type=kt -B2 -A8 | head -50

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Check Android PlayServicesGoogleAuthenticator completely
cat tasks-app-shared/src/androidMain/kotlin/net/opatry/tasks/app/auth/PlayServicesGoogleAuthenticator.kt

Length of output: 5005


🏁 Script executed:

#!/bin/bash
# Check for the full GoogleAuthenticator interface
cat tasks-app-shared/src/commonMain/kotlin/net/opatry/google/auth/GoogleAuthenticator.kt

Length of output: 116


🏁 Script executed:

#!/bin/bash
# Look for OauthGoogleAuthenticator
find tasks-app-shared -name "*.kt" -exec cat {} \; | grep -A30 "interface OauthGoogleAuthenticator"

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Get all kotlin auth files to understand flow
find tasks-app-shared -path "*auth*" -name "*.kt" -exec echo "=== {} ===" \; -exec cat {} \;

Length of output: 17329


🏁 Script executed:

#!/bin/bash
# Check if there's backend token verification anywhere
rg -n "verify.*signature\|backend.*token\|server.*validate" --type=kt -i

Length of output: 26


🏁 Script executed:

#!/bin/bash
# See comment about offline access
rg -n "offline\|access_type" tasks-app-ios/ --type=swift -B2 -A2

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check if GIDConfiguration serverClientID parameter is ever used
web_search "GIDConfiguration serverClientID parameter usage"

Length of output: 48


🏁 Script executed:

#!/bin/bash
# Look for why scopeRequest would need serverClientID
grep -r "requestOfflineAccess\|offline" tasks-app-ios/ tasks-app-shared/ 2>/dev/null | head -10

Length of output: 484


Now I have the full context. Let me generate the final rewritten review:


🌐 Web query:

GIDConfiguration serverClientID iOS when needed backend token exchange

💡 Result:

Short answer
Set GIDConfiguration.serverClientID (or GIDServerClientID in Info.plist) when your app needs to send a verifiable token/authorization code to your backend so the backend can authenticate the user or obtain tokens. Concretely:

When to set it

  • You need to send an ID token to your server for server-side verification (the ID token’s aud must match your backend client ID). [1][2]
  • You need a one‑time serverAuthCode to exchange on the backend for an access_token + refresh_token (server-side/offline access). [3]

What it does

  • serverClientID is the OAuth client ID of your backend (not the iOS app client ID). It causes Google to issue ID tokens whose audience (aud) equals that server client ID and/or returns a serverAuthCode you can exchange on the server. [1][2][3]

Quick example (Swift)

  • Init with server client ID:
    let config = GIDConfiguration(clientID: IOS_CLIENT_ID, serverClientID: BACKEND_CLIENT_ID)
    GIDSignIn.sharedInstance.signIn(with: config, presenting: self) { signInResult, error in
    // send signInResult.user.idToken or signInResult.serverAuthCode to your backend over HTTPS
    } [1][3]

Server must verify/exchange

  • Verify the ID token on the server (check signature, iss, exp, and aud equals your backend client ID). Or exchange serverAuthCode for access/refresh tokens via Google’s token endpoint. [2][3]

Sources

  • GIDConfiguration / Google Sign-In iOS reference. [1]
  • Authenticate with a backend server (ID token verification guidance). [2]
  • Access Google APIs from your app backend (serverAuthCode / offline access). [3]

If you want, tell me whether you plan to send an ID token or a serverAuthCode and I’ll give the exact verification/exchange steps + sample server code.


🌐 Web query:

Google Sign-In iOS serverClientID parameter usage

💡 Result:

Short answer

  • serverClientID is your backend (web) OAuth 2.0 client ID (the "server" client) — not the iOS app client ID. It tells the Google Sign‑In iOS SDK to request an ID token whose audience (aud) is that server client ID so your backend can verify the token. [1][2][3]

How to use it (quick)

  1. In Google Cloud Console create an OAuth 2.0 client of type "Web application" for your backend and copy its client ID. [1][3]
  2. Provide that value to the iOS SDK either:
    • in Info.plist as GIDServerClientID, or
    • when creating GIDConfiguration (init(clientID:serverClientID:)). [1][2]
  3. After sign-in, send the ID token to your server and verify the token server‑side (check aud = your server client ID, issuer, expiry, signature). [3]

Notes / Gotchas

  • Use a Web (server) client ID; using the iOS client ID will make aud equal the iOS client. [1][3]
  • There are edge cases where refreshed tokens may show a different aud (see community reports). If you rely on aud, verify tokens server‑side per the docs. [4]

Sources
[1] Google Sign‑In for iOS — Get started / Info.plist & GIDServerClientID.
[2] GIDConfiguration reference (serverClientID description).
[3] Authenticate with a backend server — send & verify ID tokens.
[4] Community report about idToken aud behavior after refresh (Stack Overflow).


Remove the conditional serverClientID logic—iOS doesn't exchange tokens with a backend.

The serverClientID should not be set for this iOS app. Unlike the Android implementation (PlayServicesGoogleAuthenticator), the iOS app does not exchange tokens with a backend server. It obtains tokens directly from GIDSignIn and returns them. Setting serverClientID to the same value as clientID is incorrect, and the conditional logic (based on stringScopes) doesn't align with any actual backend exchange flow.

The scopes parameter controls permission requests via addScopes(), not backend token exchange. If the app does plan to exchange tokens on a backend server in the future, that requires a different implementation pattern (including a separate Web OAuth client ID, backend verification logic, and potentially offline access). For now, simplify to:

         if GIDSignIn.sharedInstance.configuration == nil {
-            let config: GIDConfiguration
-            if !stringScopes.isEmpty {
-                config = GIDConfiguration(clientID: clientId, serverClientID: clientId)
-            } else {
-                config = GIDConfiguration(clientID: clientId)
-            }
+            let config = GIDConfiguration(clientID: clientId)
             GIDSignIn.sharedInstance.configuration = config
         }
🤖 Prompt for AI Agents
In tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift around lines 21 to 29,
remove the conditional setting of serverClientID and always configure GIDSignIn
with only the clientID; replace the block that creates GIDConfiguration with a
single initialization using GIDConfiguration(clientID: clientId) so no
serverClientID is supplied, and keep scope handling separate via addScopes()
elsewhere.

Comment on lines +32 to +37
// FIXME in Jvm impl, the force means &prompt=consent&access_type=offline
// is it needed here? at least no need to sign-out
// or on Android .requestOfflineAccess(config.clientId, force)
if force {
GIDSignIn.sharedInstance.signOut()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Clarify force parameter behavior and resolve FIXME.

The FIXME indicates that the iOS implementation differs from JVM/Android, which use prompt=consent and access_type=offline when force=true. Currently, iOS only signs out the user.

The Google Sign-In iOS SDK doesn't provide direct equivalents to these OAuth parameters. However, signing out before sign-in does force re-consent. If you need offline access (refresh tokens), verify that:

  1. Your OAuth client is configured for offline access in Google Cloud Console
  2. The SDK is obtaining refresh tokens correctly in getToken

Based on the PR objectives mentioning "Adjust 'force' parameter usage in the iOS OAuth implementation," this appears to be a known limitation. Would you like me to help document the behavioral difference or investigate alternative approaches to match the JVM/Android behavior more closely?

🧰 Tools
🪛 SwiftLint (0.57.0)

[Warning] 32-32: FIXMEs should be resolved (in Jvm impl, the force means &...)

(todo)

🤖 Prompt for AI Agents
In tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift around lines 32-37, the
FIXME notes JVM/Android use of prompt=consent & access_type=offline when
force=true but the iOS code only calls GIDSignIn.sharedInstance.signOut();
replace the FIXME by explicitly documenting iOS limitations and adjust logic:
keep the signOut call to force re-consent, add a clarifying comment that iOS SDK
has no direct prompt/access_type equivalents, ensure getToken verifies/returns a
refresh token (and that the Google Cloud OAuth client is configured for offline
access), and add a runtime warning/log when force==true and no refresh token is
obtained so callers know offline access may not be available.

Comment on lines +39 to +74
if !force, let currentUser = GIDSignIn.sharedInstance.currentUser {
if !stringScopes.isEmpty {
currentUser.addScopes(stringScopes, presenting: rootViewController) { result, error in
if let error = error {
continuation.resume(throwing: error)
} else if let user = result {
continuation.resume(returning: user.user.userID ?? "")
} else {
continuation.resume(throwing: GoogleSignInError.unknownError)
}
}
} else {
continuation.resume(returning: currentUser.userID ?? "")
}
return
}

GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) { result, error in
if let error = error {
continuation.resume(throwing: error)
} else if let user = result?.user {
if !stringScopes.isEmpty {
user.addScopes(stringScopes, presenting: rootViewController) { scopeResult, scopeError in
if let scopeError = scopeError {
continuation.resume(throwing: scopeError)
} else {
continuation.resume(returning: user.userID ?? "")
}
}
} else {
continuation.resume(returning: user.userID ?? "")
}
} else {
continuation.resume(throwing: GoogleSignInError.unknownError)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Refactor duplicated scope handling logic.

The scope-handling logic (lines 40-52 and 60-70) is duplicated between the existing-user and new-sign-in flows.

Extract a helper method:

private func handleScopes(
    _ scopes: [String],
    for user: GIDGoogleUser,
    presenting viewController: UIViewController
) async throws -> String {
    guard !scopes.isEmpty else {
        return user.userID ?? ""
    }
    
    return try await withCheckedThrowingContinuation { continuation in
        user.addScopes(scopes, presenting: viewController) { result, error in
            if let error = error {
                continuation.resume(throwing: error)
            } else {
                continuation.resume(returning: user.userID ?? "")
            }
        }
    }
}

Then simplify both flows:

// Existing user flow
if !force, let currentUser = GIDSignIn.sharedInstance.currentUser {
    return try await handleScopes(stringScopes, for: currentUser, presenting: rootViewController)
}

// New sign-in flow
GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) { result, error in
    if let error = error {
        continuation.resume(throwing: error)
    } else if let user = result?.user {
        Task {
            do {
                let userId = try await self.handleScopes(stringScopes, for: user, presenting: rootViewController)
                continuation.resume(returning: userId)
            } catch {
                continuation.resume(throwing: error)
            }
        }
    } else {
        continuation.resume(throwing: GoogleSignInError.unknownError)
    }
}
🧰 Tools
🪛 SwiftLint (0.57.0)

[Warning] 61-61: Unused parameter in a closure should be replaced with _

(unused_closure_parameter)

🤖 Prompt for AI Agents
In tasks-app-ios/Taskfolio/IOSGoogleAuthenticator.swift around lines 39 to 74,
the scope-handling logic is duplicated for existing-user and new-sign-in flows;
extract a private async helper method that takes scopes, a GIDGoogleUser and a
presenting UIViewController and returns the userID (or throws) by wrapping
user.addScopes in withCheckedThrowingContinuation, then replace the duplicated
blocks: for the existing-user path call and return the helper directly, and in
the new-sign-in callback call the helper inside a Task and resume the
continuation with the returned userId or thrown error.

Comment on lines +56 to +58
// Note: iOS targets are conditionally added dynamically in the root build.gradle.kts

jvmToolchain(17)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

iOS targets comment – consider centralization.

This comment duplicates similar notes in google/oauth, google/tasks, and lucide-icons build files. Consider centralizing the iOS targets documentation to reduce maintenance burden.

🤖 Prompt for AI Agents
In tasks-app-shared/build.gradle.kts around lines 56 to 58, the inline comment
about iOS targets being conditionally added duplicates notes found in
google/oauth, google/tasks, and lucide-icons; remove the duplicate comment here
and instead add a single authoritative note in the project root (e.g., root
build.gradle.kts or a central CONTRIBUTING/BUILDING.md) that explains iOS target
handling and reference that location from module files if needed; update the
module files to either remove the comment or replace it with a one-line pointer
to the centralized documentation.

Comment on lines +90 to +102
val aboutApp = AboutApp(
name = appName,
version = fullVersion
) {
val path = mainBundle.pathForResource("licenses_ios", "json")
?: error("licenses_ios.json not found in bundle")

platform.Foundation.NSString.stringWithContentsOfFile(
path,
encoding = platform.Foundation.NSUTF8StringEncoding,
error = null
) ?: error("Failed to load licenses_ios.json from bundle path: $path")
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Memoize AboutApp to avoid repeated file I/O on recomposition.

The AboutApp instance and its license-loading lambda are recreated on every recomposition. Since loading licenses_ios.json from the bundle involves file I/O, this can impact performance. Wrap the creation in remember to cache it.

Apply this diff:

-                    val aboutApp = AboutApp(
-                        name = appName,
-                        version = fullVersion
-                    ) {
-                        val path = mainBundle.pathForResource("licenses_ios", "json")
-                            ?: error("licenses_ios.json not found in bundle")
-
-                        platform.Foundation.NSString.stringWithContentsOfFile(
-                            path,
-                            encoding = platform.Foundation.NSUTF8StringEncoding,
-                            error = null
-                        ) ?: error("Failed to load licenses_ios.json from bundle path: $path")
-                    }
+                    val aboutApp = remember(appName, fullVersion) {
+                        AboutApp(
+                            name = appName,
+                            version = fullVersion
+                        ) {
+                            val path = mainBundle.pathForResource("licenses_ios", "json")
+                                ?: error("licenses_ios.json not found in bundle")
+
+                            platform.Foundation.NSString.stringWithContentsOfFile(
+                                path,
+                                encoding = platform.Foundation.NSUTF8StringEncoding,
+                                error = null
+                            ) ?: error("Failed to load licenses_ios.json from bundle path: $path")
+                        }
+                    }

Add the import if not already present:

import androidx.compose.runtime.remember
🤖 Prompt for AI Agents
In
tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/MainViewController.kt
around lines 90 to 102, the AboutApp instance and its license-loading lambda are
recreated on every recomposition causing repeated file I/O; wrap the AboutApp
creation in a remember { ... } so the instance and license-loading closure are
memoized, and add import androidx.compose.runtime.remember if not already
present; ensure the remembered value captures appName and fullVersion (or derive
keys) so it updates only when those change.

Comment on lines +28 to +44
internal actual val Typography: androidx.compose.material3.Typography = androidx.compose.material3.Typography(
displayLarge = TextStyle(fontSize = 57.sp), // TODO adjust
displayMedium = TextStyle(fontSize = 45.sp), // TODO adjust
displaySmall = TextStyle(fontSize = 36.sp), // TODO adjust
headlineLarge = TextStyle(fontSize = 32.sp), // TODO adjust
headlineMedium = TextStyle(fontSize = 28.sp), // TODO adjust
headlineSmall = TextStyle(fontSize = 24.sp), // TODO adjust
titleLarge = TextStyle(fontSize = 22.sp), // TODO adjust
titleMedium = TextStyle(fontSize = 16.sp), // TODO adjust
titleSmall = TextStyle(fontSize = 14.sp), // TODO adjust
bodyLarge = TextStyle(fontSize = 14.sp),
bodyMedium = TextStyle(fontSize = 12.sp),
bodySmall = TextStyle(fontSize = 10.sp),
labelLarge = TextStyle(fontSize = 12.sp),
labelMedium = TextStyle(fontSize = 10.sp),
labelSmall = TextStyle(fontSize = 9.sp),
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Typography defined; resolve TODOs before production.

The iOS-specific typography is functional with all Material 3 text styles defined. However, TODO comments (lines 29-37) and undersized body/label styles should be addressed. A past review comment suggested aligning with Material 3 defaults for better readability.

Do you want me to generate a follow-up issue to track the typography adjustments?

🤖 Prompt for AI Agents
In tasks-app-shared/src/iosMain/kotlin/net/opatry/tasks/app/ui/theme/Type.ios.kt
around lines 28 to 44, the typography contains TODOs and uses undersized
body/label font sizes; replace the hardcoded TODO sizes with values aligned to
Material 3 defaults (use
displayLarge/Medium/Small/headlineLarge/Medium/Small/titleLarge/Medium/Small
matching M3 default scale) and increase bodyLarge/bodyMedium/bodySmall and
labelLarge/labelMedium/labelSmall to the recommended Material 3 sizes for
readable text on iOS; remove TODO comments, add a short inline comment
referencing the Material 3 spec source/version, and ensure you test visually on
typical device sizes to confirm legibility before merging.


import com.ionspin.kotlin.bignum.integer.BigInteger
import com.ionspin.kotlin.bignum.integer.toBigInteger
import kotlin.time.Instant
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Wrong Instant import breaks build on iOS.

Use kotlinx.datetime.Instant; kotlin.time.Instant doesn’t provide toEpochMilliseconds.

-import kotlin.time.Instant
+import kotlinx.datetime.Instant
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import kotlin.time.Instant
import kotlinx.datetime.Instant
🤖 Prompt for AI Agents
In tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt around
line 27, the file imports kotlin.time.Instant which lacks toEpochMilliseconds
and breaks the iOS build; replace that import with kotlinx.datetime.Instant (and
update any fully-qualified references if needed) so the code uses
kotlinx.datetime.Instant which provides toEpochMilliseconds.

Comment on lines +67 to +68
actual fun fromCompletionDate(completionDate: Instant): DoneTaskPosition {
return DoneTaskPosition(UpperBound - completionDate.toEpochMilliseconds().toBigInteger())
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against pre-epoch completion dates.
Negative epoch millis would produce values > UPPER_BOUND (overflowing intended width). Fail fast.

 actual fun fromCompletionDate(completionDate: Instant): DoneTaskPosition {
-    return DoneTaskPosition(UPPER_BOUND - completionDate.toEpochMilliseconds().toBigInteger())
+    val ms = completionDate.toEpochMilliseconds()
+    require(ms >= 0) { "completionDate must be >= Unix epoch" }
+    return DoneTaskPosition(UPPER_BOUND - ms.toBigInteger())
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In tasks-core/src/iosMain/kotlin/net/opatry/tasks/TaskPosition.ios.kt around
lines 67 to 68, the conversion from completionDate to an epoch millis BigInteger
can produce negative values for pre-1970 dates which would make the computed
DoneTaskPosition exceed the intended width; add a guard that checks
completionDate.toEpochMilliseconds() is non-negative and if it is negative throw
an IllegalArgumentException (or another appropriate runtime exception) with a
clear message indicating pre-epoch dates are not allowed, otherwise compute
DoneTaskPosition as before using UpperBound minus the epoch milliseconds
converted to BigInteger.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci 🤖 Continuous integration related issues ios app 🍎 Related to the iOS application

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants