diff --git a/.github/workflows/build-and-deploy-site.yml b/.github/workflows/build-and-deploy-site.yml
new file mode 100644
index 0000000000..9bba343297
--- /dev/null
+++ b/.github/workflows/build-and-deploy-site.yml
@@ -0,0 +1,65 @@
+# This workflow is designed to automate the process of building and deploying a Kotlin/JS web application to GitHub Pages.
+# It ensures that whenever changes are merged into the dev branch or when manually triggered, the web application is built,
+# packaged, and deployed to the GitHub Pages environment, making it accessible online.
+
+# Key Features:
+# - Automated web application build using Kotlin/JS
+# - Deployment to GitHub Pages
+# - Supports configurable web project module name
+# - Manages deployment concurrency and environment settings
+# - Provides secure deployment with proper permissions
+
+# Prerequisites:
+# - Kotlin Multiplatform/JS project configured with Gradle
+# - Web module set up for browser distribution
+# - Java 17 or compatible version
+# - GitHub Pages enabled in repository settings
+
+# Workflow Configuration:
+# - Requires input of `web_package_name` to specify the web project module
+# - Uses Windows runner for build process
+# - Leverages GitHub Actions for build, pages configuration, and deployment
+
+# Workflow Triggers:
+# - Can be manually called from other workflows
+# - Supports workflow_call for reusability across projects
+
+# Deployment Process:
+# 1. Checkout repository code
+# 2. Set up Java development environment
+# 3. Build Kotlin/JS web application
+# 4. Configure GitHub Pages
+# 5. Upload built artifacts
+# 6. Deploy to GitHub Pages
+
+# https://github.com/openMF/mifos-x-actionhub/blob/main/.github/workflows/build-and-deploy-site.yaml
+
+# ##############################################################################
+# DON'T EDIT THIS FILE UNLESS NECESSARY #
+# ##############################################################################
+
+name: Build And Deploy Web App
+
+# Trigger conditions for the workflow
+on:
+ workflow_dispatch:
+
+# Concurrency settings to manage multiple workflow runs
+# This ensures orderly deployment to production environment
+concurrency:
+ group: "web-pages"
+ cancel-in-progress: false
+
+permissions:
+ contents: read # Read repository contents
+ pages: write # Write to GitHub Pages
+ id-token: write # Write authentication tokens
+ pull-requests: write # Write to pull requests
+
+jobs:
+ build_and_deploy_web:
+ name: Build And Deploy Web App
+ uses: openMF/mifos-x-actionhub/.github/workflows/build-and-deploy-site.yaml@v1.0.8
+ secrets: inherit
+ with:
+ web_package_name: 'cmp-web' # <-- Change with your web package name
diff --git a/.github/workflows/cache-cleanup.yaml b/.github/workflows/cache-cleanup.yaml
new file mode 100644
index 0000000000..31178eeba7
--- /dev/null
+++ b/.github/workflows/cache-cleanup.yaml
@@ -0,0 +1,15 @@
+name: Cleanup Cache
+
+on:
+ pull_request:
+ types: [ closed ]
+ workflow_dispatch:
+
+jobs:
+ cleanup:
+ uses: openMF/mifos-x-actionhub/.github/workflows/cache-cleanup.yaml@v1.0.8
+ with:
+ cleanup_pr: ${{ github.event_name == 'pull_request' && github.event.repository.private == true }}
+ cleanup_all: ${{ github.event_name == 'workflow_dispatch' }}
+ secrets:
+ token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/monthly-version-tag.yml b/.github/workflows/monthly-version-tag.yml
new file mode 100644
index 0000000000..6383b0a424
--- /dev/null
+++ b/.github/workflows/monthly-version-tag.yml
@@ -0,0 +1,66 @@
+# Automated Monthly Release Versioning Workflow
+# ============================================
+
+# Purpose:
+# - Automatically create consistent monthly version tags
+# - Implement a calendar-based versioning strategy
+# - Facilitate easy tracking of monthly releases
+
+# Versioning Strategy:
+# - Tag format: YYYY.MM.0 (e.g., 2024.01.0 for January 2024)
+# - First digit: Full year
+# - Second digit: Month (01-12)
+# - Third digit: Patch version (starts at 0, allows for potential updates)
+
+# Key Features:
+# - Runs automatically on the first day of each month at 3:30 AM UTC
+# - Can be manually triggered via workflow_dispatch
+# - Uses GitHub Actions to generate tags programmatically
+# - Provides a predictable and systematic versioning approach
+
+# Prerequisites:
+# - Repository configured with GitHub Actions
+# - Permissions to create tags
+# - Access to actions/checkout and tag creation actions
+
+# Workflow Triggers:
+# - Scheduled monthly run
+# - Manual workflow dispatch
+# - Callable from other workflows
+
+# Actions Used:
+# 1. actions/checkout@v4 - Checks out repository code
+# 2. josStorer/get-current-time - Retrieves current timestamp
+# 3. rickstaa/action-create-tag - Creates Git tags
+
+# Example Generated Tags:
+# - 2024.01.0 (January 2024 initial release)
+# - 2024.02.0 (February 2024 initial release)
+# - 2024.02.1 (Potential patch for February 2024)
+
+# https://github.com/openMF/mifos-x-actionhub/blob/main/.github/workflows/monthly-version-tag.yaml
+
+# ##############################################################################
+# DON'T EDIT THIS FILE UNLESS NECESSARY #
+# ##############################################################################
+
+name: Tag Monthly Release
+
+on:
+ # Allow manual triggering of the workflow
+ workflow_dispatch:
+ # Schedule the workflow to run monthly
+ schedule:
+ # Runs at 03:30 UTC on the first day of every month
+ # Cron syntax: minute hour day-of-month month day-of-week
+ - cron: '30 3 1 * *'
+
+concurrency:
+ group: "monthly-release"
+ cancel-in-progress: false
+
+jobs:
+ monthly_release:
+ name: Tag Monthly Release
+ uses: openMF/mifos-x-actionhub/.github/workflows/monthly-version-tag.yaml@v1.0.8
+ secrets: inherit
diff --git a/.github/workflows/multi-platform-build-and-publish.yml b/.github/workflows/multi-platform-build-and-publish.yml
index 9d256dd3e4..47a4c9eb9d 100644
--- a/.github/workflows/multi-platform-build-and-publish.yml
+++ b/.github/workflows/multi-platform-build-and-publish.yml
@@ -89,16 +89,6 @@ on:
default: false
description: Distribute iOS App to Appstore
- distribute_macos_testflight:
- type: boolean
- default: false
- description: Distribute macOS App via TestFlight (App Store Connect)
-
- distribute_macos_appstore:
- type: boolean
- default: false
- description: Distribute macOS App to Appstore
-
permissions:
contents: write
id-token: write
@@ -111,32 +101,20 @@ concurrency:
jobs:
multi_platform_build_and_publish:
name: Multi-Platform Build and Publish
- uses: openMF/mifos-x-actionhub/.github/workflows/multi-platform-build-and-publish.yaml@v1.0.7
+ uses: openMF/mifos-x-actionhub/.github/workflows/multi-platform-build-and-publish.yaml@v1.0.8
with:
- java-version: 21
release_type: ${{ inputs.release_type }}
target_branch: ${{ inputs.target_branch }}
android_package_name: 'cmp-android'
ios_package_name: 'cmp-ios'
desktop_package_name: 'cmp-desktop'
web_package_name: 'cmp-web'
- tester_groups: 'mifos-mobile-apps'
- app_identifier: 'org.mifos.mobile'
- git_url: 'git@github.com:openMF/ios-provisioning-profile.git'
- git_branch: 'mifos-mobile'
- match_type: 'adhoc'
- provisioning_profile_name: 'match AdHoc org.mifos.mobile'
- firebase_app_id: '1:728434912738:ios:ee2e0815a6915b351a1dbb'
- metadata_path: './fastlane/metadata/ios'
- use_cocoapods: true # <-- Set to true if using CocoaPods integration for KMP
- shared_module: ':cmp-shared' # <-- Gradle path to your shared KMP module (e.g., :shared)
- cmp_desktop_dir: 'cmp-desktop'
- keychain_name: signing.keychain-db # optional
+ metadata_path: './fastlane/metadata'
+ use_cocoapods: true
+ shared_module: ':cmp-shared'
distribute_ios_firebase: ${{ inputs.distribute_ios_firebase }}
distribute_ios_testflight: ${{ inputs.distribute_ios_testflight }}
distribute_ios_appstore: ${{ inputs.distribute_ios_appstore }}
- distribute_macos_testflight: ${{ inputs.distribute_macos_testflight }}
- distribute_macos_appstore: ${{ inputs.distribute_macos_appstore }}
secrets:
original_keystore_file: ${{ secrets.ORIGINAL_KEYSTORE_FILE }}
original_keystore_file_password: ${{ secrets.ORIGINAL_KEYSTORE_FILE_PASSWORD }}
@@ -151,12 +129,6 @@ jobs:
notarization_apple_id: ${{ secrets.NOTARIZATION_APPLE_ID }}
notarization_password: ${{ secrets.NOTARIZATION_PASSWORD }}
notarization_team_id: ${{ secrets.NOTARIZATION_TEAM_ID }}
- keychain_password: ${{ secrets.KEYCHAIN_PASSWORD }}
- certificates_password: ${{ secrets.CERTIFICATES_PASSWORD }}
- mac_app_distribution_certificate_b64: ${{ secrets.MAC_APP_DISTRIBUTION_CERTIFICATE_B64 }}
- mac_installer_distribution_certificate_b64: ${{ secrets.MAC_INSTALLER_DISTRIBUTION_CERTIFICATE_B64 }}
- mac_embedded_provision_b64: ${{ secrets.MAC_EMBEDDED_PROVISION_B64 }}
- mac_runtime_provision_b64: ${{ secrets.MAC_RUNTIME_PROVISION_B64 }}
appstore_key_id: ${{ secrets.APPSTORE_KEY_ID }}
appstore_issuer_id: ${{ secrets.APPSTORE_ISSUER_ID }}
appstore_auth_key: ${{ secrets.APPSTORE_AUTH_KEY }}
@@ -178,4 +150,4 @@ jobs:
google_services: ${{ secrets.GOOGLESERVICES }}
firebase_creds: ${{ secrets.FIREBASECREDS }}
playstore_creds: ${{ secrets.PLAYSTORECREDS }}
- token: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
+ token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
index 0452cca62e..42b8e3e700 100644
--- a/.github/workflows/pr-check.yml
+++ b/.github/workflows/pr-check.yml
@@ -13,7 +13,7 @@
### Workflow Jobs
# 1. **Setup**: Prepares the build environment
# - Checks out repository code
-# - Sets up Java (configurable; defaults to 17)
+# - Sets up Java 17
# - Configures Gradle
# - Manages dependency caching
#
@@ -36,7 +36,7 @@
# - Generates platform-specific executables and packages
#
### Prerequisites
-# - Java (configurable; default 17)
+# - Java 17
# - Gradle
# - Configured build scripts for:
# - Android module
@@ -49,16 +49,10 @@
### Configuration Parameters
# The workflow requires two input parameters:
#
-# | Parameter | Description | Type | Required |
-# |------------------------|------------------------------------|--------|-----------|
-# | `android_package_name` | Name of the Android project module | String | Yes |
-# | `desktop_package_name` | Name of the Desktop project module | String | Yes |
-# |`web_package_name` | Name of the Web (Kotlin/JS) project/module | String | No|
-# |`ios_package_name` | Name of the iOS project/module | String | No |
-# |`build_ios` | Build iOS targets as part of PR checks | Boolean | No |
-# |`use_cocoapods` | Use CocoaPods for iOS integration | Boolean | No |
-# |`shared_module | Path of the shared KMP module | String | (required when build_ios=true) |
-# |`java-version | Java version to use (configurable; defaults to 17)| No |
+# | Parameter | Description | Type | Required |
+# |------------------------|------------------------------------|--------|----------|
+# | `android_package_name` | Name of the Android project module | String | Yes |
+# | `desktop_package_name` | Name of the Desktop project module | String | Yes |
#
# https://github.com/openMF/mifos-x-actionhub/blob/main/.github/workflows/pr-check.yaml
@@ -68,7 +62,7 @@
# ##############################################################################
-name: PR Checks for KMP
+name: PR Checks
# Trigger conditions for the workflow
on:
@@ -79,7 +73,7 @@ on:
# Concurrency settings to prevent multiple simultaneous workflow runs
concurrency:
- group: pr-kmp-${{ github.ref }}
+ group: pr-${{ github.ref }}
cancel-in-progress: true # Cancels previous runs if a new one is triggered
permissions:
@@ -87,9 +81,8 @@ permissions:
jobs:
pr_checks:
- name: PR Checks KMP
- uses: openMF/mifos-x-actionhub/.github/workflows/pr-check.yaml@v1.0.7
- secrets: inherit
+ name: PR Checks
+ uses: openMF/mifos-x-actionhub/.github/workflows/pr-check.yaml@v1.0.8
with:
android_package_name: 'cmp-android' # <-- Change Your Android Package Name
desktop_package_name: 'cmp-desktop' # <-- Change Your Desktop Package Name
diff --git a/.github/workflows/promote-to-production.yml b/.github/workflows/promote-to-production.yml
index 8099c4ffa6..ee0067d6e3 100644
--- a/.github/workflows/promote-to-production.yml
+++ b/.github/workflows/promote-to-production.yml
@@ -43,7 +43,7 @@
# end
# ```
-# https://github.com/openMF/mifos-mobile-github-actions/blob/main/.github/workflows/promote-to-production.yaml
+# https://github.com/openMF/mifos-x-actionhub/blob/main/.github/workflows/promote-to-production.yaml
# ##############################################################################
# DON'T EDIT THIS FILE UNLESS NECESSARY #
@@ -70,6 +70,6 @@ jobs:
# Job to promote app from beta to production in Play Store
play_promote_production:
name: Promote Beta to Production Play Store
- uses: openMF/mifos-x-actionhub/.github/workflows/promote-to-production.yaml@v1.0.7
+ uses: openMF/mifos-x-actionhub/.github/workflows/promote-to-production.yaml@v1.0.8
secrets:
playstore_creds: ${{ secrets.PLAYSTORECREDS }}
diff --git a/.github/workflows/sync-dirs.yaml b/.github/workflows/sync-dirs.yaml
index 6a947f649a..e3d89f345d 100644
--- a/.github/workflows/sync-dirs.yaml
+++ b/.github/workflows/sync-dirs.yaml
@@ -32,12 +32,14 @@ jobs:
- name: Add upstream remote and fetch
run: |
+ set -euo pipefail
UPSTREAM="${{ inputs.upstream || 'https://github.com/openMF/kmp-project-template.git' }}"
git remote add upstream "$UPSTREAM" || true
git fetch upstream || exit 1
- name: Check upstream/dev exists
run: |
+ set -euo pipefail
if ! git rev-parse --verify upstream/dev >/dev/null 2>&1; then
echo "Error: upstream/dev branch does not exist"
exit 1
@@ -45,62 +47,67 @@ jobs:
- name: Create and checkout temporary branch
run: |
+ set -euo pipefail
TEMP_BRANCH="temp-sync-branch-${{ github.run_number }}"
git checkout -b "$TEMP_BRANCH" upstream/dev || exit 1
echo "TEMP_BRANCH=$TEMP_BRANCH" >> $GITHUB_ENV
- name: Sync directories and files
+ shell: bash
run: |
+ set -euo pipefail
+
# Declare directories and files to sync
DIRS=(
- "cmp-android"
"cmp-desktop"
- "cmp-ios"
"cmp-web"
"cmp-shared"
"core-base"
"build-logic"
"fastlane"
+ "fastlane-config"
"scripts"
"config"
".github"
".run"
)
-
+
FILES=(
"Gemfile"
"Gemfile.lock"
"ci-prepush.bat"
"ci-prepush.sh"
)
-
+
# Define exclusions
declare -A EXCLUSIONS=(
- ["cmp-android"]="src/main/res dependencies src/main/ic_launcher-playstore.png google-services.json"
["cmp-web"]="src/jsMain/resources src/wasmJsMain/resources"
- ["cmp-desktop"]="icons"
- ["cmp-ios"]="iosApp/Assets.xcassets"
+ ["cmp-desktop"]="icons build.gradle.kts"
+ ["fastlane-config"]="project_config.rb extract_config.rb"
+ [".github"]="workflows/sync-dirs.yaml"
["root"]="secrets.env"
)
-
+
# Function to check if path should be excluded
should_exclude() {
local dir=$1
local path=$2
-
- # Check for root exclusions
- if [[ "$dir" == "." && -n "${EXCLUSIONS["root"]}" ]]; then
- local root_excluded_paths=(${EXCLUSIONS["root"]})
+
+ # Check for root exclusions (when dir is "." or "root")
+ if [[ "$dir" == "." || "$dir" == "root" ]] && [[ -v "EXCLUSIONS[root]" ]]; then
+ local root_excluded_paths
+ IFS=' ' read -ra root_excluded_paths <<< "${EXCLUSIONS[root]}"
for excluded in "${root_excluded_paths[@]}"; do
if [[ "$path" == *"$excluded"* ]]; then
return 0
fi
done
fi
-
+
# Check directory-specific exclusions
- if [[ -n "${EXCLUSIONS[$dir]}" ]]; then
- local excluded_paths=(${EXCLUSIONS[$dir]})
+ if [[ -v "EXCLUSIONS[$dir]" ]]; then
+ local excluded_paths
+ IFS=' ' read -ra excluded_paths <<< "${EXCLUSIONS[$dir]}"
for excluded in "${excluded_paths[@]}"; do
if [[ "$path" == *"$excluded"* ]]; then
return 0
@@ -109,29 +116,31 @@ jobs:
fi
return 1
}
-
+
# Function to preserve excluded paths
preserve_excluded() {
local dir=$1
- if [[ -n "${EXCLUSIONS[$dir]}" ]]; then
- local excluded_paths=(${EXCLUSIONS[$dir]})
+ if [[ -v "EXCLUSIONS[$dir]" ]]; then
+ local excluded_paths
+ IFS=' ' read -ra excluded_paths <<< "${EXCLUSIONS[$dir]}"
for excluded in "${excluded_paths[@]}"; do
local full_path="$dir/$excluded"
if [[ -e "$full_path" ]]; then
echo "Preserving excluded path: $full_path"
local temp_path="temp_excluded/$full_path"
mkdir -p "$(dirname "$temp_path")"
- cp -r "$full_path" "$(dirname "$temp_path")"
+ cp -r "$full_path" "$(dirname "$temp_path")/"
fi
done
fi
}
-
+
# Function to restore excluded paths
restore_excluded() {
local dir=$1
- if [[ -n "${EXCLUSIONS[$dir]}" ]]; then
- local excluded_paths=(${EXCLUSIONS[$dir]})
+ if [[ -v "EXCLUSIONS[$dir]" ]]; then
+ local excluded_paths
+ IFS=' ' read -ra excluded_paths <<< "${EXCLUSIONS[$dir]}"
for excluded in "${excluded_paths[@]}"; do
local full_path="$dir/$excluded"
local temp_path="temp_excluded/$full_path"
@@ -139,16 +148,17 @@ jobs:
echo "Restoring excluded path: $full_path"
mkdir -p "$(dirname "$full_path")"
rm -rf "$full_path"
- cp -r "$temp_path" "$(dirname "$full_path")"
+ cp -r "$temp_path" "$(dirname "$full_path")/"
fi
done
fi
}
-
+
# Function to preserve root-level excluded files
preserve_root_files() {
- if [[ -n "${EXCLUSIONS["root"]}" ]]; then
- local excluded_paths=(${EXCLUSIONS["root"]})
+ if [[ -v "EXCLUSIONS[root]" ]]; then
+ local excluded_paths
+ IFS=' ' read -ra excluded_paths <<< "${EXCLUSIONS[root]}"
for excluded in "${excluded_paths[@]}"; do
if [[ -e "$excluded" ]]; then
echo "Preserving root-level excluded file: $excluded"
@@ -158,11 +168,12 @@ jobs:
done
fi
}
-
+
# Function to restore root-level excluded files
restore_root_files() {
- if [[ -n "${EXCLUSIONS["root"]}" ]]; then
- local excluded_paths=(${EXCLUSIONS["root"]})
+ if [[ -v "EXCLUSIONS[root]" ]]; then
+ local excluded_paths
+ IFS=' ' read -ra excluded_paths <<< "${EXCLUSIONS[root]}"
for excluded in "${excluded_paths[@]}"; do
if [[ -e "temp_excluded/root/$excluded" ]]; then
echo "Restoring root-level excluded file: $excluded"
@@ -171,63 +182,81 @@ jobs:
done
fi
}
-
+
# Create temp directory for exclusions
mkdir -p temp_excluded
-
+
# Preserve root-level exclusions before sync
preserve_root_files
-
+
# Switch to development branch
git checkout development
-
+
# Sync directories
for dir in "${DIRS[@]}"; do
- if [ ! -d "$dir" ]; then
+ if [[ ! -d "$dir" ]]; then
echo "Creating $dir..."
mkdir -p "$dir"
fi
-
+
# Preserve excluded paths before sync
if [[ -d "$dir" ]]; then
preserve_excluded "$dir"
fi
-
+
echo "Syncing $dir..."
- git checkout "${{ env.TEMP_BRANCH }}" -- "$dir" || exit 1
-
+ if ! git checkout "${{ env.TEMP_BRANCH }}" -- "$dir" 2>/dev/null; then
+ echo "Warning: Could not sync directory $dir (may not exist in upstream)"
+ fi
+
# Restore excluded paths after sync
restore_excluded "$dir"
done
-
+
# Sync files
for file in "${FILES[@]}"; do
- dir=$(dirname "$file")
- if ! should_exclude "$dir" "$file"; then
- echo "Syncing $file..."
- git checkout "${{ env.TEMP_BRANCH }}" -- "$file" || true
- else
+ file_dir=$(dirname "$file")
+ file_name=$(basename "$file")
+
+ # Check root exclusions for root-level files
+ if [[ "$file_dir" == "." ]]; then
+ if should_exclude "root" "$file_name"; then
+ echo "Skipping excluded file: $file"
+ continue
+ fi
+ elif should_exclude "$file_dir" "$file"; then
echo "Skipping excluded file: $file"
+ continue
+ fi
+
+ echo "Syncing $file..."
+ if ! git checkout "${{ env.TEMP_BRANCH }}" -- "$file" 2>/dev/null; then
+ echo "Warning: Could not sync file $file (may not exist in upstream)"
fi
done
-
+
# Restore root-level excluded files
restore_root_files
-
+
# Cleanup temp directory
rm -rf temp_excluded
+ echo "Sync completed successfully!"
+
- name: Clean up temporary branch
if: always()
- run: git branch -D "${{ env.TEMP_BRANCH }}" || true
+ run: git branch -D "${{ env.TEMP_BRANCH }}" 2>/dev/null || true
- name: Check for changes
id: check_changes
run: |
if [[ -n "$(git status --porcelain)" ]]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
+ echo "Changes detected:"
+ git status --short
else
echo "has_changes=false" >> $GITHUB_OUTPUT
+ echo "No changes detected"
fi
- name: Create Pull Request
@@ -239,35 +268,36 @@ jobs:
title: "chore: Sync directories and files from upstream"
body: |
Automated sync of directories and files from upstream repository.
-
+
Changes included in this sync:
-
- Directories:
- - cmp-android (excluding src/main/res, dependencies, ic_launcher-playstore.png, google-services.json)
- - cmp-desktop (excluding icons)
- - cmp-ios (excluding iosApp/Assets.xcassets)
+
+ **Directories:**
+ - cmp-desktop (excluding icons, build.gradle.kts)
- cmp-web (excluding src/jsMain/resources, src/wasmJsMain/resources)
- cmp-shared
+ - core-base
- build-logic
- fastlane
+ - fastlane-config (excluding project_config.rb, extract_config.rb)
- scripts
- config
- - .github
+ - .github (excluding workflows/sync-dirs.yaml)
- .run
-
- Files:
+
+ **Files:**
- Gemfile
- Gemfile.lock
- ci-prepush.bat
- ci-prepush.sh
-
- Root-level exclusions:
+
+ **Root-level exclusions:**
- secrets.env
-
+
+ ---
Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
branch: sync-dirs-${{ github.run_number }}
delete-branch: true
labels: |
sync
automated pr
- base: development
\ No newline at end of file
+ base: development
diff --git a/.github/workflows/tag-weekly-release.yml b/.github/workflows/tag-weekly-release.yml
index cd90ab15fd..d79246c482 100644
--- a/.github/workflows/tag-weekly-release.yml
+++ b/.github/workflows/tag-weekly-release.yml
@@ -1,29 +1,104 @@
+# Weekly Release Tagging and Beta Deployment Workflow
+# ===================================================
+
+# Purpose:
+# - Automate weekly version tagging for consistent software versioning
+# - Trigger automated beta releases across multiple platforms
+# - Maintain a predictable release cycle
+
+# Workflow Overview:
+# - Runs automatically every Sunday at 4:00 AM UTC
+# - Supports manual triggering via workflow_dispatch
+# - Utilizes Gradle Reckon plugin for intelligent versioning
+# - Triggers multi-platform build and publish workflow
+
+# Key Features:
+# - Automatic semantic versioning
+# - Cross-platform release automation
+# - Configurable target branch for releases
+# - Full repository history checkout for accurate versioning
+
+# Versioning Strategy:
+# - Uses Reckon Gradle plugin for semantic versioning
+# - Generates production-ready (final) version tags
+# - Provides consistent and predictable version incrementation
+
+# Release Process:
+# 1. Checkout repository with full commit history
+# 2. Setup Java 17 development environment
+# 3. Create and push new version tag
+# 4. Trigger multi-platform build and publish workflow
+
+# Prerequisites:
+# - Gradle project configured with Reckon plugin
+# - Java 17 development environment
+# - Configured multi-platform build workflow
+# - GitHub Actions permissions for workflow dispatch
+
+# Workflow Inputs:
+# - target_branch: Branch to use for releases (default: 'dev')
+# Allows flexible release targeting across different branches
+
+# Security Considerations:
+# - Uses GitHub's native GITHUB_TOKEN for authentication
+# - Controlled workflow dispatch with specific inputs
+# - Limited to authorized repository members
+
+# Potential Use Cases:
+# - Regular software release cycles
+# - Automated beta testing distributions
+# - Consistent multi-platform deployment
+
+# Workflow Triggers:
+# - Scheduled weekly run (Sunday 4:00 AM UTC)
+# - Manual workflow dispatch
+# - Callable from other workflows
+
+# ##############################################################################
+# DON'T EDIT THIS FILE UNLESS NECESSARY #
+# ##############################################################################
+
name: Tag Weekly Release
on:
+ # Allow manual triggering of the workflow
workflow_dispatch:
+ # Schedule the workflow to run weekly
schedule:
+ # Runs at 04:00 UTC every Sunday
+ # Cron syntax: minute hour day-of-month month day-of-week
- cron: '0 4 * * 0'
+
+concurrency:
+ group: "weekly-release"
+ cancel-in-progress: false
+
jobs:
tag:
name: Tag Weekly Release
runs-on: ubuntu-latest
steps:
+ # Checkout the repository with full history for proper versioning
- uses: actions/checkout@v4
with:
fetch-depth: 0
+ # Setup Java environment for Gradle operations
- name: Set up JDK 21
uses: actions/setup-java@v4.2.2
with:
distribution: 'temurin'
java-version: '21'
+ # Create and push a new version tag using Reckon
+ # This uses the 'final' stage for production-ready releases
- name: Tag Weekly Release
env:
- GITHUB_TOKEN: ${{ secrets.TAG_PUSH_TOKEN }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./gradlew :reckonTagPush -Preckon.stage=final
+ # Trigger the build and publish workflow for beta release
+ # This starts the process of building and deploying the app to various platforms
- name: Trigger Workflow
uses: actions/github-script@v7
with:
@@ -31,9 +106,9 @@ jobs:
github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
- workflow_id: 'android-release.yml',
- ref: 'development',
+ workflow_id: 'multi-platform-build-and-publish.yml',
+ ref: 'development',
inputs: {
"release_type": "beta",
},
- })
\ No newline at end of file
+ })
diff --git a/.ruby-version b/.ruby-version
index 0163af7e86..eedb52bac2 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-3.3.5
\ No newline at end of file
+3.3.6
\ No newline at end of file
diff --git a/.run/cmp-android.run.xml b/.run/cmp-android.run.xml
new file mode 100644
index 0000000000..ebe8232323
--- /dev/null
+++ b/.run/cmp-android.run.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/cmp-desktop.run.xml b/.run/cmp-desktop.run.xml
index 88186e065a..32bd0b3af3 100644
--- a/.run/cmp-desktop.run.xml
+++ b/.run/cmp-desktop.run.xml
@@ -1,11 +1,6 @@
-
diff --git a/.run/cmp-web-js.run.xml b/.run/cmp-web-js.run.xml
index 89a8b9653b..ae0413e3c7 100644
--- a/.run/cmp-web-js.run.xml
+++ b/.run/cmp-web-js.run.xml
@@ -10,7 +10,7 @@
diff --git a/Gemfile b/Gemfile
index 54cbd00f09..c06ae4a719 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,6 +1,15 @@
source "https://rubygems.org"
+ruby '3.3.6'
+
+# Add compatibility gems for Ruby 3.3+
+gem "abbrev"
+gem "base64"
+gem "mutex_m"
+gem "bigdecimal"
+
gem "fastlane"
-gem "cocoapods"
+gem "cocoapods", "~> 1.16"
+
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
diff --git a/Gemfile.lock b/Gemfile.lock
index 2d23059c63..a15c9492ac 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,11 +1,9 @@
GEM
remote: https://rubygems.org/
specs:
- CFPropertyList (3.0.7)
- base64
- nkf
- rexml
- activesupport (7.2.2.1)
+ CFPropertyList (3.0.8)
+ abbrev (0.1.2)
+ activesupport (7.2.3)
base64
benchmark (>= 0.3)
bigdecimal
@@ -17,34 +15,36 @@ GEM
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
- addressable (2.8.7)
- public_suffix (>= 2.0.2, < 7.0)
+ addressable (2.8.8)
+ public_suffix (>= 2.0.2, < 8.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
artifactory (3.0.17)
atomos (0.1.3)
- aws-eventstream (1.3.0)
- aws-partitions (1.1048.0)
- aws-sdk-core (3.218.1)
+ aws-eventstream (1.4.0)
+ aws-partitions (1.1208.0)
+ aws-sdk-core (3.241.4)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
+ bigdecimal
jmespath (~> 1, >= 1.6.1)
- aws-sdk-kms (1.98.0)
- aws-sdk-core (~> 3, >= 3.216.0)
+ logger
+ aws-sdk-kms (1.121.0)
+ aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
- aws-sdk-s3 (1.180.0)
- aws-sdk-core (~> 3, >= 3.216.0)
+ aws-sdk-s3 (1.212.0)
+ aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
- aws-sigv4 (1.11.0)
+ aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
- benchmark (0.3.0)
- bigdecimal (3.2.2)
+ benchmark (0.5.0)
+ bigdecimal (4.0.1)
claide (1.1.0)
cocoapods (1.16.2)
addressable (~> 2.8)
@@ -87,18 +87,18 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
- concurrent-ruby (1.3.5)
- connection_pool (2.5.0)
+ concurrent-ruby (1.3.6)
+ connection_pool (3.0.2)
+ csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
- drb (2.2.0)
- ruby2_keywords
+ drb (2.2.3)
emoji_regex (3.2.3)
escape (0.0.4)
- ethon (0.16.0)
+ ethon (0.15.0)
ffi (>= 1.15.0)
excon (0.112.0)
faraday (1.10.4)
@@ -113,14 +113,14 @@ GEM
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
- faraday-cookie_jar (0.0.7)
+ faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
- http-cookie (~> 1.0.0)
+ http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
- faraday-em_synchrony (1.0.0)
+ faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
- faraday-multipart (1.1.0)
+ faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
@@ -130,15 +130,19 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
- fastlane (2.226.0)
+ fastlane (2.231.1)
CFPropertyList (>= 2.3, < 4.0.0)
+ abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
- bundler (>= 1.12.0, < 3.0.0)
+ base64 (~> 0.2.0)
+ benchmark (>= 0.1.0)
+ bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
+ csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@@ -156,10 +160,14 @@ GEM
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
+ logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
+ mutex_m (~> 0.3.0)
naturally (~> 2.2)
+ nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
+ ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
@@ -170,15 +178,16 @@ GEM
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
- xcpretty (~> 0.4.0)
+ xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
- fastlane-plugin-firebase_app_distribution (0.10.0)
+ fastlane-plugin-firebase_app_distribution (0.10.1)
google-apis-firebaseappdistribution_v1 (~> 0.3.0)
google-apis-firebaseappdistribution_v1alpha (~> 0.2.0)
fastlane-plugin-increment_build_number (0.0.4)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
- ffi (1.17.1-arm64-darwin)
+ ffi (1.17.3)
+ ffi (1.17.3-arm64-darwin)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
@@ -202,12 +211,12 @@ GEM
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
- google-cloud-core (1.7.1)
+ google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
- google-cloud-errors (1.4.0)
+ google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
@@ -225,46 +234,51 @@ GEM
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
- httpclient (2.8.3)
- i18n (1.14.7)
+ httpclient (2.9.0)
+ mutex_m
+ i18n (1.14.8)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
- json (2.9.1)
- jwt (2.10.1)
+ json (2.18.0)
+ jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
- minitest (5.20.0)
+ minitest (6.0.1)
+ prism (~> 1.5)
molinillo (0.8.0)
- multi_json (1.15.0)
+ multi_json (1.19.1)
multipart-post (2.4.1)
+ mutex_m (0.3.0)
nanaimo (0.4.0)
nap (1.1.0)
- naturally (2.2.1)
+ naturally (2.3.0)
netrc (0.11.0)
nkf (0.2.0)
- optparse (0.6.0)
+ optparse (0.8.1)
os (1.1.4)
+ ostruct (0.6.3)
plist (3.7.2)
+ prism (1.8.0)
public_suffix (4.0.7)
- rake (13.2.1)
+ rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
- rexml (3.4.0)
+ rexml (3.4.4)
rouge (3.28.0)
ruby-macho (2.5.1)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
- securerandom (0.3.1)
+ securerandom (0.4.1)
security (0.1.5)
- signet (0.19.0)
+ signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
- jwt (>= 1.5, < 3.0)
+ jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
@@ -278,8 +292,8 @@ GEM
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
- typhoeus (1.4.1)
- ethon (>= 0.9.0)
+ typhoeus (1.5.0)
+ ethon (>= 0.9.0, < 0.16.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
@@ -292,20 +306,27 @@ GEM
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
- xcpretty (0.4.0)
+ xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
- arm64-darwin-24
+ arm64-darwin-25
ruby
DEPENDENCIES
- cocoapods
+ abbrev
+ base64
+ bigdecimal
+ cocoapods (~> 1.16)
fastlane
fastlane-plugin-firebase_app_distribution
fastlane-plugin-increment_build_number
+ mutex_m
+
+RUBY VERSION
+ ruby 3.3.6p108
BUNDLED WITH
- 2.5.18
+ 2.7.2
diff --git a/build-logic/README.md b/build-logic/README.md
index f0ea8ede99..3852831e73 100644
--- a/build-logic/README.md
+++ b/build-logic/README.md
@@ -29,10 +29,10 @@ setup.
Current list of convention plugins:
-- [`mifos.android.application`](convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt),
- [`mifos.android.library`](convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt),
- [`mifos.android.test`](convention/src/main/kotlin/AndroidTestConventionPlugin.kt):
+- [`android.application`](convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt),
+ [`android.library`](convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt),
+ [`android.test`](convention/src/main/kotlin/AndroidTestConventionPlugin.kt):
Configures common Android and Kotlin options.
-- [`mifos.android.application.compose`](convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt),
- [`mifos.android.library.compose`](convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt):
+- [`android.application.compose`](convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt),
+ [`android.library.compose`](convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt):
Configures Jetpack Compose options
diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts
index e0168aa06f..d739a995d0 100644
--- a/build-logic/convention/build.gradle.kts
+++ b/build-logic/convention/build.gradle.kts
@@ -1,4 +1,4 @@
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
`kotlin-dsl`
@@ -12,9 +12,10 @@ java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
-tasks.withType().configureEach {
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_21.toString()
+
+kotlin {
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_21
}
}
@@ -29,7 +30,7 @@ dependencies {
compileOnly(libs.ktlint.gradlePlugin)
compileOnly(libs.spotless.gradlePlugin)
implementation(libs.truth)
-
+ compileOnly(libs.room.gradlePlugin)
compileOnly(libs.firebase.crashlytics.gradlePlugin)
compileOnly(libs.firebase.performance.gradlePlugin)
}
@@ -39,6 +40,14 @@ tasks {
enableStricterValidation = true
failOnWarning = true
}
+
+ // Configure JUnit 5 for testing keystore management functionality
+ test {
+ useJUnitPlatform()
+ testLogging {
+ events("passed", "skipped", "failed")
+ }
+ }
}
gradlePlugin {
@@ -121,5 +130,16 @@ gradlePlugin {
id = "org.convention.kmp.library"
implementationClass = "KMPLibraryConventionPlugin"
}
+
+ register("kmpCoreBaseLibrary") {
+ id = "org.convention.kmp.core.base.library"
+ implementationClass = "KMPCoreBaseLibraryConventionPlugin"
+ }
+
+ register("ktlint") {
+ id = "org.convention.ktlint.plugin"
+ implementationClass = "KtlintConventionPlugin"
+ description = "Configures kotlinter for the project"
+ }
}
}
diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
index 8f19540fad..c35a1915cf 100644
--- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
@@ -6,9 +6,13 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.getByType
import org.mifos.mobile.configureBadgingTasks
+import org.mifos.mobile.configureGradleManagedDevices
import org.mifos.mobile.configureKotlinAndroid
import org.mifos.mobile.configurePrintApksTask
+/**
+ * Plugin that applies the Android application plugin and configures it.
+ */
class AndroidApplicationConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
@@ -27,7 +31,10 @@ class AndroidApplicationConventionPlugin : Plugin {
extensions.configure {
configureKotlinAndroid(this)
- defaultConfig.targetSdk = 34
+ defaultConfig.targetSdk = 36
+ @Suppress("UnstableApiUsage")
+ testOptions.animationsDisabled = true
+ configureGradleManagedDevices(this)
}
extensions.configure {
configurePrintApksTask(this)
diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt
index 8189044849..4952040f3f 100644
--- a/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt
@@ -28,10 +28,10 @@ class AndroidApplicationFirebaseConventionPlugin : Plugin {
// enabled if a Firebase backend is available and configured in
// google-services.json.
configure {
- mappingFileUploadEnabled = true
+ mappingFileUploadEnabled = false
}
}
}
}
}
-}
\ No newline at end of file
+}
diff --git a/build-logic/convention/src/main/kotlin/CMPFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/CMPFeatureConventionPlugin.kt
index ce47af4bf4..253d12b4db 100644
--- a/build-logic/convention/src/main/kotlin/CMPFeatureConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/CMPFeatureConventionPlugin.kt
@@ -7,18 +7,23 @@ class CMPFeatureConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
- with(pluginManager) {
+ pluginManager.apply {
apply("org.convention.kmp.library")
apply("org.convention.kmp.koin")
apply("org.jetbrains.kotlin.plugin.compose")
apply("org.jetbrains.compose")
+ apply("mifos.detekt.plugin")
+ apply("mifos.spotless.plugin")
}
dependencies {
add("commonMainImplementation", project(":core:ui"))
+ add("commonMainImplementation", project(":core-base:ui"))
add("commonMainImplementation", project(":core:designsystem"))
// add("commonMainImplementation", project(":core:testing"))
add("commonMainImplementation", project(":core:data"))
+ add("commonMainImplementation", project(":core-base:designsystem"))
+// add("commonMainImplementation", project(":core:analytics"))
add("commonMainImplementation", libs.findLibrary("koin.compose").get())
add("commonMainImplementation", libs.findLibrary("koin.compose.viewmodel").get())
@@ -27,7 +32,8 @@ class CMPFeatureConventionPlugin : Plugin {
add("commonMainImplementation", libs.findLibrary("jb.lifecycle.compose").get())
add("commonMainImplementation", libs.findLibrary("jb.composeViewmodel").get())
add("commonMainImplementation", libs.findLibrary("jb.lifecycleViewmodel").get())
- add("commonMainImplementation", libs.findLibrary("jb.lifecycleViewmodelSavedState").get())
+ add("commonMainImplementation", libs.findLibrary("jb.lifecycle.compose").get())
+ add("commonMainImplementation", libs.findLibrary("jb.lifecycleViewmodelSavedState").get(),)
add("commonMainImplementation", libs.findLibrary("jb.savedstate").get())
add("commonMainImplementation", libs.findLibrary("jb.bundle").get())
add("commonMainImplementation", libs.findLibrary("jb.composeNavigation").get())
diff --git a/build-logic/convention/src/main/kotlin/KMPCoreBaseLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KMPCoreBaseLibraryConventionPlugin.kt
new file mode 100644
index 0000000000..5e7f30c8bf
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/KMPCoreBaseLibraryConventionPlugin.kt
@@ -0,0 +1,49 @@
+
+import com.android.build.gradle.LibraryExtension
+import org.mifos.mobile.configureFlavors
+import org.mifos.mobile.configureKotlinAndroid
+import org.mifos.mobile.configureKotlinMultiplatform
+import org.mifos.mobile.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.dependencies
+
+/**
+ * Plugin that applies the Android library and Kotlin multiplatform plugins and configures them.
+ */
+class KMPCoreBaseLibraryConventionPlugin: Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ with(pluginManager) {
+ apply("com.android.library")
+ apply("org.jetbrains.kotlin.multiplatform")
+ apply("org.convention.kmp.koin")
+ apply("mifos.detekt.plugin")
+ apply("org.jetbrains.kotlin.plugin.serialization")
+ apply("org.jetbrains.kotlin.plugin.parcelize")
+ }
+
+ configureKotlinMultiplatform()
+
+ extensions.configure {
+ configureKotlinAndroid(this)
+ defaultConfig.targetSdk = 36
+ configureFlavors(this)
+ // The resource prefix is derived from the module name,
+ // so resources inside ":core:module1" must be prefixed with "core_module1_"
+ resourcePrefix = path
+ .split("""\W""".toRegex())
+ .drop(1).distinct()
+ .joinToString(separator = "_")
+ .lowercase() + "_"
+ }
+
+ dependencies {
+ add("commonMainImplementation", libs.findLibrary("kotlinx.serialization.json").get())
+ add("commonTestImplementation", libs.findLibrary("kotlin.test").get())
+ add("commonTestImplementation", libs.findLibrary("kotlinx.coroutines.test").get())
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/KMPLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KMPLibraryConventionPlugin.kt
index 33aa493e62..3c2faaea5f 100644
--- a/build-logic/convention/src/main/kotlin/KMPLibraryConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KMPLibraryConventionPlugin.kt
@@ -18,13 +18,15 @@ class KMPLibraryConventionPlugin : Plugin {
apply("org.convention.kmp.koin")
apply("mifos.detekt.plugin")
apply("mifos.spotless.plugin")
+ apply("org.jetbrains.kotlin.plugin.serialization")
+ apply("org.jetbrains.kotlin.plugin.parcelize")
}
configureKotlinMultiplatform()
extensions.configure {
configureKotlinAndroid(this)
- defaultConfig.targetSdk = 34
+ defaultConfig.targetSdk = 36
configureFlavors(this)
/**
* The resource prefix is derived from the module name,
@@ -38,6 +40,7 @@ class KMPLibraryConventionPlugin : Plugin {
}
dependencies {
+ add("commonMainImplementation", libs.findLibrary("kotlinx.serialization.json").get())
add("commonTestImplementation", libs.findLibrary("kotlin.test").get())
add("commonTestImplementation", libs.findLibrary("kotlinx.coroutines.test").get())
}
diff --git a/build-logic/convention/src/main/kotlin/KtlintConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KtlintConventionPlugin.kt
new file mode 100644
index 0000000000..da7e3c06c0
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/KtlintConventionPlugin.kt
@@ -0,0 +1,19 @@
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+
+/**
+ * Plugin that applies the Ktlint plugin and configures it.
+ */
+class KtlintConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ applyPlugins()
+ }
+ }
+
+ private fun Project.applyPlugins() {
+ pluginManager.apply {
+ apply("org.jlleitschuh.gradle.ktlint")
+ }
+ }
+}
\ No newline at end of file
diff --git a/build-logic/convention/src/main/kotlin/org/mifos/mobile/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/org/mifos/mobile/AndroidCompose.kt
index fe183141bd..45fe257e1c 100644
--- a/build-logic/convention/src/main/kotlin/org/mifos/mobile/AndroidCompose.kt
+++ b/build-logic/convention/src/main/kotlin/org/mifos/mobile/AndroidCompose.kt
@@ -31,6 +31,12 @@ internal fun Project.configureAndroidCompose(
unitTests {
// For Robolectric
isIncludeAndroidResources = true
+
+ isReturnDefaultValues = true
+
+ all {
+ it.systemProperties["robolectric.pixelCopyRenderMode"] = "hardware"
+ }
}
}
}
diff --git a/build-logic/convention/src/main/kotlin/org/mifos/mobile/Detekt.kt b/build-logic/convention/src/main/kotlin/org/mifos/mobile/Detekt.kt
index ed5e3465dd..196f3bf42c 100644
--- a/build-logic/convention/src/main/kotlin/org/mifos/mobile/Detekt.kt
+++ b/build-logic/convention/src/main/kotlin/org/mifos/mobile/Detekt.kt
@@ -8,6 +8,7 @@ import org.gradle.kotlin.dsl.named
internal fun Project.configureDetekt(extension: DetektExtension) = extension.apply {
tasks.named("detekt") {
+ mustRunAfter(":cmp-android:dependencyGuard")
jvmTarget = "21"
source(files(rootDir))
include("**/*.kt")
@@ -18,6 +19,7 @@ internal fun Project.configureDetekt(extension: DetektExtension) = extension.app
exclude("**/build-logic/**")
exclude("**/spotless/**")
exclude("core-base/designsystem/**")
+ exclude("feature/home/**")
reports {
xml.required.set(true)
html.required.set(true)
diff --git a/build-logic/convention/src/main/kotlin/org/mifos/mobile/Jacoco.kt b/build-logic/convention/src/main/kotlin/org/mifos/mobile/Jacoco.kt
index 71d29401d0..7aa12bd968 100644
--- a/build-logic/convention/src/main/kotlin/org/mifos/mobile/Jacoco.kt
+++ b/build-logic/convention/src/main/kotlin/org/mifos/mobile/Jacoco.kt
@@ -4,10 +4,12 @@ package org.mifos.mobile
import com.android.build.api.artifact.ScopedArtifact
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.ScopedArtifacts
+import com.android.build.api.variant.SourceDirectories
import org.gradle.api.Project
import org.gradle.api.file.Directory
import org.gradle.api.file.RegularFile
import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Provider
import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.register
@@ -68,8 +70,16 @@ internal fun Project.configureJacoco(
html.required.set(true)
}
- // TODO: This is missing files in src/debug/, src/prod, src/demo, src/demoDebug...
- sourceDirectories.setFrom(files("$projectDir/src/main/java", "$projectDir/src/main/kotlin"))
+ fun SourceDirectories.Flat?.toFilePaths(): Provider> = this
+ ?.all
+ ?.map { directories -> directories.map { it.asFile.path } }
+ ?: provider { emptyList() }
+ sourceDirectories.setFrom(
+ files(
+ "$projectDir/src/main/java",
+ "$projectDir/src/main/kotlin"
+ )
+ )
executionData.setFrom(
project.fileTree("$buildDir/outputs/unit_test_code_coverage/${variant.name}UnitTest")
diff --git a/build-logic/convention/src/main/kotlin/org/mifos/mobile/KotlinMultiplatform.kt b/build-logic/convention/src/main/kotlin/org/mifos/mobile/KotlinMultiplatform.kt
index 8337939845..5d6b7268f0 100644
--- a/build-logic/convention/src/main/kotlin/org/mifos/mobile/KotlinMultiplatform.kt
+++ b/build-logic/convention/src/main/kotlin/org/mifos/mobile/KotlinMultiplatform.kt
@@ -27,6 +27,8 @@ internal fun Project.configureKotlinMultiplatform() {
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
+ freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn")
+ freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
}
}
}
\ No newline at end of file
diff --git a/ci-prepush.sh b/ci-prepush.sh
index ada49c9473..14d83940f3 100755
--- a/ci-prepush.sh
+++ b/ci-prepush.sh
@@ -28,6 +28,8 @@ tasks=(
"spotlessApply --no-configuration-cache"
"dependencyGuardBaseline"
"detekt"
+ ":cmp-android:build"
+ ":cmp-android:updateProdReleaseBadging"
)
for task in "${tasks[@]}"; do
diff --git a/cmp-android/build.gradle.kts b/cmp-android/build.gradle.kts
index 204e41b68f..86d4ed5393 100644
--- a/cmp-android/build.gradle.kts
+++ b/cmp-android/build.gradle.kts
@@ -103,8 +103,13 @@ dependencyGuard {
dependencies {
implementation(projects.cmpShared)
-
implementation(projects.core.ui)
+ implementation(projects.coreBase.ui)
+ implementation(projects.coreBase.analytics)
+
+ implementation(projects.core.model)
+ implementation(projects.core.data)
+ implementation(projects.core.datastore)
implementation(projects.coreBase.platform)
diff --git a/cmp-android/dependencies/demoDebugRuntimeClasspath.txt b/cmp-android/dependencies/demoDebugRuntimeClasspath.txt
index acd2ed4856..d1fd532f4d 100644
--- a/cmp-android/dependencies/demoDebugRuntimeClasspath.txt
+++ b/cmp-android/dependencies/demoDebugRuntimeClasspath.txt
@@ -252,6 +252,14 @@ de.jensklingenberg.ktorfit:ktorfit-lib:2.5.2
dev.chrisbanes.material3:material3-window-size-class-multiplatform-android:0.5.0
dev.chrisbanes.material3:material3-window-size-class-multiplatform:0.5.0
dev.chrisbanes.snapper:snapper:0.2.2
+dev.gitlive:firebase-analytics-android-debug:2.1.0
+dev.gitlive:firebase-analytics:2.1.0
+dev.gitlive:firebase-app-android-debug:2.1.0
+dev.gitlive:firebase-app:2.1.0
+dev.gitlive:firebase-common-android-debug:2.1.0
+dev.gitlive:firebase-common:2.1.0
+dev.gitlive:firebase-crashlytics-android-debug:2.1.0
+dev.gitlive:firebase-crashlytics:2.1.0
io.coil-kt.coil3:coil-android:3.2.0
io.coil-kt.coil3:coil-compose-android:3.2.0
io.coil-kt.coil3:coil-compose-core-android:3.2.0
@@ -292,6 +300,8 @@ io.github.panpf.zoomimage:zoomimage-core-android:1.3.0
io.github.panpf.zoomimage:zoomimage-core-coil3-android:1.3.0
io.github.panpf.zoomimage:zoomimage-core-coil3:1.3.0
io.github.panpf.zoomimage:zoomimage-core:1.3.0
+io.github.vinceglb:filekit-coil-android:0.10.0-beta04
+io.github.vinceglb:filekit-coil:0.10.0-beta04
io.github.vinceglb:filekit-compose-android:0.8.8
io.github.vinceglb:filekit-compose:0.8.8
io.github.vinceglb:filekit-core-android:0.10.0-beta04
diff --git a/cmp-android/dependencies/demoReleaseRuntimeClasspath.txt b/cmp-android/dependencies/demoReleaseRuntimeClasspath.txt
index 5867bd9857..d78d3f00b5 100644
--- a/cmp-android/dependencies/demoReleaseRuntimeClasspath.txt
+++ b/cmp-android/dependencies/demoReleaseRuntimeClasspath.txt
@@ -252,6 +252,14 @@ de.jensklingenberg.ktorfit:ktorfit-lib:2.5.2
dev.chrisbanes.material3:material3-window-size-class-multiplatform-android:0.5.0
dev.chrisbanes.material3:material3-window-size-class-multiplatform:0.5.0
dev.chrisbanes.snapper:snapper:0.2.2
+dev.gitlive:firebase-analytics-android:2.1.0
+dev.gitlive:firebase-analytics:2.1.0
+dev.gitlive:firebase-app-android:2.1.0
+dev.gitlive:firebase-app:2.1.0
+dev.gitlive:firebase-common-android:2.1.0
+dev.gitlive:firebase-common:2.1.0
+dev.gitlive:firebase-crashlytics-android:2.1.0
+dev.gitlive:firebase-crashlytics:2.1.0
io.coil-kt.coil3:coil-android:3.2.0
io.coil-kt.coil3:coil-compose-android:3.2.0
io.coil-kt.coil3:coil-compose-core-android:3.2.0
@@ -292,6 +300,8 @@ io.github.panpf.zoomimage:zoomimage-core-android:1.3.0
io.github.panpf.zoomimage:zoomimage-core-coil3-android:1.3.0
io.github.panpf.zoomimage:zoomimage-core-coil3:1.3.0
io.github.panpf.zoomimage:zoomimage-core:1.3.0
+io.github.vinceglb:filekit-coil-android:0.10.0-beta04
+io.github.vinceglb:filekit-coil:0.10.0-beta04
io.github.vinceglb:filekit-compose-android:0.8.8
io.github.vinceglb:filekit-compose:0.8.8
io.github.vinceglb:filekit-core-android:0.10.0-beta04
diff --git a/cmp-android/dependencies/prodDebugRuntimeClasspath.txt b/cmp-android/dependencies/prodDebugRuntimeClasspath.txt
index acd2ed4856..d1fd532f4d 100644
--- a/cmp-android/dependencies/prodDebugRuntimeClasspath.txt
+++ b/cmp-android/dependencies/prodDebugRuntimeClasspath.txt
@@ -252,6 +252,14 @@ de.jensklingenberg.ktorfit:ktorfit-lib:2.5.2
dev.chrisbanes.material3:material3-window-size-class-multiplatform-android:0.5.0
dev.chrisbanes.material3:material3-window-size-class-multiplatform:0.5.0
dev.chrisbanes.snapper:snapper:0.2.2
+dev.gitlive:firebase-analytics-android-debug:2.1.0
+dev.gitlive:firebase-analytics:2.1.0
+dev.gitlive:firebase-app-android-debug:2.1.0
+dev.gitlive:firebase-app:2.1.0
+dev.gitlive:firebase-common-android-debug:2.1.0
+dev.gitlive:firebase-common:2.1.0
+dev.gitlive:firebase-crashlytics-android-debug:2.1.0
+dev.gitlive:firebase-crashlytics:2.1.0
io.coil-kt.coil3:coil-android:3.2.0
io.coil-kt.coil3:coil-compose-android:3.2.0
io.coil-kt.coil3:coil-compose-core-android:3.2.0
@@ -292,6 +300,8 @@ io.github.panpf.zoomimage:zoomimage-core-android:1.3.0
io.github.panpf.zoomimage:zoomimage-core-coil3-android:1.3.0
io.github.panpf.zoomimage:zoomimage-core-coil3:1.3.0
io.github.panpf.zoomimage:zoomimage-core:1.3.0
+io.github.vinceglb:filekit-coil-android:0.10.0-beta04
+io.github.vinceglb:filekit-coil:0.10.0-beta04
io.github.vinceglb:filekit-compose-android:0.8.8
io.github.vinceglb:filekit-compose:0.8.8
io.github.vinceglb:filekit-core-android:0.10.0-beta04
diff --git a/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt b/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt
index 5867bd9857..d78d3f00b5 100644
--- a/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt
+++ b/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt
@@ -252,6 +252,14 @@ de.jensklingenberg.ktorfit:ktorfit-lib:2.5.2
dev.chrisbanes.material3:material3-window-size-class-multiplatform-android:0.5.0
dev.chrisbanes.material3:material3-window-size-class-multiplatform:0.5.0
dev.chrisbanes.snapper:snapper:0.2.2
+dev.gitlive:firebase-analytics-android:2.1.0
+dev.gitlive:firebase-analytics:2.1.0
+dev.gitlive:firebase-app-android:2.1.0
+dev.gitlive:firebase-app:2.1.0
+dev.gitlive:firebase-common-android:2.1.0
+dev.gitlive:firebase-common:2.1.0
+dev.gitlive:firebase-crashlytics-android:2.1.0
+dev.gitlive:firebase-crashlytics:2.1.0
io.coil-kt.coil3:coil-android:3.2.0
io.coil-kt.coil3:coil-compose-android:3.2.0
io.coil-kt.coil3:coil-compose-core-android:3.2.0
@@ -292,6 +300,8 @@ io.github.panpf.zoomimage:zoomimage-core-android:1.3.0
io.github.panpf.zoomimage:zoomimage-core-coil3-android:1.3.0
io.github.panpf.zoomimage:zoomimage-core-coil3:1.3.0
io.github.panpf.zoomimage:zoomimage-core:1.3.0
+io.github.vinceglb:filekit-coil-android:0.10.0-beta04
+io.github.vinceglb:filekit-coil:0.10.0-beta04
io.github.vinceglb:filekit-compose-android:0.8.8
io.github.vinceglb:filekit-compose:0.8.8
io.github.vinceglb:filekit-core-android:0.10.0-beta04
diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt
index 0314444d1f..4e107ddba0 100644
--- a/cmp-android/prodRelease-badging.txt
+++ b/cmp-android/prodRelease-badging.txt
@@ -1,12 +1,11 @@
-package: name='org.mifos.mobile' versionCode='1' versionName='2024.12.4-beta.0.4' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15'
-sdkVersion:'26'
-targetSdkVersion:'34'
+package: name='org.mifos.mobile' versionCode='1' versionName='0.0.1-beta.0.1813' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15'
+minSdkVersion:'26'
+targetSdkVersion:'36'
uses-permission: name='android.permission.INTERNET'
uses-permission: name='android.permission.CAMERA'
+uses-permission: name='android.permission.VIBRATE'
uses-permission: name='android.permission.READ_EXTERNAL_STORAGE' maxSdkVersion='32'
uses-permission: name='android.permission.WRITE_EXTERNAL_STORAGE' maxSdkVersion='32'
-uses-permission: name='android.permission.VIBRATE'
-uses-permission: name='android.permission.FLASHLIGHT'
uses-permission: name='android.permission.ACCESS_NETWORK_STATE'
uses-permission: name='android.permission.WAKE_LOCK'
uses-permission: name='com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE'
@@ -33,11 +32,12 @@ application-label-en-CA:'Mifos Mobile'
application-label-en-GB:'Mifos Mobile'
application-label-en-IN:'Mifos Mobile'
application-label-en-XC:'Mifos Mobile'
-application-label-es:'Mifos Mobile'
-application-label-es-US:'Mifos Mobile'
+application-label-es:'Mifos MΓ³vil'
+application-label-es-US:'Mifos MΓ³vil'
application-label-et:'Mifos Mobile'
application-label-eu:'Mifos Mobile'
application-label-fa:'Mifos Mobile'
+application-label-fa-AF:'Mifos Mobile'
application-label-fi:'Mifos Mobile'
application-label-fr:'Mifos Mobile'
application-label-fr-CA:'Mifos Mobile'
@@ -106,24 +106,21 @@ application-icon-480:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-640:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-65534:'res/mipmap-anydpi-v26/ic_launcher.xml'
application: label='Mifos Mobile' icon='res/mipmap-anydpi-v26/ic_launcher.xml'
-launchable-activity: name='org.mifospay.MainActivity' label='' icon=''
-property: name='android.adservices.AD_SERVICES_CONFIG' resource='res/xml/ga_ad_services_config.xml'
+launchable-activity: name='cmp.android.app.MainActivity' label='' icon=''
+uses-library-not-required:'org.apache.http.legacy'
uses-library-not-required:'androidx.window.extensions'
uses-library-not-required:'androidx.window.sidecar'
uses-library-not-required:'android.ext.adservices'
feature-group: label=''
- uses-feature: name='android.hardware.camera'
- uses-feature: name='android.hardware.camera.autofocus'
- uses-feature: name='android.hardware.faketouch'
- uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps'
- uses-feature: name='android.hardware.screen.portrait'
- uses-implied-feature: name='android.hardware.screen.portrait' reason='one or more activities have specified a portrait orientation'
+ uses-gl-es: '0x20000'
+ uses-feature-not-required: name='android.hardware.camera'
+ uses-feature-not-required: name='android.hardware.touchscreen'
main
other-activities
other-receivers
other-services
supports-screens: 'small' 'normal' 'large' 'xlarge'
supports-any-density: 'true'
-locales: '--_--' 'af' 'am' 'ar' 'as' 'az' 'be' 'bg' 'bn' 'bs' 'ca' 'cs' 'da' 'de' 'el' 'en-AU' 'en-CA' 'en-GB' 'en-IN' 'en-XC' 'es' 'es-US' 'et' 'eu' 'fa' 'fi' 'fr' 'fr-CA' 'gl' 'gu' 'hi' 'hr' 'hu' 'hy' 'in' 'is' 'it' 'iw' 'ja' 'ka' 'kk' 'km' 'kn' 'ko' 'ky' 'lo' 'lt' 'lv' 'mk' 'ml' 'mn' 'mr' 'ms' 'my' 'nb' 'ne' 'nl' 'or' 'pa' 'pl' 'pt' 'pt-BR' 'pt-PT' 'ro' 'ru' 'si' 'sk' 'sl' 'sq' 'sr' 'sr-Latn' 'sv' 'sw' 'ta' 'te' 'th' 'tl' 'tr' 'uk' 'ur' 'uz' 'vi' 'zh-CN' 'zh-HK' 'zh-TW' 'zu'
+locales: '--_--' 'af' 'am' 'ar' 'as' 'az' 'be' 'bg' 'bn' 'bs' 'ca' 'cs' 'da' 'de' 'el' 'en-AU' 'en-CA' 'en-GB' 'en-IN' 'en-XC' 'es' 'es-US' 'et' 'eu' 'fa' 'fa-AF' 'fi' 'fr' 'fr-CA' 'gl' 'gu' 'hi' 'hr' 'hu' 'hy' 'in' 'is' 'it' 'iw' 'ja' 'ka' 'kk' 'km' 'kn' 'ko' 'ky' 'lo' 'lt' 'lv' 'mk' 'ml' 'mn' 'mr' 'ms' 'my' 'nb' 'ne' 'nl' 'or' 'pa' 'pl' 'pt' 'pt-BR' 'pt-PT' 'ro' 'ru' 'si' 'sk' 'sl' 'sq' 'sr' 'sr-Latn' 'sv' 'sw' 'ta' 'te' 'th' 'tl' 'tr' 'uk' 'ur' 'uz' 'vi' 'zh-CN' 'zh-HK' 'zh-TW' 'zu'
densities: '160' '240' '320' '480' '640' '65534'
native-code: 'arm64-v8a' 'armeabi-v7a' 'x86' 'x86_64'
diff --git a/cmp-android/src/main/kotlin/cmp/android/app/BuildConfigUtils.kt b/cmp-android/src/main/kotlin/cmp/android/app/BuildConfigUtils.kt
new file mode 100644
index 0000000000..c65bbab1e0
--- /dev/null
+++ b/cmp-android/src/main/kotlin/cmp/android/app/BuildConfigUtils.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
+ */
+package cmp.android.app
+
+import android.os.Build
+
+/**
+ * A boolean property that indicates whether the current build is a dev build.
+ */
+val isDevBuild: Boolean
+ get() = BuildConfig.BUILD_TYPE == "debug"
+
+/**
+ * A string that represents a displayable app version.
+ */
+val versionData: String
+ get() = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
+
+/**
+ * A string that represents device data.
+ */
+val deviceData: String get() = "$deviceBrandModel $osInfo $buildInfo"
+
+/**
+ * A string representing the build flavor or blank if it is the standard configuration.
+ */
+private val buildFlavorName: String
+ get() = when (BuildConfig.FLAVOR) {
+ "demo" -> ""
+ else -> "-${BuildConfig.FLAVOR}"
+ }
+
+/**
+ * A string representing the build type.
+ */
+private val buildTypeName: String
+ get() = when (BuildConfig.BUILD_TYPE) {
+ "debug" -> "dev"
+ "release" -> "prod"
+ else -> BuildConfig.BUILD_TYPE
+ }
+
+/**
+ * A string representing the device brand and model.
+ */
+private val deviceBrandModel: String get() = "\uD83D\uDCF1 ${Build.BRAND} ${Build.MODEL}"
+
+/**
+ * A string representing the operating system information.
+ */
+private val osInfo: String get() = "\uD83E\uDD16 ${Build.VERSION.RELEASE}@${Build.VERSION.SDK_INT}"
+
+/**
+ * A string representing the build information.
+ */
+private val buildInfo: String
+ get() = "\uD83D\uDCE6 $buildTypeName" +
+ buildFlavorName.takeUnless { it.isBlank() }?.let { " $it" }.orEmpty()
diff --git a/cmp-android/src/main/kotlin/cmp/android/app/ConfigurationExtension.kt b/cmp-android/src/main/kotlin/cmp/android/app/ConfigurationExtension.kt
new file mode 100644
index 0000000000..5436afca97
--- /dev/null
+++ b/cmp-android/src/main/kotlin/cmp/android/app/ConfigurationExtension.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
+ */
+package cmp.android.app
+
+import android.content.res.Configuration
+
+val Configuration.isSystemInDarkMode
+ get() = (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
diff --git a/cmp-android/src/main/kotlin/cmp/android/app/MainActivity.kt b/cmp-android/src/main/kotlin/cmp/android/app/MainActivity.kt
index 1cd7e025a0..c15d7ef502 100644
--- a/cmp-android/src/main/kotlin/cmp/android/app/MainActivity.kt
+++ b/cmp-android/src/main/kotlin/cmp/android/app/MainActivity.kt
@@ -9,7 +9,9 @@
*/
package cmp.android.app
+import android.content.res.Resources
import android.os.Bundle
+import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
@@ -23,18 +25,18 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.koin.android.ext.android.inject
import org.mifos.mobile.core.datastore.UserPreferencesRepository
-import org.mifos.mobile.core.ui.utils.ShareUtils
-import template.core.base.platform.LocalManagerProvider
+import template.core.base.ui.ShareUtils
import java.util.Locale
import kotlin.getValue
/**
- * Main activity class.
- * This class is used to set the content view of the activity.
+ * Main activity class. This class is used to set the content view of the
+ * activity.
*
* @constructor Create empty Main activity
- * @see AppCompatActivity
+ * @see ComponentActivity
*/
+@Suppress("UnusedPrivateProperty")
class MainActivity : AppCompatActivity() {
/**
* Called when the activity is starting.
@@ -64,30 +66,38 @@ class MainActivity : AppCompatActivity() {
* @see setContent
*/
setContent {
- LocalManagerProvider(context = this) {
- SharedApp(
- handleThemeMode = {
- AppCompatDelegate.setDefaultNightMode(it)
- },
- handleAppLocale = {
- if (it.isNullOrBlank()) {
- AppCompatDelegate.setApplicationLocales(
- LocaleListCompat.getEmptyLocaleList(),
- )
+ SharedApp(
+ handleThemeMode = {
+ AppCompatDelegate.setDefaultNightMode(it)
+ },
+ handleAppLocale = { localeTag ->
+ val currentLocales = AppCompatDelegate.getApplicationLocales()
+ val newLocales = if (localeTag != null) {
+ LocaleListCompat.forLanguageTags(localeTag)
+ } else {
+ // System Default: clear app-specific locale
+ LocaleListCompat.getEmptyLocaleList()
+ }
+
+ // Only update if the locale has actually changed
+ if (currentLocales != newLocales) {
+ AppCompatDelegate.setApplicationLocales(newLocales)
+ // Update Locale.setDefault for non-UI formatting
+ if (localeTag != null) {
+ // Use forLanguageTag to properly parse locales like "en-GB", "pt-BR"
+ Locale.setDefault(Locale.forLanguageTag(localeTag))
} else {
- AppCompatDelegate.setApplicationLocales(
- LocaleListCompat.forLanguageTags(
- it,
- ),
- )
- Locale.setDefault(Locale(it))
+ // Reset to true system default locale from device configuration
+ // Use Resources.getSystem() to get device locale unaffected by app overrides
+ val systemLocale = Resources.getSystem().configuration.locales[0]
+ Locale.setDefault(systemLocale)
}
- },
- onSplashScreenRemoved = {
- shouldShowSplashScreen = false
- },
- )
- }
+ }
+ },
+ onSplashScreenRemoved = {
+ shouldShowSplashScreen = false
+ },
+ )
}
}
}
diff --git a/cmp-android/src/prod/AndroidManifest.xml b/cmp-android/src/prod/AndroidManifest.xml
new file mode 100644
index 0000000000..045bc956c5
--- /dev/null
+++ b/cmp-android/src/prod/AndroidManifest.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cmp-desktop/src/jvmMain/kotlin/main.kt b/cmp-desktop/src/jvmMain/kotlin/main.kt
index b6b1cb530f..6b727443da 100644
--- a/cmp-desktop/src/jvmMain/kotlin/main.kt
+++ b/cmp-desktop/src/jvmMain/kotlin/main.kt
@@ -45,6 +45,7 @@ fun main() {
// Creates a window state to manage the window's state.
val windowState = rememberWindowState()
+ // State to trigger recomposition when locale changes
var localeVersion by remember { mutableStateOf(0) }
// Creates a window with a specified title and close request handler.
@@ -80,4 +81,3 @@ fun main() {
}
}
}
-
diff --git a/cmp-ios/Configuration/Config.xcconfig b/cmp-ios/Configuration/Config.xcconfig
index ce3c249aef..4436d8acee 100644
--- a/cmp-ios/Configuration/Config.xcconfig
+++ b/cmp-ios/Configuration/Config.xcconfig
@@ -1,3 +1,3 @@
TEAM_ID=L432S2FZP5
BUNDLE_ID=org.mifos.mobile
-APP_NAME=Mifos Mobile
+APP_NAME=Mifos Mobile
\ No newline at end of file
diff --git a/cmp-ios/Podfile b/cmp-ios/Podfile
index 0f05686a76..fb7d97ce0b 100644
--- a/cmp-ios/Podfile
+++ b/cmp-ios/Podfile
@@ -1,21 +1,36 @@
-deployment_target = '16.0'
+source 'https://cdn.cocoapods.org'
+platform :ios, '16.0'
+use_frameworks!
target 'iosApp' do
- use_frameworks!
- platform :ios, deployment_target
- # Pods for iosApp
+ project 'iosApp.xcodeproj'
pod 'cmp_shared', :path => '../cmp-shared'
+
+ # Native Firebase iOS Pods required by firebase-kotlin-sdk
+ pod 'FirebaseCore'
+ pod 'FirebaseAnalytics'
+ pod 'FirebaseCrashlytics'
end
post_install do |installer|
- installer.generated_projects.each do |project|
- project.targets.each do |target|
- target.build_configurations.each do |config|
- config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = deployment_target
- end
- end
- project.build_configurations.each do |config|
- config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = deployment_target
- end
+ installer.pods_project.targets.each do |target|
+ target.build_configurations.each do |config|
+ # Disable code signing for Pod targets
+ config.build_settings['CODE_SIGN_IDENTITY'] = ''
+ config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
+ config.build_settings['CODE_SIGNING_REQUIRED'] = 'NO'
+ config.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = ''
+
+ # Remove provisioning profile settings from Pod targets
+ config.build_settings.delete('PROVISIONING_PROFILE_SPECIFIER')
+ config.build_settings.delete('PROVISIONING_PROFILE')
+ config.build_settings.delete('DEVELOPMENT_TEAM')
+
+ # Fix deployment target warnings
+ deployment_target = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET']
+ if deployment_target && deployment_target.to_f < 12.0
+ config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0'
+ end
end
-end
\ No newline at end of file
+ end
+end
diff --git a/cmp-ios/iosApp.xcodeproj/project.pbxproj b/cmp-ios/iosApp.xcodeproj/project.pbxproj
index 1cbac6655b..5e27706fc5 100644
--- a/cmp-ios/iosApp.xcodeproj/project.pbxproj
+++ b/cmp-ios/iosApp.xcodeproj/project.pbxproj
@@ -9,22 +9,22 @@
/* Begin PBXBuildFile section */
058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; };
058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; };
- 1C3FE2006C77932769810076 /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6DF4DB1CF5E68C5614135A56 /* Pods_iosApp.framework */; };
2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; };
+ 73867D86E599B875F7561EBD /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8B63CDEE6A83A279F370FDF /* Pods_iosApp.framework */; };
7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
- 1EB8E354CA5D35F960D11D5D /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; };
2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; };
- 6DF4DB1CF5E68C5614135A56 /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; };
- 7555FF7B242A565900829871 /* Mifos Mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mifos Mobile.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 7555FF7B242A565900829871 /* LiteDo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LiteDo.app; sourceTree = BUILT_PRODUCTS_DIR; };
7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
- 8DADAFB5E75F5E24CA4F0EB4 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; };
AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; };
+ B5AB1CF32380D00920DC66AD /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; };
+ F8B63CDEE6A83A279F370FDF /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ FCFBF765CF1AE0CE66538ADF /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -32,7 +32,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- 1C3FE2006C77932769810076 /* Pods_iosApp.framework in Frameworks */,
+ 73867D86E599B875F7561EBD /* Pods_iosApp.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -47,19 +47,10 @@
path = "Preview Content";
sourceTree = "";
};
- 1826950F674A72E12E219090 /* Pods */ = {
- isa = PBXGroup;
- children = (
- 8DADAFB5E75F5E24CA4F0EB4 /* Pods-iosApp.debug.xcconfig */,
- 1EB8E354CA5D35F960D11D5D /* Pods-iosApp.release.xcconfig */,
- );
- path = Pods;
- sourceTree = "";
- };
42799AB246E5F90AF97AA0EF /* Frameworks */ = {
isa = PBXGroup;
children = (
- 6DF4DB1CF5E68C5614135A56 /* Pods_iosApp.framework */,
+ F8B63CDEE6A83A279F370FDF /* Pods_iosApp.framework */,
);
name = Frameworks;
sourceTree = "";
@@ -71,14 +62,14 @@
7555FF7D242A565900829871 /* iosApp */,
7555FF7C242A565900829871 /* Products */,
42799AB246E5F90AF97AA0EF /* Frameworks */,
- 1826950F674A72E12E219090 /* Pods */,
+ AF5B1102F079B671D1EB1074 /* Pods */,
);
sourceTree = "";
};
7555FF7C242A565900829871 /* Products */ = {
isa = PBXGroup;
children = (
- 7555FF7B242A565900829871 /* Mifos Mobile.app */,
+ 7555FF7B242A565900829871 /* LiteDo.app */,
);
name = Products;
sourceTree = "";
@@ -103,6 +94,15 @@
path = Configuration;
sourceTree = "";
};
+ AF5B1102F079B671D1EB1074 /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ B5AB1CF32380D00920DC66AD /* Pods-iosApp.debug.xcconfig */,
+ FCFBF765CF1AE0CE66538ADF /* Pods-iosApp.release.xcconfig */,
+ );
+ path = Pods;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -110,11 +110,12 @@
isa = PBXNativeTarget;
buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */;
buildPhases = (
- D005710AF42AFBD3373CBB90 /* [CP] Check Pods Manifest.lock */,
+ C0BDCDFA5D651BAABC1D1A96 /* [CP] Check Pods Manifest.lock */,
7555FF77242A565900829871 /* Sources */,
B92378962B6B1156000C7307 /* Frameworks */,
7555FF79242A565900829871 /* Resources */,
- 3843B476B28208558ACE8C15 /* [CP] Copy Pods Resources */,
+ F3CE6A35EE97CC26402CBE37 /* [CP] Embed Pods Frameworks */,
+ 087C63914A01F15B2F3377F1 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -122,7 +123,7 @@
);
name = iosApp;
productName = iosApp;
- productReference = 7555FF7B242A565900829871 /* Mifos Mobile.app */;
+ productReference = 7555FF7B242A565900829871 /* LiteDo.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@@ -172,7 +173,7 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
- 3843B476B28208558ACE8C15 /* [CP] Copy Pods Resources */ = {
+ 087C63914A01F15B2F3377F1 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -189,7 +190,7 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n";
showEnvVarsInLog = 0;
};
- D005710AF42AFBD3373CBB90 /* [CP] Check Pods Manifest.lock */ = {
+ C0BDCDFA5D651BAABC1D1A96 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -211,6 +212,23 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
+ F3CE6A35EE97CC26402CBE37 /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -350,7 +368,7 @@
};
7555FFA6242A565B00829871 /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 8DADAFB5E75F5E24CA4F0EB4 /* Pods-iosApp.debug.xcconfig */;
+ baseConfigurationReference = B5AB1CF32380D00920DC66AD /* Pods-iosApp.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
@@ -363,15 +381,17 @@
"$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
);
INFOPLIST_FILE = iosApp/Info.plist;
- INFOPLIST_KEY_CFBundleDisplayName = "Mifos Mobile";
- INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
+ INFOPLIST_KEY_CFBundleDisplayName = LiteDo;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
+ CURRENT_PROJECT_VERSION = 47;
PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
+ "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = org.mifos.kmp.template;
PRODUCT_NAME = "${APP_NAME}";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
@@ -381,28 +401,31 @@
};
7555FFA7242A565B00829871 /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 1EB8E354CA5D35F960D11D5D /* Pods-iosApp.release.xcconfig */;
+ baseConfigurationReference = FCFBF765CF1AE0CE66538ADF /* Pods-iosApp.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
- DEVELOPMENT_TEAM = "${TEAM_ID}";
+ DEVELOPMENT_TEAM = L432S2FZP5;
ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
- "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
+ "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
+ "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
);
INFOPLIST_FILE = iosApp/Info.plist;
- INFOPLIST_KEY_CFBundleDisplayName = "Mifos Mobile";
- INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
+ INFOPLIST_KEY_CFBundleDisplayName = LiteDo;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
+ CURRENT_PROJECT_VERSION = 47;
PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
+ "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = org.mifos.kmp.template;
PRODUCT_NAME = "${APP_NAME}";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
diff --git a/cmp-ios/iosApp.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/cmp-ios/iosApp.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000000..0c67376eba
--- /dev/null
+++ b/cmp-ios/iosApp.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/cmp-ios/iosApp/ContentView.swift b/cmp-ios/iosApp/ContentView.swift
index e130b8a2e4..63b9da7e03 100644
--- a/cmp-ios/iosApp/ContentView.swift
+++ b/cmp-ios/iosApp/ContentView.swift
@@ -13,7 +13,8 @@ struct ComposeView: UIViewControllerRepresentable {
struct ContentView: View {
var body: some View {
ComposeView()
- .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
+ .ignoresSafeArea(edges: .all)
+ .ignoresSafeArea(.keyboard) // .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
}
}
diff --git a/cmp-ios/iosApp/Info.plist b/cmp-ios/iosApp/Info.plist
index 597a03ea9f..87d3ab6368 100644
--- a/cmp-ios/iosApp/Info.plist
+++ b/cmp-ios/iosApp/Info.plist
@@ -17,19 +17,21 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 1.0.0
+ $(MARKETING_VERSION)
CFBundleVersion
- 4
+ $(CURRENT_PROJECT_VERSION)
ITSAppUsesNonExemptEncryption
LSRequiresIPhoneOS
- NSCameraUsageDescription
- We use the camera to scan QR codes for payments and to add beneficiaries. No images or video are stored.
+ NSBluetoothAlwaysUsageDescription
+ This app does not use Bluetooth. This message is required for compliance only.
+ NSContactsUsageDescription
+ This app does not access your contacts. This message is required for compliance only.
+ NSLocationWhenInUseUsageDescription
+ This app does not access your location. This message is required for compliance only.
NSPhotoLibraryAddUsageDescription
- Allow access to choose a photo or document you decide to upload (e.g., profile photo or ID).
- NSPhotoLibraryUsageDescription
- Allow access to choose a photo or document you decide to upload (e.g., profile photo or ID).
+ Allow access to add photos to your library so you can save artworks directly to your device and view them offline.
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
diff --git a/cmp-navigation/build.gradle.kts b/cmp-navigation/build.gradle.kts
index 74681bf297..e92f859127 100644
--- a/cmp-navigation/build.gradle.kts
+++ b/cmp-navigation/build.gradle.kts
@@ -44,6 +44,10 @@ kotlin {
implementation(projects.core.data)
implementation(projects.core.common)
implementation(projects.core.network)
+ implementation(projects.core.model)
+ implementation(projects.core.datastore)
+ implementation(projects.coreBase.common)
+ implementation(projects.coreBase.platform)
implementation(projects.libs.mifosPasscode)
//put your multiplatform dependencies here
implementation(compose.material3)
diff --git a/cmp-shared/build.gradle.kts b/cmp-shared/build.gradle.kts
index 39dec004c3..eb624fd359 100644
--- a/cmp-shared/build.gradle.kts
+++ b/cmp-shared/build.gradle.kts
@@ -24,8 +24,12 @@ kotlin {
// Navigation Modules
implementation(projects.cmpNavigation)
implementation(compose.components.resources)
+ implementation(projects.coreBase.platform)
+ implementation(projects.coreBase.ui)
api(projects.core.data)
api(projects.core.network)
+ implementation(libs.coil.kt.compose)
+
//put your multiplatform dependencies here
implementation(compose.material)
implementation(compose.material3)
diff --git a/cmp-shared/src/commonMain/kotlin/cmp/shared/SharedApp.kt b/cmp-shared/src/commonMain/kotlin/cmp/shared/SharedApp.kt
index aefea2f802..b070047b54 100644
--- a/cmp-shared/src/commonMain/kotlin/cmp/shared/SharedApp.kt
+++ b/cmp-shared/src/commonMain/kotlin/cmp/shared/SharedApp.kt
@@ -12,18 +12,27 @@ package cmp.shared
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import cmp.navigation.ComposeApp
+import coil3.compose.LocalPlatformContext
+import template.core.base.platform.LocalManagerProvider
+import template.core.base.platform.context.LocalContext
+import template.core.base.ui.LocalImageLoaderProvider
+import template.core.base.ui.getDefaultImageLoader
@Composable
fun SharedApp(
handleThemeMode: (osValue: Int) -> Unit,
handleAppLocale: (locale: String?) -> Unit,
- onSplashScreenRemoved: () -> Unit,
modifier: Modifier = Modifier,
+ onSplashScreenRemoved: () -> Unit,
) {
- ComposeApp(
- handleThemeMode = handleThemeMode,
- handleAppLocale = handleAppLocale,
- onSplashScreenRemoved = onSplashScreenRemoved,
- modifier = modifier,
- )
+ LocalManagerProvider(LocalContext.current) {
+ LocalImageLoaderProvider(getDefaultImageLoader(LocalPlatformContext.current)) {
+ ComposeApp(
+ handleThemeMode = handleThemeMode,
+ handleAppLocale = handleAppLocale,
+ onSplashScreenRemoved = onSplashScreenRemoved,
+ modifier = modifier,
+ )
+ }
+ }
}
diff --git a/cmp-shared/src/commonMain/kotlin/cmp/shared/utils/KoinExt.kt b/cmp-shared/src/commonMain/kotlin/cmp/shared/utils/KoinExt.kt
index a92847b988..d5a794a774 100644
--- a/cmp-shared/src/commonMain/kotlin/cmp/shared/utils/KoinExt.kt
+++ b/cmp-shared/src/commonMain/kotlin/cmp/shared/utils/KoinExt.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2026 Mifos Initiative
+ * Copyright 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
diff --git a/cmp-shared/src/nativeMain/kotlin/org/mifos/shared/ViewController.kt b/cmp-shared/src/nativeMain/kotlin/org/mifos/shared/ViewController.kt
index ab1a019465..f7f0920c87 100644
--- a/cmp-shared/src/nativeMain/kotlin/org/mifos/shared/ViewController.kt
+++ b/cmp-shared/src/nativeMain/kotlin/org/mifos/shared/ViewController.kt
@@ -7,12 +7,16 @@
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
+@file:Suppress("UnusedPrivateMember")
+
package org.mifos.shared
import androidx.compose.ui.window.ComposeUIViewController
import cmp.shared.SharedApp
import cmp.shared.utils.initKoin
import platform.Foundation.NSUserDefaults
+import platform.UIKit.UIApplication
+import platform.UIKit.UIUserInterfaceStyle
fun viewController() = ComposeUIViewController(
configure = {
@@ -20,11 +24,23 @@ fun viewController() = ComposeUIViewController(
},
) {
SharedApp(
- handleThemeMode = {},
+ handleThemeMode = { osValue ->
+ val style = when (osValue) {
+ 1 -> UIUserInterfaceStyle.UIUserInterfaceStyleLight
+ 2 -> UIUserInterfaceStyle.UIUserInterfaceStyleDark
+ else -> UIUserInterfaceStyle.UIUserInterfaceStyleUnspecified
+ }
+ UIApplication.sharedApplication.keyWindow?.overrideUserInterfaceStyle = style
+ },
handleAppLocale = { languageTag ->
- applyIosLocale(
- languageTag = languageTag,
- )
+ if (languageTag != null) {
+ // Set specific language
+ NSUserDefaults.standardUserDefaults.setObject(listOf(languageTag), forKey = "AppleLanguages")
+ } else {
+ // System Default: remove app-specific language setting
+ NSUserDefaults.standardUserDefaults.removeObjectForKey("AppleLanguages")
+ }
+ NSUserDefaults.standardUserDefaults.synchronize()
},
onSplashScreenRemoved = {},
)
diff --git a/cmp-web/src/jsMain/kotlin/Application.kt b/cmp-web/src/jsMain/kotlin/Application.kt
index 68bdefc704..11bb563121 100644
--- a/cmp-web/src/jsMain/kotlin/Application.kt
+++ b/cmp-web/src/jsMain/kotlin/Application.kt
@@ -1,8 +1,15 @@
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import cmp.shared.SharedApp
import cmp.shared.utils.initKoin
import kotlinx.browser.document
+import kotlinx.browser.localStorage
+import kotlinx.browser.window
import org.jetbrains.skiko.wasm.onWasmReady
/*
@@ -18,13 +25,41 @@ fun main() {
initKoin() // Set up Koin for dependency injection.
+ // Apply stored language preference on startup
+ val storedLanguage = localStorage.getItem("app_language")
+ if (storedLanguage != null) {
+ document.documentElement?.setAttribute("lang", storedLanguage)
+ }
+
onWasmReady {
ComposeViewport(document.body!!) {
- SharedApp(
- handleThemeMode = {},
- handleAppLocale = {},
- onSplashScreenRemoved = {},
- )
+ // State to trigger recomposition when locale changes
+ var localeVersion by remember { mutableStateOf(0) }
+
+ // Use key() to force complete recomposition when locale changes
+ key(localeVersion) {
+ SharedApp(
+ handleThemeMode = {},
+ handleAppLocale = { languageTag ->
+ if (languageTag != null) {
+ // Store language preference in localStorage
+ localStorage.setItem("app_language", languageTag)
+ // Set HTML lang attribute for accessibility
+ document.documentElement?.setAttribute("lang", languageTag)
+ } else {
+ // System Default: remove stored language preference
+ localStorage.removeItem("app_language")
+ // Reset to browser's default language
+ val browserLang = window.navigator.language
+ document.documentElement?.setAttribute("lang", browserLang)
+ }
+ // Reload page to apply language changes (required for web)
+ // Note: This will reload the page, and locale selection depends on browser settings
+ // window.location.reload()
+ },
+ onSplashScreenRemoved = {}
+ )
+ }
}
}
}
diff --git a/cmp-web/src/wasmJsMain/kotlin/Main.kt b/cmp-web/src/wasmJsMain/kotlin/Main.kt
index e91410a38d..6ad9e613ff 100644
--- a/cmp-web/src/wasmJsMain/kotlin/Main.kt
+++ b/cmp-web/src/wasmJsMain/kotlin/Main.kt
@@ -1,8 +1,18 @@
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.CanvasBasedWindow
+import androidx.compose.ui.window.ComposeViewport
import cmp.shared.SharedApp
import cmp.shared.utils.initKoin
+import kotlinx.browser.document
+import kotlinx.browser.localStorage
+import kotlinx.browser.window
import org.jetbrains.compose.resources.configureWebResources
+import org.jetbrains.skiko.wasm.onWasmReady
/**
* Main function.
@@ -24,6 +34,12 @@ fun main() {
*/
initKoin()
+ // Apply stored language preference on startup
+ val storedLanguage = localStorage.getItem("app_language")
+ if (storedLanguage != null) {
+ document.documentElement?.setAttribute("lang", storedLanguage)
+ }
+
/*
* Configures the web resources for the application.
* Specifically, it sets a path mapping for resources (e.g., CSS, JS).
@@ -36,18 +52,39 @@ fun main() {
* Creates a Canvas-based window for rendering the Compose UI.
* This window uses the canvas element with the ID "ComposeTarget" and has the title "WebApp".
*/
- CanvasBasedWindow(
- title = "Mifos Mobile", // Window title
- canvasElementId = "ComposeTarget", // The canvas element where the Compose UI will be rendered
- ) {
- /*
- * Invokes the root composable of the application.
- * This function is responsible for setting up the entire UI structure of the app.
- */
- SharedApp(
- handleThemeMode = {},
- handleAppLocale = {},
- onSplashScreenRemoved = {},
- )
+ onWasmReady {
+ ComposeViewport(document.body!!) {
+ // State to trigger recomposition when locale changes
+ var localeVersion by remember { mutableStateOf(0) }
+
+ // Use key() to force complete recomposition when locale changes
+ key(localeVersion) {
+ /*
+ * Invokes the root composable of the application.
+ * This function is responsible for setting up the entire UI structure of the app.
+ */
+ SharedApp(
+ handleThemeMode = {},
+ handleAppLocale = { languageTag ->
+ if (languageTag != null) {
+ // Store language preference in localStorage
+ localStorage.setItem("app_language", languageTag)
+ // Set HTML lang attribute for accessibility
+ document.documentElement?.setAttribute("lang", languageTag)
+ } else {
+ // System Default: remove stored language preference
+ localStorage.removeItem("app_language")
+ // Reset to browser's default language
+ val browserLang = window.navigator.language
+ document.documentElement?.setAttribute("lang", browserLang)
+ }
+ // Reload page to apply language changes (required for web)
+ // Note: This will reload the page, and locale selection depends on browser settings
+ // window.location.reload()
+ },
+ onSplashScreenRemoved = {},
+ )
+ }
+ }
}
}
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index 8bcd830b72..b765b3701f 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -432,6 +432,7 @@ naming:
"**/androidInstrumentedTest/**",
"**/jsTest/**",
"**/iosTest/**",
+ "**/generated/**",
]
functionPattern: "[a-z][a-zA-Z0-9]*"
excludeClassPattern: "$^"
diff --git a/core-base/analytics/build.gradle.kts b/core-base/analytics/build.gradle.kts
index bf8cc7a4e5..b707345107 100644
--- a/core-base/analytics/build.gradle.kts
+++ b/core-base/analytics/build.gradle.kts
@@ -8,7 +8,7 @@
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
plugins {
- alias(libs.plugins.kmp.library.convention)
+ alias(libs.plugins.kmp.core.base.library.convention)
alias(libs.plugins.jetbrainsCompose)
alias(libs.plugins.compose.compiler)
}
diff --git a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt
index ff57128e1e..47dceb1c8c 100644
--- a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt
+++ b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt
@@ -255,11 +255,26 @@ object Types {
*
* @since 1.0.0
*/
-data class Param(val key: String, val value: String) {
- init {
- require(key.isNotBlank()) { "Parameter key cannot be blank" }
- require(key.length <= 40) { "Parameter key cannot exceed 40 characters" }
- require(value.length <= 100) { "Parameter value cannot exceed 100 characters" }
+@ConsistentCopyVisibility
+data class Param private constructor(
+ val key: String,
+ val value: String,
+) {
+ companion object {
+ private const val MAX_VALUE_LENGTH = 100
+ private const val MAX_KEY_LENGTH = 40
+ private const val FALLBACK_KEY = "unknown_param"
+
+ operator fun invoke(key: String, value: String): Param {
+ val safeKey = key
+ .takeIf { it.isNotBlank() }
+ ?.take(MAX_KEY_LENGTH)
+ ?: FALLBACK_KEY
+
+ val safeValue = value.take(MAX_VALUE_LENGTH)
+
+ return Param(safeKey, safeValue)
+ }
}
}
diff --git a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsExtensions.kt b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsExtensions.kt
index a755deab7a..01152e3a67 100644
--- a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsExtensions.kt
+++ b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsExtensions.kt
@@ -9,7 +9,7 @@
*/
package template.core.base.analytics
-import kotlinx.datetime.Clock
+import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.DurationUnit
diff --git a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/PerformanceTracker.kt b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/PerformanceTracker.kt
index e910de3a18..8e3ad78280 100644
--- a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/PerformanceTracker.kt
+++ b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/PerformanceTracker.kt
@@ -9,7 +9,7 @@
*/
package template.core.base.analytics
-import kotlinx.datetime.Clock
+import kotlin.time.Clock
/** Performance tracking utilities for analytics */
diff --git a/core-base/common/build.gradle.kts b/core-base/common/build.gradle.kts
index c64c7ea544..e81a5a85ed 100644
--- a/core-base/common/build.gradle.kts
+++ b/core-base/common/build.gradle.kts
@@ -8,7 +8,7 @@
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
plugins {
- alias(libs.plugins.kmp.library.convention)
+ alias(libs.plugins.kmp.core.base.library.convention)
alias(libs.plugins.kotlin.parcelize)
}
diff --git a/core-base/database/build.gradle.kts b/core-base/database/build.gradle.kts
index ba0d0a811c..6ddd5180c7 100644
--- a/core-base/database/build.gradle.kts
+++ b/core-base/database/build.gradle.kts
@@ -19,7 +19,7 @@ import org.jetbrains.compose.compose
* See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
plugins {
- alias(libs.plugins.kmp.library.convention)
+ alias(libs.plugins.kmp.core.base.library.convention)
}
android {
diff --git a/core-base/datastore/build.gradle.kts b/core-base/datastore/build.gradle.kts
index fbea2a2aa6..2c85c91207 100644
--- a/core-base/datastore/build.gradle.kts
+++ b/core-base/datastore/build.gradle.kts
@@ -8,7 +8,7 @@
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
plugins {
- alias(libs.plugins.kmp.library.convention)
+ alias(libs.plugins.kmp.core.base.library.convention)
id("kotlinx-serialization")
}
diff --git a/core-base/designsystem/build.gradle.kts b/core-base/designsystem/build.gradle.kts
index 1cf2c93623..12a52c80cd 100644
--- a/core-base/designsystem/build.gradle.kts
+++ b/core-base/designsystem/build.gradle.kts
@@ -8,7 +8,7 @@
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
plugins {
- alias(libs.plugins.kmp.library.convention)
+ alias(libs.plugins.kmp.core.base.library.convention)
alias(libs.plugins.jetbrainsCompose)
alias(libs.plugins.compose.compiler)
}
diff --git a/core-base/network/build.gradle.kts b/core-base/network/build.gradle.kts
index 9c8ee7a265..75625ecac8 100644
--- a/core-base/network/build.gradle.kts
+++ b/core-base/network/build.gradle.kts
@@ -8,7 +8,7 @@
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
plugins {
- alias(libs.plugins.kmp.library.convention)
+ alias(libs.plugins.kmp.core.base.library.convention)
alias(libs.plugins.kotlin.serialization)
}
diff --git a/core-base/network/src/androidMain/kotlin/template/core/base/network/KtorHttpClient.android.kt b/core-base/network/src/androidMain/kotlin/template/core/base/network/KtorHttpClient.android.kt
new file mode 100644
index 0000000000..65edfc3301
--- /dev/null
+++ b/core-base/network/src/androidMain/kotlin/template/core/base/network/KtorHttpClient.android.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
+ */
+package template.core.base.network
+
+import io.ktor.client.HttpClient
+import io.ktor.client.HttpClientConfig
+import io.ktor.client.engine.okhttp.OkHttp
+
+actual fun httpClient(config: HttpClientConfig<*>.() -> Unit) = HttpClient(OkHttp) {
+ config(this)
+}
diff --git a/core-base/network/src/commonMain/kotlin/template/core/base/network/KtorHttpClient.kt b/core-base/network/src/commonMain/kotlin/template/core/base/network/KtorHttpClient.kt
new file mode 100644
index 0000000000..e9d9584f48
--- /dev/null
+++ b/core-base/network/src/commonMain/kotlin/template/core/base/network/KtorHttpClient.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
+ */
+package template.core.base.network
+
+import io.ktor.client.HttpClient
+import io.ktor.client.HttpClientConfig
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.auth.Auth
+import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
+import io.ktor.client.plugins.auth.providers.BearerTokens
+import io.ktor.client.plugins.auth.providers.DigestAuthCredentials
+import io.ktor.client.plugins.auth.providers.basic
+import io.ktor.client.plugins.auth.providers.bearer
+import io.ktor.client.plugins.auth.providers.digest
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.plugins.logging.DEFAULT
+import io.ktor.client.plugins.logging.LogLevel
+import io.ktor.client.plugins.logging.Logger
+import io.ktor.client.plugins.logging.Logging
+import io.ktor.http.HttpHeaders
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.serialization.json.Json
+import co.touchlab.kermit.Logger.Companion as KermitLogger
+
+expect fun httpClient(config: HttpClientConfig<*>.() -> Unit): HttpClient
+
+/**
+ * Provides a default [HttpClientConfig] setup for use with a Ktor-based HTTP client.
+ *
+ * This function simplifies client configuration by handling common concerns such as:
+ * - Authentication (Bearer, Basic, Digest)
+ * - Default headers
+ * - Timeouts
+ * - Logging
+ * - JSON serialization
+ *
+ * It can be passed directly into a Ktor client builder via the `config` lambda:
+ * ```kotlin
+ * val client = httpClient(setupDefaultHttpClient(baseUrl = "https://api.example.com"))
+ * ```
+ *
+ * @param baseUrl The base URL to be applied to all requests unless explicitly overridden.
+ * @param authRequiredUrl A list of hostnames that require authentication.
+ * @param defaultHeaders Headers that are applied to every request.
+ * @param requestTimeout Timeout in milliseconds for entire request lifecycle.
+ * @param socketTimeout Timeout in milliseconds for socket-level communication.
+ * @param httpLogger A logger used for HTTP logging (defaults to `Logger.DEFAULT`).
+ * @param httpLogLevel Level of HTTP logging (e.g. `LogLevel.ALL`).
+ * @param loggableHosts A list of hostnames for which HTTP logging is enabled.
+ * @param sensitiveHeaders List of headers to be hidden in logs (defaults to Authorization).
+ * @param jsonConfig Custom [Json] configuration used by `ContentNegotiation`.
+ * @param basicCredentialsProvider Provider for Basic authentication credentials.
+ * @param digestCredentialsProvider Provider for Digest authentication credentials.
+ * @param bearerTokensProvider Provider for Bearer token authentication.
+ * @param bearerRefreshProvider Optional refresh logic for Bearer tokens (only used if Bearer auth is configured).
+ *
+ * @return A configuration lambda to be passed into the Ktor [HttpClient].
+ */
+fun setupDefaultHttpClient(
+ baseUrl: String,
+ authRequiredUrl: List = emptyList(),
+ defaultHeaders: Map = emptyMap(),
+ requestTimeout: Long = 60_000L,
+ socketTimeout: Long = 60_000L,
+ httpLogger: Logger = Logger.DEFAULT,
+ httpLogLevel: LogLevel = LogLevel.ALL,
+ loggableHosts: List = emptyList(),
+ sensitiveHeaders: List = listOf(HttpHeaders.Authorization),
+ jsonConfig: Json = Json {
+ prettyPrint = true
+ isLenient = true
+ ignoreUnknownKeys = true
+ explicitNulls = false
+ },
+ basicCredentialsProvider: (() -> BasicAuthCredentials)? = null,
+ digestCredentialsProvider: (() -> DigestAuthCredentials)? = null,
+ bearerTokensProvider: (() -> BearerTokens)? = null,
+ bearerRefreshProvider: (() -> BearerTokens)? = null,
+): HttpClientConfig<*>.() -> Unit = {
+ when {
+ bearerTokensProvider != null -> {
+ install(Auth) {
+ bearer {
+ loadTokens { bearerTokensProvider() }
+ if (bearerRefreshProvider != null) {
+ refreshTokens {
+ bearerRefreshProvider()
+ }
+ }
+ sendWithoutRequest { request ->
+ request.url.host in authRequiredUrl
+ }
+ }
+ }
+ }
+
+ basicCredentialsProvider != null -> {
+ install(Auth) {
+ basic {
+ credentials {
+ basicCredentialsProvider()
+ }
+ sendWithoutRequest { request ->
+ request.url.host in authRequiredUrl
+ }
+ }
+ }
+ }
+
+ digestCredentialsProvider != null -> {
+ install(Auth) {
+ digest {
+ credentials {
+ digestCredentialsProvider()
+ }
+ }
+ }
+ }
+ }
+
+ defaultRequest {
+ url(baseUrl)
+ defaultHeaders.forEach { (key, value) ->
+ headers.append(key, value)
+ }
+ }
+
+ install(HttpTimeout) {
+ requestTimeoutMillis = requestTimeout
+ socketTimeoutMillis = socketTimeout
+ }
+
+ install(Logging) {
+ logger = httpLogger
+ level = httpLogLevel
+ filter { request ->
+ loggableHosts.any { host ->
+ request.url.host.contains(host)
+ }
+ }
+ sanitizeHeader { header ->
+ header in sensitiveHeaders
+ }
+ logger = object : Logger {
+ override fun log(message: String) {
+ KermitLogger.d(tag = "KtorClient", messageString = message)
+ }
+ }
+ }
+
+ install(ContentNegotiation) {
+ json(jsonConfig)
+ }
+}
diff --git a/core-base/network/src/commonMain/kotlin/template/core/base/network/NetworkError.kt b/core-base/network/src/commonMain/kotlin/template/core/base/network/NetworkError.kt
new file mode 100644
index 0000000000..c8c4f52f17
--- /dev/null
+++ b/core-base/network/src/commonMain/kotlin/template/core/base/network/NetworkError.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
+ */
+package template.core.base.network
+
+/**
+ * Represents standardized error types for remote or network operations.
+ *
+ * This enum is typically used with the [NetworkResult.Error] variant to describe what kind of failure occurred.
+ */
+enum class NetworkError {
+
+ /**
+ * The request was malformed or missing required parameters (HTTP 400).
+ */
+ BAD_REQUEST,
+
+ /**
+ * The requested resource could not be found (HTTP 404).
+ */
+ NOT_FOUND,
+
+ /**
+ * Authentication failed due to invalid or missing credentials (HTTP 401).
+ */
+ UNAUTHORIZED,
+
+ /**
+ * The request timed out, usually due to a slow or unresponsive network (HTTP 408 or socket timeout).
+ */
+ REQUEST_TIMEOUT,
+
+ /**
+ * The client has sent too many requests in a given amount of time (HTTP 429).
+ */
+ TOO_MANY_REQUESTS,
+
+ /**
+ * A server-side error occurred (HTTP 5xx).
+ */
+ SERVER,
+
+ /**
+ * The response could not be deserialized, likely due to mismatched or invalid data formats.
+ */
+ SERIALIZATION,
+
+ /**
+ * An unknown or unexpected error occurred, used as a fallback when the specific cause is not identifiable.
+ */
+ UNKNOWN,
+}
diff --git a/core-base/network/src/commonMain/kotlin/template/core/base/network/NetworkResult.kt b/core-base/network/src/commonMain/kotlin/template/core/base/network/NetworkResult.kt
new file mode 100644
index 0000000000..abcf6845ab
--- /dev/null
+++ b/core-base/network/src/commonMain/kotlin/template/core/base/network/NetworkResult.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
+ */
+package template.core.base.network
+
+/**
+ * Represents the result of a network or remote operation, encapsulating either a success or an error.
+ *
+ * This is a sealed interface with two implementations:
+ * - [Success] indicates the operation completed successfully and contains the resulting data.
+ * - [Error] represents a failure and contains a [NetworkError] describing the error condition.
+ *
+ * @param D The type of data returned on success.
+ * @param E The type of error returned on failure, constrained to [NetworkError].
+ */
+sealed interface NetworkResult {
+
+ /**
+ * Represents a successful result.
+ *
+ * @param D The type of the successful response data.
+ * @property data The actual result of the operation.
+ */
+ data class Success(val data: D) : NetworkResult
+
+ /**
+ * Represents a failed result due to a [NetworkError].
+ *
+ * @param E The specific type of [NetworkError] encountered.
+ * @property error Details about the error that occurred.
+ */
+ data class Error(val error: E) : NetworkResult
+}
diff --git a/core-base/network/src/commonMain/kotlin/template/core/base/network/factory/ResultSuspendConverterFactory.kt b/core-base/network/src/commonMain/kotlin/template/core/base/network/factory/ResultSuspendConverterFactory.kt
new file mode 100644
index 0000000000..64f26cb878
--- /dev/null
+++ b/core-base/network/src/commonMain/kotlin/template/core/base/network/factory/ResultSuspendConverterFactory.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
+ */
+package template.core.base.network.factory
+
+import de.jensklingenberg.ktorfit.Ktorfit
+import de.jensklingenberg.ktorfit.converter.Converter
+import de.jensklingenberg.ktorfit.converter.KtorfitResult
+import de.jensklingenberg.ktorfit.converter.TypeData
+import io.ktor.client.call.NoTransformationFoundException
+import io.ktor.client.call.body
+import io.ktor.client.statement.HttpResponse
+import kotlinx.serialization.SerializationException
+import template.core.base.network.NetworkError
+import template.core.base.network.NetworkResult
+
+/**
+ * A custom [Converter.Factory] for Ktorfit that provides a suspend
+ * response converter which wraps successful or error HTTP responses into a
+ * sealed [NetworkResult] type.
+ *
+ * This is useful for abstracting error handling logic across your network
+ * layer while providing strong typing for both success and failure
+ * outcomes.
+ *
+ * This converter handles:
+ * - HTTP 2xx responses by deserializing the response body into the
+ * expected type.
+ * - Known HTTP error codes like 400, 401, 404, etc., by mapping them to
+ * [NetworkError] types.
+ * - Deserialization issues via [SerializationException].
+ * - Unknown failures via [KtorfitResult.Failure].
+ *
+ * Example usage:
+ * ```kotlin
+ * interface ApiService {
+ * @GET("users")
+ * suspend fun getUsers(): Result, RemoteError>
+ * }
+ * ```
+ */
+@Suppress("NestedBlockDepth")
+class ResultSuspendConverterFactory : Converter.Factory {
+
+ /**
+ * Creates a [Converter.SuspendResponseConverter] that wraps an HTTP
+ * response into a [NetworkResult] type.
+ *
+ * @param typeData Metadata about the expected response type.
+ * @param ktorfit The [Ktorfit] instance requesting this converter.
+ * @return A [Converter.SuspendResponseConverter] if the return type is
+ * `Result`, or `null` otherwise.
+ */
+ override fun suspendResponseConverter(
+ typeData: TypeData,
+ ktorfit: Ktorfit,
+ ): Converter.SuspendResponseConverter? {
+ if (typeData.typeInfo.type == NetworkResult::class) {
+ val successType = typeData.typeArgs.first().typeInfo
+ return object :
+ Converter.SuspendResponseConverter> {
+
+ /**
+ * Converts a [KtorfitResult] into a [NetworkResult], handling success and
+ * various failure scenarios.
+ *
+ * @param result The response wrapped in [KtorfitResult].
+ * @return A [NetworkResult.Success] if the response is successful, or a
+ * [NetworkResult.Error] if an error occurred.
+ */
+ override suspend fun convert(result: KtorfitResult): NetworkResult {
+ return when (result) {
+ is KtorfitResult.Failure -> {
+ println("Failure: " + result.throwable.message)
+ NetworkResult.Error(NetworkError.UNKNOWN)
+ }
+
+ is KtorfitResult.Success -> {
+ val status = result.response.status.value
+
+ when (status) {
+ in 200..209 -> {
+ try {
+ val data = result.response.body(successType) as Any
+ NetworkResult.Success(data)
+ } catch (e: NoTransformationFoundException) {
+ NetworkResult.Error(NetworkError.SERIALIZATION)
+ } catch (e: SerializationException) {
+ println("Serialization error: ${e.message}")
+ NetworkResult.Error(NetworkError.SERIALIZATION)
+ }
+ }
+
+ 400 -> NetworkResult.Error(NetworkError.BAD_REQUEST)
+ 401 -> NetworkResult.Error(NetworkError.UNAUTHORIZED)
+ 404 -> NetworkResult.Error(NetworkError.NOT_FOUND)
+ 408 -> NetworkResult.Error(NetworkError.REQUEST_TIMEOUT)
+ 429 -> NetworkResult.Error(NetworkError.TOO_MANY_REQUESTS)
+ in 500..599 -> NetworkResult.Error(NetworkError.SERVER)
+ else -> {
+ println("Status code $status")
+ NetworkResult.Error(NetworkError.UNKNOWN)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ return null
+ }
+}
diff --git a/core-base/network/src/desktopMain/kotlin/template/core/base/network/KtorHttpClient.desktop.kt b/core-base/network/src/desktopMain/kotlin/template/core/base/network/KtorHttpClient.desktop.kt
new file mode 100644
index 0000000000..65edfc3301
--- /dev/null
+++ b/core-base/network/src/desktopMain/kotlin/template/core/base/network/KtorHttpClient.desktop.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
+ */
+package template.core.base.network
+
+import io.ktor.client.HttpClient
+import io.ktor.client.HttpClientConfig
+import io.ktor.client.engine.okhttp.OkHttp
+
+actual fun httpClient(config: HttpClientConfig<*>.() -> Unit) = HttpClient(OkHttp) {
+ config(this)
+}
diff --git a/core-base/network/src/jsMain/kotlin/template/core/base/network/KtorHttpClient.js.kt b/core-base/network/src/jsMain/kotlin/template/core/base/network/KtorHttpClient.js.kt
new file mode 100644
index 0000000000..beaa36ea3a
--- /dev/null
+++ b/core-base/network/src/jsMain/kotlin/template/core/base/network/KtorHttpClient.js.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
+ */
+package template.core.base.network
+
+import io.ktor.client.HttpClient
+import io.ktor.client.HttpClientConfig
+import io.ktor.client.engine.js.Js
+
+actual fun httpClient(config: HttpClientConfig<*>.() -> Unit) = HttpClient(Js) {
+ config(this)
+}
diff --git a/core-base/network/src/nativeMain/kotlin/template/core/base/network/KtorHttpClient.native.kt b/core-base/network/src/nativeMain/kotlin/template/core/base/network/KtorHttpClient.native.kt
new file mode 100644
index 0000000000..3f6c9a4d4e
--- /dev/null
+++ b/core-base/network/src/nativeMain/kotlin/template/core/base/network/KtorHttpClient.native.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
+ */
+package template.core.base.network
+
+import io.ktor.client.HttpClient
+import io.ktor.client.HttpClientConfig
+import io.ktor.client.engine.darwin.Darwin
+
+actual fun httpClient(config: HttpClientConfig<*>.() -> Unit) = HttpClient(Darwin) {
+ config(this)
+}
diff --git a/core-base/network/src/wasmJsMain/kotlin/template/core/base/network/KtorHttpClient.wasmJs.kt b/core-base/network/src/wasmJsMain/kotlin/template/core/base/network/KtorHttpClient.wasmJs.kt
new file mode 100644
index 0000000000..beaa36ea3a
--- /dev/null
+++ b/core-base/network/src/wasmJsMain/kotlin/template/core/base/network/KtorHttpClient.wasmJs.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
+ */
+package template.core.base.network
+
+import io.ktor.client.HttpClient
+import io.ktor.client.HttpClientConfig
+import io.ktor.client.engine.js.Js
+
+actual fun httpClient(config: HttpClientConfig<*>.() -> Unit) = HttpClient(Js) {
+ config(this)
+}
diff --git a/core-base/platform/build.gradle.kts b/core-base/platform/build.gradle.kts
index c12ae05b54..17634a6f8e 100644
--- a/core-base/platform/build.gradle.kts
+++ b/core-base/platform/build.gradle.kts
@@ -19,10 +19,9 @@ import org.gradle.kotlin.dsl.implementation
* See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
plugins {
- alias(libs.plugins.kmp.library.convention)
+ alias(libs.plugins.kmp.core.base.library.convention)
alias(libs.plugins.jetbrainsCompose)
alias(libs.plugins.compose.compiler)
- alias(libs.plugins.kotlin.parcelize)
}
android {
diff --git a/core-base/platform/src/commonMain/kotlin/template/core/base/platform/LocalManagerProviders.kt b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/LocalManagerProviders.kt
index a292ebcf41..4a78fdce69 100644
--- a/core-base/platform/src/commonMain/kotlin/template/core/base/platform/LocalManagerProviders.kt
+++ b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/LocalManagerProviders.kt
@@ -1,11 +1,11 @@
/*
- * Copyright 2026 Mifos Initiative
+ * Copyright 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
- * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
+ * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
package template.core.base.platform
@@ -42,7 +42,7 @@ expect fun LocalManagerProvider(
* Provides access to the app review manager throughout the app.
*/
val LocalAppReviewManager: ProvidableCompositionLocal = compositionLocalOf {
- NoOpAppReviewManager
+ error("CompositionLocal AppReviewManager not present")
}
/**
@@ -58,8 +58,3 @@ val LocalIntentManager: ProvidableCompositionLocal = compositionL
val LocalAppUpdateManager: ProvidableCompositionLocal = compositionLocalOf {
error("CompositionLocal LocalAppUpdateManager not present")
}
-
-object NoOpAppReviewManager : AppReviewManager {
- override fun promptForReview() = Unit
- override fun promptForCustomReview() = Unit
-}
diff --git a/core-base/ui/README.md b/core-base/ui/README.md
index eec63d0235..91ec552b51 100644
--- a/core-base/ui/README.md
+++ b/core-base/ui/README.md
@@ -381,29 +381,6 @@ internal fun rememberDefaultImageLoader(context: PlatformContext): ImageLoader {
The `maxSizePercent` call is crucialβit adapts the cache size to the device's available memory,
ensuring efficient resource usage across a wide range of devices.
-### Request Optimization
-
-The image request builder includes memory cache optimization:
-
-```kotlin
-@Composable
-fun rememberImageRequest(
- context: PlatformContext,
- wallpaper: String,
-): ImageRequest {
- return remember(wallpaper) {
- ImageRequest.Builder(context)
- .data(wallpaper)
- .memoryCacheKey(wallpaper)
- .placeholderMemoryCacheKey(wallpaper)
- .build()
- }
-}
-```
-
-The use of `memoryCacheKey` and `placeholderMemoryCacheKey` ensures that images with the same URL
-share the same cache entry, reducing memory usage and improving load times.
-
### Common Use Patterns
For profile pictures and avatars:
@@ -411,8 +388,7 @@ For profile pictures and avatars:
```kotlin
@Composable
fun CircularProfileImage(url: String, size: Dp = 48.dp) {
- val context = LocalPlatformContext.current
- val imageLoader = rememberImageLoader(context)
+ val imageLoader = rememberImageLoader()
AsyncImage(
model = rememberImageRequest(context, url),
@@ -434,8 +410,7 @@ For background images:
```kotlin
@Composable
fun BackgroundImage(url: String, overlay: Color = Color.Black.copy(alpha = 0.3f)) {
- val context = LocalPlatformContext.current
- val imageLoader = rememberImageLoader(context)
+ val imageLoader = rememberImageLoader()
Box {
AsyncImage(
diff --git a/core-base/ui/build.gradle.kts b/core-base/ui/build.gradle.kts
index 1270cdaccd..062d36793a 100644
--- a/core-base/ui/build.gradle.kts
+++ b/core-base/ui/build.gradle.kts
@@ -19,7 +19,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
* See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
*/
plugins {
- alias(libs.plugins.kmp.library.convention)
+ alias(libs.plugins.kmp.core.base.library.convention)
alias(libs.plugins.jetbrainsCompose)
alias(libs.plugins.compose.compiler)
}
diff --git a/core-base/ui/src/androidMain/kotlin/template/core/base/ui/ShareUtils.android.kt b/core-base/ui/src/androidMain/kotlin/template/core/base/ui/ShareUtils.android.kt
index 213a894731..72b541fd84 100644
--- a/core-base/ui/src/androidMain/kotlin/template/core/base/ui/ShareUtils.android.kt
+++ b/core-base/ui/src/androidMain/kotlin/template/core/base/ui/ShareUtils.android.kt
@@ -14,10 +14,12 @@ import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
+import android.provider.Settings
import android.util.Log
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.core.content.FileProvider
+import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.ExperimentalResourceApi
@@ -31,8 +33,8 @@ actual object ShareUtils {
private var activityProvider: () -> Activity = {
throw IllegalArgumentException(
"You need to implement the 'activityProvider' to provide the required Activity. " +
- "Just make sure to set a valid activity using " +
- "the 'setActivityProvider()' method.",
+ "Just make sure to set a valid activity using " +
+ "the 'setActivityProvider()' method.",
)
}
@@ -102,4 +104,85 @@ actual object ShareUtils {
}
}
}
+
+ actual fun openUrl(url: String) {
+ val context = ShareUtils.activityProvider.invoke().application.baseContext
+ val uri = url.let { url.toUri() }
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ data = uri
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+
+ actual fun openAppInfo() {
+ val context = activityProvider.invoke().application.baseContext
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = Uri.parse("package:${context.packageName}")
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+
+ actual fun callPhone(number: String) {
+ val context = activityProvider.invoke().application.baseContext
+ val uri = Uri.parse("tel:$number")
+ val intent = Intent(Intent.ACTION_DIAL).apply {
+ data = uri
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+
+ actual fun sendEmail(to: String, subject: String?, body: String?) {
+ val context = activityProvider.invoke().application.baseContext
+ val uriBuilder = StringBuilder("mailto:").append(to)
+ val query = mutableListOf()
+ subject?.let { query.add("subject=" + Uri.encode(it)) }
+ body?.let { query.add("body=" + Uri.encode(it)) }
+ if (query.isNotEmpty()) {
+ uriBuilder.append("?").append(query.joinToString("&"))
+ }
+ val intent = Intent(Intent.ACTION_SENDTO).apply {
+ data = Uri.parse(uriBuilder.toString())
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+
+ actual fun sendViaSMS(number: String, message: String) {
+ val context = activityProvider.invoke().application.baseContext
+ val uri = if (number.isNotEmpty()) {
+ Uri.parse("sms:$number")
+ } else {
+ Uri.parse("sms:")
+ }
+ val intent = Intent(Intent.ACTION_SENDTO).apply {
+ data = uri
+ putExtra("sms_body", message)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+
+ actual fun copyText(text: String) {
+ val context = activityProvider.invoke().application.baseContext
+ val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
+ val clip = android.content.ClipData.newPlainText("Copied Text", text)
+ clipboardManager.setPrimaryClip(clip)
+ }
+
+ actual suspend fun shareApp(storeLink: String, message: String) {
+ val shareContent = if (message.isNotEmpty()) {
+ "$message\n$storeLink"
+ } else {
+ storeLink
+ }
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, shareContent)
+ }
+ val intentChooser = Intent.createChooser(intent, null)
+ activityProvider.invoke().startActivity(intentChooser)
+ }
}
diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ImageLoaderExt.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ImageLoaderExt.kt
index 9846ea66f2..de63264c40 100644
--- a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ImageLoaderExt.kt
+++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ImageLoaderExt.kt
@@ -15,78 +15,60 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import coil3.ImageLoader
import coil3.PlatformContext
+import coil3.compose.LocalPlatformContext
import coil3.memory.MemoryCache
-import coil3.request.ImageRequest
+import coil3.request.CachePolicy
import coil3.util.DebugLogger
import io.github.vinceglb.filekit.coil.addPlatformFileSupport
/**
- * CompositionLocal that provides access to an [ImageLoader] within the composition hierarchy.
- * Default value is null, requiring an explicit provider or fallback to default implementation.
- */
-internal val LocalAppImageLoader = compositionLocalOf { null }
-
-/**
- * Returns an [ImageLoader] from the current composition or creates a default one if none is available.
+ * A CompositionLocal instance used to provide a shared [ImageLoader] across the composable hierarchy.
+ * This is useful for performing image loading and caching within Jetpack Compose UIs.
*
- * @param context Platform context required for image loading
- * @return An [ImageLoader] instance that can be used for image loading operations
+ * If no [ImageLoader] is provided within the context, an error will be thrown when accessing this CompositionLocal.
*/
-@Composable
-fun rememberImageLoader(context: PlatformContext): ImageLoader {
- return LocalAppImageLoader.current ?: rememberDefaultImageLoader(context)
+internal val LocalAppImageLoader = compositionLocalOf {
+ error("No ImageLoader provided. Have you forgotten the LocalImageLoaderProvider?")
}
/**
- * Creates and remembers a default [ImageLoader] with memory cache and debug logging.
+ * Creates and remembers an instance of the default ImageLoader configured with platform-specific
+ * settings for caching and logging. The ImageLoader is initialized using the current platform context.
*
- * @param context Platform context required for image loading and memory calculations
- * @return A default configured [ImageLoader] instance
+ * @return A remembered instance of the default ImageLoader configured with common settings.
*/
@Composable
-internal fun rememberDefaultImageLoader(context: PlatformContext): ImageLoader {
- return remember(context) {
- ImageLoader.Builder(context)
- .memoryCache {
- MemoryCache.Builder()
- .maxSizePercent(context, 0.25)
- .build()
- }
- .logger(DebugLogger())
- .components {
- addPlatformFileSupport()
- }
- .build()
- }
+fun rememberImageLoader(): ImageLoader {
+ val context = LocalPlatformContext.current
+ return remember(context) { getDefaultImageLoader(context) }
}
/**
- * Creates and remembers an [ImageRequest] for loading the specified wallpaper.
+ * Creates and returns a default instance of an ImageLoader configured with common settings such as
+ * logging, caching policies, memory caching, and platform-specific component support.
*
- * @param context Platform context required for the image request
- * @param wallpaper String identifier for the wallpaper to be loaded
- * @return An [ImageRequest] configured for the specified wallpaper
+ * @param context The platform-specific context required to initialize the ImageLoader
+ * @return A configured ImageLoader instance ready for use
*/
-@Composable
-fun rememberImageRequest(
- context: PlatformContext,
- wallpaper: String,
-): ImageRequest {
- return remember(wallpaper) {
- ImageRequest.Builder(context)
- .data(wallpaper)
- .memoryCacheKey(wallpaper)
- .placeholderMemoryCacheKey(wallpaper)
+fun getDefaultImageLoader(context: PlatformContext): ImageLoader = ImageLoader
+ .Builder(context)
+ .logger(DebugLogger())
+ .networkCachePolicy(CachePolicy.ENABLED)
+ .memoryCachePolicy(CachePolicy.ENABLED)
+ .memoryCache {
+ MemoryCache.Builder()
+ .maxSizePercent(context, 0.25)
.build()
}
-}
+ .components {
+ addPlatformFileSupport()
+ }.build()
/**
- * Provides the specified [ImageLoader] to all composables within the [content] lambda
- * via [CompositionLocalProvider].
+ * Provides a composable local context for an `ImageLoader` to be used within a Composable hierarchy.
*
- * @param imageLoader The [ImageLoader] to provide downstream
- * @param content The composable content that will have access to the provided [ImageLoader]
+ * @param imageLoader The `ImageLoader` instance to be provided to the local composition.
+ * @param content A composable lambda function representing the UI content that can access the provided `ImageLoader`.
*/
@Composable
fun LocalImageLoaderProvider(imageLoader: ImageLoader, content: @Composable () -> Unit) {
diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ShareUtils.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ShareUtils.kt
index 7e8d6bf4be..471e3146ec 100644
--- a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ShareUtils.kt
+++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ShareUtils.kt
@@ -39,4 +39,61 @@ expect object ShareUtils {
* @param byte The raw image data as ByteArray
*/
suspend fun shareImage(title: String, byte: ByteArray)
+
+ /**
+ * Opens the specified URL in the device's default web browser.
+ *
+ * @param url The URL to open.
+ */
+ fun openUrl(url: String)
+
+ /**
+ * Opens the application info screen in the device settings.
+ *
+ * Typically used to allow users to manage app permissions,
+ * storage, or other app-specific settings.
+ */
+ fun openAppInfo()
+
+ /**
+ * Initiates a phone call using the platform dialer UI.
+ *
+ * @param number The phone number to dial, digits only or including country code.
+ */
+ fun callPhone(number: String)
+
+ /**
+ * Opens the platform email composer with the given parameters.
+ *
+ * @param to Recipient email address
+ * @param subject Optional subject line
+ * @param body Optional email body
+ */
+ fun sendEmail(to: String, subject: String? = null, body: String? = null)
+
+ /**
+ * Opens the platform SMS composer with the given parameters.
+ *
+ * @param number Recipient phone number (can be empty to let user choose)
+ * @param message The SMS message body
+ */
+ fun sendViaSMS(number: String, message: String)
+
+ /**
+ * Copies the given text to the system clipboard.
+ *
+ * @param text The text to copy to clipboard
+ */
+ fun copyText(text: String)
+
+ /**
+ * Shares the app store link with a custom message.
+ *
+ * Opens the platform share sheet with the store link and message combined,
+ * allowing users to share the app with others via various apps.
+ *
+ * @param storeLink The app store URL (Play Store, App Store, etc.)
+ * @param message Optional promotional message to include with the link
+ */
+ suspend fun shareApp(storeLink: String, message: String = "")
}
diff --git a/core-base/ui/src/desktopMain/java/template/core/base/ui/ShareUtils.desktop.kt b/core-base/ui/src/desktopMain/java/template/core/base/ui/ShareUtils.desktop.kt
index 2d64f7761b..f326ad6558 100644
--- a/core-base/ui/src/desktopMain/java/template/core/base/ui/ShareUtils.desktop.kt
+++ b/core-base/ui/src/desktopMain/java/template/core/base/ui/ShareUtils.desktop.kt
@@ -13,6 +13,8 @@ import androidx.compose.ui.graphics.asSkiaBitmap
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.saveImageToGallery
import kotlinx.coroutines.DelicateCoroutinesApi
+import java.awt.Desktop
+import java.net.URI
actual object ShareUtils {
@OptIn(DelicateCoroutinesApi::class)
@@ -41,4 +43,80 @@ actual object ShareUtils {
filename = "$title.png",
)
}
+
+ actual fun openUrl(url: String) {
+ try {
+ if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
+ Desktop.getDesktop().browse(URI(url))
+ }
+ } catch (e: Exception) {
+ println("Error opening URL: ${e.message}")
+ }
+ }
+
+ actual fun openAppInfo() {
+ // Not applicable on Desktop; no-op
+ }
+
+ actual fun callPhone(number: String) {
+ try {
+ if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
+ Desktop.getDesktop().browse(URI("tel:$number"))
+ }
+ } catch (e: Exception) {
+ println("Error opening dialer: ${e.message}")
+ }
+ }
+
+ actual fun sendEmail(to: String, subject: String?, body: String?) {
+ val q = mutableListOf()
+ subject?.let { q.add("subject=" + java.net.URLEncoder.encode(it, Charsets.UTF_8)) }
+ body?.let { q.add("body=" + java.net.URLEncoder.encode(it, Charsets.UTF_8)) }
+ val query = if (q.isNotEmpty()) "?" + q.joinToString("&") else ""
+ val mailto = "mailto:$to$query"
+ try {
+ if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
+ Desktop.getDesktop().browse(URI(mailto))
+ }
+ } catch (e: Exception) {
+ println("Error opening email client: ${e.message}")
+ }
+ }
+
+ actual fun sendViaSMS(number: String, message: String) {
+ val encodedMessage = java.net.URLEncoder.encode(message, Charsets.UTF_8)
+ val smsUrl = if (number.isNotEmpty()) {
+ "sms:$number?body=$encodedMessage"
+ } else {
+ "sms:?body=$encodedMessage"
+ }
+ try {
+ if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
+ Desktop.getDesktop().browse(URI(smsUrl))
+ }
+ } catch (e: Exception) {
+ println("Error opening SMS: ${e.message}")
+ }
+ }
+
+ actual fun copyText(text: String) {
+ try {
+ val clipboard = java.awt.Toolkit.getDefaultToolkit().systemClipboard
+ val stringSelection = java.awt.datatransfer.StringSelection(text)
+ clipboard.setContents(stringSelection, null)
+ } catch (e: Exception) {
+ println("Error copying to clipboard: ${e.message}")
+ }
+ }
+
+ actual suspend fun shareApp(storeLink: String, message: String) {
+ val shareContent = if (message.isNotEmpty()) {
+ "$message\n$storeLink"
+ } else {
+ storeLink
+ }
+ // On Desktop, we copy to clipboard and open the URL
+ copyText(shareContent)
+ openUrl(storeLink)
+ }
}
diff --git a/core-base/ui/src/jsCommonMain/kotlin/template/core/base/ui/ShareUtils.kt b/core-base/ui/src/jsCommonMain/kotlin/template/core/base/ui/ShareUtils.kt
index 45879017ff..8747f541cd 100644
--- a/core-base/ui/src/jsCommonMain/kotlin/template/core/base/ui/ShareUtils.kt
+++ b/core-base/ui/src/jsCommonMain/kotlin/template/core/base/ui/ShareUtils.kt
@@ -42,4 +42,34 @@ actual object ShareUtils {
fileName = "$title.png",
)
}
+
+ actual fun openUrl(url: String) {
+ }
+
+ actual fun openAppInfo() {
+ }
+
+ actual fun callPhone(number: String) {
+ }
+
+ actual fun sendEmail(to: String, subject: String?, body: String?) {
+ }
+
+ actual fun sendViaSMS(number: String, message: String) {
+ }
+
+ actual fun copyText(text: String) {
+ }
+
+ actual suspend fun shareApp(storeLink: String, message: String) {
+ val shareContent = if (message.isNotEmpty()) {
+ "$message\n$storeLink"
+ } else {
+ storeLink
+ }
+ FileKit.download(
+ bytes = shareContent.encodeToByteArray(),
+ fileName = "share_app.txt",
+ )
+ }
}
diff --git a/core-base/ui/src/nativeMain/kotlin/template/core/base/ui/ShareUtils.native.kt b/core-base/ui/src/nativeMain/kotlin/template/core/base/ui/ShareUtils.native.kt
index 7ca96fe537..2c9277f3cc 100644
--- a/core-base/ui/src/nativeMain/kotlin/template/core/base/ui/ShareUtils.native.kt
+++ b/core-base/ui/src/nativeMain/kotlin/template/core/base/ui/ShareUtils.native.kt
@@ -13,10 +13,25 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asSkiaBitmap
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.saveImageToGallery
+import platform.Foundation.NSCharacterSet
+import platform.Foundation.NSString
+import platform.Foundation.NSURL
+import platform.Foundation.URLQueryAllowedCharacterSet
+import platform.Foundation.stringByAddingPercentEncodingWithAllowedCharacters
import platform.UIKit.UIActivityViewController
import platform.UIKit.UIApplication
+import platform.UIKit.UIApplicationOpenSettingsURLString
+@Suppress("CAST_NEVER_SUCCEEDS")
actual object ShareUtils {
+
+ private fun String.urlEncode(): String {
+ val nsString = this as NSString
+ return nsString.stringByAddingPercentEncodingWithAllowedCharacters(
+ NSCharacterSet.URLQueryAllowedCharacterSet,
+ ) ?: this
+ }
+
actual suspend fun shareText(text: String) {
val currentViewController = UIApplication.sharedApplication().keyWindow?.rootViewController
val activityViewController = UIActivityViewController(listOf(text), null)
@@ -42,4 +57,75 @@ actual object ShareUtils {
filename = "$title.png",
)
}
+
+ actual fun openUrl(url: String) {
+ val nsUrl = NSURL.URLWithString(url)
+ if (nsUrl != null) {
+ UIApplication.sharedApplication.openURL(
+ nsUrl,
+ options = emptyMap(),
+ completionHandler = null,
+ )
+ }
+ }
+
+ actual fun openAppInfo() {
+ val url = NSURL.URLWithString(UIApplicationOpenSettingsURLString)
+ if (url != null && UIApplication.sharedApplication.canOpenURL(url)) {
+ UIApplication.sharedApplication.openURL(url)
+ }
+ }
+
+ actual fun callPhone(number: String) {
+ val url = NSURL.URLWithString("tel:$number")
+ if (url != null && UIApplication.sharedApplication.canOpenURL(url)) {
+ UIApplication.sharedApplication.openURL(url)
+ }
+ }
+
+ actual fun sendEmail(to: String, subject: String?, body: String?) {
+ val encodedSubject = subject?.urlEncode() ?: ""
+ val encodedBody = body?.urlEncode() ?: ""
+ val query = buildList {
+ if (encodedSubject.isNotEmpty()) add("subject=$encodedSubject")
+ if (encodedBody.isNotEmpty()) add("body=$encodedBody")
+ }.joinToString("&")
+ val mailto = if (query.isNotEmpty()) "mailto:$to?$query" else "mailto:$to"
+ val url = NSURL.URLWithString(mailto)
+ if (url != null && UIApplication.sharedApplication.canOpenURL(url)) {
+ UIApplication.sharedApplication.openURL(url)
+ }
+ }
+
+ actual fun sendViaSMS(number: String, message: String) {
+ val encodedMessage = message.urlEncode()
+ val smsUrl = if (number.isNotEmpty()) {
+ "sms:$number&body=$encodedMessage"
+ } else {
+ "sms:&body=$encodedMessage"
+ }
+ val url = NSURL.URLWithString(smsUrl)
+ if (url != null && UIApplication.sharedApplication.canOpenURL(url)) {
+ UIApplication.sharedApplication.openURL(url)
+ }
+ }
+
+ actual fun copyText(text: String) {
+ platform.UIKit.UIPasteboard.generalPasteboard.string = text
+ }
+
+ actual suspend fun shareApp(storeLink: String, message: String) {
+ val shareContent = if (message.isNotEmpty()) {
+ "$message\n$storeLink"
+ } else {
+ storeLink
+ }
+ val currentViewController = UIApplication.sharedApplication().keyWindow?.rootViewController
+ val activityViewController = UIActivityViewController(listOf(shareContent), null)
+ currentViewController?.presentViewController(
+ viewControllerToPresent = activityViewController,
+ animated = true,
+ completion = null,
+ )
+ }
}
diff --git a/core/analytics/.gitignore b/core/analytics/.gitignore
new file mode 100644
index 0000000000..42afabfd2a
--- /dev/null
+++ b/core/analytics/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/analytics/README.md b/core/analytics/README.md
new file mode 100644
index 0000000000..bd289c7e84
--- /dev/null
+++ b/core/analytics/README.md
@@ -0,0 +1,266 @@
+# :core:analytics module
+
+## Overview
+
+This module provides Project-specific analytics tracking functionality built on top of the base
+analytics library (`core-base:analytics`). It offers domain-specific tracking methods, extension
+functions, and Compose utilities tailored for microfinance applications.
+
+## Enhanced Features
+
+### π Base Analytics Enhancements (`core-base:analytics`)
+
+#### Enhanced AnalyticsEvent
+
+- **Builder Pattern Support**: Added `withParam()` and `withParams()` methods for fluent event
+ creation
+- **Parameter Validation**: Automatic validation of parameter keys (β€40 chars) and values (β€100
+ chars)
+- **Comprehensive Event Types**: Extended from 1 to 25+ predefined event types including:
+ - Navigation events (screen_view, screen_transition)
+ - User interactions (button_click, search_performed, filter_applied)
+ - Form events (form_started, form_completed, form_abandoned)
+ - Error tracking (error_occurred, api_error, network_error)
+ - Performance metrics (app_launch, loading_time)
+ - Authentication events (login_attempt, login_success, logout)
+
+#### Enhanced AnalyticsHelper Interface
+
+- **Convenience Methods**: Added overloaded `logEvent()` methods for simplified usage
+- **Built-in Helpers**: Pre-defined methods for common events:
+ - `logScreenView()`
+ - `logButtonClick()`
+ - `logError()`
+ - `logFeatureUsed()`
+- **User Management**: Support for `setUserProperty()` and `setUserId()`
+
+#### New Extension Functions (`AnalyticsExtensions.kt`)
+
+- **Event Builders**: Factory methods for creating common events
+- **Timing Utilities**:
+ - `startTiming()` and `timeExecution()` for performance tracking
+ - `TimedEvent` class for manual timing control
+- **Batch Processing**: `AnalyticsBatch` for efficient multiple event logging
+- **Safe Parameter Creation**: Validation helpers for robust parameter handling
+
+#### Enhanced UI Integration (`UiHelpers.kt`)
+
+- **Compose Integration**:
+ - `TrackScreenView()` composable for automatic screen tracking
+ - `Modifier.trackClick()` for button click tracking
+ - `TrackComposableLifecycle()` for component lifecycle tracking
+ - `rememberAnalytics()` for easy analytics access
+
+### π― Mifos-Specific Features (`core:analytics`)
+
+#### MifosAnalyticsTracker
+
+A specialized tracker providing domain-specific methods for microfinance operations:
+
+```kotlin
+val tracker = analyticsHelper.mifosTracker()
+
+// Track client operations
+tracker.trackClientOperation("create", clientId = "12345", success = true)
+
+// Track loan operations
+tracker.trackLoanOperation("apply", loanType = "personal", amount = "5000")
+
+// Track savings operations
+tracker.trackSavingsOperation("deposit", accountId = "67890", amount = "1000")
+
+// Track performance metrics
+tracker.trackPerformance(
+ "api_call",
+ duration = 250,
+ additionalMetrics = mapOf("endpoint" to "/clients")
+)
+```
+
+#### Mifos Extension Functions (`MifosAnalyticsExtensions.kt`)
+
+Convenient extension methods for common Mifos workflows:
+
+```kotlin
+// Track client creation flow
+analyticsHelper.trackClientCreationFlow("personal_info", success = true)
+
+// Track API performance
+analyticsHelper.trackApiCall("/api/clients", "GET", responseTime = 150, statusCode = 200)
+
+// Track navigation patterns
+analyticsHelper.trackNavigation("ClientList", "ClientDetails", "item_click")
+
+// Track form validation
+analyticsHelper.trackValidationError("ClientForm", "phoneNumber", "invalid_format")
+```
+
+#### Compose Analytics Utilities (`MifosComposeAnalytics.kt`)
+
+Seamless integration with Jetpack Compose:
+
+```kotlin
+@Composable
+fun ClientDetailsScreen(clientId: String) {
+ // Automatic screen tracking with business context
+ TrackMifosScreen("ClientDetails", clientId = clientId)
+
+ // Track business flow progress
+ TrackMifosFlowCompletion("client_onboarding", "step_2", totalSteps = 5)
+
+ // Document operations tracking
+ val documentTracker = rememberMifosDocumentTracker()
+
+ Button(
+ modifier = Modifier.trackClientAction("view_documents", clientId),
+ onClick = { documentTracker.trackView("identity_proof") }
+ ) {
+ Text("View Documents")
+ }
+}
+```
+
+#### Comprehensive Event Definitions (`MifosAnalyticsEvents.kt`)
+
+Over 50 predefined event types and parameter keys specific to microfinance:
+
+- **Client Management**: `CLIENT_CREATED`, `CLIENT_PROFILE_VIEWED`, etc.
+- **Loan Operations**: `LOAN_APPLICATION_STARTED`, `LOAN_DISBURSED`, `LOAN_REPAYMENT_MADE`
+- **Savings Management**: `SAVINGS_DEPOSIT_MADE`, `SAVINGS_WITHDRAWAL_MADE`
+- **Group Operations**: `GROUP_MEETING_CONDUCTED`, `GROUP_COLLECTION_MADE`
+- **Reporting**: `REPORT_GENERATED`, `DASHBOARD_VIEWED`
+- **Sync Operations**: `DATA_SYNC_COMPLETED`, `OFFLINE_TRANSACTION_SYNCED`
+
+## Usage Examples
+
+### Basic Event Logging
+
+```kotlin
+// Simple event
+analyticsHelper.logEvent("button_clicked", "button_name" to "save_client")
+
+// Event with builder pattern
+val event = AnalyticsEvent.buttonClick("save_client", "ClientForm")
+ .withParam("client_type", "individual")
+ .withParam("form_step", "3")
+analyticsHelper.logEvent(event)
+```
+
+### Performance Tracking
+
+```kotlin
+// Time a code block
+val result = analyticsHelper.timeExecution("database_query", "table" to "clients") {
+ clientRepository.getAllClients()
+}
+
+// Manual timing
+val timer = analyticsHelper.startTiming("file_upload", "file_type" to "pdf")
+uploadFile(file)
+timer.complete(mapOf("file_size" to file.size.toString()))
+```
+
+### Batch Processing
+
+```kotlin
+analyticsHelper.batch()
+ .add("client_created", "client_id" to "123")
+ .add("document_uploaded", "doc_type" to "photo")
+ .add("form_completed", "form_name" to "client_registration")
+ .flush()
+```
+
+### Error Tracking
+
+```kotlin
+// Automatic error tracking with context
+try {
+ loanService.approveLoan(loanId)
+} catch (e: ApiException) {
+ analyticsHelper.logError(e.message, e.code, "LoanApproval")
+}
+
+// Custom error tracking
+analyticsHelper.trackApiCall(
+ endpoint = "/loans/approve",
+ method = "POST",
+ responseTime = 500,
+ statusCode = 400,
+ success = false
+)
+```
+
+## Integration
+
+### Dependencies
+
+The core analytics module automatically includes the base analytics library:
+
+```kotlin
+// In your module's build.gradle.kts
+dependencies {
+ implementation(projects.core.analytics) // Includes base analytics
+}
+```
+
+### DI Setup
+
+```kotlin
+// In your DI module
+single { FirebaseAnalyticsHelper(get()) }
+single { MifosAnalyticsTracker(get()) }
+```
+
+### Compose Setup
+
+```kotlin
+@Composable
+fun App() {
+ CompositionLocalProvider(
+ LocalAnalyticsHelper provides analyticsHelper
+ ) {
+ // Your app content
+ }
+}
+```
+
+## Best Practices
+
+1. **Use Appropriate Granularity**: Track meaningful user actions, not every UI interaction
+2. **Include Business Context**: Add relevant IDs (client_id, loan_id) to events
+3. **Handle Errors Gracefully**: Use safe parameter creation for dynamic values
+4. **Batch Related Events**: Use batch processing for multiple related events
+5. **Respect Privacy**: Avoid logging sensitive personal or financial data
+6. **Performance Conscious**: Use timing utilities to track performance bottlenecks
+
+## Migration from Basic Analytics
+
+If you're migrating from basic analytics usage:
+
+```kotlin
+// Before
+analyticsHelper.logEvent(AnalyticsEvent("client_created", listOf(Param("client_id", "123"))))
+
+// After - multiple options
+analyticsHelper.logEvent("client_created", "client_id" to "123")
+analyticsHelper.trackClientOperation("create", clientId = "123")
+tracker.trackClientOperation("create", clientId = "123")
+```
+
+## Platform Support
+
+- β
**Android**: Full Firebase Analytics support
+- β
**Desktop**: Stub implementation for development
+- β
**iOS**: Firebase Analytics support (via nonJsCommonMain)
+- β
**Web**: Stub implementation
+- β
**Native**: Firebase Analytics support
+
+## Contributing
+
+When adding new analytics events:
+
+1. Add event type constants to `MifosEventTypes`
+2. Add parameter keys to `MifosParamKeys`
+3. Consider adding convenience methods to `MifosAnalyticsTracker`
+4. Add Compose utilities if UI-related
+5. Update this documentation
\ No newline at end of file
diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts
new file mode 100644
index 0000000000..47519fba00
--- /dev/null
+++ b/core/analytics/build.gradle.kts
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2026 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
+ */
+plugins {
+ alias(libs.plugins.kmp.library.convention)
+ alias(libs.plugins.jetbrainsCompose)
+ alias(libs.plugins.compose.compiler)
+}
+
+android {
+ namespace = "org.mifos.mobile.core.analytics"
+}
+
+kotlin {
+ sourceSets {
+ commonMain.dependencies {
+ api(projects.coreBase.analytics)
+ implementation(compose.runtime)
+ implementation(compose.material3)
+ implementation(compose.ui)
+ }
+ }
+}
+
diff --git a/core/analytics/src/commonMain/kotlin/org/mifos/core/analytics/MifosAnalyticsEvent.kt b/core/analytics/src/commonMain/kotlin/org/mifos/core/analytics/MifosAnalyticsEvent.kt
new file mode 100644
index 0000000000..a72aa9d4a4
--- /dev/null
+++ b/core/analytics/src/commonMain/kotlin/org/mifos/core/analytics/MifosAnalyticsEvent.kt
@@ -0,0 +1,241 @@
+/*
+ * Copyright 2026 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
+ */
+package org.mifos.core.analytics
+/**
+ * Mifos-specific event types
+ */
+object MifosEventTypes {
+ // Client Management Events
+ const val CLIENT_CREATED = "client_created"
+ const val CLIENT_UPDATED = "client_updated"
+ const val CLIENT_ACTIVATED = "client_activated"
+ const val CLIENT_CLOSED = "client_closed"
+ const val CLIENT_SEARCHED = "client_searched"
+ const val CLIENT_PROFILE_VIEWED = "client_profile_viewed"
+
+ // Loan Management Events
+ const val LOAN_APPLICATION_STARTED = "loan_application_started"
+ const val LOAN_APPLICATION_SUBMITTED = "loan_application_submitted"
+ const val LOAN_APPROVED = "loan_approved"
+ const val LOAN_REJECTED = "loan_rejected"
+ const val LOAN_DISBURSED = "loan_disbursed"
+ const val LOAN_REPAYMENT_MADE = "loan_repayment_made"
+ const val LOAN_RESCHEDULED = "loan_rescheduled"
+ const val LOAN_WRITTEN_OFF = "loan_written_off"
+ const val LOAN_CALCULATOR_USED = "loan_calculator_used"
+
+ // Savings Account Events
+ const val SAVINGS_ACCOUNT_CREATED = "savings_account_created"
+ const val SAVINGS_ACCOUNT_ACTIVATED = "savings_account_activated"
+ const val SAVINGS_DEPOSIT_MADE = "savings_deposit_made"
+ const val SAVINGS_WITHDRAWAL_MADE = "savings_withdrawal_made"
+ const val SAVINGS_ACCOUNT_CLOSED = "savings_account_closed"
+ const val SAVINGS_INTEREST_CALCULATED = "savings_interest_calculated"
+
+ // Group Management Events
+ const val GROUP_CREATED = "group_created"
+ const val GROUP_MEMBER_ADDED = "group_member_added"
+ const val GROUP_MEMBER_REMOVED = "group_member_removed"
+ const val GROUP_MEETING_CONDUCTED = "group_meeting_conducted"
+ const val GROUP_LOAN_DISBURSED = "group_loan_disbursed"
+ const val GROUP_COLLECTION_MADE = "group_collection_made"
+
+ // Center Management Events
+ const val CENTER_CREATED = "center_created"
+ const val CENTER_MEETING_CONDUCTED = "center_meeting_conducted"
+ const val CENTER_COLLECTION_SHEET_GENERATED = "center_collection_sheet_generated"
+ const val CENTER_ATTENDANCE_RECORDED = "center_attendance_recorded"
+
+ // Survey and Data Collection Events
+ const val SURVEY_STARTED = "survey_started"
+ const val SURVEY_QUESTION_ANSWERED = "survey_question_answered"
+ const val SURVEY_COMPLETED = "survey_completed"
+ const val SURVEY_ABANDONED = "survey_abandoned"
+ const val DATA_TABLE_ENTRY_CREATED = "data_table_entry_created"
+
+ // Reporting Events
+ const val REPORT_GENERATED = "report_generated"
+ const val REPORT_EXPORTED = "report_exported"
+ const val REPORT_SHARED = "report_shared"
+ const val DASHBOARD_VIEWED = "dashboard_viewed"
+ const val CHART_VIEWED = "chart_viewed"
+
+ // Sync and Offline Events
+ const val OFFLINE_MODE_ENABLED = "offline_mode_enabled"
+ const val OFFLINE_MODE_DISABLED = "offline_mode_disabled"
+ const val DATA_SYNC_INITIATED = "data_sync_initiated"
+ const val DATA_SYNC_COMPLETED = "data_sync_completed"
+ const val DATA_SYNC_FAILED = "data_sync_failed"
+ const val OFFLINE_TRANSACTION_QUEUED = "offline_transaction_queued"
+ const val OFFLINE_TRANSACTION_SYNCED = "offline_transaction_synced"
+
+ // Authentication and Security Events
+ const val BIOMETRIC_LOGIN_ATTEMPT = "biometric_login_attempt"
+ const val PIN_LOGIN_ATTEMPT = "pin_login_attempt"
+ const val PASSWORD_CHANGED = "password_changed"
+ const val SESSION_TIMEOUT = "session_timeout"
+ const val UNAUTHORIZED_ACCESS_ATTEMPT = "unauthorized_access_attempt"
+
+ // Document and File Events
+ const val DOCUMENT_UPLOADED = "document_uploaded"
+ const val DOCUMENT_DOWNLOADED = "document_downloaded"
+ const val PHOTO_CAPTURED = "photo_captured"
+ const val SIGNATURE_CAPTURED = "signature_captured"
+ const val FILE_SHARED = "file_shared"
+
+ // Configuration and Settings Events
+ const val LANGUAGE_CHANGED = "language_changed"
+ const val THEME_CHANGED = "theme_changed"
+ const val NOTIFICATION_SETTING_CHANGED = "notification_setting_changed"
+ const val BACKUP_CREATED = "backup_created"
+ const val BACKUP_RESTORED = "backup_restored"
+
+ // Performance and Error Events
+ const val APP_CRASH_OCCURRED = "app_crash_occurred"
+ const val API_TIMEOUT = "api_timeout"
+ const val NETWORK_CONNECTION_LOST = "network_connection_lost"
+ const val NETWORK_CONNECTION_RESTORED = "network_connection_restored"
+ const val LOW_STORAGE_WARNING = "low_storage_warning"
+ const val BATTERY_LOW_WARNING = "battery_low_warning"
+}
+
+/**
+ * Mifos-specific parameter keys
+ */
+object MifosParamKeys {
+ // Client-specific parameters
+ const val CLIENT_ID = "client_id"
+ const val CLIENT_TYPE = "client_type"
+ const val CLIENT_STATUS = "client_status"
+ const val CLIENT_OFFICE = "client_office"
+ const val CLIENT_STAFF = "client_staff"
+
+ // Loan-specific parameters
+ const val LOAN_ID = "loan_id"
+ const val LOAN_PRODUCT_ID = "loan_product_id"
+ const val LOAN_PRODUCT_NAME = "loan_product_name"
+ const val LOAN_AMOUNT = "loan_amount"
+ const val LOAN_TERM = "loan_term"
+ const val LOAN_INTEREST_RATE = "loan_interest_rate"
+ const val LOAN_STATUS = "loan_status"
+ const val REPAYMENT_AMOUNT = "repayment_amount"
+ const val REPAYMENT_METHOD = "repayment_method"
+
+ // Savings-specific parameters
+ const val SAVINGS_ACCOUNT_ID = "savings_account_id"
+ const val SAVINGS_PRODUCT_ID = "savings_product_id"
+ const val DEPOSIT_AMOUNT = "deposit_amount"
+ const val WITHDRAWAL_AMOUNT = "withdrawal_amount"
+ const val ACCOUNT_BALANCE = "account_balance"
+ const val TRANSACTION_TYPE = "transaction_type"
+
+ // Group-specific parameters
+ const val GROUP_ID = "group_id"
+ const val GROUP_NAME = "group_name"
+ const val GROUP_TYPE = "group_type"
+ const val MEMBER_COUNT = "member_count"
+ const val MEETING_DATE = "meeting_date"
+ const val ATTENDANCE_COUNT = "attendance_count"
+
+ // Center-specific parameters
+ const val CENTER_ID = "center_id"
+ const val CENTER_NAME = "center_name"
+ const val COLLECTION_AMOUNT = "collection_amount"
+ const val COLLECTION_METHOD = "collection_method"
+
+ // Survey-specific parameters
+ const val SURVEY_ID = "survey_id"
+ const val SURVEY_NAME = "survey_name"
+ const val QUESTION_ID = "question_id"
+ const val QUESTION_TYPE = "question_type"
+ const val ANSWER_VALUE = "answer_value"
+
+ // Report-specific parameters
+ const val REPORT_ID = "report_id"
+ const val REPORT_NAME = "report_name"
+ const val REPORT_TYPE = "report_type"
+ const val EXPORT_FORMAT = "export_format"
+ const val FILTER_OFFICE = "filter_office"
+ const val FILTER_STAFF = "filter_staff"
+ const val FILTER_DATE_FROM = "filter_date_from"
+ const val FILTER_DATE_TO = "filter_date_to"
+
+ // Sync-specific parameters
+ const val SYNC_TYPE = "sync_type"
+ const val ENTITY_TYPE = "entity_type"
+ const val RECORD_COUNT = "record_count"
+ const val SYNC_DIRECTION = "sync_direction"
+ const val CONFLICT_COUNT = "conflict_count"
+
+ // Document-specific parameters
+ const val DOCUMENT_ID = "document_id"
+ const val DOCUMENT_TYPE = "document_type"
+ const val FILE_SIZE = "file_size"
+ const val FILE_FORMAT = "file_format"
+ const val UPLOAD_METHOD = "upload_method"
+
+ // Performance-specific parameters
+ const val MEMORY_USAGE = "memory_usage_mb"
+ const val STORAGE_AVAILABLE = "storage_available_mb"
+ const val BATTERY_LEVEL = "battery_level"
+ const val NETWORK_SPEED = "network_speed_kbps"
+ const val GPS_ACCURACY = "gps_accuracy_meters"
+
+ // Security-specific parameters
+ const val AUTH_METHOD = "auth_method"
+ const val SECURITY_LEVEL = "security_level"
+ const val PERMISSION_TYPE = "permission_type"
+ const val ACCESS_LEVEL = "access_level"
+
+ // Configuration-specific parameters
+ const val SETTING_NAME = "setting_name"
+ const val OLD_VALUE = "old_value"
+ const val NEW_VALUE = "new_value"
+ const val LANGUAGE_CODE = "language_code"
+ const val THEME_NAME = "theme_name"
+}
+
+/**
+ * Predefined common parameter values
+ */
+object MifosParamValues {
+ // Client types
+ const val CLIENT_TYPE_INDIVIDUAL = "individual"
+ const val CLIENT_TYPE_ENTITY = "entity"
+
+ // Loan statuses
+ const val LOAN_STATUS_PENDING = "pending"
+ const val LOAN_STATUS_APPROVED = "approved"
+ const val LOAN_STATUS_ACTIVE = "active"
+ const val LOAN_STATUS_CLOSED = "closed"
+ const val LOAN_STATUS_REJECTED = "rejected"
+
+ // Transaction types
+ const val TRANSACTION_DEPOSIT = "deposit"
+ const val TRANSACTION_WITHDRAWAL = "withdrawal"
+ const val TRANSACTION_TRANSFER = "transfer"
+
+ // Authentication methods
+ const val AUTH_PASSWORD = "password"
+ const val AUTH_PIN = "pin"
+ const val AUTH_BIOMETRIC = "biometric"
+ const val AUTH_PATTERN = "pattern"
+
+ // Sync types
+ const val SYNC_FULL = "full"
+ const val SYNC_INCREMENTAL = "incremental"
+ const val SYNC_FORCED = "forced"
+
+ // Export formats
+ const val FORMAT_PDF = "pdf"
+ const val FORMAT_EXCEL = "excel"
+ const val FORMAT_CSV = "csv"
+ const val FORMAT_JSON = "json"
+}
diff --git a/core/analytics/src/commonMain/kotlin/org/mifos/core/analytics/MifosAnalyticsExtensions.kt b/core/analytics/src/commonMain/kotlin/org/mifos/core/analytics/MifosAnalyticsExtensions.kt
new file mode 100644
index 0000000000..2726e6c6d6
--- /dev/null
+++ b/core/analytics/src/commonMain/kotlin/org/mifos/core/analytics/MifosAnalyticsExtensions.kt
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2026 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
+ */
+@file:Suppress("ktlint:standard:discouraged-comment-location")
+
+package org.mifos.core.analytics
+
+import template.core.base.analytics.AnalyticsHelper
+import template.core.base.analytics.ParamKeys
+
+/**
+ * Extension functions for Mifos-specific analytics operations
+ */
+
+/**
+ * Extension for tracking client creation flow
+ */
+fun AnalyticsHelper.trackClientCreationFlow(step: String, success: Boolean = true) {
+ logEvent(
+ "client_creation_flow",
+ "step" to step,
+ ParamKeys.SUCCESS to success.toString(),
+ )
+}
+
+/**
+ * Extension for tracking loan application flow
+ */
+fun AnalyticsHelper.trackLoanApplicationFlow(
+ step: String,
+ loanProductId: String? = null,
+ success: Boolean = true,
+) {
+ val params = mutableMapOf(
+ "step" to step,
+ ParamKeys.SUCCESS to success.toString(),
+ )
+ loanProductId?.let { params["loan_product_id"] = it }
+ logEvent("loan_application_flow", params)
+}
+
+/**
+ * Extension for tracking navigation patterns
+ */
+fun AnalyticsHelper.trackNavigation(from: String, to: String, trigger: String = "user_action") {
+ logEvent(
+ "navigation_flow",
+ "from_screen" to from,
+ "to_screen" to to,
+ "trigger" to trigger,
+ )
+}
+
+/**
+ * Extension for tracking API call performance
+ */
+fun AnalyticsHelper.trackApiCall(
+ endpoint: String,
+ method: String,
+ responseTime: Long,
+ statusCode: Int,
+ success: Boolean = statusCode in 200..299,
+) {
+ logEvent(
+ "api_call",
+ ParamKeys.API_ENDPOINT to endpoint,
+ "http_method" to method,
+ "response_time_ms" to responseTime.toString(),
+ "status_code" to statusCode.toString(),
+ ParamKeys.SUCCESS to success.toString(),
+ )
+}
+
+/**
+ * Extension for tracking form validation errors
+ */
+fun AnalyticsHelper.trackValidationError(
+ formName: String,
+ fieldName: String,
+ errorType: String,
+ screenName: String? = null,
+) {
+ val params = mutableMapOf(
+ ParamKeys.FORM_NAME to formName,
+ ParamKeys.FIELD_NAME to fieldName,
+ "validation_error_type" to errorType,
+ )
+ screenName?.let { params[ParamKeys.SCREEN_NAME] = it }
+ logEvent("validation_error", params)
+}
+
+/**
+ * Extension for tracking data synchronization
+ */
+fun AnalyticsHelper.trackDataSync(
+ entityType: String,
+ operation: String, // "upload", "download", "merge"
+ recordCount: Int,
+ duration: Long,
+ success: Boolean = true,
+) {
+ logEvent(
+ "data_sync",
+ "entity_type" to entityType,
+ "sync_operation" to operation,
+ "record_count" to recordCount.toString(),
+ ParamKeys.DURATION to "${duration}ms",
+ ParamKeys.SUCCESS to success.toString(),
+ )
+}
+
+/**
+ * Extension for tracking user preferences changes
+ */
+fun AnalyticsHelper.trackPreferenceChange(
+ preferenceName: String,
+ oldValue: String?,
+ newValue: String,
+) {
+ val params = mutableMapOf(
+ "preference_name" to preferenceName,
+ "new_value" to newValue,
+ )
+ oldValue?.let { params["old_value"] = it }
+ logEvent("preference_changed", params)
+}
+
+/**
+ * Extension for tracking tutorial interactions
+ */
+fun AnalyticsHelper.trackTutorial(action: String, step: Int, tutorialName: String) {
+ logEvent(
+ "tutorial_interaction",
+ "tutorial_name" to tutorialName,
+ "tutorial_action" to action,
+ ParamKeys.TUTORIAL_STEP to step.toString(),
+ )
+}
+
+/**
+ * Extension for tracking document operations
+ */
+fun AnalyticsHelper.trackDocumentOperation(
+ // "upload", "download", "view", "delete"
+ operation: String,
+ documentType: String,
+ fileSize: Long? = null,
+ success: Boolean = true,
+) {
+ val params = mutableMapOf(
+ "document_operation" to operation,
+ "document_type" to documentType,
+ ParamKeys.SUCCESS to success.toString(),
+ )
+ fileSize?.let { params["file_size_bytes"] = it.toString() }
+ logEvent("document_operation", params)
+}
+
+/**
+ * Extension for tracking biometric authentication
+ */
+fun AnalyticsHelper.trackBiometricAuth(
+ // "fingerprint", "face", "voice"
+ authType: String,
+ success: Boolean,
+ fallbackUsed: Boolean = false,
+) {
+ logEvent(
+ "biometric_auth",
+ "auth_type" to authType,
+ ParamKeys.SUCCESS to success.toString(),
+ "fallback_used" to fallbackUsed.toString(),
+ )
+}
+
+/**
+ * Extension for tracking geolocation usage
+ */
+fun AnalyticsHelper.trackLocationUsage(
+ // "client_visit", "center_meeting", "field_collection"
+ feature: String,
+ accuracy: Float? = null,
+ permissionGranted: Boolean = true,
+) {
+ val params = mutableMapOf(
+ "location_feature" to feature,
+ "permission_granted" to permissionGranted.toString(),
+ )
+ accuracy?.let { params["location_accuracy"] = it.toString() }
+ logEvent("location_usage", params)
+}
+
+/**
+ * Extension for tracking notification interactions
+ */
+fun AnalyticsHelper.trackNotificationInteraction(
+ notificationType: String,
+ // "opened", "dismissed", "action_clicked"
+ action: String,
+ notificationId: String? = null,
+) {
+ val params = mutableMapOf(
+ "notification_type" to notificationType,
+ "notification_action" to action,
+ )
+ notificationId?.let { params["notification_id"] = it }
+ logEvent("notification_interaction", params)
+}
+
+/**
+ * Extension for tracking accessibility features usage
+ */
+fun AnalyticsHelper.trackAccessibilityUsage(
+ // "talkback", "large_text", "high_contrast"
+ feature: String,
+ enabled: Boolean,
+) {
+ logEvent(
+ "accessibility_usage",
+ "accessibility_feature" to feature,
+ "enabled" to enabled.toString(),
+ )
+}
+
+/**
+ * Extension for tracking backup and restore operations
+ */
+fun AnalyticsHelper.trackBackupRestore(
+ // "backup", "restore"
+ operation: String,
+ dataSize: Long,
+ duration: Long,
+ success: Boolean = true,
+) {
+ logEvent(
+ "backup_restore",
+ "backup_operation" to operation,
+ "data_size_bytes" to dataSize.toString(),
+ ParamKeys.DURATION to "${duration}ms",
+ ParamKeys.SUCCESS to success.toString(),
+ )
+}
+
+/**
+ * Convenience function to create Mifos analytics tracker
+ */
+fun AnalyticsHelper.mifosTracker(): MifosAnalyticsTracker = MifosAnalyticsTracker(this)
+
+/**
+ * Extension for tracking custom business events specific to microfinance
+ */
+fun AnalyticsHelper.trackMicrofinanceEvent(
+ eventName: String,
+ businessParams: Map,
+) {
+ logEvent(
+ "microfinance_event",
+ mapOf("event_name" to eventName) + businessParams,
+ )
+}
diff --git a/core/analytics/src/commonMain/kotlin/org/mifos/core/analytics/MifosAnalyticsTracker.kt b/core/analytics/src/commonMain/kotlin/org/mifos/core/analytics/MifosAnalyticsTracker.kt
new file mode 100644
index 0000000000..4f42cd4236
--- /dev/null
+++ b/core/analytics/src/commonMain/kotlin/org/mifos/core/analytics/MifosAnalyticsTracker.kt
@@ -0,0 +1,239 @@
+/*
+ * Copyright 2026 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
+ */
+package org.mifos.core.analytics
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import template.core.base.analytics.AnalyticsEvent
+import template.core.base.analytics.AnalyticsHelper
+import template.core.base.analytics.Param
+import template.core.base.analytics.ParamKeys
+import template.core.base.analytics.Types
+import template.core.base.analytics.rememberAnalyticsHelper
+
+/**
+ * Project-specific analytics tracker that provides domain-specific
+ * tracking methods for the Mifos application.
+ */
+class MifosAnalyticsTracker(
+ private val analyticsHelper: AnalyticsHelper,
+) {
+
+ /** Track user authentication events */
+ fun trackLogin(method: String, success: Boolean, errorCode: String? = null) {
+ val eventType = if (success) Types.LOGIN_SUCCESS else Types.LOGIN_FAILURE
+ val params = mutableListOf(
+ Param("login_method", method),
+ Param(ParamKeys.SUCCESS, success.toString()),
+ )
+ errorCode?.let { params.add(Param(ParamKeys.ERROR_CODE, it)) }
+
+ analyticsHelper.logEvent(AnalyticsEvent(eventType, params))
+ }
+
+ /** Track client-related operations */
+ fun trackClientOperation(
+ // "create", "view", "update", "search"
+ operation: String,
+ clientId: String? = null,
+ success: Boolean = true,
+ duration: Long? = null,
+ ) {
+ val params = mutableListOf(
+ Param("client_operation", operation),
+ Param(ParamKeys.SUCCESS, success.toString()),
+ )
+ clientId?.let { params.add(Param("client_id", it)) }
+ duration?.let { params.add(Param(ParamKeys.DURATION, "${it}ms")) }
+
+ analyticsHelper.logEvent(AnalyticsEvent("client_operation", params))
+ }
+
+ /** Track loan-related operations */
+ fun trackLoanOperation(
+ // "apply", "approve", "disburse", "repay", "view"
+ operation: String,
+ loanId: String? = null,
+ loanType: String? = null,
+ amount: String? = null,
+ success: Boolean = true,
+ ) {
+ val params = mutableListOf(
+ Param("loan_operation", operation),
+ Param(ParamKeys.SUCCESS, success.toString()),
+ )
+ loanId?.let { params.add(Param("loan_id", it)) }
+ loanType?.let { params.add(Param("loan_type", it)) }
+ amount?.let { params.add(Param("loan_amount", it)) }
+
+ analyticsHelper.logEvent(AnalyticsEvent("loan_operation", params))
+ }
+
+ /** Track savings account operations */
+ fun trackSavingsOperation(
+ // "create", "deposit", "withdraw", "view"
+ operation: String,
+ accountId: String? = null,
+ amount: String? = null,
+ success: Boolean = true,
+ ) {
+ val params = mutableListOf(
+ Param("savings_operation", operation),
+ Param(ParamKeys.SUCCESS, success.toString()),
+ )
+ accountId?.let { params.add(Param("account_id", it)) }
+ amount?.let { params.add(Param("transaction_amount", it)) }
+
+ analyticsHelper.logEvent(AnalyticsEvent("savings_operation", params))
+ }
+
+ /** Track group operations */
+ fun trackGroupOperation(
+ // "create", "join", "leave", "view"
+ operation: String,
+ groupId: String? = null,
+ groupType: String? = null,
+ memberCount: Int? = null,
+ success: Boolean = true,
+ ) {
+ val params = mutableListOf(
+ Param("group_operation", operation),
+ Param(ParamKeys.SUCCESS, success.toString()),
+ )
+ groupId?.let { params.add(Param("group_id", it)) }
+ groupType?.let { params.add(Param("group_type", it)) }
+ memberCount?.let { params.add(Param("member_count", it.toString())) }
+
+ analyticsHelper.logEvent(AnalyticsEvent("group_operation", params))
+ }
+
+ /** Track center operations (Mifos-specific) */
+ fun trackCenterOperation(
+ // "create", "view", "meeting", "collection"
+ operation: String,
+ centerId: String? = null,
+ meetingDate: String? = null,
+ attendance: Int? = null,
+ success: Boolean = true,
+ ) {
+ val params = mutableListOf(
+ Param("center_operation", operation),
+ Param(ParamKeys.SUCCESS, success.toString()),
+ )
+ centerId?.let { params.add(Param("center_id", it)) }
+ meetingDate?.let { params.add(Param("meeting_date", it)) }
+ attendance?.let { params.add(Param("attendance_count", it.toString())) }
+
+ analyticsHelper.logEvent(AnalyticsEvent("center_operation", params))
+ }
+
+ /** Track survey operations */
+ fun trackSurveyOperation(
+ // "start", "complete", "abandon"
+ operation: String,
+ surveyId: String? = null,
+ questionCount: Int? = null,
+ completionTime: Long? = null,
+ ) {
+ val params = mutableListOf(Param("survey_operation", operation))
+ surveyId?.let { params.add(Param("survey_id", it)) }
+ questionCount?.let { params.add(Param("question_count", it.toString())) }
+ completionTime?.let { params.add(Param(ParamKeys.COMPLETION_TIME, "${it}ms")) }
+
+ analyticsHelper.logEvent(AnalyticsEvent("survey_operation", params))
+ }
+
+ /** Track report generation */
+ fun trackReportGeneration(
+ reportType: String,
+ filterParams: Map = emptyMap(),
+ resultCount: Int? = null,
+ generationTime: Long? = null,
+ success: Boolean = true,
+ ) {
+ val params = mutableListOf(
+ Param("report_type", reportType),
+ Param(ParamKeys.SUCCESS, success.toString()),
+ )
+ resultCount?.let { params.add(Param(ParamKeys.RESULT_COUNT, it.toString())) }
+ generationTime?.let { params.add(Param("generation_time_ms", it.toString())) }
+
+ // Add filter parameters with prefix
+ filterParams.forEach { (key, value) ->
+ params.add(Param("filter_$key", value))
+ }
+
+ analyticsHelper.logEvent(AnalyticsEvent("report_generated", params))
+ }
+
+ /** Track sync operations */
+ fun trackSync(
+ // "full", "incremental", "force"
+ syncType: String,
+ itemCount: Int? = null,
+ duration: Long? = null,
+ success: Boolean = true,
+ errorMessage: String? = null,
+ ) {
+ val params = mutableListOf(
+ Param("sync_type", syncType),
+ Param(ParamKeys.SUCCESS, success.toString()),
+ )
+ itemCount?.let { params.add(Param("sync_item_count", it.toString())) }
+ duration?.let { params.add(Param(ParamKeys.DURATION, "${it}ms")) }
+ errorMessage?.let { params.add(Param(ParamKeys.ERROR_MESSAGE, it)) }
+
+ analyticsHelper.logEvent(AnalyticsEvent("sync_operation", params))
+ }
+
+ /** Track offline operations */
+ fun trackOfflineOperation(
+ operation: String,
+ // "client", "loan", "savings", etc.
+ entityType: String,
+ queueSize: Int? = null,
+ success: Boolean = true,
+ ) {
+ val params = mutableListOf(
+ Param("offline_operation", operation),
+ Param("entity_type", entityType),
+ Param(ParamKeys.SUCCESS, success.toString()),
+ )
+ queueSize?.let { params.add(Param("queue_size", it.toString())) }
+
+ analyticsHelper.logEvent(AnalyticsEvent("offline_operation", params))
+ }
+
+ /** Track performance metrics */
+ fun trackPerformance(
+ operation: String,
+ duration: Long,
+ success: Boolean = true,
+ additionalMetrics: Map = emptyMap(),
+ ) {
+ val params = mutableListOf(
+ Param("performance_operation", operation),
+ Param(ParamKeys.DURATION, "${duration}ms"),
+ Param(ParamKeys.SUCCESS, success.toString()),
+ )
+
+ additionalMetrics.forEach { (key, value) ->
+ params.add(Param("metric_$key", value))
+ }
+
+ analyticsHelper.logEvent(AnalyticsEvent("performance_metric", params))
+ }
+}
+
+@Composable
+fun rememberMifosAnalyticsTracker(): MifosAnalyticsTracker {
+ val analyticsHelper = rememberAnalyticsHelper()
+ return remember(analyticsHelper) { MifosAnalyticsTracker(analyticsHelper) }
+}
diff --git a/core/analytics/src/commonMain/kotlin/org/mifos/core/analytics/MifosComposeAnalytics.kt b/core/analytics/src/commonMain/kotlin/org/mifos/core/analytics/MifosComposeAnalytics.kt
new file mode 100644
index 0000000000..6fd8b291ca
--- /dev/null
+++ b/core/analytics/src/commonMain/kotlin/org/mifos/core/analytics/MifosComposeAnalytics.kt
@@ -0,0 +1,249 @@
+/*
+ * Copyright 2026 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
+ */
+@file:Suppress("SpreadOperator")
+
+package org.mifos.core.analytics
+
+import androidx.compose.foundation.clickable
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import template.core.base.analytics.AnalyticsHelper
+import template.core.base.analytics.rememberAnalyticsHelper
+
+/** Mifos-specific Compose analytics utilities */
+
+/** Track Mifos screen views with additional business context */
+@Composable
+fun TrackMifosScreen(
+ screenName: String,
+ clientId: String? = null,
+ loanId: String? = null,
+ groupId: String? = null,
+ additionalParams: Map = emptyMap(),
+) {
+ val analytics = rememberAnalyticsHelper()
+ val mifosTracker = remember(analytics) { analytics.mifosTracker() }
+
+ LaunchedEffect(screenName) {
+ val params = mutableMapOf()
+ clientId?.let { params[MifosParamKeys.CLIENT_ID] = it }
+ loanId?.let { params[MifosParamKeys.LOAN_ID] = it }
+ groupId?.let { params[MifosParamKeys.GROUP_ID] = it }
+ params.putAll(additionalParams)
+
+ analytics.logScreenView(screenName)
+ if (params.isNotEmpty()) {
+ analytics.logEvent("mifos_screen_context", params)
+ }
+ }
+}
+
+/** Track client-related button clicks */
+fun Modifier.trackClientAction(
+ action: String,
+ clientId: String? = null,
+): Modifier = this.then(
+ Modifier.trackMifosAction(
+ "client_action",
+ mapOf(
+ "action" to action,
+ *clientId?.let { arrayOf(MifosParamKeys.CLIENT_ID to it) } ?: emptyArray(),
+ ),
+ ),
+)
+
+/** Track loan-related button clicks */
+fun Modifier.trackLoanAction(
+ action: String,
+ loanId: String? = null,
+ loanProductId: String? = null,
+): Modifier = this.then(
+ Modifier.trackMifosAction(
+ "loan_action",
+ mapOf(
+ "action" to action,
+ *loanId?.let { arrayOf(MifosParamKeys.LOAN_ID to it) } ?: emptyArray(),
+ *loanProductId?.let { arrayOf(MifosParamKeys.LOAN_PRODUCT_ID to it) } ?: emptyArray(),
+ ),
+ ),
+)
+
+/** Track savings-related button clicks */
+fun Modifier.trackSavingsAction(
+ action: String,
+ accountId: String? = null,
+): Modifier = this.then(
+ Modifier.trackMifosAction(
+ "savings_action",
+ mapOf(
+ "action" to action,
+ *accountId?.let { arrayOf(MifosParamKeys.SAVINGS_ACCOUNT_ID to it) } ?: emptyArray(),
+ ),
+ ),
+)
+
+/** Generic Mifos action tracker */
+@Suppress("UnusedParameter")
+private fun Modifier.trackMifosAction(
+ eventType: String,
+ params: Map,
+): Modifier = this.clickable {
+ // Note: In a real implementation, you'd need to access the analytics helper here
+ // This is a simplified version for demonstration
+}
+
+/** Track form field interactions in Mifos forms */
+@Composable
+fun TrackMifosFormField(
+ fieldName: String,
+ formName: String,
+ fieldType: String = "text",
+) {
+ val analytics = rememberAnalyticsHelper()
+
+ LaunchedEffect(fieldName, formName) {
+ analytics.logEvent(
+ "form_field_focused",
+ "field_name" to fieldName,
+ "form_name" to formName,
+ "field_type" to fieldType,
+ )
+ }
+}
+
+/** Track Mifos business flow completion */
+@Composable
+fun TrackMifosFlowCompletion(
+ flowName: String,
+ step: String,
+ totalSteps: Int,
+ entityId: String? = null,
+) {
+ val analytics = rememberAnalyticsHelper()
+
+ LaunchedEffect(step) {
+ val params = mutableMapOf(
+ "flow_name" to flowName,
+ "current_step" to step,
+ "total_steps" to totalSteps.toString(),
+ "progress_percentage" to "${(step.toIntOrNull() ?: 0) * 100 / totalSteps}",
+ )
+ entityId?.let { params["entity_id"] = it }
+
+ analytics.logEvent("mifos_flow_progress", params)
+ }
+}
+
+/** Track navigation within Mifos workflows */
+@Composable
+fun TrackMifosNavigation(
+ fromScreen: String,
+ toScreen: String,
+ navigationTrigger: String = "user_action",
+ workflowName: String? = null,
+) {
+ val analytics = rememberAnalyticsHelper()
+
+ LaunchedEffect(fromScreen, toScreen) {
+ val params = mutableMapOf(
+ "from_screen" to fromScreen,
+ "to_screen" to toScreen,
+ "trigger" to navigationTrigger,
+ )
+ workflowName?.let { params["workflow"] = it }
+
+ analytics.logEvent("mifos_navigation", params)
+ }
+}
+
+/** Track document operations in Mifos */
+@Composable
+fun rememberMifosDocumentTracker(): DocumentTracker {
+ val analytics = rememberAnalyticsHelper()
+ return remember(analytics) { DocumentTracker(analytics) }
+}
+
+class DocumentTracker(private val analytics: AnalyticsHelper) {
+ fun trackUpload(documentType: String, fileSize: Long, success: Boolean = true) {
+ analytics.trackDocumentOperation("upload", documentType, fileSize, success)
+ }
+
+ fun trackDownload(documentType: String, success: Boolean = true) {
+ analytics.trackDocumentOperation("download", documentType, success = success)
+ }
+
+ fun trackView(documentType: String) {
+ analytics.trackDocumentOperation("view", documentType, success = true)
+ }
+}
+
+/** Track survey interactions in Mifos */
+@Composable
+fun TrackMifosSurvey(
+ surveyId: String,
+ // "started", "answered", "completed", "abandoned"
+ action: String,
+ questionId: String? = null,
+) {
+ val analytics = rememberAnalyticsHelper()
+
+ LaunchedEffect(surveyId, questionId, action) {
+ val params = mutableMapOf(
+ MifosParamKeys.SURVEY_ID to surveyId,
+ "survey_action" to action,
+ )
+ questionId?.let { params[MifosParamKeys.QUESTION_ID] = it }
+
+ analytics.logEvent("mifos_survey_interaction", params)
+ }
+}
+
+/** Track report generation in Mifos */
+@Composable
+fun rememberMifosReportTracker(): ReportTracker {
+ val analytics = rememberAnalyticsHelper()
+ return remember(analytics) { ReportTracker(analytics) }
+}
+
+class ReportTracker(private val analytics: AnalyticsHelper) {
+ val analyticsTracker = MifosAnalyticsTracker(analytics)
+ fun trackGeneration(
+ reportName: String,
+ filters: Map = emptyMap(),
+ duration: Long,
+ success: Boolean = true,
+ ) {
+ analyticsTracker.trackReportGeneration(
+ reportType = reportName,
+ filterParams = filters,
+ generationTime = duration,
+ success = success,
+ )
+ }
+
+ fun trackExport(reportName: String, format: String, success: Boolean = true) {
+ analytics.logEvent(
+ "report_exported",
+ MifosParamKeys.REPORT_NAME to reportName,
+ MifosParamKeys.EXPORT_FORMAT to format,
+ "success" to success.toString(),
+ )
+ }
+
+ fun trackShare(reportName: String, method: String) {
+ analytics.logEvent(
+ "report_shared",
+ MifosParamKeys.REPORT_NAME to reportName,
+ "share_method" to method,
+ )
+ }
+}
diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts
index 1a0a1c75c7..572f2473b8 100644
--- a/core/data/build.gradle.kts
+++ b/core/data/build.gradle.kts
@@ -36,6 +36,7 @@ kotlin {
api(projects.core.datastore)
api(projects.core.model)
implementation(projects.core.network)
+ implementation(projects.core.analytics)
implementation(libs.kotlinx.serialization.json)
}
androidMain.dependencies {
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index f189c14c0d..eac8ec9e25 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -30,6 +30,7 @@ kotlin{
implementation(libs.google.oss.licenses)
}
commonMain.dependencies {
+ implementation(projects.core.analytics)
api(projects.core.designsystem)
implementation(projects.core.model)
api(libs.kotlinx.datetime)
diff --git a/fastlane/README.md b/fastlane/README.md
index c8de7f0021..af00a1e17d 100644
--- a/fastlane/README.md
+++ b/fastlane/README.md
@@ -114,7 +114,15 @@ Generate full release notes from specified tag or latest release tag
[bundle exec] fastlane ios build_ios
```
-Build iOS application
+Build Ios application
+
+### ios build_signed_ios
+
+```sh
+[bundle exec] fastlane ios build_signed_ios
+```
+
+Build Signed Ios application
### ios increment_version
@@ -122,7 +130,15 @@ Build iOS application
[bundle exec] fastlane ios increment_version
```
+Increment build number from latest Firebase release and set version number
+
+### ios generateReleaseNote
+
+```sh
+[bundle exec] fastlane ios generateReleaseNote
+```
+Generate release notes
### ios deploy_on_firebase
@@ -132,13 +148,21 @@ Build iOS application
Upload iOS application to Firebase App Distribution
-### ios generateReleaseNote
+### ios beta
```sh
-[bundle exec] fastlane ios generateReleaseNote
+[bundle exec] fastlane ios beta
```
-Generate release notes
+Upload beta build to TestFlight
+
+### ios release
+
+```sh
+[bundle exec] fastlane ios release
+```
+
+Upload iOS Application to App Store
----
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index efd7a28ad3..03aec1b641 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -358,6 +358,7 @@ baselineprofile = { id = "androidx.baselineprofile", version.ref = "androidxMacr
cmp-feature-convention = { id = "org.convention.cmp.feature", version = "unspecified" }
kmp-koin-convention = { id = "org.convention.kmp.koin", version = "unspecified" }
kmp-library-convention = { id = "org.convention.kmp.library", version = "unspecified" }
+kmp-core-base-library-convention = { id = "org.convention.kmp.core.base.library", version = "unspecified" }
android-application-firebase = { id = "org.convention.android.application.firebase" }
android-lint = { id = "org.convention.android.application.lint" }
diff --git a/scripts/check_ios_version.sh b/scripts/check_ios_version.sh
new file mode 100644
index 0000000000..8b023dd6b0
--- /dev/null
+++ b/scripts/check_ios_version.sh
@@ -0,0 +1,135 @@
+#!/bin/bash
+
+# ==============================================================================
+# iOS Version Check Script
+# ==============================================================================
+# This script displays current iOS version configuration
+# ==============================================================================
+
+set -e
+
+# Color codes
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+
+print_success() {
+ echo -e "${GREEN}β $1${NC}"
+}
+
+print_info() {
+ echo -e "${CYAN}βΉ $1${NC}"
+}
+
+print_section() {
+ echo
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo -e "${BLUE}$1${NC}"
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo
+}
+
+# Navigate to project root
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+cd "$PROJECT_ROOT"
+
+print_section "π± iOS Version Configuration Check"
+
+# Check version.txt (Gradle-generated)
+if [ -f "version.txt" ]; then
+ VERSION_TXT=$(cat version.txt)
+
+ # Extract App Store version: Year.Month.CommitCount
+ # From: 2026.1.1-beta.0.9 β To: 2026.1.9
+ BASE_VERSION=$(echo "$VERSION_TXT" | cut -d'-' -f1 | cut -d'+' -f1)
+ YEAR=$(echo "$BASE_VERSION" | cut -d'.' -f1)
+ MONTH=$(echo "$BASE_VERSION" | cut -d'.' -f2)
+
+ # Extract commit count from pre-release identifier
+ if echo "$VERSION_TXT" | grep -q '\-'; then
+ PRE_RELEASE=$(echo "$VERSION_TXT" | cut -d'-' -f2 | cut -d'+' -f1)
+ COMMIT_COUNT=$(echo "$PRE_RELEASE" | rev | cut -d'.' -f1 | rev)
+ else
+ COMMIT_COUNT="0"
+ fi
+
+ APPSTORE_VERSION="${YEAR}.${MONTH}.${COMMIT_COUNT}"
+
+ print_success "Gradle version.txt: $VERSION_TXT"
+ if [ "$VERSION_TXT" != "$APPSTORE_VERSION" ]; then
+ print_info "App Store sanitized version: $APPSTORE_VERSION"
+ echo " (Format: Year.Month.CommitCount - commit count extracted from pre-release)"
+ fi
+else
+ echo "β οΈ version.txt not found. Run: ./gradlew versionFile"
+fi
+echo
+
+# Check Xcode project settings
+print_info "Xcode Project Settings:"
+MARKETING_VERSION=$(xcodebuild -project cmp-ios/iosApp.xcodeproj -showBuildSettings 2>/dev/null | grep "MARKETING_VERSION" | awk '{print $3}' | head -1)
+CURRENT_PROJECT_VERSION=$(xcodebuild -project cmp-ios/iosApp.xcodeproj -showBuildSettings 2>/dev/null | grep "CURRENT_PROJECT_VERSION" | awk '{print $3}' | head -1)
+
+if [ -n "$MARKETING_VERSION" ]; then
+ print_success "MARKETING_VERSION: $MARKETING_VERSION"
+else
+ echo "β οΈ MARKETING_VERSION not found"
+fi
+
+if [ -n "$CURRENT_PROJECT_VERSION" ]; then
+ print_success "CURRENT_PROJECT_VERSION: $CURRENT_PROJECT_VERSION"
+else
+ echo "β οΈ CURRENT_PROJECT_VERSION not found"
+fi
+echo
+
+# Check Info.plist
+print_info "Info.plist Configuration:"
+SHORT_VERSION=$(plutil -p cmp-ios/iosApp/Info.plist 2>/dev/null | grep CFBundleShortVersionString | awk -F'"' '{print $4}')
+BUNDLE_VERSION=$(plutil -p cmp-ios/iosApp/Info.plist 2>/dev/null | grep "\"CFBundleVersion\"" | awk -F'"' '{print $4}')
+
+echo " CFBundleShortVersionString: $SHORT_VERSION"
+echo " CFBundleVersion: $BUNDLE_VERSION"
+
+if [[ "$SHORT_VERSION" == "\$(MARKETING_VERSION)" ]]; then
+ print_success "Using dynamic versioning (MARKETING_VERSION)"
+else
+ echo " β οΈ Warning: Info.plist has hardcoded version"
+fi
+
+if [[ "$BUNDLE_VERSION" == "\$(CURRENT_PROJECT_VERSION)" ]]; then
+ print_success "Using dynamic build number (CURRENT_PROJECT_VERSION)"
+else
+ echo " β οΈ Warning: Info.plist has hardcoded build number"
+fi
+echo
+
+# Summary
+print_section "π Summary"
+echo "When you deploy iOS:"
+echo " 1. Fastlane runs: gradle(tasks: [\"versionFile\"])"
+echo " 2. Reads version from: version.txt"
+if [ -n "$VERSION_TXT" ] && [ -n "$APPSTORE_VERSION" ]; then
+ echo " β’ Full version: $VERSION_TXT"
+ if [ "$VERSION_TXT" != "$APPSTORE_VERSION" ]; then
+ echo " β’ TestFlight/App Store version: $APPSTORE_VERSION (sanitized)"
+ fi
+else
+ echo " β’ Version: (version.txt not found)"
+fi
+echo " 3. Updates Xcode MARKETING_VERSION accordingly"
+echo " 4. Auto-increments CURRENT_PROJECT_VERSION from TestFlight/Firebase"
+echo " 5. Info.plist uses: \$(MARKETING_VERSION) and \$(CURRENT_PROJECT_VERSION)"
+echo
+echo "Note: Firebase accepts full semantic versions with pre-release identifiers"
+echo " TestFlight/App Store require sanitized versions (MAJOR.MINOR.PATCH only)"
+echo
+
+print_info "To update version for next release:"
+echo " 1. Update version in Gradle (where project.version is defined)"
+echo " 2. Run: ./gradlew versionFile"
+echo " 3. Deploy: bash scripts/deploy_testflight.sh"
+echo
diff --git a/scripts/deploy_appstore.sh b/scripts/deploy_appstore.sh
new file mode 100644
index 0000000000..0d2421c8a7
--- /dev/null
+++ b/scripts/deploy_appstore.sh
@@ -0,0 +1,311 @@
+#!/bin/bash
+
+# ==============================================================================
+# iOS App Store Production Deployment Script
+# ==============================================================================
+# This script deploys your iOS app to the App Store for production release
+# ==============================================================================
+
+set -e # Exit on any error
+
+# Color codes for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+MAGENTA='\033[0;35m'
+NC='\033[0m' # No Color
+
+# Script directory and project root
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+cd "$PROJECT_ROOT"
+
+# Print functions
+print_success() {
+ echo -e "${GREEN}β $1${NC}"
+}
+
+print_error() {
+ echo -e "${RED}β $1${NC}"
+}
+
+print_warning() {
+ echo -e "${YELLOW}β $1${NC}"
+}
+
+print_info() {
+ echo -e "${CYAN}βΉ $1${NC}"
+}
+
+print_critical() {
+ echo -e "${MAGENTA}β οΈ CRITICAL: $1${NC}"
+}
+
+print_section() {
+ echo
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo -e "${BLUE}$1${NC}"
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo
+}
+
+# Print banner
+print_section "π iOS App Store Production Deployment"
+
+# Check if running on macOS
+if [[ "$OSTYPE" != "darwin"* ]]; then
+ print_error "This script must be run on macOS"
+ exit 1
+fi
+
+# Validate prerequisites
+print_info "Checking prerequisites..."
+
+# Check for Xcode
+if ! command -v xcodebuild &> /dev/null; then
+ print_error "Xcode is not installed"
+ print_info "Install Xcode from the App Store"
+ exit 1
+fi
+print_success "Xcode installed"
+
+# Check for Ruby
+if ! command -v ruby &> /dev/null; then
+ print_error "Ruby is not installed"
+ exit 1
+fi
+print_success "Ruby installed"
+
+# Check for Bundler
+if ! command -v bundle &> /dev/null; then
+ print_error "Bundler is not installed"
+ print_info "Install with: gem install bundler"
+ exit 1
+fi
+print_success "Bundler installed"
+
+# Validate required files
+print_section "π Validating Configuration"
+
+REQUIRED_FILES=(
+ "secrets/shared_keys.env"
+ "secrets/.match_password"
+ "secrets/match_ci_key"
+ "secrets/AuthKey.p8"
+)
+
+MISSING_FILES=()
+
+for file in "${REQUIRED_FILES[@]}"; do
+ if [ ! -f "$file" ]; then
+ MISSING_FILES+=("$file")
+ else
+ print_success "Found: $file"
+ fi
+done
+
+if [ ${#MISSING_FILES[@]} -gt 0 ]; then
+ print_error "Missing required files:"
+ for file in "${MISSING_FILES[@]}"; do
+ echo " - $file"
+ done
+ echo
+ print_info "Run the iOS setup wizard: bash scripts/setup_ios_complete.sh"
+ exit 1
+fi
+
+# Load shared configuration
+print_info "Loading iOS shared configuration..."
+source secrets/shared_keys.env
+
+# Validate App Store Connect API key configuration
+if [ -z "$APPSTORE_KEY_ID" ] || [ -z "$APPSTORE_ISSUER_ID" ]; then
+ print_error "App Store Connect API credentials not configured"
+ print_info "Update secrets/shared_keys.env with APPSTORE_KEY_ID and APPSTORE_ISSUER_ID"
+ exit 1
+fi
+
+# Load Match password
+export MATCH_PASSWORD=$(cat secrets/.match_password)
+
+# Setup SSH for Match
+export GIT_SSH_COMMAND="ssh -i secrets/match_ci_key -o IdentitiesOnly=yes"
+
+print_success "Configuration loaded"
+
+# Display configuration summary
+print_section "π± Deployment Configuration"
+echo "Team ID: ${TEAM_ID}"
+echo "App Store Connect Key ID: ${APPSTORE_KEY_ID}"
+echo "Match Repository: ${MATCH_GIT_URL}"
+echo "Match Branch: ${MATCH_GIT_BRANCH}"
+echo
+
+# Important warnings for App Store submission
+print_section "β οΈ IMPORTANT: App Store Submission Checklist"
+print_critical "This is a PRODUCTION deployment to the App Store!"
+echo
+print_warning "Before proceeding, ensure you have:"
+echo " β Tested the app thoroughly on physical devices"
+echo " β Verified all features work as expected"
+echo " β Checked for crashes and bugs"
+echo " β Tested on multiple iOS versions (if supporting multiple)"
+echo " β Verified in-app purchases work (if applicable)"
+echo " β Reviewed app metadata in App Store Connect:"
+echo " - App Description"
+echo " - Screenshots (all required sizes)"
+echo " - Privacy Policy URL"
+echo " - Support URL"
+echo " - Age Rating"
+echo " - Keywords"
+echo " β Prepared release notes for this version"
+echo " β Set up app pricing and availability"
+echo
+
+print_section "π App Store Review Process"
+print_info "What happens after submission:"
+echo " 1. Binary upload to App Store Connect (this script)"
+echo " 2. Binary processing by Apple (10-30 minutes)"
+echo " 3. Submission for App Store review (automatic)"
+echo " 4. 'Waiting for Review' status (can take hours to days)"
+echo " 5. 'In Review' status (Apple is reviewing)"
+echo " 6. Review decision:"
+echo " β Approved β App goes live automatically (or on release date)"
+echo " β Rejected β Fix issues and resubmit"
+echo
+print_warning "App Store review typically takes 24-72 hours"
+print_warning "Rejections are common - be prepared to fix issues and resubmit"
+echo
+
+print_section "βοΈ Deployment Options"
+echo "This script will use settings from fastlane-config/project_config.rb:"
+echo " - Submit for Review: ${APPSTORE_SUBMIT_FOR_REVIEW:-true}"
+echo " - Automatic Release: ${APPSTORE_AUTOMATIC_RELEASE:-true}"
+echo
+
+print_info "You can override these with command-line options:"
+echo " --submit-for-review=false Skip auto-submit (manual submit later)"
+echo " --automatic-release=false Manual release after approval"
+echo
+
+# Parse command-line arguments
+SUBMIT_FOR_REVIEW=""
+AUTOMATIC_RELEASE=""
+
+for arg in "$@"; do
+ case $arg in
+ --submit-for-review=*)
+ SUBMIT_FOR_REVIEW="${arg#*=}"
+ ;;
+ --automatic-release=*)
+ AUTOMATIC_RELEASE="${arg#*=}"
+ ;;
+ --help)
+ echo "Usage: bash scripts/deploy_appstore.sh [OPTIONS]"
+ echo
+ echo "Options:"
+ echo " --submit-for-review=true|false Submit for review after upload (default: true)"
+ echo " --automatic-release=true|false Automatically release after approval (default: true)"
+ echo " --help Show this help message"
+ exit 0
+ ;;
+ esac
+done
+
+# Final confirmation with double-check
+print_section "β οΈ FINAL CONFIRMATION"
+print_critical "You are about to deploy to the PRODUCTION App Store!"
+print_critical "This action CANNOT be undone!"
+echo
+print_warning "Are you absolutely sure you want to continue?"
+read -p "Type 'YES' (in capital letters) to confirm: " CONFIRM
+
+if [ "$CONFIRM" != "YES" ]; then
+ print_info "Deployment cancelled"
+ exit 0
+fi
+
+# Second confirmation for extra safety
+read -p "Final confirmation - deploy to App Store? [y/N]: " -n 1 -r
+echo
+
+if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ print_info "Deployment cancelled"
+ exit 0
+fi
+
+# Install/Update Fastlane dependencies
+print_section "π¦ Installing Dependencies"
+bundle install
+print_success "Dependencies installed"
+
+# Build deployment command
+FASTLANE_CMD="bundle exec fastlane ios release"
+
+if [ -n "$SUBMIT_FOR_REVIEW" ]; then
+ FASTLANE_CMD="$FASTLANE_CMD submit_for_review:$SUBMIT_FOR_REVIEW"
+fi
+
+if [ -n "$AUTOMATIC_RELEASE" ]; then
+ FASTLANE_CMD="$FASTLANE_CMD automatic_release:$AUTOMATIC_RELEASE"
+fi
+
+# Run Fastlane deployment
+print_section "π Deploying to App Store"
+print_info "This process will:"
+echo " 1. Sync code signing certificates with Match"
+echo " 2. Increment version and build number"
+echo " 3. Update Info.plist with privacy descriptions"
+echo " 4. Build signed IPA"
+echo " 5. Upload binary to App Store Connect"
+echo " 6. Submit for App Store review (if enabled)"
+echo
+
+eval $FASTLANE_CMD
+
+# Check if deployment was successful
+if [ $? -eq 0 ]; then
+ print_section "β
Deployment Successful!"
+ print_success "Your iOS app has been uploaded to App Store Connect"
+
+ if [ "$SUBMIT_FOR_REVIEW" != "false" ]; then
+ print_info "Next steps:"
+ echo " 1. Monitor review status in App Store Connect"
+ echo " 2. Check https://appstoreconnect.apple.com/"
+ echo " 3. Go to App Store β iOS App β App Store"
+ echo " 4. Watch for status changes:"
+ echo " - Processing β Waiting for Review β In Review β Approved/Rejected"
+ echo
+ print_warning "Review typically takes 24-72 hours"
+
+ if [ "$AUTOMATIC_RELEASE" != "false" ]; then
+ print_warning "App will go live AUTOMATICALLY after approval!"
+ else
+ print_info "You'll need to manually release the app after approval"
+ fi
+ else
+ print_info "Binary uploaded but NOT submitted for review"
+ print_info "Go to App Store Connect to manually submit when ready"
+ fi
+
+ echo
+ print_info "Useful links:"
+ echo " - App Store Connect: https://appstoreconnect.apple.com/"
+ echo " - App Store Review Guidelines: https://developer.apple.com/app-store/review/guidelines/"
+ echo " - Common Rejection Reasons: https://developer.apple.com/app-store/review/"
+else
+ print_section "β Deployment Failed"
+ print_error "Please check the error messages above"
+ print_info "Common issues:"
+ echo " - Invalid Match password"
+ echo " - SSH key not added to Match repository"
+ echo " - Invalid App Store Connect API key"
+ echo " - Certificate/provisioning profile issues"
+ echo " - Build number conflicts (already uploaded)"
+ echo " - Missing app metadata in App Store Connect"
+ echo " - Missing required assets (screenshots, app icon, etc.)"
+ echo " - Export compliance information missing"
+ exit 1
+fi
diff --git a/scripts/deploy_firebase.sh b/scripts/deploy_firebase.sh
new file mode 100644
index 0000000000..467b838d2c
--- /dev/null
+++ b/scripts/deploy_firebase.sh
@@ -0,0 +1,166 @@
+#!/bin/bash
+
+# ==============================================================================
+# iOS Firebase App Distribution Deployment Script
+# ==============================================================================
+# This script deploys your iOS app to Firebase App Distribution for testing
+# ==============================================================================
+
+set -e # Exit on any error
+
+# Color codes for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+
+# Script directory and project root
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+cd "$PROJECT_ROOT"
+
+# Print functions
+print_success() {
+ echo -e "${GREEN}β $1${NC}"
+}
+
+print_error() {
+ echo -e "${RED}β $1${NC}"
+}
+
+print_warning() {
+ echo -e "${YELLOW}β $1${NC}"
+}
+
+print_info() {
+ echo -e "${CYAN}βΉ $1${NC}"
+}
+
+print_section() {
+ echo
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo -e "${BLUE}$1${NC}"
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo
+}
+
+# Print banner
+print_section "π iOS Firebase App Distribution Deployment"
+
+# Check if running on macOS
+if [[ "$OSTYPE" != "darwin"* ]]; then
+ print_error "This script must be run on macOS"
+ exit 1
+fi
+
+# Validate prerequisites
+print_info "Checking prerequisites..."
+
+# Check for Xcode
+if ! command -v xcodebuild &> /dev/null; then
+ print_error "Xcode is not installed"
+ print_info "Install Xcode from the App Store"
+ exit 1
+fi
+print_success "Xcode installed"
+
+# Check for Ruby
+if ! command -v ruby &> /dev/null; then
+ print_error "Ruby is not installed"
+ exit 1
+fi
+print_success "Ruby installed"
+
+# Check for Bundler
+if ! command -v bundle &> /dev/null; then
+ print_error "Bundler is not installed"
+ print_info "Install with: gem install bundler"
+ exit 1
+fi
+print_success "Bundler installed"
+
+# Validate required files
+print_section "π Validating Configuration"
+
+REQUIRED_FILES=(
+ "secrets/shared_keys.env"
+ "secrets/.match_password"
+ "secrets/match_ci_key"
+ "secrets/firebaseAppDistributionServiceCredentialsFile.json"
+)
+
+MISSING_FILES=()
+
+for file in "${REQUIRED_FILES[@]}"; do
+ if [ ! -f "$file" ]; then
+ MISSING_FILES+=("$file")
+ else
+ print_success "Found: $file"
+ fi
+done
+
+if [ ${#MISSING_FILES[@]} -gt 0 ]; then
+ print_error "Missing required files:"
+ for file in "${MISSING_FILES[@]}"; do
+ echo " - $file"
+ done
+ echo
+ print_info "Run the iOS setup wizard: bash scripts/setup_ios_complete.sh"
+ exit 1
+fi
+
+# Load shared configuration
+print_info "Loading iOS shared configuration..."
+source secrets/shared_keys.env
+
+# Load Match password
+export MATCH_PASSWORD=$(cat secrets/.match_password)
+
+# Setup SSH for Match
+export GIT_SSH_COMMAND="ssh -i secrets/match_ci_key -o IdentitiesOnly=yes"
+
+print_success "Configuration loaded"
+
+# Display configuration summary
+print_section "π± Deployment Configuration"
+echo "Team ID: ${TEAM_ID}"
+echo "Match Repository: ${MATCH_GIT_URL}"
+echo "Match Branch: ${MATCH_GIT_BRANCH}"
+echo
+
+# Confirmation prompt
+print_warning "This will build and deploy your iOS app to Firebase App Distribution"
+read -p "Do you want to continue? [y/N]: " -n 1 -r
+echo
+
+if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ print_info "Deployment cancelled"
+ exit 0
+fi
+
+# Install/Update Fastlane dependencies
+print_section "π¦ Installing Dependencies"
+bundle install
+print_success "Dependencies installed"
+
+# Run Fastlane deployment
+print_section "π Deploying to Firebase"
+bundle exec fastlane ios deploy_on_firebase
+
+# Check if deployment was successful
+if [ $? -eq 0 ]; then
+ print_section "β
Deployment Successful!"
+ print_success "Your iOS app has been deployed to Firebase App Distribution"
+ print_info "Testers will receive a notification with download instructions"
+else
+ print_section "β Deployment Failed"
+ print_error "Please check the error messages above"
+ print_info "Common issues:"
+ echo " - Invalid Match password"
+ echo " - SSH key not added to Match repository"
+ echo " - Invalid Firebase credentials"
+ echo " - Certificate/provisioning profile issues"
+ exit 1
+fi
diff --git a/scripts/deploy_testflight.sh b/scripts/deploy_testflight.sh
new file mode 100644
index 0000000000..8c38027cab
--- /dev/null
+++ b/scripts/deploy_testflight.sh
@@ -0,0 +1,207 @@
+#!/bin/bash
+
+# ==============================================================================
+# iOS TestFlight Deployment Script
+# ==============================================================================
+# This script deploys your iOS app to TestFlight for beta testing
+# ==============================================================================
+
+set -e # Exit on any error
+
+# Color codes for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+
+# Script directory and project root
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+cd "$PROJECT_ROOT"
+
+# Print functions
+print_success() {
+ echo -e "${GREEN}β $1${NC}"
+}
+
+print_error() {
+ echo -e "${RED}β $1${NC}"
+}
+
+print_warning() {
+ echo -e "${YELLOW}β $1${NC}"
+}
+
+print_info() {
+ echo -e "${CYAN}βΉ $1${NC}"
+}
+
+print_section() {
+ echo
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo -e "${BLUE}$1${NC}"
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo
+}
+
+# Print banner
+print_section "π iOS TestFlight Beta Deployment"
+
+# Check if running on macOS
+if [[ "$OSTYPE" != "darwin"* ]]; then
+ print_error "This script must be run on macOS"
+ exit 1
+fi
+
+# Validate prerequisites
+print_info "Checking prerequisites..."
+
+# Check for Xcode
+if ! command -v xcodebuild &> /dev/null; then
+ print_error "Xcode is not installed"
+ print_info "Install Xcode from the App Store"
+ exit 1
+fi
+print_success "Xcode installed"
+
+# Check for Ruby
+if ! command -v ruby &> /dev/null; then
+ print_error "Ruby is not installed"
+ exit 1
+fi
+print_success "Ruby installed"
+
+# Check for Bundler
+if ! command -v bundle &> /dev/null; then
+ print_error "Bundler is not installed"
+ print_info "Install with: gem install bundler"
+ exit 1
+fi
+print_success "Bundler installed"
+
+# Validate required files
+print_section "π Validating Configuration"
+
+REQUIRED_FILES=(
+ "secrets/shared_keys.env"
+ "secrets/.match_password"
+ "secrets/match_ci_key"
+ "secrets/AuthKey.p8"
+)
+
+MISSING_FILES=()
+
+for file in "${REQUIRED_FILES[@]}"; do
+ if [ ! -f "$file" ]; then
+ MISSING_FILES+=("$file")
+ else
+ print_success "Found: $file"
+ fi
+done
+
+if [ ${#MISSING_FILES[@]} -gt 0 ]; then
+ print_error "Missing required files:"
+ for file in "${MISSING_FILES[@]}"; do
+ echo " - $file"
+ done
+ echo
+ print_info "Run the iOS setup wizard: bash scripts/setup_ios_complete.sh"
+ exit 1
+fi
+
+# Load shared configuration
+print_info "Loading iOS shared configuration..."
+source secrets/shared_keys.env
+
+# Validate App Store Connect API key configuration
+if [ -z "$APPSTORE_KEY_ID" ] || [ -z "$APPSTORE_ISSUER_ID" ]; then
+ print_error "App Store Connect API credentials not configured"
+ print_info "Update secrets/shared_keys.env with APPSTORE_KEY_ID and APPSTORE_ISSUER_ID"
+ exit 1
+fi
+
+# Load Match password
+export MATCH_PASSWORD=$(cat secrets/.match_password)
+
+# Setup SSH for Match
+export GIT_SSH_COMMAND="ssh -i secrets/match_ci_key -o IdentitiesOnly=yes"
+
+print_success "Configuration loaded"
+
+# Display configuration summary
+print_section "π± Deployment Configuration"
+echo "Team ID: ${TEAM_ID}"
+echo "App Store Connect Key ID: ${APPSTORE_KEY_ID}"
+echo "Match Repository: ${MATCH_GIT_URL}"
+echo "Match Branch: ${MATCH_GIT_BRANCH}"
+echo "TestFlight Groups: ${TESTFLIGHT_GROUPS:-mifos-mobile-apps}"
+echo
+
+# Important warnings
+print_section "β οΈ Important Information"
+print_warning "TestFlight Deployment Process:"
+echo " 1. Build will be uploaded to App Store Connect"
+echo " 2. Build will be submitted for beta review by Apple"
+echo " 3. Review typically takes 24-48 hours"
+echo " 4. Once approved, testers will be notified automatically"
+echo
+
+print_warning "Beta Review Information:"
+echo " - Contact Email: ${TESTFLIGHT_CONTACT_EMAIL:-team@mifos.org}"
+echo " - Contact Name: ${TESTFLIGHT_FIRST_NAME:-Mifos} ${TESTFLIGHT_LAST_NAME:-Initiative}"
+echo " - Contact Phone: ${TESTFLIGHT_PHONE:-+1234567890}"
+echo
+
+# Confirmation prompt
+print_warning "This will build and deploy your iOS app to TestFlight"
+read -p "Do you want to continue? [y/N]: " -n 1 -r
+echo
+
+if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ print_info "Deployment cancelled"
+ exit 0
+fi
+
+# Install/Update Fastlane dependencies
+print_section "π¦ Installing Dependencies"
+bundle install
+print_success "Dependencies installed"
+
+# Run Fastlane deployment
+print_section "π Deploying to TestFlight"
+print_info "This process will:"
+echo " 1. Sync code signing certificates with Match"
+echo " 2. Increment version and build number"
+echo " 3. Build signed IPA"
+echo " 4. Upload to App Store Connect"
+echo " 5. Submit for beta review"
+echo
+
+bundle exec fastlane ios beta
+
+# Check if deployment was successful
+if [ $? -eq 0 ]; then
+ print_section "β
Deployment Successful!"
+ print_success "Your iOS app has been uploaded to TestFlight"
+ print_info "Next steps:"
+ echo " 1. Monitor beta review status in App Store Connect"
+ echo " 2. Check https://appstoreconnect.apple.com/"
+ echo " 3. Go to TestFlight β iOS β Builds"
+ echo " 4. Wait for 'Ready to Submit' β 'Waiting for Review' β 'In Review' β 'Approved'"
+ echo " 5. Once approved, testers will receive notifications"
+ echo
+ print_info "Beta review typically takes 24-48 hours"
+else
+ print_section "β Deployment Failed"
+ print_error "Please check the error messages above"
+ print_info "Common issues:"
+ echo " - Invalid Match password"
+ echo " - SSH key not added to Match repository"
+ echo " - Invalid App Store Connect API key"
+ echo " - Certificate/provisioning profile issues"
+ echo " - Build number conflicts (already uploaded)"
+ echo " - Missing beta review information"
+ exit 1
+fi
diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh
index 04c785606b..2f87f220e8 100644
--- a/scripts/pre-commit.sh
+++ b/scripts/pre-commit.sh
@@ -4,7 +4,7 @@
check_current_branch() {
echo "\nπ Checking the current git branch..."
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
- if [ "$CURRENT_BRANCH" = "master" ] || [ "$CURRENT_BRANCH" = "development" ]; then
+ if [ "$CURRENT_BRANCH" = "master" ] || [ "$CURRENT_BRANCH" = "dev" ]; then
echo "π Hold it right there! Committing directly to the '$CURRENT_BRANCH' branch? That's a big no-no!"
echo "π« Direct commits to '$CURRENT_BRANCH' are like trying to use a wrench to write codeβdoesn't work! π"
echo "\nABORTING COMMIT: You must navigate to a feature branch or create a new one to save the day! π¦ΈββοΈπ¦ΈββοΈ\n"
@@ -37,16 +37,19 @@ run_spotless_checks() {
# Function to run ktlint checks
run_dependency_guard() {
printf "\nπ Brace yourself! We're about to generate dependency guard baseline!"
- ./gradlew dependencyGuardBaseline
+ ./gradlew dependencyGuardBaseline > /tmp/dependency-result
KT_EXIT_CODE=$?
if [ ${KT_EXIT_CODE} -ne 0 ]; then
+ cat /tmp/dependency-result
+ rm /tmp/dependency-result
printf "\n*********************************************************************************"
echo " π₯ Oh no! Something went wrong! π₯"
echo " π‘ Unable to generate dependency baseline. π οΈ"
printf "*********************************************************************************\n"
exit ${KT_EXIT_CODE}
else
+ rm /tmp/dependency-result
echo "π Bravo! Dependency baseline has been generated successfully! Keep rocking that clean code! ππ«"
fi
}
diff --git a/scripts/pre-push.sh b/scripts/pre-push.sh
index 71bf5c432f..d628bc07b2 100644
--- a/scripts/pre-push.sh
+++ b/scripts/pre-push.sh
@@ -4,7 +4,7 @@
check_current_branch() {
printf "\nπ Checking the current git branch..."
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
- if [ "$CURRENT_BRANCH" = "master" ] || [ "$CURRENT_BRANCH" = "development" ]; then
+ if [ "$CURRENT_BRANCH" = "master" ] || [ "$CURRENT_BRANCH" = "dev" ]; then
echo "π Hold it right there! Committing directly to the '$CURRENT_BRANCH' branch? That's a big no-no!"
echo "π« Direct commits to '$CURRENT_BRANCH' are like trying to use a wrench to write codeβdoesn't work! π"
printf "\nABORTING COMMIT: You must navigate to a feature branch or create a new one to save the day! π¦ΈββοΈπ¦ΈββοΈ\n"
@@ -28,7 +28,9 @@ run_spotless_checks() {
echo " π‘ Tip: Check the reported issues and fix formatting errors. π οΈ"
echo "*********************************************************************************"
echo "π Attempting to apply Spotless formatting fixes..."
- ./gradlew spotlessApply --daemon
+ ./gradlew spotlessApply --daemon > /tmp/spotless-result
+ rm /tmp/spotless-result
+ echo "π Stellar job! Your code is pristine and has passed Spotless's formatting checks without a hitch! Keep shining bright! β¨π"
else
rm /tmp/spotless-result
echo "π Stellar job! Your code is pristine and has passed Spotless's formatting checks without a hitch! Keep shining bright! β¨π"
@@ -58,18 +60,23 @@ run_detekt_checks() {
# Function to run ktlint checks
run_dependency_guard() {
printf "\nπ Brace yourself! We're about to generate dependency guard baseline!"
- ./gradlew dependencyGuard
+ ./gradlew dependencyGuard > /tmp/dependency-result
KT_EXIT_CODE=$?
if [ ${KT_EXIT_CODE} -ne 0 ]; then
+ cat /tmp/dependency-result
+ rm /tmp/dependency-result
printf "\n*********************************************************************************"
echo " π₯ Oh no! Something went wrong! π₯"
echo " π‘ Unable to generate dependency baseline. π οΈ"
printf "*********************************************************************************\n"
echo "π Attempting to generate dependency baseline again..."
- ./gradlew dependencyGuardBaseline
- else
+ ./gradlew dependencyGuardBaseline > /tmp/dependency-result
+ rm /tmp/dependency-result
echo "π Bravo! Dependency baseline has been generated successfully! Keep rocking that clean code! ππ«"
+ else
+ rm /tmp/dependency-result
+ echo "π Bravo! Dependency baseline has been checked successfully! Keep rocking that clean code! ππ«"
fi
}
diff --git a/scripts/setup_apn_key.sh b/scripts/setup_apn_key.sh
new file mode 100644
index 0000000000..d014e44551
--- /dev/null
+++ b/scripts/setup_apn_key.sh
@@ -0,0 +1,225 @@
+#!/bin/bash
+
+# ==============================================================================
+# APN (Apple Push Notification) Key Setup Script
+# ==============================================================================
+# This script helps configure APN keys for Firebase Cloud Messaging on iOS
+# ==============================================================================
+
+set -e # Exit on any error
+
+# Color codes
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+
+# Script directory
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+cd "$PROJECT_ROOT"
+
+# Print functions
+print_success() {
+ echo -e "${GREEN}β $1${NC}"
+}
+
+print_error() {
+ echo -e "${RED}β $1${NC}"
+}
+
+print_warning() {
+ echo -e "${YELLOW}β $1${NC}"
+}
+
+print_info() {
+ echo -e "${CYAN}βΉ $1${NC}"
+}
+
+print_section() {
+ echo
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo -e "${BLUE}$1${NC}"
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo
+}
+
+# Print banner
+print_section "π APN (Apple Push Notification) Key Setup"
+
+# Check if shared_keys.env exists
+if [ ! -f "secrets/shared_keys.env" ]; then
+ print_error "secrets/shared_keys.env not found"
+ print_info "Run the iOS setup wizard first: bash scripts/setup_ios_complete.sh"
+ exit 1
+fi
+
+# Load existing configuration
+source secrets/shared_keys.env
+
+# Introduction
+print_info "This script helps you configure Apple Push Notification (APN) keys"
+print_info "APN keys are required if your app uses Firebase Cloud Messaging for push notifications"
+echo
+
+print_warning "If your app does NOT use push notifications, you can skip this setup"
+read -p "Do you want to configure APN keys? [y/N]: " -n 1 -r
+echo
+if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ print_info "Skipping APN setup"
+ exit 0
+fi
+
+# Instructions for creating APN key
+print_section "π Creating APN Key in Apple Developer Portal"
+
+print_info "Follow these steps to create an APN key:"
+echo " 1. Go to: https://developer.apple.com/account/resources/authkeys/list"
+echo " 2. Click the '+' button"
+echo " 3. Name it: 'APNs Auth Key' or similar"
+echo " 4. Check the box for 'Apple Push Notifications service (APNs)'"
+echo " 5. Click 'Continue'"
+echo " 6. Click 'Register'"
+echo " 7. Click 'Download' (you can only download once!)"
+echo " 8. Note the Key ID (10 characters shown on the screen)"
+echo
+
+read -p "Have you created and downloaded an APN key? [y/N]: " -n 1 -r
+echo
+if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ print_warning "Please create an APN key first, then run this script again"
+ exit 0
+fi
+
+# Get APN Key ID
+print_section "π APN Key Configuration"
+
+read -p "Enter your APN Key ID (10 characters): " APN_KEY_ID
+while [[ ! $APN_KEY_ID =~ ^[A-Z0-9]{10}$ ]]; do
+ print_error "Invalid Key ID format. Must be 10 characters (letters and numbers)"
+ read -p "Enter APN Key ID: " APN_KEY_ID
+done
+print_success "APN Key ID: $APN_KEY_ID"
+echo
+
+# Locate APN key file
+print_info "Looking for APN .p8 key file..."
+
+# Find .p8 files
+P8_FILES=($(find . -maxdepth 2 -name "AuthKey_*.p8" 2>/dev/null | grep -v "secrets/AuthKey.p8"))
+
+if [ ${#P8_FILES[@]} -eq 0 ]; then
+ print_warning "No APN .p8 key files found in current directory"
+ read -p "Enter the full path to your APN .p8 key file: " APN_P8_PATH
+else
+ echo "Found .p8 files:"
+ for i in "${!P8_FILES[@]}"; do
+ echo " $((i+1)). ${P8_FILES[$i]}"
+ done
+ read -p "Select file number (or enter path to another file): " SELECTION
+
+ if [[ $SELECTION =~ ^[0-9]+$ ]] && [ $SELECTION -ge 1 ] && [ $SELECTION -le ${#P8_FILES[@]} ]; then
+ APN_P8_PATH="${P8_FILES[$((SELECTION-1))]}"
+ else
+ APN_P8_PATH="$SELECTION"
+ fi
+fi
+
+# Validate file exists
+while [ ! -f "$APN_P8_PATH" ]; do
+ print_error "File not found: $APN_P8_PATH"
+ read -p "Enter the full path to your APN .p8 key file: " APN_P8_PATH
+done
+
+# Validate it's a valid P8 key
+if ! grep -q "BEGIN PRIVATE KEY" "$APN_P8_PATH"; then
+ print_error "This doesn't appear to be a valid .p8 key file"
+ exit 1
+fi
+
+# Copy to secrets directory
+cp "$APN_P8_PATH" "secrets/APNAuthKey.p8"
+chmod 600 "secrets/APNAuthKey.p8"
+print_success "Copied APN key to secrets/APNAuthKey.p8"
+echo
+
+# Update shared_keys.env
+print_section "πΎ Updating Configuration"
+
+# Check if APN configuration already exists
+if grep -q "^export APN_KEY_ID=" secrets/shared_keys.env; then
+ print_info "Updating existing APN configuration..."
+ # Update existing values
+ sed -i.bak "s/^export APN_KEY_ID=.*/export APN_KEY_ID=\"$APN_KEY_ID\"/" secrets/shared_keys.env
+ sed -i.bak "s|^export APN_KEY_PATH=.*|export APN_KEY_PATH=\"./secrets/APNAuthKey.p8\"|" secrets/shared_keys.env
+ sed -i.bak "s/^export APN_TEAM_ID=.*/export APN_TEAM_ID=\"$TEAM_ID\"/" secrets/shared_keys.env
+ rm -f secrets/shared_keys.env.bak
+else
+ print_info "Adding APN configuration..."
+ # Add APN configuration block
+ cat >> secrets/shared_keys.env << EOF
+
+# ==============================================================================
+# APN (Apple Push Notification) Configuration
+# ==============================================================================
+# For Firebase Cloud Messaging on iOS
+export APN_KEY_ID="$APN_KEY_ID"
+export APN_KEY_PATH="./secrets/APNAuthKey.p8"
+export APN_TEAM_ID="$TEAM_ID"
+EOF
+fi
+
+print_success "Updated secrets/shared_keys.env with APN configuration"
+echo
+
+# Firebase Console Instructions
+print_section "π₯ Uploading APN Key to Firebase Console"
+
+print_info "You need to upload the APN key to Firebase Console for each iOS app:"
+echo
+echo "Steps:"
+echo " 1. Go to: https://console.firebase.google.com/"
+echo " 2. Select your Firebase project"
+echo " 3. Click the gear icon β Project settings"
+echo " 4. Go to the 'Cloud Messaging' tab"
+echo " 5. Scroll to 'Apple app configuration'"
+echo " 6. Under 'APNs authentication key', click 'Upload'"
+echo " 7. Upload: secrets/APNAuthKey.p8"
+echo " 8. Enter Key ID: $APN_KEY_ID"
+echo " 9. Enter Team ID: $TEAM_ID"
+echo " 10. Click 'Upload'"
+echo
+
+print_warning "You need to do this for EACH iOS app in your Firebase project"
+echo
+
+read -p "Press Enter after uploading to Firebase Console..."
+
+# Verification
+print_section "β
APN Setup Complete!"
+
+print_success "APN key configured successfully!"
+echo
+print_info "Configuration Summary:"
+echo " β APN Key ID: $APN_KEY_ID"
+echo " β APN Team ID: $TEAM_ID"
+echo " β APN Key File: secrets/APNAuthKey.p8"
+echo
+
+print_info "Files created/updated:"
+echo " β secrets/APNAuthKey.p8"
+echo " β secrets/shared_keys.env (updated with APN config)"
+echo
+
+print_warning "IMPORTANT: Keep secrets/APNAuthKey.p8 secure and NEVER commit to git!"
+echo
+
+print_info "Next Steps:"
+echo " 1. Verify setup: bash scripts/verify_apn_setup.sh"
+echo " 2. Test push notifications in your app"
+echo " 3. Check Firebase Console for delivery reports"
+echo
+
+print_success "Happy pushing! π"
diff --git a/scripts/setup_ios_complete.sh b/scripts/setup_ios_complete.sh
new file mode 100644
index 0000000000..8cabd1523a
--- /dev/null
+++ b/scripts/setup_ios_complete.sh
@@ -0,0 +1,517 @@
+#!/bin/bash
+
+# ==============================================================================
+# Complete iOS Deployment Setup Wizard
+# ==============================================================================
+# This script sets up everything needed for iOS deployment:
+# - Shared iOS configuration (Team ID, API keys, Match repo)
+# - Code signing certificates via Fastlane Match
+# - SSH keys for Match repository access
+# ==============================================================================
+
+set -e # Exit on any error
+
+# Color codes
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+MAGENTA='\033[0;35m'
+BOLD='\033[1m'
+NC='\033[0m' # No Color
+
+# Script directory
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+cd "$PROJECT_ROOT"
+
+# Print functions
+print_success() {
+ echo -e "${GREEN}β $1${NC}"
+}
+
+print_error() {
+ echo -e "${RED}β $1${NC}"
+}
+
+print_warning() {
+ echo -e "${YELLOW}β $1${NC}"
+}
+
+print_info() {
+ echo -e "${CYAN}βΉ $1${NC}"
+}
+
+print_section() {
+ echo
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo -e "${BLUE}${BOLD}$1${NC}"
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo
+}
+
+print_step() {
+ echo -e "${MAGENTA}${BOLD}βΆ Step $1: $2${NC}"
+}
+
+# Print banner
+clear
+echo -e "${BLUE}${BOLD}"
+cat << "EOF"
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β β
+β π iOS Deployment Setup Wizard β
+β β
+β This wizard will guide you through setting up iOS β
+β deployment infrastructure for your project β
+β β
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+EOF
+echo -e "${NC}"
+
+# Check prerequisites
+print_section "1οΈβ£ Checking Prerequisites"
+
+# Check macOS
+if [[ "$OSTYPE" != "darwin"* ]]; then
+ print_error "This script must be run on macOS"
+ exit 1
+fi
+print_success "Running on macOS"
+
+# Check Xcode
+if ! command -v xcodebuild &> /dev/null; then
+ print_error "Xcode is not installed"
+ print_info "Install Xcode from the App Store"
+ exit 1
+fi
+XCODE_VERSION=$(xcodebuild -version | head -n 1)
+print_success "$XCODE_VERSION installed"
+
+# Check Ruby
+if ! command -v ruby &> /dev/null; then
+ print_error "Ruby is not installed"
+ exit 1
+fi
+RUBY_VERSION=$(ruby -v | awk '{print $2}')
+print_success "Ruby $RUBY_VERSION installed"
+
+# Check Bundler
+if ! command -v bundle &> /dev/null; then
+ print_warning "Bundler not installed, installing..."
+ gem install bundler
+fi
+print_success "Bundler installed"
+
+# Check Git
+if ! command -v git &> /dev/null; then
+ print_error "Git is not installed"
+ exit 1
+fi
+print_success "Git installed"
+
+# Check GitHub CLI (optional but helpful)
+if ! command -v gh &> /dev/null; then
+ print_warning "GitHub CLI (gh) not installed"
+ print_info "Recommended for easier Match repository setup"
+ print_info "Install: brew install gh"
+else
+ print_success "GitHub CLI installed"
+fi
+
+# Create secrets directory
+print_section "2οΈβ£ Setting Up Secrets Directory"
+
+if [ ! -d "secrets" ]; then
+ mkdir -p secrets
+ print_success "Created secrets/ directory"
+else
+ print_success "secrets/ directory exists"
+fi
+
+# Check if shared_keys.env already exists
+if [ -f "secrets/shared_keys.env" ]; then
+ print_warning "secrets/shared_keys.env already exists"
+ read -p "Do you want to overwrite it? [y/N]: " -n 1 -r
+ echo
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ print_info "Using existing secrets/shared_keys.env"
+ print_info "You can edit it manually or delete it and run this script again"
+ exit 0
+ fi
+fi
+
+# Collect iOS configuration
+print_section "3οΈβ£ Apple Developer Account Information"
+
+print_info "You'll need information from your Apple Developer Account"
+print_info "Log in to: https://developer.apple.com/account"
+echo
+
+# Team ID
+print_step "3.1" "Team ID"
+echo "Find your Team ID at: https://developer.apple.com/account -> Membership"
+read -p "Enter your Apple Team ID (10 characters): " TEAM_ID
+while [[ ! $TEAM_ID =~ ^[A-Z0-9]{10}$ ]]; do
+ print_error "Invalid Team ID format. Must be 10 characters (letters and numbers)"
+ read -p "Enter your Apple Team ID: " TEAM_ID
+done
+print_success "Team ID: $TEAM_ID"
+echo
+
+# App Store Connect API
+print_section "4οΈβ£ App Store Connect API Configuration"
+print_info "You need to create an API key for Fastlane to access App Store Connect"
+echo
+print_info "Steps to create API key:"
+echo " 1. Go to: https://appstoreconnect.apple.com/access/api"
+echo " 2. Click the '+' button to create a new key"
+echo " 3. Name it: 'Fastlane Deploy Key'"
+echo " 4. Select role: 'App Manager' or 'Admin'"
+echo " 5. Click 'Generate'"
+echo " 6. Download the .p8 key file (you can only do this once!)"
+echo " 7. Note the Key ID (10 characters)"
+echo " 8. Note the Issuer ID (UUID format)"
+echo
+
+read -p "Have you created an API key? [y/N]: " -n 1 -r
+echo
+if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ print_warning "Please create an API key first, then run this script again"
+ exit 0
+fi
+
+# API Key ID
+print_step "4.1" "App Store Connect Key ID"
+read -p "Enter your App Store Connect Key ID (10 characters): " APPSTORE_KEY_ID
+while [[ ! $APPSTORE_KEY_ID =~ ^[A-Z0-9]{10}$ ]]; do
+ print_error "Invalid Key ID format. Must be 10 characters"
+ read -p "Enter Key ID: " APPSTORE_KEY_ID
+done
+print_success "Key ID: $APPSTORE_KEY_ID"
+
+# Issuer ID
+print_step "4.2" "App Store Connect Issuer ID"
+read -p "Enter your Issuer ID (UUID format): " APPSTORE_ISSUER_ID
+while [[ ! $APPSTORE_ISSUER_ID =~ ^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$ ]]; do
+ print_error "Invalid Issuer ID format. Must be UUID format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)"
+ read -p "Enter Issuer ID: " APPSTORE_ISSUER_ID
+done
+print_success "Issuer ID: $APPSTORE_ISSUER_ID"
+
+# API Key file
+print_step "4.3" "App Store Connect .p8 Key File"
+print_info "Looking for .p8 key file in current directory..."
+
+# Find .p8 files
+P8_FILES=($(find . -maxdepth 2 -name "AuthKey_*.p8" -o -name "*.p8" 2>/dev/null))
+
+if [ ${#P8_FILES[@]} -eq 0 ]; then
+ print_warning "No .p8 key files found"
+ read -p "Enter the full path to your .p8 key file: " P8_PATH
+else
+ echo "Found .p8 files:"
+ for i in "${!P8_FILES[@]}"; do
+ echo " $((i+1)). ${P8_FILES[$i]}"
+ done
+ read -p "Select file number (or enter path to another file): " SELECTION
+
+ if [[ $SELECTION =~ ^[0-9]+$ ]] && [ $SELECTION -ge 1 ] && [ $SELECTION -le ${#P8_FILES[@]} ]; then
+ P8_PATH="${P8_FILES[$((SELECTION-1))]}"
+ else
+ P8_PATH="$SELECTION"
+ fi
+fi
+
+# Validate file exists
+while [ ! -f "$P8_PATH" ]; do
+ print_error "File not found: $P8_PATH"
+ read -p "Enter the full path to your .p8 key file: " P8_PATH
+done
+
+# Copy to secrets directory
+cp "$P8_PATH" "secrets/AuthKey.p8"
+chmod 600 "secrets/AuthKey.p8"
+print_success "Copied .p8 key to secrets/AuthKey.p8"
+echo
+
+# Fastlane Match Configuration
+print_section "5οΈβ£ Fastlane Match Configuration"
+print_info "Fastlane Match stores your iOS certificates and provisioning profiles"
+print_info "in a private Git repository for sharing across team and CI/CD"
+echo
+
+print_info "You need a private Git repository for Match. Options:"
+echo " 1. Create new private repo on GitHub/GitLab/Bitbucket"
+echo " 2. Use existing repository"
+echo
+
+# Match repository URL
+print_step "5.1" "Match Repository URL"
+read -p "Enter Match repository URL (git@github.com:org/repo.git): " MATCH_GIT_URL
+while [[ ! $MATCH_GIT_URL =~ ^git@.*\.git$ ]]; do
+ print_error "Invalid Git URL format. Must start with 'git@' and end with '.git'"
+ read -p "Enter Match repository URL: " MATCH_GIT_URL
+done
+print_success "Match Repository: $MATCH_GIT_URL"
+
+# Match branch
+print_step "5.2" "Match Repository Branch"
+read -p "Enter Match repository branch [main]: " MATCH_GIT_BRANCH
+MATCH_GIT_BRANCH=${MATCH_GIT_BRANCH:-main}
+print_success "Match Branch: $MATCH_GIT_BRANCH"
+
+# SSH Key for Match
+print_step "5.3" "SSH Key for Match Repository"
+
+if [ -f "secrets/match_ci_key" ]; then
+ print_warning "SSH key already exists: secrets/match_ci_key"
+ read -p "Do you want to generate a new one? [y/N]: " -n 1 -r
+ echo
+ GENERATE_NEW=$REPLY
+else
+ GENERATE_NEW="y"
+fi
+
+if [[ $GENERATE_NEW =~ ^[Yy]$ ]]; then
+ print_info "Generating new SSH key pair for Match..."
+ ssh-keygen -t ed25519 -C "fastlane-match-$TEAM_ID" -f "secrets/match_ci_key" -N ""
+ chmod 600 "secrets/match_ci_key"
+ chmod 644 "secrets/match_ci_key.pub"
+ print_success "Generated SSH key: secrets/match_ci_key"
+fi
+
+print_info "Public key (add this as deploy key to Match repository):"
+echo
+cat "secrets/match_ci_key.pub"
+echo
+print_warning "IMPORTANT: Add the above public key as a deploy key to your Match repository"
+print_info "Steps:"
+echo " 1. Go to your Match repository on GitHub/GitLab"
+echo " 2. Go to Settings β Deploy Keys"
+echo " 3. Click 'Add deploy key'"
+echo " 4. Title: 'Fastlane Match CI'"
+echo " 5. Paste the public key above"
+echo " 6. Check 'Allow write access' (Match needs to push certificates)"
+echo " 7. Click 'Add key'"
+echo
+
+read -p "Press Enter after adding the deploy key..."
+
+# Test SSH connection
+print_info "Testing SSH connection to Match repository..."
+ssh -i secrets/match_ci_key -o StrictHostKeyChecking=no -T git@github.com 2>&1 | grep -q "successfully authenticated" && print_success "SSH connection successful" || print_warning "Could not verify SSH connection"
+echo
+
+# Match password
+print_step "5.4" "Match Encryption Password"
+print_info "Match encrypts your certificates with a password"
+print_warning "IMPORTANT: Store this password securely! You'll need it on all machines and CI/CD"
+echo
+
+if [ -f "secrets/.match_password" ]; then
+ print_warning "Match password file already exists"
+ read -p "Do you want to generate a new password? [y/N]: " -n 1 -r
+ echo
+ GENERATE_NEW_PWD=$REPLY
+else
+ GENERATE_NEW_PWD="y"
+fi
+
+if [[ $GENERATE_NEW_PWD =~ ^[Yy]$ ]]; then
+ # Generate secure random password
+ MATCH_PASSWORD=$(openssl rand -base64 32)
+ echo "$MATCH_PASSWORD" > "secrets/.match_password"
+ chmod 600 "secrets/.match_password"
+ print_success "Generated Match password (stored in secrets/.match_password)"
+ echo
+ print_warning "Match Password: $MATCH_PASSWORD"
+ print_warning "Save this password in your password manager!"
+else
+ MATCH_PASSWORD=$(cat "secrets/.match_password")
+ print_success "Using existing Match password"
+fi
+echo
+
+# TestFlight Beta Review Contact Information
+print_section "6οΈβ£ TestFlight Beta Review Contact Information"
+print_info "This information is shown to Apple reviewers during beta review"
+echo
+
+read -p "TestFlight Contact Email: " TESTFLIGHT_CONTACT_EMAIL
+TESTFLIGHT_CONTACT_EMAIL=${TESTFLIGHT_CONTACT_EMAIL:-team@example.com}
+
+read -p "TestFlight Contact First Name: " TESTFLIGHT_FIRST_NAME
+TESTFLIGHT_FIRST_NAME=${TESTFLIGHT_FIRST_NAME:-Team}
+
+read -p "TestFlight Contact Last Name: " TESTFLIGHT_LAST_NAME
+TESTFLIGHT_LAST_NAME=${TESTFLIGHT_LAST_NAME:-Name}
+
+read -p "TestFlight Contact Phone (with country code): " TESTFLIGHT_PHONE
+TESTFLIGHT_PHONE=${TESTFLIGHT_PHONE:-+1234567890}
+
+read -p "Beta Feedback Email: " BETA_FEEDBACK_EMAIL
+BETA_FEEDBACK_EMAIL=${BETA_FEEDBACK_EMAIL:-$TESTFLIGHT_CONTACT_EMAIL}
+
+read -p "TestFlight Tester Groups (comma-separated): " TESTFLIGHT_GROUPS
+TESTFLIGHT_GROUPS=${TESTFLIGHT_GROUPS:-internal-testers}
+
+print_success "TestFlight configuration collected"
+echo
+
+# App Store Review Contact Information
+print_section "7οΈβ£ App Store Review Contact Information"
+print_info "This information is shown to Apple reviewers during App Store review"
+echo
+
+read -p "App Store Review First Name [$TESTFLIGHT_FIRST_NAME]: " APPSTORE_REVIEW_FIRST_NAME
+APPSTORE_REVIEW_FIRST_NAME=${APPSTORE_REVIEW_FIRST_NAME:-$TESTFLIGHT_FIRST_NAME}
+
+read -p "App Store Review Last Name [$TESTFLIGHT_LAST_NAME]: " APPSTORE_REVIEW_LAST_NAME
+APPSTORE_REVIEW_LAST_NAME=${APPSTORE_REVIEW_LAST_NAME:-$TESTFLIGHT_LAST_NAME}
+
+read -p "App Store Review Phone [$TESTFLIGHT_PHONE]: " APPSTORE_REVIEW_PHONE
+APPSTORE_REVIEW_PHONE=${APPSTORE_REVIEW_PHONE:-$TESTFLIGHT_PHONE}
+
+read -p "App Store Review Email [$TESTFLIGHT_CONTACT_EMAIL]: " APPSTORE_REVIEW_EMAIL
+APPSTORE_REVIEW_EMAIL=${APPSTORE_REVIEW_EMAIL:-$TESTFLIGHT_CONTACT_EMAIL}
+
+print_success "App Store review configuration collected"
+echo
+
+# Write shared_keys.env
+print_section "8οΈβ£ Generating Configuration File"
+
+cat > secrets/shared_keys.env << EOF
+# ==============================================================================
+# Shared iOS Keys - Generated by setup_ios_complete.sh
+# ==============================================================================
+# These keys are SHARED across all your iOS apps.
+# Load this file before running deployment scripts: source secrets/shared_keys.env
+# ==============================================================================
+
+# Apple Developer Team ID
+export TEAM_ID="$TEAM_ID"
+
+# App Store Connect API (Shared across all apps)
+export APPSTORE_KEY_ID="$APPSTORE_KEY_ID"
+export APPSTORE_ISSUER_ID="$APPSTORE_ISSUER_ID"
+export APPSTORE_KEY_PATH="./secrets/AuthKey.p8"
+
+# Fastlane Match Configuration (Shared certificate repository)
+export MATCH_GIT_URL="$MATCH_GIT_URL"
+export MATCH_GIT_BRANCH="$MATCH_GIT_BRANCH"
+export MATCH_SSH_KEY_PATH="./secrets/match_ci_key"
+
+# Match password is stored in: secrets/.match_password
+# Load it with: export MATCH_PASSWORD=\$(cat secrets/.match_password)
+
+# TestFlight Beta Review Configuration
+export TESTFLIGHT_CONTACT_EMAIL="$TESTFLIGHT_CONTACT_EMAIL"
+export TESTFLIGHT_FIRST_NAME="$TESTFLIGHT_FIRST_NAME"
+export TESTFLIGHT_LAST_NAME="$TESTFLIGHT_LAST_NAME"
+export TESTFLIGHT_PHONE="$TESTFLIGHT_PHONE"
+export TESTFLIGHT_DEMO_EMAIL=""
+export TESTFLIGHT_DEMO_PASSWORD=""
+
+# Beta Feedback Configuration
+export BETA_FEEDBACK_EMAIL="$BETA_FEEDBACK_EMAIL"
+
+# TestFlight Tester Groups (comma-separated)
+export TESTFLIGHT_GROUPS="$TESTFLIGHT_GROUPS"
+
+# App Store Review Configuration
+export APPSTORE_REVIEW_FIRST_NAME="$APPSTORE_REVIEW_FIRST_NAME"
+export APPSTORE_REVIEW_LAST_NAME="$APPSTORE_REVIEW_LAST_NAME"
+export APPSTORE_REVIEW_PHONE="$APPSTORE_REVIEW_PHONE"
+export APPSTORE_REVIEW_EMAIL="$APPSTORE_REVIEW_EMAIL"
+export APPSTORE_DEMO_EMAIL=""
+export APPSTORE_DEMO_PASSWORD=""
+
+# App Marketing URLs (customize per app or use shared defaults)
+export APP_MARKETING_URL="https://example.com"
+export APP_PRIVACY_URL="https://example.com/privacy"
+export APP_SUPPORT_URL="https://example.com/support"
+
+# Firebase Configuration (same file for all apps)
+export FIREBASE_SERVICE_CREDS="./secrets/firebaseAppDistributionServiceCredentialsFile.json"
+
+# ==============================================================================
+# Per-App Configuration (DON'T SET HERE - managed by fastlane-config)
+# ==============================================================================
+# These are set in fastlane-config/project_config.rb:
+# - APP_IDENTIFIER (iOS Bundle ID)
+# - FIREBASE_IOS_APP_ID
+# The customizer.sh script updates these when creating a new project.
+EOF
+
+chmod 600 secrets/shared_keys.env
+print_success "Created secrets/shared_keys.env"
+echo
+
+# Initialize Match
+print_section "9οΈβ£ Initializing Fastlane Match"
+print_info "This will sync your code signing certificates from the Match repository"
+print_info "If this is the first time, Match will create new certificates"
+echo
+
+# Load configuration
+source secrets/shared_keys.env
+export MATCH_PASSWORD
+
+# Install Fastlane
+print_info "Installing Fastlane dependencies..."
+bundle install
+
+# Run Match for adhoc
+print_info "Syncing adhoc certificates..."
+bundle exec fastlane ios sync_certificates match_type:adhoc || print_warning "Match sync encountered issues (this is normal for first run)"
+
+# Run Match for appstore
+print_info "Syncing appstore certificates..."
+bundle exec fastlane ios sync_certificates match_type:appstore || print_warning "Match sync encountered issues (this is normal for first run)"
+
+echo
+
+# Summary
+print_section "β
Setup Complete!"
+
+print_success "iOS deployment infrastructure is configured!"
+echo
+print_info "Configuration Summary:"
+echo " β Team ID: $TEAM_ID"
+echo " β App Store Connect API configured"
+echo " β Match repository: $MATCH_GIT_URL"
+echo " β SSH key generated and configured"
+echo " β Match password generated"
+echo " β TestFlight & App Store review info configured"
+echo
+
+print_info "Configuration files created:"
+echo " β secrets/shared_keys.env"
+echo " β secrets/AuthKey.p8"
+echo " β secrets/match_ci_key"
+echo " β secrets/.match_password"
+echo
+
+print_warning "IMPORTANT: Keep these files secure and NEVER commit them to git!"
+print_info "They are already in .gitignore"
+echo
+
+print_info "Next Steps:"
+echo " 1. Test deployment to Firebase:"
+echo " bash scripts/deploy_firebase.sh"
+echo
+echo " 2. Test deployment to TestFlight:"
+echo " bash scripts/deploy_testflight.sh"
+echo
+echo " 3. Deploy to App Store (when ready):"
+echo " bash scripts/deploy_appstore.sh"
+echo
+
+print_info "Optional: Setup APN for push notifications"
+echo " bash scripts/setup_apn_key.sh"
+echo
+
+print_success "Happy deploying! π"
diff --git a/scripts/verify_apn_setup.sh b/scripts/verify_apn_setup.sh
new file mode 100644
index 0000000000..04ed11f78b
--- /dev/null
+++ b/scripts/verify_apn_setup.sh
@@ -0,0 +1,220 @@
+#!/bin/bash
+
+# ==============================================================================
+# APN Setup Verification Script
+# ==============================================================================
+# This script verifies that your APN configuration is correct
+# ==============================================================================
+
+set -e # Exit on any error
+
+# Color codes
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+
+# Script directory
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+cd "$PROJECT_ROOT"
+
+# Print functions
+print_success() {
+ echo -e "${GREEN}β $1${NC}"
+}
+
+print_error() {
+ echo -e "${RED}β $1${NC}"
+}
+
+print_warning() {
+ echo -e "${YELLOW}β $1${NC}"
+}
+
+print_info() {
+ echo -e "${CYAN}βΉ $1${NC}"
+}
+
+print_section() {
+ echo
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo -e "${BLUE}$1${NC}"
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo
+}
+
+# Print banner
+print_section "π APN Configuration Verification"
+
+ERRORS=0
+WARNINGS=0
+
+# Check if shared_keys.env exists
+print_info "Checking configuration files..."
+if [ ! -f "secrets/shared_keys.env" ]; then
+ print_error "secrets/shared_keys.env not found"
+ print_info "Run: bash scripts/setup_ios_complete.sh"
+ exit 1
+fi
+print_success "secrets/shared_keys.env exists"
+
+# Load configuration
+source secrets/shared_keys.env
+
+# Check APN configuration in shared_keys.env
+print_section "π Checking APN Configuration"
+
+if [ -z "$APN_KEY_ID" ]; then
+ print_error "APN_KEY_ID not set in secrets/shared_keys.env"
+ print_info "Run: bash scripts/setup_apn_key.sh"
+ ((ERRORS++))
+else
+ # Validate format (10 characters)
+ if [[ ! $APN_KEY_ID =~ ^[A-Z0-9]{10}$ ]]; then
+ print_error "APN_KEY_ID has invalid format: $APN_KEY_ID"
+ print_info "Must be 10 characters (letters and numbers)"
+ ((ERRORS++))
+ else
+ print_success "APN_KEY_ID format valid: $APN_KEY_ID"
+ fi
+fi
+
+if [ -z "$APN_TEAM_ID" ]; then
+ print_error "APN_TEAM_ID not set in secrets/shared_keys.env"
+ ((ERRORS++))
+else
+ print_success "APN_TEAM_ID set: $APN_TEAM_ID"
+
+ # Check if it matches TEAM_ID
+ if [ "$APN_TEAM_ID" != "$TEAM_ID" ]; then
+ print_warning "APN_TEAM_ID ($APN_TEAM_ID) differs from TEAM_ID ($TEAM_ID)"
+ print_info "They should usually be the same"
+ ((WARNINGS++))
+ fi
+fi
+
+if [ -z "$APN_KEY_PATH" ]; then
+ print_error "APN_KEY_PATH not set in secrets/shared_keys.env"
+ ((ERRORS++))
+else
+ print_success "APN_KEY_PATH set: $APN_KEY_PATH"
+fi
+
+# Check APN key file
+print_section "π Checking APN Key File"
+
+APN_KEY_FILE="secrets/APNAuthKey.p8"
+
+if [ ! -f "$APN_KEY_FILE" ]; then
+ print_error "APN key file not found: $APN_KEY_FILE"
+ print_info "Run: bash scripts/setup_apn_key.sh"
+ ((ERRORS++))
+else
+ print_success "APN key file exists: $APN_KEY_FILE"
+
+ # Check file permissions
+ PERMS=$(stat -f "%A" "$APN_KEY_FILE" 2>/dev/null || stat -c "%a" "$APN_KEY_FILE" 2>/dev/null)
+ if [ "$PERMS" != "600" ]; then
+ print_warning "APN key file permissions: $PERMS (should be 600)"
+ print_info "Fix with: chmod 600 $APN_KEY_FILE"
+ ((WARNINGS++))
+ else
+ print_success "File permissions correct: 600"
+ fi
+
+ # Validate it's a valid P8 key
+ if ! grep -q "BEGIN PRIVATE KEY" "$APN_KEY_FILE"; then
+ print_error "File doesn't appear to be a valid .p8 key"
+ ((ERRORS++))
+ else
+ print_success "Valid P8 key format detected"
+ fi
+
+ # Check file size (should be around 200-300 bytes for a valid key)
+ FILE_SIZE=$(wc -c < "$APN_KEY_FILE" | tr -d ' ')
+ if [ "$FILE_SIZE" -lt 100 ] || [ "$FILE_SIZE" -gt 500 ]; then
+ print_warning "APN key file size unusual: $FILE_SIZE bytes"
+ print_info "Valid P8 keys are typically 200-300 bytes"
+ ((WARNINGS++))
+ else
+ print_success "File size looks reasonable: $FILE_SIZE bytes"
+ fi
+fi
+
+# Check if key filename matches Key ID
+if [ -f "$APN_KEY_FILE" ] && [ -n "$APN_KEY_ID" ]; then
+ EXPECTED_NAME="AuthKey_$APN_KEY_ID.p8"
+ if [ "$(basename $APN_KEY_FILE)" != "$EXPECTED_NAME" ]; then
+ print_warning "APN key filename: $(basename $APN_KEY_FILE)"
+ print_info "Expected filename based on Key ID: $EXPECTED_NAME"
+ print_info "This is usually OK, but verify the Key ID is correct"
+ ((WARNINGS++))
+ fi
+fi
+
+# Firebase Console Check
+print_section "π₯ Firebase Console Integration"
+
+print_info "Manual verification needed in Firebase Console:"
+echo " 1. Go to: https://console.firebase.google.com/"
+echo " 2. Select your project"
+echo " 3. Settings β Cloud Messaging tab"
+echo " 4. Check 'APNs authentication key' section"
+echo " 5. Verify:"
+if [ -n "$APN_KEY_ID" ]; then
+ echo " - Key ID matches: $APN_KEY_ID"
+fi
+if [ -n "$APN_TEAM_ID" ]; then
+ echo " - Team ID matches: $APN_TEAM_ID"
+fi
+echo
+
+read -p "Have you verified the APN key in Firebase Console? [y/N]: " -n 1 -r
+echo
+if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ print_warning "Please verify APN configuration in Firebase Console"
+ ((WARNINGS++))
+else
+ print_success "Firebase Console verification confirmed"
+fi
+
+# Summary
+print_section "π Verification Summary"
+
+if [ $ERRORS -eq 0 ] && [ $WARNINGS -eq 0 ]; then
+ print_success "β
All checks passed!"
+ echo
+ print_info "Your APN configuration is correct and ready to use"
+ echo
+ print_info "Next Steps:"
+ echo " 1. Test push notifications in your app"
+ echo " 2. Check Firebase Console β Cloud Messaging β Send test message"
+ echo " 3. Monitor delivery reports in Firebase Console"
+elif [ $ERRORS -eq 0 ]; then
+ print_warning "β οΈ Configuration OK with $WARNINGS warning(s)"
+ echo
+ print_info "Your APN configuration should work, but review the warnings above"
+else
+ print_error "β Found $ERRORS error(s) and $WARNINGS warning(s)"
+ echo
+ print_info "Please fix the errors above before using APN"
+ print_info "Run: bash scripts/setup_apn_key.sh"
+ exit 1
+fi
+
+print_info "Configuration Details:"
+if [ -n "$APN_KEY_ID" ]; then
+ echo " β’ APN Key ID: $APN_KEY_ID"
+fi
+if [ -n "$APN_TEAM_ID" ]; then
+ echo " β’ APN Team ID: $APN_TEAM_ID"
+fi
+if [ -f "$APN_KEY_FILE" ]; then
+ echo " β’ APN Key File: $APN_KEY_FILE"
+fi
+echo
+
+print_success "Verification complete! π"
diff --git a/scripts/verify_ios_deployment.sh b/scripts/verify_ios_deployment.sh
new file mode 100644
index 0000000000..7b27893576
--- /dev/null
+++ b/scripts/verify_ios_deployment.sh
@@ -0,0 +1,364 @@
+#!/bin/bash
+
+# ==============================================================================
+# iOS Deployment Verification Script
+# ==============================================================================
+# This script performs comprehensive verification of iOS deployment configuration
+# ==============================================================================
+
+set -e
+
+# Color codes for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+
+# Script directory and project root
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+cd "$PROJECT_ROOT"
+
+# Print functions
+print_success() {
+ echo -e "${GREEN}β $1${NC}"
+}
+
+print_error() {
+ echo -e "${RED}β $1${NC}"
+}
+
+print_warning() {
+ echo -e "${YELLOW}β $1${NC}"
+}
+
+print_info() {
+ echo -e "${CYAN}βΉ $1${NC}"
+}
+
+print_section() {
+ echo
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo -e "${BLUE}$1${NC}"
+ echo -e "${BLUE}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
+ echo
+}
+
+# Initialize counters
+TOTAL_CHECKS=0
+PASSED_CHECKS=0
+FAILED_CHECKS=0
+WARNING_CHECKS=0
+
+# Check function
+check() {
+ TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
+ if eval "$2"; then
+ print_success "$1"
+ PASSED_CHECKS=$((PASSED_CHECKS + 1))
+ return 0
+ else
+ print_error "$1"
+ FAILED_CHECKS=$((FAILED_CHECKS + 1))
+ return 1
+ fi
+}
+
+# Warning function
+warn() {
+ TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
+ if eval "$2"; then
+ print_success "$1"
+ PASSED_CHECKS=$((PASSED_CHECKS + 1))
+ return 0
+ else
+ print_warning "$1 (optional)"
+ WARNING_CHECKS=$((WARNING_CHECKS + 1))
+ return 0
+ fi
+}
+
+# Print banner
+print_section "π iOS Deployment Verification"
+
+# ============================================================================
+# 1. Version Handling Verification
+# ============================================================================
+print_section "1. Version Handling"
+
+check "Version check script exists" \
+ "[ -f 'scripts/check_ios_version.sh' ]"
+
+check "version.txt can be generated" \
+ "cd '$PROJECT_ROOT' && ./gradlew versionFile > /dev/null 2>&1 && [ -f 'version.txt' ]"
+
+if [ -f "version.txt" ]; then
+ FULL_VERSION=$(cat version.txt)
+ print_info "Current version: $FULL_VERSION"
+
+ # Verify version format
+ if [[ "$FULL_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
+ print_success "Version format is valid"
+ PASSED_CHECKS=$((PASSED_CHECKS + 1))
+ else
+ print_error "Version format is invalid"
+ FAILED_CHECKS=$((FAILED_CHECKS + 1))
+ fi
+ TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
+fi
+
+check "Info.plist uses MARKETING_VERSION variable" \
+ "grep -q '\$(MARKETING_VERSION)' 'cmp-ios/iosApp/Info.plist'"
+
+check "Info.plist uses CURRENT_PROJECT_VERSION variable" \
+ "grep -q '\$(CURRENT_PROJECT_VERSION)' 'cmp-ios/iosApp/Info.plist'"
+
+check "Info.plist has no hardcoded CFBundleShortVersionString" \
+ "! grep -A1 'CFBundleShortVersionString' 'cmp-ios/iosApp/Info.plist' | grep -q '[0-9]'"
+
+check "Xcode project has MARKETING_VERSION in Debug config" \
+ "grep -q 'MARKETING_VERSION' 'cmp-ios/iosApp.xcodeproj/project.pbxproj'"
+
+check "Xcode project has CURRENT_PROJECT_VERSION in Debug config" \
+ "grep -q 'CURRENT_PROJECT_VERSION' 'cmp-ios/iosApp.xcodeproj/project.pbxproj'"
+
+# ============================================================================
+# 2. FastFile Configuration
+# ============================================================================
+print_section "2. FastFile Configuration"
+
+check "FastFile exists" \
+ "[ -f 'fastlane/FastFile' ]"
+
+check "Version sanitization helper exists" \
+ "grep -q 'get_version_from_gradle' 'fastlane/FastFile'"
+
+check "Version sanitization has sanitize_for_appstore parameter" \
+ "grep -q 'sanitize_for_appstore' 'fastlane/FastFile'"
+
+check "Firebase lane exists" \
+ "grep -q 'lane :deploy_on_firebase' 'fastlane/FastFile'"
+
+check "TestFlight beta lane exists" \
+ "grep -q 'lane :beta' 'fastlane/FastFile'"
+
+check "App Store release lane exists" \
+ "grep -q 'lane :release' 'fastlane/FastFile'"
+
+check "Release lane generates release notes" \
+ "grep -q 'generateReleaseNote()' 'fastlane/FastFile'"
+
+check "Release lane creates metadata directory" \
+ "grep -q 'FileUtils.mkdir_p' 'fastlane/FastFile'"
+
+check "Release lane writes release_notes.txt" \
+ "grep -q 'File.write(release_notes_path' 'fastlane/FastFile'"
+
+check "Release lane has copyright parameter" \
+ "grep -q 'copyright:' 'fastlane/FastFile'"
+
+check "Deliver action has skip_metadata: false" \
+ "grep -q 'skip_metadata: false' 'fastlane/FastFile'"
+
+check "Deliver action has skip_screenshots: true" \
+ "grep -q 'skip_screenshots: true' 'fastlane/FastFile'"
+
+# ============================================================================
+# 3. Configuration Files
+# ============================================================================
+print_section "3. Configuration Files"
+
+check "project_config.rb exists" \
+ "[ -f 'fastlane-config/project_config.rb' ]"
+
+check "ios_config.rb exists" \
+ "[ -f 'fastlane-config/ios_config.rb' ]"
+
+check "project_config.rb defines IOS hash" \
+ "grep -q 'IOS = {' 'fastlane-config/project_config.rb'"
+
+check "project_config.rb defines IOS_SHARED hash" \
+ "grep -q 'IOS_SHARED = {' 'fastlane-config/project_config.rb'"
+
+check "project_config.rb has app_identifier" \
+ "grep -q 'app_identifier:' 'fastlane-config/project_config.rb'"
+
+check "project_config.rb has metadata_path" \
+ "grep -q 'metadata_path:' 'fastlane-config/project_config.rb'"
+
+check "skip_app_version_update is false (allows version creation)" \
+ "grep -q 'skip_app_version_update: false' 'fastlane-config/project_config.rb'"
+
+# ============================================================================
+# 4. Export Compliance
+# ============================================================================
+print_section "4. Export Compliance"
+
+check "Info.plist has ITSAppUsesNonExemptEncryption key" \
+ "grep -q 'ITSAppUsesNonExemptEncryption' 'cmp-ios/iosApp/Info.plist'"
+
+check "ITSAppUsesNonExemptEncryption is set to false" \
+ "grep -A1 'ITSAppUsesNonExemptEncryption' 'cmp-ios/iosApp/Info.plist' | grep -q ''"
+
+check "TestFlight lane has uses_non_exempt_encryption parameter" \
+ "grep -q 'uses_non_exempt_encryption:' 'fastlane/FastFile'"
+
+# ============================================================================
+# 5. Required Secret Files
+# ============================================================================
+print_section "5. Required Secret Files"
+
+check "secrets directory exists" \
+ "[ -d 'secrets' ]"
+
+warn "shared_keys.env exists" \
+ "[ -f 'secrets/shared_keys.env' ]"
+
+warn ".match_password exists" \
+ "[ -f 'secrets/.match_password' ]"
+
+warn "match_ci_key (SSH key) exists" \
+ "[ -f 'secrets/match_ci_key' ]"
+
+warn "AuthKey.p8 (App Store Connect API) exists" \
+ "[ -f 'secrets/AuthKey.p8' ]"
+
+warn "Firebase credentials exist" \
+ "[ -f 'secrets/firebaseAppDistributionServiceCredentialsFile.json' ]"
+
+# ============================================================================
+# 6. Deployment Scripts
+# ============================================================================
+print_section "6. Deployment Scripts"
+
+check "deploy_firebase.sh exists" \
+ "[ -f 'scripts/deploy_firebase.sh' ]"
+
+check "deploy_testflight.sh exists" \
+ "[ -f 'scripts/deploy_testflight.sh' ]"
+
+check "deploy_appstore.sh exists" \
+ "[ -f 'scripts/deploy_appstore.sh' ]"
+
+check "check_ios_version.sh exists" \
+ "[ -f 'scripts/check_ios_version.sh' ]"
+
+check "deploy_firebase.sh is executable" \
+ "[ -x 'scripts/deploy_firebase.sh' ]"
+
+check "deploy_testflight.sh is executable" \
+ "[ -x 'scripts/deploy_testflight.sh' ]"
+
+check "deploy_appstore.sh is executable" \
+ "[ -x 'scripts/deploy_appstore.sh' ]"
+
+check "check_ios_version.sh is executable" \
+ "[ -x 'scripts/check_ios_version.sh' ]"
+
+# ============================================================================
+# 7. Documentation
+# ============================================================================
+print_section "7. Documentation"
+
+check "IOS_DEPLOYMENT.md exists" \
+ "[ -f 'docs/IOS_DEPLOYMENT.md' ]"
+
+check "IOS_SETUP.md exists" \
+ "[ -f 'docs/IOS_SETUP.md' ]"
+
+check "IOS_DEPLOYMENT_CHECKLIST.md exists" \
+ "[ -f 'docs/IOS_DEPLOYMENT_CHECKLIST.md' ]"
+
+check "FASTLANE_CONFIGURATION.md exists" \
+ "[ -f 'docs/FASTLANE_CONFIGURATION.md' ]"
+
+# ============================================================================
+# 8. Metadata Configuration
+# ============================================================================
+print_section "8. Metadata Configuration"
+
+check "metadata directory is in .gitignore" \
+ "grep -q '^fastlane/metadata' '.gitignore' || grep -q '^fastlane/metadata$' '.gitignore'"
+
+check "screenshots directory is in .gitignore" \
+ "grep -q '^fastlane/screenshots' '.gitignore' || grep -q '^fastlane/screenshots$' '.gitignore'"
+
+# ============================================================================
+# 9. Ruby & Bundler
+# ============================================================================
+print_section "9. Ruby & Bundler"
+
+check "Ruby is installed" \
+ "command -v ruby > /dev/null 2>&1"
+
+check "Bundler is installed" \
+ "command -v bundle > /dev/null 2>&1"
+
+check "Gemfile exists" \
+ "[ -f 'Gemfile' ]"
+
+check "Gemfile.lock exists" \
+ "[ -f 'Gemfile.lock' ]"
+
+warn "Bundle dependencies are installed" \
+ "bundle check > /dev/null 2>&1"
+
+# ============================================================================
+# 10. Xcode Configuration
+# ============================================================================
+print_section "10. Xcode Configuration"
+
+if [[ "$OSTYPE" == "darwin"* ]]; then
+ check "Xcode is installed" \
+ "command -v xcodebuild > /dev/null 2>&1"
+
+ check "Xcode project exists" \
+ "[ -d 'cmp-ios/iosApp.xcodeproj' ]"
+
+ check "Xcode workspace exists" \
+ "[ -d 'cmp-ios/iosApp.xcworkspace' ]"
+else
+ print_warning "Skipping Xcode checks (not running on macOS)"
+fi
+
+# ============================================================================
+# Summary
+# ============================================================================
+print_section "π Verification Summary"
+
+echo "Total Checks: $TOTAL_CHECKS"
+print_success "Passed: $PASSED_CHECKS"
+if [ $FAILED_CHECKS -gt 0 ]; then
+ print_error "Failed: $FAILED_CHECKS"
+fi
+if [ $WARNING_CHECKS -gt 0 ]; then
+ print_warning "Warnings: $WARNING_CHECKS (optional checks)"
+fi
+echo
+
+# Calculate pass rate
+PASS_RATE=$(awk "BEGIN {printf \"%.1f\", ($PASSED_CHECKS / $TOTAL_CHECKS) * 100}")
+
+if [ $FAILED_CHECKS -eq 0 ]; then
+ print_section "β
All Critical Checks Passed!"
+ echo "Pass Rate: ${PASS_RATE}%"
+ echo
+ print_success "iOS deployment configuration is ready for automatic deployment!"
+ echo
+ if [ $WARNING_CHECKS -gt 0 ]; then
+ print_info "Note: ${WARNING_CHECKS} optional checks failed (secret files)"
+ print_info "These are expected if you haven't set up secrets yet"
+ print_info "Run: bash scripts/setup_ios_complete.sh"
+ fi
+ exit 0
+else
+ print_section "β Verification Failed"
+ echo "Pass Rate: ${PASS_RATE}%"
+ echo
+ print_error "${FAILED_CHECKS} critical check(s) failed"
+ print_info "Please review the errors above and fix the issues"
+ print_info "See docs/IOS_DEPLOYMENT_CHECKLIST.md for detailed troubleshooting"
+ exit 1
+fi
diff --git a/settings.gradle.kts b/settings.gradle.kts
index e6d7e6ff3e..0dcec5d5da 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -47,6 +47,7 @@ include(":cmp-navigation")
include(":core:ui")
include(":core:designsystem")
include(":core:logs")
+include(":core:analytics")
include(":core:model")
include(":core:common")
include(":core:data")