Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions .github/workflows/build-apps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,21 @@ on:

jobs:
build:
name: 🔨 Build
runs-on: ubuntu-24.04
name: 🔨 Build - ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: 📱 Android App
gradle_module: tasks-app-android
os: ubuntu-24.04
- name: 🖥️ Desktop App
gradle_module: tasks-app-desktop
os: ubuntu-24.04
- name: 🍎 iOS App
gradle_module: tasks-app-ios
os: macos-15
permissions:
contents: write
checks: write
Expand All @@ -30,12 +35,24 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-jdk-gradle

- name: Cache Gradle
if: ${{ matrix.gradle_module == 'tasks-app-ios' }}
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 }}-

Comment on lines +38 to +49
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

Gradle/Konan cache limited to iOS only

The actions/cache step is currently gated by
if: ${{ matrix.gradle_module == 'tasks-app-ios' }}.
Android & Desktop builds therefore skip the cache entirely, which means no reuse of .gradle nor Kotlin/Native artifacts for those jobs.

If that was not a deliberate optimisation, drop the condition so every matrix entry benefits from caching (it is cheap on Ubuntu runners too).

🤖 Prompt for AI Agents
In .github/workflows/build-apps.yml around lines 38 to 49, the Gradle cache step
is limited to only the 'tasks-app-ios' matrix entry by the if condition. To
enable caching for all matrix entries including Android and Desktop, remove the
if condition so that the cache action runs unconditionally for every job. This
will allow reuse of .gradle and Kotlin/Native artifacts across all builds.

- name: 🔓 Decrypt secrets
env:
PLAYSTORE_SECRET_PASSPHRASE: ${{ secrets.PLAYSTORE_SECRET_PASSPHRASE }}
run: ./_ci/decrypt_secrets.sh

- name: ${{ matrix.name }}
- name: ${{ matrix.gradle_module }}
env:
PLAYSTORE_SECRET_PASSPHRASE: ${{ secrets.PLAYSTORE_SECRET_PASSPHRASE }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
Expand All @@ -50,4 +67,6 @@ jobs:
-Pplaystore.keystore.file="${PWD}/_ci/tasksApp.keystore" \
-Pplaystore.keystore.password="${KEYSTORE_PASSWORD}" \
-Pplaystore.keystore.key_password="${KEYSTORE_KEY_PASSWORD}"
elif [ "${gradle_module}" = "tasks-app-ios" ]; then
IOS_TARGET=simulator ./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64
fi
Comment on lines +70 to 72
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

IOS_TARGET appears unused – drop it or wire it up explicitly

The environment variable is exported but the Gradle task you call (linkDebugFrameworkIosSimulatorArm64) already hard-codes the target/arch. Keeping the variable adds noise and implies configurability that does not 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
📝 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
elif [ "${gradle_module}" = "tasks-app-ios" ]; then
IOS_TARGET=simulator ./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64
fi
elif [ "${gradle_module}" = "tasks-app-ios" ]; then
./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64
fi
🤖 Prompt for AI Agents
In .github/workflows/build-apps.yml around lines 70 to 72, the IOS_TARGET
environment variable is set but not used by the Gradle task, which hard-codes
the target architecture. Remove the IOS_TARGET=simulator assignment from the
command line to eliminate unused code and reduce confusion about
configurability.

2 changes: 1 addition & 1 deletion .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:

jobs:
check-changes:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

Prefer ubuntu-latest unless you have a hard dependency on 24.04

Pinning to a specific Ubuntu image means you will miss future ubuntu-latest upgrades (e.g. 26.04) and may hit sudden deprecations when 24.04 is retired. If there is no strict requirement for 24.04, revert to the generic label to stay on a supported runner automatically.

-    runs-on: ubuntu-24.04
+    runs-on: ubuntu-latest
📝 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
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
🤖 Prompt for AI Agents
In .github/workflows/e2e-tests.yml at line 10, the runner is pinned to
'ubuntu-24.04', which can cause issues when that version is deprecated. Change
the runner label from 'ubuntu-24.04' to 'ubuntu-latest' to automatically use the
most current supported Ubuntu runner unless there is a strict dependency on
version 24.04.

outputs:
changes-detected: ${{ steps.check.outputs.changes_detected }}
steps:
Expand Down
73 changes: 73 additions & 0 deletions .github/workflows/ios-app-nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: 🍎 iOS App Nightly

on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:

Comment on lines +3 to +7
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.

jobs:
check-changes:
runs-on: macos-15
outputs:
changes-detected: ${{ steps.check.outputs.changes_detected }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Check for changes
id: check
run: |
git fetch origin main
if git log --since="24 hours ago" --pretty=format:%H -- . \
':(exclude)website/' \
':(exclude)fastlane/' \
':(exclude)assets/' \
':(exclude)**/*.md' \
| grep .; then
echo "changes_detected=true" >> "$GITHUB_OUTPUT"
else
echo "changes_detected=false" >> "$GITHUB_OUTPUT"
fi
build-ios-app:
timeout-minutes: 15
needs: check-changes
if: needs.check-changes.outputs.changes-detected == 'true' || github.event_name == 'workflow_dispatch'
name: 🍎 Build iOS App
runs-on: macos-15

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
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 }}-
Comment on lines +45 to +55
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

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.

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

Either delete the if: or convert the job to a proper matrix before using the context.

📝 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
if: ${{ matrix.gradle_module == 'tasks-app-ios' }}
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 }}-
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 }}-
🧰 Tools
🪛 actionlint (1.7.7)

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

(expression)

🤖 Prompt for AI Agents
In .github/workflows/ios-app-nightly.yml around lines 45 to 55, the if condition
uses an undefined matrix variable `matrix.gradle_module`, causing runtime
failure. Remove the entire `if: ${{ matrix.gradle_module == 'tasks-app-ios' }}`
line since no matrix is defined for this job, or alternatively define a matrix
including `gradle_module` before using this condition.

- 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
Comment on lines +63 to +73
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

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.
-          IOS_TARGET=simulator xcodebuild \
+          xcodebuild \
             -project Taskfolio.xcodeproj \
             -scheme Taskfolio \
             -sdk iphonesimulator \
+            -destination "platform=iOS Simulator,name=iPhone 15" \
             -arch arm64 \
📝 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
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
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
🤖 Prompt for AI Agents
In .github/workflows/ios-app-nightly.yml around lines 54 to 64, remove the
unused environment variable IOS_TARGET=simulator from the xcodebuild command.
Then, add an explicit -destination argument specifying a fixed simulator device
and OS version to ensure consistent simulator selection and prevent flaky
builds.

6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ local.properties
.kotlin/metadata/

tasks-app-android/google-services.json
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
Comment on lines +36 to +39
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

Wildcard patterns could be more generic

The new ignores work, but you can reduce future churn by covering all Xcode user data in one line:

-tasks-app-ios/Taskfolio.xcodeproj/xcuserdata/*.xcuserdatad
-tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/xcuserdata/*.xcuserdatad
+tasks-app-ios/**/*.xcuserdatad

Same effect, less maintenance if more iOS modules appear.

📝 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
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-desktop/bin/
tasks-app-ios/**/*.xcuserdatad
tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/contents.xcworkspacedata
🤖 Prompt for AI Agents
In the .gitignore file around lines 36 to 39, the current ignore patterns for
Xcode user data are specific and repetitive. Replace these multiple lines with a
single, more generic wildcard pattern that covers all Xcode user data
directories and files across any iOS modules. This will reduce maintenance and
automatically cover new modules without needing updates.

tasks-app-ios/Taskfolio.xcodeproj/project.xcworkspace/xcshareddata/*

*token_cache.*
client_secret_*.apps.googleusercontent.com.json
Expand All @@ -42,4 +47,3 @@ _ci/api-*.json
bundletool-*.jar

_site/
tasks-app-desktop/bin/
79 changes: 74 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

The coverage report excludes code not intended to be covered.

This avoids the [“broken window” effect](https://blog.codinghorror.com/the-broken-window-theory/): whether coverage is at 43% or 56%, its perceived as equally low—so efforts to improve it are often dismissed. In contrast, high or near-100% coverage is seen as achievable and worth tracking.
This avoids the [“broken window” effect](https://blog.codinghorror.com/the-broken-window-theory/): whether coverage is at 43% or 56%, it's perceived as equally low—so efforts to improve it are often dismissed. In contrast, high or near-100% coverage is seen as achievable and worth tracking.

Refer to the root project's [`build.gradle.kts`](build.gradle.kts#L55-L90) for details.

Expand All @@ -36,13 +36,16 @@ Refer to the root project's [`build.gradle.kts`](build.gradle.kts#L55-L90) for d

[**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.


| ![](assets/screens/task_lists_light.png) | ![](assets/screens/groceries_light.png) | ![](assets/screens/add_task_light.png) | ![](assets/screens/home_dark.png) |
| --------------------------------------- |--------------------------------------- | ---------------------------------- | ---------------------------------- |

[![Taskfolio on Play Store](assets/GetItOnGooglePlay_Badge_Web_color_English.png)](https://play.google.com/store/apps/details?id=net.opatry.tasks.app)

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

Comment on lines +46 to +48
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.

## 🎯 Project intentions

- [x] Showcase my expertise in Android application development
Expand Down Expand Up @@ -76,9 +79,10 @@ I do not aim to implement advanced features beyond what is supported by the Goog

## 🛠️ Tech stack

- [Kotlin](https://kotlinlang.org/), [Multiplatform (aka KMP)](https://kotlinlang.org/docs/multiplatform.html) (currently Desktop & Android are supported)
- iOS wasn’t initially planned, but I bootstrapped a [PR to evaluate the feasibility of the iOS target]((https://github.com/opatry/taskfolio/pull/269)). It turned out to be quite achievable and just needs some polishing.
- Web is not planned any time soon (contribution are welcome 🤝)
- [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 🤝)
- [Kotlin coroutines](https://kotlinlang.org/docs/reference/coroutines/coroutines-guide.html)
- [Ktor client](https://ktor.io/) (+ [Kotlinx serialization](https://kotlinlang.org/docs/serialization.html))
- [Room](https://developer.android.com/training/data-storage/room) for local persistence
Expand Down Expand Up @@ -123,6 +127,9 @@ I do not aim to implement advanced features beyond what is supported by the Goog
- The Desktop application (thin layer fully reusing `:tasks-app-shared`)
- [`:tasks-app-android`](tasks-app-android) <span style="color: #66FF00;">■■■■■■■■</span>□□ 80%
- The Android application (thin layer fully reusing `:tasks-app-shared`)
- [`:tasks-app-ios/Taskfolio`](tasks-app-ios/Taskfolio) <span style="color: #33FF00;">■■■■■■■■■</span>□ 90%
- The iOS application (thin layer fully reusing `:tasks-app-shared`)
- Xcode project, written in Swift
- [`website/`](website) <span style="color: #00FF00;">■■■■■■■■■■</span> 100%
- The [static site](https://opatry.github.io/taskfolio/) presenting the project
- Made with [Jekyll](https://jekyllrb.com/) and served by [Github pages](https://pages.github.com/)
Expand Down Expand Up @@ -177,6 +184,68 @@ When clicking on it, it will open a new window with the hot reload status.
![](assets/compose-hot-reload-console.png)
</details>

## 🍎 Build for iOS target

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.
> 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`.

<details>
<summary>See details…</summary>

You can build the `:tasks-app-shared` code for iOS using Gradle (to check if everything compiles on Kotlin side):

```bash
./gradlew tasks-app-shared:linkDebugFrameworkIosSimulatorArm64 -Pios.target=simulator
```

### Building & Running from IntelliJ/Android Studio

You can also use the incubating [Kotlin Multiplatform IntelliJ plugin](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform) to build and launch the iOS app directly from IntelliJ/Android Studio (starting from Narwhal | 2025.1.1).
This plugin allows you to choose whether to run the app on a device or simulator, and enables debugging of Kotlin code even when called from iOS/Swift.

It builds the Kotlin code as a native framework, then triggers the appropriate Gradle task to build Kotlin first, followed by `xcodebuild` for the Xcode and iOS-specific parts, ensuring a seamless integration between Kotlin and Swift code (see next section for details).

### Building & Running from Xcode

For full XCFramework build (to be consumed by the iOS application), you'll have to rely on `xcodebuild` (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:embedAndSignAppleFrameworkForXcode` Gradle task under the hood.
Comment on lines +215 to +229
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

Fix formatting and remove unused env var in the xcodebuild example.

  • Add a blank line before the closing fence (MD031).
  • IOS_TARGET=simulator isn’t used by the command—drop it or show how it’s consumed.
-```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 \
   -arch arm64 \
   -configuration Debug \
   build \
   CODE_SIGNING_ALLOWED=NO \
   CODE_SIGN_IDENTITY="" \
   CODE_SIGNING_REQUIRED=NO
-```
+```
🧰 Tools
🪛 LanguageTool

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

🪛 markdownlint-cli2 (0.17.2)

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

(MD031, blanks-around-fences)

🤖 Prompt for AI Agents
In README.md around lines 215 to 229, the markdown code block is malformed and
includes an unused environment variable; fix by removing the unused
IOS_TARGET=simulator from the example command and ensure the code block has a
blank line before the closing ``` fence so the block is properly formatted;
update the snippet to start with ```bash, keep the xcodebuild invocation and
flags as shown, and close the fence on its own line.


For Xcode integration, it's recommended to install the [Xcode Kotlin plugin](https://touchlab.co/xcodekotlin):

```bash
brew install xcode-kotlin
xcode-kotlin install
```

When you update Xcode, you'll have to sync the plugin:

```bash
xcode-kotlin sync
```

If you want to debug the Kotlin code from Xcode, you'll have to add the needed source sets in Xcode:
Add Group > Add folders as **reference** > `tasks-app-shared/{commonMain,iosMain}` (or any other module you want to debug).
If you properly installed the Xcode Kotlin plugin, you'll be able to set a breakpoint in the Kotlin code and see syntax coloring as well.
</details>

## ⚖️ License

```
Expand Down
35 changes: 35 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import kotlinx.kover.gradle.plugin.dsl.CoverageUnit
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension

plugins {
alias(libs.plugins.jetbrains.kotlin.multiplatform) apply false
Expand Down Expand Up @@ -135,7 +136,32 @@ kover {
}
}

private val kmpPluginId = libs.plugins.jetbrains.kotlin.multiplatform.get().pluginId
subprojects {
plugins.withId(kmpPluginId) {
if (project == project(":google:oauth-http")) return@withId

extensions.configure<KotlinMultiplatformExtension> {
// foo-bar-zorg → FooBarZorg
val frameworkBaseName = project.name.split('-').joinToString("") { part ->
part.replaceFirstChar(Char::uppercase)
}
Comment on lines +146 to +148
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

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.

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

Add import java.util.Locale at the top of the file if not already present.

🤖 Prompt for AI Agents
In build.gradle.kts around lines 150 to 152, the code uses
replaceFirstChar(Char::uppercase) without specifying a locale, which can cause
inconsistent capitalization across different environments. Fix this by
explicitly specifying Locale.US in the replaceFirstChar call to ensure
deterministic framework names. Also, add import java.util.Locale at the top of
the file if it is not already imported.

iosTargets.mapNotNull {
when (it) {
"iosX64" -> iosX64()
"iosArm64" -> iosArm64()
"iosSimulatorArm64" -> iosSimulatorArm64()
else -> null
}
}.forEach { iosTarget ->
Comment on lines +149 to +156
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

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”.

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

This removes the undeclared symbol while retaining identical behaviour.

🤖 Prompt for AI Agents
In build.gradle.kts around lines 153 to 160, the variable iosTargets is used but
never declared, causing a compilation error. To fix this, declare iosTargets as
a list of the target names you want to configure (e.g., listOf("iosX64",
"iosArm64", "iosSimulatorArm64")) before this code block. This will define
iosTargets in scope and retain the intended behavior without causing unresolved
reference errors.

iosTarget.binaries.framework {
baseName = frameworkBaseName
isStatic = true
}
}
}
}

tasks {
findByName("test") ?: return@tasks
named<Test>("test") {
Expand Down Expand Up @@ -174,3 +200,12 @@ subprojects {
}
}
}

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)
}
}
Comment on lines +204 to +211
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.

13 changes: 13 additions & 0 deletions buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
plugins {
`kotlin-dsl`
}

repositories {
gradlePluginPortal()
mavenCentral()
}

kotlin {
jvmToolchain(17)
}
Comment on lines +10 to +12
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 +1 to +13
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

Pin the Kotlin version & expose the JVM toolchain at project level

The script is succinct; a couple of improvements will future-proof it:

 plugins {
-  `kotlin-dsl`
+  `kotlin-dsl` // uses the same Kotlin version as the running Gradle
 }
 
 kotlin {
-    jvmToolchain(17)
+    jvmToolchain(17)            // good ✔
 }
 
+// Explicitly pin the Kotlin version to the one used in the catalog.
+// Avoids accidental bumps when Gradle upgrades its embedded Kotlin.
+dependencies {
+    implementation("org.jetbrains.kotlin:kotlin-stdlib:${libs.versions.kotlin.get()}")
+}

This keeps buildSrc aligned with the rest of the repo and avoids subtle compiler drifts when Gradle is updated.

📝 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
plugins {
`kotlin-dsl`
}
repositories {
gradlePluginPortal()
mavenCentral()
}
kotlin {
jvmToolchain(17)
}
plugins {
`kotlin-dsl` // uses the same Kotlin version as the running Gradle
}
repositories {
gradlePluginPortal()
mavenCentral()
}
kotlin {
jvmToolchain(17) // good ✔
}
// Explicitly pin the Kotlin version to the one used in the catalog.
// Avoids accidental bumps when Gradle upgrades its embedded Kotlin.
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib:${libs.versions.kotlin.get()}")
}
🤖 Prompt for AI Agents
In buildSrc/build.gradle.kts lines 1 to 13, the Kotlin version is not explicitly
pinned and the JVM toolchain is configured only locally. To fix this, explicitly
specify the Kotlin version in the plugins block to align with the rest of the
project, and move the JVM toolchain configuration to the project-level Kotlin
settings to ensure consistent compiler versions across the repo and prevent
subtle drifts during Gradle updates.

Loading