diff --git a/.github/workflows/check-dependecy-updates.yml b/.github/workflows/check-dependecy-updates.yml deleted file mode 100644 index 145c7a48..00000000 --- a/.github/workflows/check-dependecy-updates.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Check Dependency Updates -on: - schedule: - - cron: "37 13 * * SAT" - workflow_dispatch: -jobs: - dependency-updates: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Use Java 17 - uses: actions/setup-java@v4 - with: - distribution: 'corretto' - java-version: '17' - cache: 'gradle' - - name: Make gradlew executable - run: chmod +x ./gradlew - - name: Check Dependency Updates - run: ./gradlew dependencyUpdates - - name: Log dependency update report - run: cat build/dependencyUpdates/dependency_update_report.txt - - name: Save report - uses: actions/upload-artifact@v4 - with: - name: dependency-update-reports - path: build/dependencyUpdates \ No newline at end of file diff --git a/.github/workflows/check-dependency-updates.yml b/.github/workflows/check-dependency-updates.yml new file mode 100644 index 00000000..98faa2a8 --- /dev/null +++ b/.github/workflows/check-dependency-updates.yml @@ -0,0 +1,84 @@ +name: Check Dependency Updates + +on: + schedule: + - cron: "37 13 * * SAT" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + issues: write + +jobs: + dependency-updates: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Check Dependency Updates + run: ./gradlew --no-daemon dependencyUpdates + + - name: Log dependency update report + run: cat build/dependencyUpdates/dependency_update_report.txt + + - name: Save report + uses: actions/upload-artifact@v4 + with: + name: dependency-update-reports + path: build/dependencyUpdates + + - name: Create issue if outdated dependencies found + if: success() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const reportPath = 'build/dependencyUpdates/dependency_update_report.txt'; + if (!fs.existsSync(reportPath)) return; + + const report = fs.readFileSync(reportPath, 'utf8'); + const hasUpdates = report.includes('The following dependencies have later milestone versions'); + if (!hasUpdates) { + console.log('No outdated dependencies found.'); + return; + } + + const title = `Dependency updates available (${new Date().toISOString().split('T')[0]})`; + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'dependencies', + }); + + const alreadyOpen = issues.some(i => i.title === title); + if (alreadyOpen) { + console.log('Issue already exists for today.'); + return; + } + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body: `## Outdated Dependencies Detected\n\nThe weekly dependency check found updates available.\n\n
Full Report\n\n\`\`\`\n${report}\n\`\`\`\n\n
\n\n[View artifact](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`, + labels: ['dependencies'], + }); \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c6d3e9ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: CI + +on: + pull_request: + branches: [ "**" ] + push: + branches: [ "main" ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + with: + packages: 'platforms;android-36 build-tools;36.0.0 platform-tools' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Decode google-services.json + run: echo "${{ secrets.GOOGLE_SERVICES_JSON }}" | base64 --decode > app/google-services.json + + - name: Build + run: ./gradlew --no-daemon --parallel --configuration-cache build + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: debug-apk + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: ignore + + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() && hashFiles('**/build/test-results/**/*.xml') != '' + with: + files: '**/build/test-results/**/*.xml' + + lint: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref || github.ref_name }} + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + with: + packages: 'platforms;android-36 build-tools;36.0.0 platform-tools' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Decode google-services.json + run: echo "${{ secrets.GOOGLE_SERVICES_JSON }}" | base64 --decode > app/google-services.json + + - name: Apply code formatting + run: ./gradlew --no-daemon --parallel codeFormat + + - name: Commit formatting fixes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "style: apply automatic code formatting" + file_pattern: "**/*.kt **/*.kts" + + - name: Code check (spotless + detekt) + run: ./gradlew --no-daemon --parallel codeCheck diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml deleted file mode 100644 index a7ca880e..00000000 --- a/.github/workflows/code_quality.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Qodana -on: - workflow_dispatch: - pull_request: - push: - branches: - - develop - - master - -jobs: - qodana: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - checks: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit - fetch-depth: 0 # a full history is required for pull request analysis - - name: 'Qodana Scan' - uses: JetBrains/qodana-action@v2023.2 - env: - QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} # read the steps about it below \ No newline at end of file diff --git a/README.md b/README.md index eacf58ba..06c9483e 100644 --- a/README.md +++ b/README.md @@ -1,193 +1,297 @@ [![Dependency Updates](https://github.com/bosankus/Compose-Weatherify/actions/workflows/check-dependecy-updates.yml/badge.svg)](https://github.com/bosankus/Compose-Weatherify/actions/workflows/check-dependecy-updates.yml) -[![Codacy Badge](https://app.codacy.com/project/badge/Grade/dda6430161e146518704730d9916dba7)](https://www.codacy.com/gh/bosankus/Compose-Weatherify/dashboard?utm_source=github.com&utm_medium=referral&utm_content=bosankus/Compose-Weatherify&utm_campaign=Badge_Grade) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/dda6430161e146518704730d9916dba7)](https://www.codacy.com/gh/bosankus/Compose-Weatherify/dashboard?utm_source=github.com&utm_medium=referral&utm_content=bosankus/Compose-Weatherify&utm_campaign=Badge_Grade) [![Qodana](https://github.com/bosankus/Compose-Weatherify/actions/workflows/code_quality.yml/badge.svg)](https://github.com/bosankus/Compose-Weatherify/actions/workflows/code_quality.yml) +![Kotlin](https://img.shields.io/badge/Kotlin-2.2.21-7F52FF?style=flat&logo=kotlin&logoColor=white) +![Android](https://img.shields.io/badge/Min%20SDK-26%20(Oreo)-3DDC84?style=flat&logo=android&logoColor=white) +![Version](https://img.shields.io/badge/Version-1.1-0078D4?style=flat) # Weatherify -A modern weather application built with Jetpack Compose that provides current weather conditions, forecasts, and air quality information. +A production-grade Android weather app built with **Jetpack Compose**, **Clean Architecture**, and a **Kotlin Multiplatform-ready** module structure. It shows real-time weather, 5-day forecasts, air quality data, and sunrise/sunset animations โ€” with multi-language support and an in-app premium upgrade flow. -+[![Download APK](https://img.shields.io/badge/download-APK-22272E.svg?style=for-the-badge&logo=android&logoColor=47954A)](https://github.com/bosankus/Compose-Weatherify/releases/latest) +[![Download APK](https://img.shields.io/badge/Download%20Latest%20APK-22272E.svg?style=for-the-badge&logo=android&logoColor=47954A)](https://github.com/bosankus/Compose-Weatherify/releases/latest) -## ๐Ÿ“ฑ Features +--- -- **Current Weather**: View today's temperature and weather conditions -- **5-Day Forecast**: See weather predictions for the next 4 days -- **Air Quality Index**: Monitor air pollution levels -- **Multiple Cities**: Search and save your favorite locations -- **Multi-language Support**: Available in English, Hindi, and Hebrew -- **Material 3 Design**: Modern UI with dynamic theming -- **Location-based Weather**: Automatic weather updates based on your current location +## Features -## ๐Ÿ—๏ธ Architecture +| Category | Details | +|---|---| +| **Weather** | Current conditions, feels-like temp, humidity, wind speed | +| **Forecast** | 5-day weather forecast with hourly breakdown | +| **Air Quality** | Real-time AQI with pollutant details | +| **Location** | GPS-based auto-detection + manual city search | +| **Sunrise/Sunset** | Custom animated sunrise/sunset arc (`:sunriseui` module) | +| **Multi-language** | English, Bengali (เฆฌเฆพเฆ‚เฆฒเฆพ), Hindi (เคนเคฟเคจเฅเคฆเฅ€), Kannada (เฒ•เฒจเณเฒจเฒก), Malayalam (เดฎเดฒเดฏเดพเดณเด‚), Tamil (เฎคเฎฎเฎฟเฎดเฏ), Telugu (เฐคเฑ†เฐฒเฑเฐ—เฑ), Hebrew (ืขื‘ืจื™ืช) via Per-App Language API | +| **Premium** | In-app purchase flow via Razorpay with a premium bottom sheet | +| **Notifications** | Firebase Cloud Messaging (FCM) push notifications | +| **In-App Updates** | Google Play in-app update prompts | +| **Theming** | Material 3 + dynamic color + dark/light mode | -The app follows Clean Architecture principles with MVVM pattern: +--- -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ โ”‚ -โ”‚ Presentation Layer โ”‚ -โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ”‚ ViewModel calls Use Cases - โ–ผ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ โ”‚ -โ”‚ Domain Layer โ”‚ -โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ”‚ Use Cases call Repository - โ–ผ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ โ”‚ -โ”‚ Data Layer โ”‚ -โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ”‚ Repository calls API/Storage - โ–ผ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ โ”‚ -โ”‚ External Data Sources โ”‚ -โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +## Module Architecture + +The project is split into clearly bounded Gradle modules. `common-ui` and `feature-payment` are **Kotlin Multiplatform (KMP)** modules with `commonMain`, `androidMain`, and `iosMain` source sets โ€” making the app iOS-portable without a full rewrite. + +```mermaid +graph TD + subgraph APP["๐ŸŸฆ :app (Android)"] + A[WeatherifyApplication\nMainActivity\nMainViewModel] + end + + subgraph COMMON["๐ŸŸฉ :common-ui (KMP)"] + B[SettingsScreen\nLoginScreen\nInAppWebView\nPermissionDialog\nDateFormatter] + end + + subgraph PAYMENT["๐ŸŸจ :feature-payment (KMP)"] + C[PaymentViewModel\nCreateOrderUseCase\nVerifyPaymentUseCase\nPremiumStore] + end + + subgraph NETWORK["๐ŸŸง :network (Android)"] + D[Ktor Client\nWeatherApi\nKotlinx Serialization] + end + + subgraph STORAGE["๐ŸŸฅ :storage (Android)"] + E[Room Database\nDataStore Preferences\nWeatherDao] + end + + subgraph LANGUAGE["๐ŸŸช :language (Android)"] + F[LanguageScreen\nLocale Config] + end + + subgraph SUNRISE["โฌ› :sunriseui (Android)"] + G[Sunrise/Sunset\nCanvas Animation] + end + + APP --> COMMON + APP --> PAYMENT + APP --> NETWORK + APP --> STORAGE + APP --> LANGUAGE + APP --> SUNRISE ``` -### Data Flow +--- + +## Clean Architecture + +Each feature inside `:app` is structured across three layers. Dependency arrows always point **inward** โ€” the domain layer has zero Android or framework dependencies. + +```mermaid +graph LR + subgraph Presentation["๐ŸŽจ Presentation Layer"] + UI["Compose Screens\n(HomeScreen, CitiesListScreen\nProfileScreen, PaymentScreen)"] + VM["ViewModels\n(MainViewModel, CitiesViewModel)"] + UI -- "UI Events" --> VM + VM -- "UI State (StateFlow)" --> UI + end + + subgraph Domain["๐Ÿง  Domain Layer"] + UC["Use Cases\n(GetWeatherReports\nGetForecastReports\nGetAirQuality...)"] + REPO_IF["Repository Interfaces"] + UC --> REPO_IF + end + + subgraph Data["๐Ÿ’พ Data Layer"] + REPO_IMPL["WeatherRepositoryImpl"] + MAPPER["Mappers\n(Network โ†’ Storage\nStorage โ†’ Domain)"] + REPO_IMPL --> MAPPER + end + + subgraph External["๐ŸŒ External Sources"] + NET[":network\nKtor + OpenWeatherMap API"] + DB[":storage\nRoom DB + DataStore"] + end + + VM -- "calls" --> UC + UC -- "calls" --> REPO_IF + REPO_IF -. "implemented by" .-> REPO_IMPL + REPO_IMPL --> NET + REPO_IMPL --> DB +``` +--- + +## Data Flow + +```text +OpenWeatherMap API + โ”‚ JSON (Ktor + Kotlinx Serialization) + โ–ผ + :network module โ”€โ”€โ”€โ”€โ”€โ”€โ–บ Network Models + โ”‚ + NetworkToStorageMapper + โ”‚ + โ–ผ + :storage module (Room DB / DataStore) + โ”‚ + Storage โ†’ Domain mapper + โ”‚ + โ–ผ + Domain Models + โ”‚ + Use Cases (domain layer) + โ”‚ + โ–ผ + MainViewModel / CitiesViewModel + (StateFlow) + โ”‚ + โ–ผ + Jetpack Compose UI (screens) ``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” API Data โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” Network โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€> โ”‚ โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ -โ”‚ Androidplay โ”‚ โ”‚ Network โ”‚ โ”‚ Network โ”‚ -โ”‚ API โ”‚ โ”‚ Module โ”‚ โ”‚ Repository โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ”‚ Network Models - โ”‚ - โ–ผ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” Cache โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” Entities โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ โ”‚ -โ”‚ Local DB โ”‚ โ”‚ Storage โ”‚ โ”‚ Repository โ”‚ -โ”‚ โ”‚ โ”‚ Module โ”‚ โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ”‚ Domain Models - โ”‚ - โ–ผ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ โ”‚ - โ”‚ Use Cases โ”‚ - โ”‚ โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ”‚ View States - โ”‚ - โ–ผ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ โ”‚ - โ”‚ ViewModel โ”‚ - โ”‚ โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ”‚ UI Events - โ”‚ - โ–ผ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ โ”‚ - โ”‚ Compose UI โ”‚ - โ”‚ โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +--- + +## Tech Stack + +### UI + +| Library | Version | Purpose | +|---|---|---| +| Jetpack Compose BOM | `2025.06.01` | Declarative UI framework | +| Material 3 | BOM-managed | Design system + dynamic theming | +| Compose Navigation | `2.7.7` | Type-safe screen navigation | +| Accompanist Permissions | `0.36.0` | Runtime permissions in Compose | +| Coil Compose | `2.7.0` | Async image loading | +| Splash Screen API | `1.2.0` | Android 12+ splash screen | + +### Architecture & DI + +| Library | Version | Purpose | +|---|---|---| +| Hilt | `2.58` | Dependency injection (Android) | +| Koin | โ€” | DI bridge for KMP modules | +| Kotlin Coroutines | `1.10.2` | Async & structured concurrency | +| StateFlow / Flow | โ€” | Reactive UI state management | + +### Networking + +| Library | Version | Purpose | +|---|---|---| +| Ktor Client | โ€” | KMP-compatible HTTP client | +| Kotlinx Serialization | โ€” | JSON parsing | +| OkHttp MockWebServer | `4.12.0` | Network mocking in tests | + +### Local Storage + +| Library | Version | Purpose | +|---|---|---| +| Room | `2.8.4` | SQLite ORM (weather cache) | +| DataStore Preferences | `1.1.1` | Key-value persistent settings | +| Kotlinx DateTime | `0.6.2` | KMP-compatible date/time | + +### Firebase + +| SDK | Purpose | +|---|---| +| Firebase BOM `34.10.0` | BoM for consistent versions | +| Analytics | User behaviour tracking | +| Remote Config | Server-driven feature flags | +| Performance Monitoring | Network + rendering metrics | +| Cloud Messaging (FCM) | Push notifications | + +### Testing + +| Library | Purpose | +|---|---| +| JUnit 4 + Truth | Unit assertions | +| Turbine `1.2.1` | Flow/StateFlow testing | +| Mockk `1.14.9` | Kotlin-first mocking | +| Mockito + Nhaarman | Java-style mocking | +| Espresso | Instrumentation UI tests | +| Hilt Testing | DI in Android tests | + +### Other + +| Library | Purpose | +|---|---| +| Timber `5.0.1` | Structured logging | +| LeakCanary `2.13` | Memory leak detection (debug) | +| Razorpay `1.6.41` | In-app payment checkout | +| Google Play In-App Update | Forced/flexible update prompts | +| Google Play Location `21.3.0` | FusedLocationProvider | + +--- + +## Screens + +```text +MainActivity +โ”œโ”€โ”€ HomeScreen โ€” current weather + AQI card + hourly strip +โ”œโ”€โ”€ CitiesListScreen โ€” search & manage saved cities +โ”œโ”€โ”€ ProfileScreen โ€” user profile & settings shortcut +โ”œโ”€โ”€ SettingsScreen โ€” language, theme, notification toggles +โ”œโ”€โ”€ LoginScreen โ€” authentication entry point +โ”œโ”€โ”€ PaymentScreen โ€” Razorpay premium upgrade flow +โ””โ”€โ”€ InAppWebView โ€” in-app browser for T&C / privacy policy ``` -## ๐Ÿš€ Recent Updates - -### ๐Ÿงฉ Language Support -- App language change implemented using [Per App Language Preference](https://developer.android.com/guide/topics/resources/app-languages#androidx-impl) -- Material 3 migration -- Added dynamic theme - -### ๐Ÿ“ฑ Demo -[POC-1.webm](https://github.com/bosankus/Compose-Weatherify/assets/46471379/455f1c9d-f1e5-482d-9c29-a1c23b4e3679) - -## ๐Ÿ› ๏ธ Tech Stack - -- **UI Framework**: - - Jetpack Compose with Material 3 - - Compose Navigation - - Compose Permissions - - Lottie Compose for animations - - Coil Compose for image loading - - Custom Sunrise/Sunset animation UI - -- **Architecture**: - - MVVM (Model-View-ViewModel) - - Clean Architecture (Presentation, Domain, Data layers) - - Multi-module project structure - - Kotlin Multiplatform Mobile (KMM) for shared code - -- **Concurrency & Reactive Programming**: - - Kotlin Coroutines - - Flow - - StateFlow for UI state management - -- **Dependency Injection**: - - Hilt for Android - - Koin for KMM modules - -- **Networking**: - - Ktor client - - Kotlinx Serialization - - Content negotiation - -- **Local Storage**: - - Room Database - - DataStore Preferences - - Kotlinx DateTime - -- **Testing**: - - JUnit for unit tests - - Turbine for Flow testing - - Mockk and Mockito for mocking - - Espresso for UI testing - -- **Firebase**: - - Analytics - - Remote Config - - Performance Monitoring - -- **Other Tools & Libraries**: - - Timber for logging - - LeakCanary for memory leak detection - - In-app updates - - Splash Screen API - - Dynamic theming - - Multi-language support - -## ๐Ÿ”ง Setup & Installation - -1. Clone the repository +--- + +## Setup & Installation + +### Prerequisites +- Android Studio Narwhal or later +- JDK 17 +- An [OpenWeatherMap](https://openweathermap.org/api) API key (free tier works) + +### Steps + +1. **Clone the repo** ```bash git clone https://github.com/bosankus/Compose-Weatherify.git + cd Compose-Weatherify ``` -2. Open the project in Android Studio +2. **Add your API key** to `local.properties` (create the file if it doesn't exist): + ```properties + OPEN_WEATHER_API_KEY=your_api_key_here + ``` -3. Get an API key from [OpenWeatherMap](https://openweathermap.org/api) +3. **Add `google-services.json`** to `app/` (from Firebase console โ€” required for Analytics/FCM to compile). -4. Add your API key to `local.properties`: - ``` - OPEN_WEATHER_API_KEY=your_api_key_here +4. **Build & run** + ```bash + ./gradlew assembleDebug + # or just hit Run in Android Studio ``` -5. Build and run the app +> **Minimum Android version:** API 26 (Android 8.0 Oreo) +> **Target SDK:** 36 + +--- -## ๐Ÿค Contributing +## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Contributions are very welcome! 1. Fork the repository -2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'feat/bug/refactor/migrate/update:Add some amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request +2. Create a feature branch: `git checkout -b feature/your-feature` +3. Commit using the project convention: + ```text + feat|fix|refactor|migrate|update: short description + ``` +4. Push and open a Pull Request against **`develop`** + +Please check the [PR template](.github/PULL_REQUEST_TEMPLATE.md) before submitting. + +--- + +## What's Next + +These are the planned improvements currently in progress or on the roadmap: + +- **iOS target** โ€” the KMP foundation is in place (`commonMain`/`iosMain` source sets exist in `:common-ui` and `:feature-payment`). The next step is wiring up a SwiftUI host app and completing the iOS-specific implementations. +- **Navigation v3 migration** โ€” active migration branch (`migration/navigation-3`) moving from Navigation 2.x to the new type-safe Navigation 3 APIs with full back-stack support. +- **Offline-first strategy** โ€” full read-from-cache-then-network flow using Room as the single source of truth, with explicit stale-data indicators in the UI. +- **Widget support** โ€” a Glance-based home screen widget showing current temperature and conditions. +- **Wear OS companion** โ€” lightweight Wear Compose screen for wrist-based weather glances. +- **CI/CD pipeline** โ€” automated release builds and Play Store internal track deployments via GitHub Actions. +- **Accessibility pass** โ€” semantic descriptions, touch target sizing, and TalkBack compatibility audit. + +--- + +## License + +This project is open-sourced under the [MIT License](LICENSE). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dc7be8df..dd750c25 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,9 +1,9 @@ -import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") id("kotlin-android") - id("kotlin-kapt") + id("com.google.devtools.ksp") id("com.google.gms.google-services") id("dagger.hilt.android.plugin") id("kotlin-parcelize") @@ -23,13 +23,11 @@ android { versionName = ConfigData.versionName multiDexEnabled = ConfigData.multiDexEnabled testInstrumentationRunner = "bose.ankush.weatherify.helper.HiltTestRunner" - resourceConfigurations.addAll(listOf("en", "hi", "iw")) } - kapt { - arguments { - arg("room.schemaLocation", "$projectDir/schemas") - } + @Suppress("UnstableApiUsage") + androidResources { + localeFilters.addAll(listOf("en", "hi", "iw", "bn", "kn", "ml", "ta", "te")) } packaging { @@ -44,8 +42,11 @@ android { isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) + signingConfig = signingConfigs.getByName("debug") + // Release signing config should be configured via gradle.properties or build command + // e.g., -Pandroid.injected.signing.store.file=/path/to/release.keystore } } @@ -58,37 +59,30 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - - kotlin { - sourceSets.all { - languageSettings { - languageVersion = Versions.kotlinCompiler - } - } - } lint { abortOnError = false } namespace = "bose.ankush.weatherify" + kotlin { + compilerOptions { + freeCompilerArgs.add("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode") + } + } } -composeCompiler { - featureFlags = setOf( - ComposeFeatureFlag.StrongSkipping.disabled() - ) +ksp { + arg("room.schemaLocation", "$projectDir/schemas") } dependencies { + api(project(":common-ui")) + api(project(":feature-payment")) api(project(":language")) api(project(":storage")) api(project(":network")) - api(project(":sunriseui")) // Core implementation(Deps.androidCore) @@ -104,13 +98,13 @@ dependencies { implementation(Deps.dataStore) implementation(Deps.splashScreen) - // Compose implementation(platform(Deps.composeBom)) implementation(Deps.composeUi) debugImplementation(Deps.composeUiTooling) implementation(Deps.composeUiToolingPreview) implementation(Deps.composeMaterial3) + implementation(Deps.composeIconsExtended) // Unit Testing testImplementation(Deps.junit) @@ -128,31 +122,58 @@ dependencies { androidTestImplementation(Deps.espressoCore) androidTestImplementation(Deps.espressoContrib) androidTestImplementation(Deps.hiltTesting) - kaptAndroidTest(Deps.hiltDaggerAndroidCompiler) + kspAndroidTest(Deps.hiltDaggerAndroidCompiler) // Networking implementation(Deps.gson) - // Firebase + // Room runtime for providing WeatherDatabase from app DI + implementation(Deps.room) + implementation(Deps.roomKtx) + ksp(Deps.roomCompiler) + + // Firebase - BOM implementation(platform(Deps.firebaseBom)) implementation(Deps.firebaseConfig) implementation(Deps.firebaseAnalytics) implementation(Deps.firebasePerformanceMonitoring) + implementation(Deps.firebaseMessaging) // Coroutines implementation(Deps.coroutinesCore) implementation(Deps.coroutinesAndroid) + // Date/Time (KMP-compatible, replaces java.time) + implementation(Deps.kotlinxDatetime) + // Dependency Injection implementation(Deps.hilt) implementation(Deps.hiltNavigationCompose) - kapt(Deps.hiltDaggerAndroidCompiler) + ksp(Deps.hiltDaggerAndroidCompiler) + ksp(Deps.hiltAndroidXCompiler) // Miscellaneous implementation(Deps.timber) - implementation(Deps.lottieCompose) + // Removed Lottie dependency as per requirements implementation(Deps.coilCompose) // Memory leak debugImplementation(Deps.leakCanary) + + // Payment SDK (Android-only โ€” Razorpay checkout is launched from the app layer) + implementation(Deps.razorPay) + + // Koin โ€” bridges the feature-payment Koin module with Hilt-managed singletons + implementation(KmmDeps.koinAndroid) + implementation(KmmDeps.koinAndroidCompose) +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.addAll( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=androidx.compose.animation.ExperimentalAnimationApi", + ) + } } diff --git a/app/src/androidTest/java/bose/ankush/weatherify/helper/HiltTestApplication.kt b/app/src/androidTest/java/bose/ankush/weatherify/helper/HiltTestApplication.kt deleted file mode 100644 index 2becd798..00000000 --- a/app/src/androidTest/java/bose/ankush/weatherify/helper/HiltTestApplication.kt +++ /dev/null @@ -1,7 +0,0 @@ -package bose.ankush.weatherify.helper - -import bose.ankush.weatherify.WeatherifyApplicationCore -import dagger.hilt.android.testing.CustomTestApplication - -@CustomTestApplication(WeatherifyApplicationCore::class) -interface HiltTestApplication \ No newline at end of file diff --git a/app/src/androidTest/java/bose/ankush/weatherify/helper/HiltTestRunner.kt b/app/src/androidTest/java/bose/ankush/weatherify/helper/HiltTestRunner.kt deleted file mode 100644 index 184b2f70..00000000 --- a/app/src/androidTest/java/bose/ankush/weatherify/helper/HiltTestRunner.kt +++ /dev/null @@ -1,15 +0,0 @@ -package bose.ankush.weatherify.helper - -import android.app.Application -import android.content.Context -import androidx.test.runner.AndroidJUnitRunner - -class HiltTestRunner : AndroidJUnitRunner() { - - override fun newApplication( - cl: ClassLoader?, - className: String?, - context: Context? - ): Application = - super.newApplication(cl, HiltTestApplication_Application::class.java.name, context) -} \ No newline at end of file diff --git a/app/src/androidTest/java/bose/ankush/weatherify/presentation/MainActivityTest.kt b/app/src/androidTest/java/bose/ankush/weatherify/presentation/MainActivityTest.kt deleted file mode 100644 index d01acd60..00000000 --- a/app/src/androidTest/java/bose/ankush/weatherify/presentation/MainActivityTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -package bose.ankush.weatherify.presentation - -import dagger.hilt.android.testing.HiltAndroidTest - -@HiltAndroidTest -class MainActivityTest { - - /*@get: Rule(order = 1) - val hiltAndroidRule = HiltAndroidRule(this) - - @get:Rule(order = 2) - val createComposeRule = createAndroidComposeRule() - - private lateinit var viewModel: MainViewModel - - @Before - fun setup() { - hiltAndroidRule.inject() - } - - @Test - fun verify_HomeScreen_isShown() { - createComposeRule.activity.setContent { - viewModel = hiltViewModel() - WeatherifyTheme { AppNavigation() } - } - }*/ -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8127e848..899c46c2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,9 +10,13 @@ - - + + + + + @@ -46,6 +52,23 @@ android:name="autoStoreLocales" android:value="true" /> + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/countryConfig.json b/app/src/main/assets/countryConfig.json index affee109..361fdf66 100644 --- a/app/src/main/assets/countryConfig.json +++ b/app/src/main/assets/countryConfig.json @@ -4,7 +4,12 @@ "languages": [ "en-IN", "hi-IN", - "iw-IL" + "iw-IL", + "kn-IN", + "ta-IN", + "te-IN", + "bn-IN", + "ml-IN" ], "defaultLanguage": "en-IN", "localCurrency": ["INR"] diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/di/StorageModule.kt b/app/src/main/java/bose/ankush/storage/di/StorageModule.kt similarity index 50% rename from storage/src/androidMain/kotlin/bose/ankush/storage/di/StorageModule.kt rename to app/src/main/java/bose/ankush/storage/di/StorageModule.kt index 5c27145c..d3a5852f 100644 --- a/storage/src/androidMain/kotlin/bose/ankush/storage/di/StorageModule.kt +++ b/app/src/main/java/bose/ankush/storage/di/StorageModule.kt @@ -2,13 +2,14 @@ package bose.ankush.storage.di import android.content.Context import androidx.room.Room -import bose.ankush.network.repository.WeatherRepository as NetworkWeatherRepository +import bose.ankush.storage.api.TokenStorage import bose.ankush.storage.api.WeatherStorage import bose.ankush.storage.common.WEATHER_DATABASE_NAME +import bose.ankush.storage.impl.EncryptedTokenStorageImpl import bose.ankush.storage.impl.WeatherStorageImpl import bose.ankush.storage.room.JsonParser -import bose.ankush.storage.room.WeatherDatabase import bose.ankush.storage.room.WeatherDataModelConverters +import bose.ankush.storage.room.WeatherDatabase import com.google.gson.Gson import dagger.Module import dagger.Provides @@ -20,7 +21,6 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object StorageModule { - @Provides @Singleton fun provideGson(): Gson = Gson() @@ -31,32 +31,40 @@ object StorageModule { @Provides @Singleton - fun provideWeatherDataModelConverters(jsonParser: JsonParser): WeatherDataModelConverters { - return WeatherDataModelConverters(jsonParser) - } + fun provideWeatherDataModelConverters(jsonParser: JsonParser): WeatherDataModelConverters = + WeatherDataModelConverters(jsonParser) @Provides @Singleton fun provideWeatherDatabase( @ApplicationContext context: Context, - converters: WeatherDataModelConverters - ): WeatherDatabase { - return Room.databaseBuilder( - context, - WeatherDatabase::class.java, - WEATHER_DATABASE_NAME - ) - .addTypeConverter(converters) - .fallbackToDestructiveMigration() + converters: WeatherDataModelConverters, + ): WeatherDatabase = + Room + .databaseBuilder( + context, + WeatherDatabase::class.java, + WEATHER_DATABASE_NAME, + ).addTypeConverter(converters) + .fallbackToDestructiveMigration(false) .build() + + @Provides + @Singleton + fun provideWeatherStorage(weatherDatabase: WeatherDatabase): WeatherStorage { + // Storage module is responsible ONLY for database operations + // Network synchronization is handled by WeatherRepository in the orchestration layer + return WeatherStorageImpl(weatherDatabase) } @Provides @Singleton - fun provideWeatherStorage( - networkRepository: NetworkWeatherRepository, - weatherDatabase: WeatherDatabase - ): WeatherStorage { - return WeatherStorageImpl(networkRepository, weatherDatabase) + fun provideTokenStorage( + @ApplicationContext context: Context, + ): TokenStorage { + // SECURITY: Initialize Android context for platform-specific token storage + bose.ankush.storage.impl + .setApplicationContext(context) + return EncryptedTokenStorageImpl() } } diff --git a/app/src/main/java/bose/ankush/weatherify/WeatherifyApplication.kt b/app/src/main/java/bose/ankush/weatherify/WeatherifyApplication.kt index 591e5d11..eb00750d 100644 --- a/app/src/main/java/bose/ankush/weatherify/WeatherifyApplication.kt +++ b/app/src/main/java/bose/ankush/weatherify/WeatherifyApplication.kt @@ -2,11 +2,16 @@ package bose.ankush.weatherify import android.app.NotificationChannel import android.app.NotificationManager -import android.content.Context +import bose.ankush.payment.di.featurePaymentModules import bose.ankush.weatherify.base.location.LocationService.Companion.NOTIFICATION_CHANNEL_ID import bose.ankush.weatherify.base.location.LocationService.Companion.NOTIFICATION_NAME +import bose.ankush.weatherify.di.appPaymentKoinModule import bose.ankush.weatherify.domain.remote_config.RemoteConfigService +import com.google.firebase.FirebaseApp +import com.google.firebase.messaging.FirebaseMessaging import dagger.hilt.android.HiltAndroidApp +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin import timber.log.Timber import javax.inject.Inject @@ -17,15 +22,41 @@ Date: 05,May,2021 @HiltAndroidApp class WeatherifyApplication : WeatherifyApplicationCore() { - @Inject lateinit var remoteConfigService: RemoteConfigService override fun onCreate() { - super.onCreate() + super.onCreate() // Hilt initializes here โ€” EntryPointAccessors is safe after this call + initKoin() enableTimber() + initializeFirebase() createNotificationChannel() initializeRemoteConfig() + subscribeToTopics() + } + + private fun initKoin() { + startKoin { + androidContext(this@WeatherifyApplication) + modules(featurePaymentModules + appPaymentKoinModule(this@WeatherifyApplication)) + } + } + + private fun initializeFirebase() { + FirebaseApp.initializeApp(this) + } + + private fun subscribeToTopics() { + FirebaseMessaging + .getInstance() + .subscribeToTopic("weather_alerts") + .addOnCompleteListener { task -> + if (!task.isSuccessful) { + Timber.e(task.exception, "Failed to subscribe to weather_alerts topic") + } else { + Timber.d("Successfully subscribed to weather_alerts topic") + } + } } private fun initializeRemoteConfig() { @@ -33,17 +64,44 @@ class WeatherifyApplication : WeatherifyApplicationCore() { } private fun enableTimber() { - Timber.plant(Timber.DebugTree()) + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } else { + Timber.plant( + object : Timber.Tree() { + override fun log( + priority: Int, + tag: String?, + message: String, + t: Throwable?, + ) { + // Only log WARN, ERROR, and WTF in release; avoid verbose/debug/info + val isLowPriority = + priority == android.util.Log.VERBOSE || + priority == android.util.Log.DEBUG || + priority == android.util.Log.INFO + if (isLowPriority) return + android.util.Log.println(priority, tag, message) + } + }, + ) + } } private fun createNotificationChannel() { - val channel = NotificationChannel( - NOTIFICATION_CHANNEL_ID, - NOTIFICATION_NAME, - NotificationManager.IMPORTANCE_HIGH - ) + val channel = + NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_NAME, + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Channel for weather alerts and updates" + enableVibration(true) + } + val notificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) + Timber.d("Notification channel created: $NOTIFICATION_CHANNEL_ID") } } diff --git a/app/src/main/java/bose/ankush/weatherify/WeatherifyApplicationCore.kt b/app/src/main/java/bose/ankush/weatherify/WeatherifyApplicationCore.kt index 5905443c..3923d127 100644 --- a/app/src/main/java/bose/ankush/weatherify/WeatherifyApplicationCore.kt +++ b/app/src/main/java/bose/ankush/weatherify/WeatherifyApplicationCore.kt @@ -2,4 +2,4 @@ package bose.ankush.weatherify import android.app.Application -open class WeatherifyApplicationCore: Application() \ No newline at end of file +open class WeatherifyApplicationCore : Application() diff --git a/app/src/main/java/bose/ankush/weatherify/base/AssetLoader.kt b/app/src/main/java/bose/ankush/weatherify/base/AssetLoader.kt deleted file mode 100644 index 0c9aff20..00000000 --- a/app/src/main/java/bose/ankush/weatherify/base/AssetLoader.kt +++ /dev/null @@ -1,14 +0,0 @@ -package bose.ankush.weatherify.base - -import android.content.Context -import com.google.gson.Gson -import java.io.IOException - -class AssetLoader(val context: Context) { - @Throws(IOException::class) - inline fun loadJSONAndConvertToObject(fileName: String): T { - return context.assets.open(fileName).use { inputStream -> - Gson().fromJson(inputStream.bufferedReader().use { it.readText() }, T::class.java) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/bose/ankush/weatherify/base/DateTimeUtils.kt b/app/src/main/java/bose/ankush/weatherify/base/DateTimeUtils.kt index 1fd21285..75bb7deb 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/DateTimeUtils.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/DateTimeUtils.kt @@ -4,30 +4,24 @@ import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.util.Date import java.util.Calendar +import java.util.Date import java.util.Locale /** * Singleton class to provide utility values related to date and time throughout all the modules. */ object DateTimeUtils { - - /** - * Returns current timestamp as per device in String - */ - fun getCurrentTimestamp(): String = Instant.now().toEpochMilli().toString() - /** * Returns numbers of days between today and given time on argument */ - fun getDayWiseDifferenceFromToday(day: Int): Int { + fun getDayWiseDifferenceFromToday(day: Long): Int { val todayDate = getTodayDateInCalenderFormat() - val givenDate = Date(day.toLong() * 1000) + val givenDate = Date(day * 1000) val calenderForGivenDate = Calendar.getInstance() calenderForGivenDate.time = givenDate - val givenDateNumber = calenderForGivenDate.get(Calendar.DAY_OF_MONTH + 1) - val todayDateNumber = todayDate.get(Calendar.DAY_OF_MONTH + 1) + val givenDateNumber = calenderForGivenDate.get(Calendar.DAY_OF_MONTH) + val todayDateNumber = todayDate.get(Calendar.DAY_OF_MONTH) return givenDateNumber - todayDateNumber } @@ -60,41 +54,18 @@ object DateTimeUtils { return calendarForToday } - /** - * Returns time from given epoch in String. - * Takes epoch in Integer format and device zone in String, as the arguments - */ - fun getTimeFromEpoch(epoch: Int?, zone: String = "Asia/Kolkata"): String { - val format = "K:mm a" - return epoch?.let { - val zoneId = ZoneId.of(zone) - val instant = Instant.ofEpochSecond(epoch.toLong()) - val formatter = DateTimeFormatter.ofPattern(format, Locale.ENGLISH) - instant.atZone(zoneId).format(formatter) - }.toString() - } - fun Long.toFormattedTime(zone: String = "Asia/Kolkata"): String { val format = "K:mm a" val zoneId = ZoneId.of(zone) val instant = Instant.ofEpochSecond(this) val formatter = DateTimeFormatter.ofPattern(format, Locale.ENGLISH) - return instant.atZone(zoneId).format(formatter).toString() + return instant.atZone(zoneId).format(formatter) } fun getFormattedDateTimeFromEpoch(epoch: Long?): String { - epoch?.let { - val instant = Instant.ofEpochSecond(it) - val zoneId = ZoneId.systemDefault() - - // convert instant to local date time - val localDateTime = LocalDateTime.ofInstant(instant, zoneId) - - // creating desired date time format - val dateTimeFormat = DateTimeFormatter.ofPattern("EEE, dd MMM") - - return dateTimeFormat.format(localDateTime) - } ?: - return "Date & Time is unavailable at this moment" + epoch ?: return "Date & Time is unavailable at this moment" + val instant = Instant.ofEpochSecond(epoch) + val localDateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()) + return DateTimeFormatter.ofPattern("EEE, dd MMM").format(localDateTime) } -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/LocaleConfigMapper.kt b/app/src/main/java/bose/ankush/weatherify/base/LocaleConfigMapper.kt index 02dfeb09..150829f0 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/LocaleConfigMapper.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/LocaleConfigMapper.kt @@ -5,19 +5,23 @@ import com.google.gson.Gson import com.google.gson.GsonBuilder object LocaleConfigMapper { - - fun getAvailableLanguagesFromJson(jsonFile: String, context: Context): Array { + fun getAvailableLanguagesFromJson( + jsonFile: String, + context: Context, + ): Array { // read JSON file - val jsonString: String = context.assets.open(jsonFile) - .bufferedReader() - .use { it.readText() } + val jsonString: String = + context.assets + .open(jsonFile) + .bufferedReader() + .use { it.readText() } // convert JSON to Map val gson: Gson = GsonBuilder().setPrettyPrinting().create() val map = gson.fromJson(jsonString, Map::class.java) - // extract language data from JSON and return as Array + @Suppress("UNCHECKED_CAST") val languages = map["languages"] as List return languages.toTypedArray() } diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/AirQualityIndexAnalyser.kt b/app/src/main/java/bose/ankush/weatherify/base/common/AirQualityIndexAnalyser.kt index 3973ef3c..7b1ac45c 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/common/AirQualityIndexAnalyser.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/common/AirQualityIndexAnalyser.kt @@ -1,13 +1,12 @@ package bose.ankush.weatherify.base.common object AirQualityIndexAnalyser { - /** * Used to analyse the air quality index number, * and generate a string accordingly for UI to show */ - internal fun getAQIAnalysedText(aqi: Int): Pair { - return when (aqi) { + internal fun getAQIAnalysedText(aqi: Int): Pair = + when (aqi) { 1 -> Pair("Air quality is Good", aqi) 2 -> Pair("Air quality is fair", aqi) 3 -> Pair("Air quality is moderate", aqi) @@ -15,14 +14,15 @@ object AirQualityIndexAnalyser { 5 -> Pair("Air quality is Very Unhealthy", aqi) else -> Pair("Air quality is Hazardous", aqi) } - } /** * This method is actually for making look pretty by adding * adding `0` to single digit number */ - internal fun Int.getFormattedAQI(): String { - return if (this in 0..9) "0$this" - else "$this" - } -} \ No newline at end of file + internal fun Int.getFormattedAQI(): String = + if (this in 0..9) { + "0$this" + } else { + "$this" + } +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/AndroidDeviceInfoProvider.kt b/app/src/main/java/bose/ankush/weatherify/base/common/AndroidDeviceInfoProvider.kt new file mode 100644 index 00000000..c55dc9b6 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/base/common/AndroidDeviceInfoProvider.kt @@ -0,0 +1,20 @@ +package bose.ankush.weatherify.base.common + +/** Android implementation of [DeviceInfoProvider] backed by the existing Extension helpers. */ +class AndroidDeviceInfoProvider : DeviceInfoProvider { + override fun getDeviceModel(): String = Extension.getDeviceModel() + + override fun getOperatingSystem(): String = Extension.getOperatingSystem() + + override fun getOsVersion(): String = Extension.getOsVersion() + + override fun getAppVersion(): String = Extension.getAppVersion() + + override fun getRegistrationSource(): String = Extension.getRegistrationSource() + + override fun getIpAddress(): String? = Extension.getIpAddress() + + override fun getCurrentUtcTimestamp(): String = Extension.getCurrentUtcTimestamp() + + override suspend fun getFirebaseToken(): String? = Extension.getFirebaseToken() +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/ConnectivityManager.kt b/app/src/main/java/bose/ankush/weatherify/base/common/ConnectivityManager.kt deleted file mode 100644 index 62abd0ea..00000000 --- a/app/src/main/java/bose/ankush/weatherify/base/common/ConnectivityManager.kt +++ /dev/null @@ -1,25 +0,0 @@ -@file:Suppress("DEPRECATION") - -package bose.ankush.weatherify.base.common - -import android.annotation.SuppressLint -import android.content.Context -import android.net.ConnectivityManager -import android.net.NetworkCapabilities - -object ConnectivityManager { - - @SuppressLint("ServiceCast") - fun isNetworkAvailable(context: Context): Boolean { - val connManager = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val networkCapabilities = connManager.activeNetwork ?: return false - val activeNetwork = - connManager.getNetworkCapabilities(networkCapabilities) ?: return false - return when { - activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true - activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true - else -> false - } - } -} diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/Constants.kt b/app/src/main/java/bose/ankush/weatherify/base/common/Constants.kt index 3b11899a..533f9bd4 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/common/Constants.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/common/Constants.kt @@ -6,35 +6,28 @@ import android.annotation.SuppressLint Author: Ankush Bose Date: 05,May,2021 **/ - -/*General constants*/ -const val WEATHER_BASE_URL = "https://data.androidplay.in/" const val WEATHER_IMG_URL = "https://openweathermap.org/img/wn/" const val APP_UPDATE_REQ_CODE = 111 -/*Shared Preference Keys*/ +// Shared Preference Keys const val APP_PREFERENCE_KEY = "app_preferences" -/*Fallback user location coordinates*/ +// Fallback user location coordinates const val DEFAULT_CITY_NAME = "New Delhi" -/* Permission constants */ +// Permission constants const val ACCESS_FINE_LOCATION = android.Manifest.permission.ACCESS_FINE_LOCATION const val ACCESS_COARSE_LOCATION = android.Manifest.permission.ACCESS_COARSE_LOCATION -const val ACCESS_PHONE_CALL = android.Manifest.permission.CALL_PHONE + @SuppressLint("InlinedApi") const val ACCESS_NOTIFICATION = android.Manifest.permission.POST_NOTIFICATIONS -val PERMISSIONS_TO_REQUEST = arrayOf( - ACCESS_FINE_LOCATION, - ACCESS_COARSE_LOCATION -) - -/*Room central db name*/ -const val WEATHER_DATABASE_NAME = "central_weather_table" -const val AQ_DATABASE_NAME = "central_aq_table" -const val PHONE_NUMBER = "tel:+91XXXXXXXXX" +val PERMISSIONS_TO_REQUEST = + arrayOf( + ACCESS_FINE_LOCATION, + ACCESS_COARSE_LOCATION, + ) -/*Remote keys*/ +// Remote keys const val ENABLE_NOTIFICATION = "enable_notification" diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/DeviceInfoProvider.kt b/app/src/main/java/bose/ankush/weatherify/base/common/DeviceInfoProvider.kt new file mode 100644 index 00000000..ffb2303d --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/base/common/DeviceInfoProvider.kt @@ -0,0 +1,26 @@ +package bose.ankush.weatherify.base.common + +/** + * Platform-agnostic interface for device and app metadata. + * Replaces direct Extension.* calls in shared/common code to enable KMP compatibility. + * + * Android provides an implementation backed by android.os.Build, BuildConfig, and Firebase. + * iOS (or other KMP targets) would provide their own implementation. + */ +interface DeviceInfoProvider { + fun getDeviceModel(): String + + fun getOperatingSystem(): String + + fun getOsVersion(): String + + fun getAppVersion(): String + + fun getRegistrationSource(): String + + fun getIpAddress(): String? + + fun getCurrentUtcTimestamp(): String + + suspend fun getFirebaseToken(): String? +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/Extension.kt b/app/src/main/java/bose/ankush/weatherify/base/common/Extension.kt index e1128194..d58ef9b5 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/common/Extension.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/common/Extension.kt @@ -1,14 +1,22 @@ package bose.ankush.weatherify.base.common +import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.provider.Settings -import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat -import androidx.core.net.toUri +import bose.ankush.weatherify.BuildConfig +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.suspendCancellableCoroutine +import java.net.NetworkInterface +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import kotlin.coroutines.resume import kotlin.math.roundToInt /**Created by @@ -17,7 +25,6 @@ Date: 06,May,2021 **/ object Extension { - fun Double.toCelsius() = (this - 273).roundToInt().toString() fun String.getIconUrl(size: String = "@2x.png") = "$WEATHER_IMG_URL$this$size" @@ -26,39 +33,90 @@ object Extension { fun isDeviceSDKAndroid13OrAbove() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - fun Context.openAppSystemSettings() = startActivity( - Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", packageName, null) + fun Context.openAppSystemSettings() = + startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + }, + ) + + fun Context.openLocationSettings() = + startActivity( + Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS), + ) + + @SuppressLint("QueryPermissionsNeeded") + fun Context.openAppLocaleSettings() { + // Try opening the per-app language settings if available, otherwise fall back safely + val pm = packageManager + // Primary: Per-app language settings (Android 13+) + val appLocaleIntent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } else { + Intent(Settings.ACTION_LOCALE_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + + try { + val canHandleAppLocale = appLocaleIntent.resolveActivity(pm) != null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && canHandleAppLocale) { + startActivity(appLocaleIntent) + return + } + } catch (_: Exception) { + // Ignore and try fallbacks } - ) - @RequiresApi(Build.VERSION_CODES.TIRAMISU) - fun Context.openAppLocaleSettings() = startActivity( - Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { - data = Uri.fromParts("package", packageName, null) + // Fallback 1: App details/settings screen + val appDetailsIntent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + val canHandleAppDetails = appDetailsIntent.resolveActivity(pm) != null + if (canHandleAppDetails) { + startActivity(appDetailsIntent) + return + } + } catch (_: Exception) { + // Ignore and try next fallback } - ) - fun Context.hasLocationPermission(): Boolean = listOf( - android.Manifest.permission.ACCESS_COARSE_LOCATION, - android.Manifest.permission.ACCESS_FINE_LOCATION - ).all { permission -> - ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + // Fallback 2: System language settings + val localeSettingsIntent = + Intent(Settings.ACTION_LOCALE_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + val canHandleLocaleSettings = localeSettingsIntent.resolveActivity(pm) != null + if (canHandleLocaleSettings) { + startActivity(localeSettingsIntent) + return + } + } catch (_: Exception) { + // Final fallback: do nothing; avoid crash + } } - private fun Context.hasPhoneCallPermission(): Boolean { - return ContextCompat.checkSelfPermission( - this, - ACCESS_PHONE_CALL - ) == PackageManager.PERMISSION_GRANTED - } + fun Context.hasLocationPermission(): Boolean = + listOf( + android.Manifest.permission.ACCESS_COARSE_LOCATION, + android.Manifest.permission.ACCESS_FINE_LOCATION, + ).all { permission -> + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + } - fun Context.hasNotificationPermission(): Boolean { - return ContextCompat.checkSelfPermission( + fun Context.hasNotificationPermission(): Boolean = + ContextCompat.checkSelfPermission( this, - ACCESS_NOTIFICATION + ACCESS_NOTIFICATION, ) == PackageManager.PERMISSION_GRANTED - } fun String.wrapText(): String { val words: List = this.split(" ") @@ -69,11 +127,89 @@ object Extension { } } - fun Context.callNumber(): Boolean = hasPhoneCallPermission().also { hasPermission -> - if (hasPermission) startActivity( - Intent(Intent.ACTION_CALL).apply { - data = PHONE_NUMBER.toUri() + /** + * Gets the device model (e.g., "Pixel 7 Pro", "iPhone 15") + * @return The device model name + */ + fun getDeviceModel(): String = Build.MODEL + + /** + * Gets the operating system name (e.g., "Android") + * @return The operating system name + */ + fun getOperatingSystem(): String = "Android" + + /** + * Gets the operating system version (e.g., "14", "13.1") + * @return The operating system version + */ + fun getOsVersion(): String = Build.VERSION.RELEASE + + /** + * Gets the app version from BuildConfig + * @return The app version + */ + fun getAppVersion(): String = BuildConfig.VERSION_NAME + + /** + * Gets the current UTC timestamp in ISO 8601 format + * @return The current UTC timestamp + */ + fun getCurrentUtcTimestamp(): String { + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + dateFormat.timeZone = TimeZone.getTimeZone("UTC") + return dateFormat.format(Date()) + } + + /** + * Gets the registration source + * @return The registration source (e.g., "Android App") + */ + fun getRegistrationSource(): String = "Android App" + + /** + * Attempts to get the device's IP address + * Note: This is a best-effort approach and may not always return the correct IP + * @return The IP address or null if not available + */ + fun getIpAddress(): String? { + try { + val networkInterfaces = NetworkInterface.getNetworkInterfaces() + while (networkInterfaces.hasMoreElements()) { + val networkInterface = networkInterfaces.nextElement() + val inetAddresses = networkInterface.inetAddresses + while (inetAddresses.hasMoreElements()) { + val inetAddress = inetAddresses.nextElement() + if (!inetAddress.isLoopbackAddress && !inetAddress.isLinkLocalAddress) { + return inetAddress.hostAddress + } + } } - ) + } catch (_: Exception) { + // Ignore exceptions and return null + } + return null } + + /** + * Best-effort fetch of Firebase Cloud Messaging registration token + * Kept here to follow the same Extension helper pattern as other device/app info getters + */ + suspend fun getFirebaseToken(): String? = + try { + suspendCancellableCoroutine { cont -> + try { + FirebaseMessaging + .getInstance() + .token + .addOnCompleteListener { task: com.google.android.gms.tasks.Task -> + if (cont.isActive) cont.resume(if (task.isSuccessful) task.result else null) + } + } catch (_: Exception) { + if (cont.isActive) cont.resume(null) + } + } + } catch (_: Exception) { + null + } } diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/InAppUpdateManager.kt b/app/src/main/java/bose/ankush/weatherify/base/common/InAppUpdateManager.kt index e962204b..acade7fe 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/common/InAppUpdateManager.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/common/InAppUpdateManager.kt @@ -9,26 +9,24 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi @ExperimentalCoroutinesApi fun startInAppUpdate(activity: Activity) { - val appUpdateManager = AppUpdateManagerFactory.create(activity) val appUpdateInfoTask = appUpdateManager.appUpdateInfo appUpdateInfoTask.addOnSuccessListener { appUpdateInfo -> - if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE - && appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) + if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE && + appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) ) { try { + @Suppress("DEPRECATION") appUpdateManager.startUpdateFlowForResult( appUpdateInfo, AppUpdateType.IMMEDIATE, activity, - APP_UPDATE_REQ_CODE + APP_UPDATE_REQ_CODE, ) - } catch (exception: IntentSender.SendIntentException) { } } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/Logger.kt b/app/src/main/java/bose/ankush/weatherify/base/common/Logger.kt new file mode 100644 index 00000000..4a07bb79 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/base/common/Logger.kt @@ -0,0 +1,28 @@ +package bose.ankush.weatherify.base.common + +/** + * Platform-agnostic logging interface. + * Replaces direct Timber usage in shared/common code to enable KMP compatibility. + */ +interface Logger { + fun d(message: String) + + fun i(message: String) + + fun w(message: String) + + fun e( + message: String, + throwable: Throwable? = null, + ) + + fun v(message: String) +} + +/** + * Factory that creates [Logger] instances scoped to a specific tag. + * Android provides a Timber-backed implementation; other platforms can provide their own. + */ +interface LoggerFactory { + fun create(tag: String): Logger +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/TimberLogger.kt b/app/src/main/java/bose/ankush/weatherify/base/common/TimberLogger.kt new file mode 100644 index 00000000..643c3d20 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/base/common/TimberLogger.kt @@ -0,0 +1,32 @@ +package bose.ankush.weatherify.base.common + +import timber.log.Timber + +/** Timber-backed [Logger] implementation for Android. */ +class TimberLogger( + private val tag: String, +) : Logger { + override fun d(message: String) = Timber.tag(tag).d(message) + + override fun i(message: String) = Timber.tag(tag).i(message) + + override fun w(message: String) = Timber.tag(tag).w(message) + + override fun e( + message: String, + throwable: Throwable?, + ) { + if (throwable != null) { + Timber.tag(tag).e(throwable, message) + } else { + Timber.tag(tag).e(message) + } + } + + override fun v(message: String) = Timber.tag(tag).v(message) +} + +/** [LoggerFactory] that creates [TimberLogger] instances. */ +class TimberLoggerFactory : LoggerFactory { + override fun create(tag: String): Logger = TimberLogger(tag) +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/UiText.kt b/app/src/main/java/bose/ankush/weatherify/base/common/UiText.kt index f8eae807..7e1c4518 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/common/UiText.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/common/UiText.kt @@ -2,25 +2,57 @@ package bose.ankush.weatherify.base.common import android.content.Context import androidx.annotation.StringRes +import bose.ankush.network.common.NetworkException import bose.ankush.weatherify.R sealed class UiText { - data class DynamicText(val value: String) : UiText() - class StringResource(@StringRes val resId: Int, vararg val args: String) : UiText() + data class DynamicText( + val value: String, + ) : UiText() - fun asString(context: Context): String { - return when (this) { + class StringResource( + @StringRes val resId: Int, + vararg val args: String, + ) : UiText() + + fun asString(context: Context): String = + when (this) { is DynamicText -> value is StringResource -> context.getString(resId, *args) } - } } -fun errorResponse(errorCode: Int): UiText.StringResource { - return when (errorCode) { - 401 -> UiText.StringResource(resId = R.string.unauthorised_access_txt) - 400, 404 -> UiText.StringResource(resId = R.string.city_error_txt) - 500 -> UiText.StringResource(resId = R.string.server_error_txt) +/** + * Maps error codes to user-friendly messages + * @param errorCode The HTTP or custom error code + * @return A user-friendly error message as a StringResource + */ +fun errorResponse(errorCode: Int): UiText.StringResource = + when (errorCode) { + // HTTP error codes + NetworkException.BAD_REQUEST -> UiText.StringResource(resId = R.string.city_error_txt) + NetworkException.UNAUTHORIZED -> UiText.StringResource(resId = R.string.unauthorised_access_txt) + NetworkException.FORBIDDEN -> UiText.StringResource(resId = R.string.unauthorised_access_txt) + NetworkException.NOT_FOUND -> UiText.StringResource(resId = R.string.city_error_txt) + NetworkException.SERVER_ERROR -> UiText.StringResource(resId = R.string.server_error_txt) + NetworkException.SERVICE_UNAVAILABLE -> UiText.StringResource(resId = R.string.server_error_txt) + + // Network-specific error codes + NetworkException.NETWORK_UNAVAILABLE -> UiText.StringResource(resId = R.string.network_unavailable_txt) + NetworkException.TIMEOUT -> UiText.StringResource(resId = R.string.network_timeout_txt) + NetworkException.UNKNOWN_HOST -> UiText.StringResource(resId = R.string.network_unavailable_txt) + + // Default case + else -> UiText.StringResource(resId = R.string.general_error_txt) + } + +/** + * Maps an exception to a user-friendly message + * @param exception The exception to map + * @return A user-friendly error message + */ +fun errorResponseFromException(exception: Exception): UiText = + when (exception) { + is NetworkException -> errorResponse(exception.errorCode) else -> UiText.StringResource(resId = R.string.general_error_txt) } -} diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/component/ScreenTopAppBar.kt b/app/src/main/java/bose/ankush/weatherify/base/common/component/ScreenTopAppBar.kt index 707bb5c8..0744fb5e 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/common/component/ScreenTopAppBar.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/common/component/ScreenTopAppBar.kt @@ -28,7 +28,7 @@ fun ScreenTopAppBar( text = stringResource(id = headlineId), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 16.dp) + modifier = Modifier.padding(start = 16.dp), ) }, navigationIcon = { @@ -36,11 +36,12 @@ fun ScreenTopAppBar( painter = painterResource(id = R.drawable.ic_back), tint = MaterialTheme.colorScheme.onBackground, contentDescription = stringResource(id = R.string.close_icon_content), - modifier = Modifier - .clip(CircleShape) - .clickable { navIconAction.invoke() } - .padding(all = 3.dp) + modifier = + Modifier + .clip(CircleShape) + .clickable { navIconAction.invoke() } + .padding(all = 3.dp), ) - } + }, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/config/AndroidAppConfig.kt b/app/src/main/java/bose/ankush/weatherify/base/config/AndroidAppConfig.kt new file mode 100644 index 00000000..e20f7f99 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/base/config/AndroidAppConfig.kt @@ -0,0 +1,8 @@ +package bose.ankush.weatherify.base.config + +import bose.ankush.weatherify.BuildConfig + +/** Android implementation of [AppConfig] backed by BuildConfig generated values. */ +class AndroidAppConfig : AppConfig { + override val razorpayKey: String get() = BuildConfig.RAZORPAY_KEY +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/config/AppConfig.kt b/app/src/main/java/bose/ankush/weatherify/base/config/AppConfig.kt new file mode 100644 index 00000000..60a80a0f --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/base/config/AppConfig.kt @@ -0,0 +1,9 @@ +package bose.ankush.weatherify.base.config + +/** + * Platform-agnostic interface for build/environment configuration values. + * Replaces direct BuildConfig references in shared/common code to enable KMP compatibility. + */ +interface AppConfig { + val razorpayKey: String +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/dispatcher/AppDispatcher.kt b/app/src/main/java/bose/ankush/weatherify/base/dispatcher/AppDispatcher.kt index c9f20eb3..6df7f706 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/dispatcher/AppDispatcher.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/dispatcher/AppDispatcher.kt @@ -4,7 +4,6 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers class AppDispatcher : DispatcherProvider { - override val main: CoroutineDispatcher get() = Dispatchers.Main @@ -16,4 +15,4 @@ class AppDispatcher : DispatcherProvider { override val unconfined: CoroutineDispatcher get() = Dispatchers.Unconfined -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/dispatcher/DispatcherProvider.kt b/app/src/main/java/bose/ankush/weatherify/base/dispatcher/DispatcherProvider.kt index 0c52eaa1..fafa5f1b 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/dispatcher/DispatcherProvider.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/dispatcher/DispatcherProvider.kt @@ -3,7 +3,6 @@ package bose.ankush.weatherify.base.dispatcher import kotlinx.coroutines.CoroutineDispatcher interface DispatcherProvider { - val main: CoroutineDispatcher val io: CoroutineDispatcher @@ -11,4 +10,4 @@ interface DispatcherProvider { val default: CoroutineDispatcher val unconfined: CoroutineDispatcher -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/location/Coordinates.kt b/app/src/main/java/bose/ankush/weatherify/base/location/Coordinates.kt new file mode 100644 index 00000000..e79d3edd --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/base/location/Coordinates.kt @@ -0,0 +1,10 @@ +package bose.ankush.weatherify.base.location + +/** + * Platform-agnostic representation of a geographic coordinate. + * Used instead of android.location.Location to enable KMP compatibility. + */ +data class Coordinates( + val latitude: Double, + val longitude: Double, +) diff --git a/app/src/main/java/bose/ankush/weatherify/base/location/DeviceLocationClient.kt b/app/src/main/java/bose/ankush/weatherify/base/location/DeviceLocationClient.kt index ea9677ee..a8e6914c 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/location/DeviceLocationClient.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/location/DeviceLocationClient.kt @@ -2,7 +2,6 @@ package bose.ankush.weatherify.base.location import android.annotation.SuppressLint import android.content.Context -import android.location.Location import android.location.LocationManager import android.os.Looper import bose.ankush.weatherify.base.common.Extension.hasLocationPermission @@ -12,21 +11,24 @@ import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationTokenSource import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.resume @Singleton -class DeviceLocationClient @Inject constructor( +class DeviceLocationClient +@Inject +constructor( private val context: Context, - private val client: FusedLocationProviderClient + private val client: FusedLocationProviderClient, ) : LocationClient { - private fun checkLocationPermission() { // if user did not give location permission if (!context.hasLocationPermission()) { @@ -48,64 +50,74 @@ class DeviceLocationClient @Inject constructor( } @SuppressLint("MissingPermission") - override fun getLocationUpdates(interval: Long): Flow { - return callbackFlow { + override fun getLocationUpdates(interval: Long): Flow = + callbackFlow { checkLocationPermission() checkGpsEnabled() - val request = LocationRequest.Builder( - Priority.PRIORITY_HIGH_ACCURACY, - interval - ).apply { - setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL) - setWaitForAccurateLocation(true) - }.build() + val request = + LocationRequest + .Builder( + Priority.PRIORITY_HIGH_ACCURACY, + interval, + ).apply { + setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL) + setWaitForAccurateLocation(true) + }.build() - val locationCallback = object : LocationCallback() { - override fun onLocationResult(result: LocationResult) { - super.onLocationResult(result) - result.locations.lastOrNull()?.let { location -> - launch { send(location) } + val locationCallback = + object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + super.onLocationResult(result) + result.locations.lastOrNull()?.let { location -> + launch { send(location) } + } } } - } client.requestLocationUpdates( request, locationCallback, - Looper.getMainLooper() + Looper.getMainLooper(), ) awaitClose { client.removeLocationUpdates(locationCallback) } - } - } + }.map { loc -> Coordinates(loc.latitude, loc.longitude) } @SuppressLint("MissingPermission") - override suspend fun getCurrentLocation(): Result = suspendCancellableCoroutine { continuation -> - try { - checkLocationPermission() - checkGpsEnabled() - } catch (e: LocationClient.LocationException) { - continuation.resume(Result.failure(e)) - return@suspendCancellableCoroutine - } + override suspend fun getCurrentLocation(): Result = + suspendCancellableCoroutine { continuation -> + try { + checkLocationPermission() + checkGpsEnabled() + } catch (e: LocationClient.LocationException) { + continuation.resume(Result.failure(e)) + return@suspendCancellableCoroutine + } - client.lastLocation - .addOnSuccessListener { location -> - if (location != null) { - continuation.resume(Result.success(location)) - } else { - continuation.resume(Result.failure(LocationClient.LocationException("Location is null"))) + // Create a cancellation token source to allow cancellation of the location request + val cts = CancellationTokenSource() + + client + .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, cts.token) + .addOnSuccessListener { location -> + if (location != null) { + val coords = Coordinates(location.latitude, location.longitude) + continuation.resume(Result.success(coords)) + } else { + val ex = LocationClient.LocationException("Location is null") + continuation.resume(Result.failure(ex)) + } + }.addOnFailureListener { e -> + val ex = LocationClient.LocationException(e.message ?: "Unknown error") + continuation.resume(Result.failure(ex)) } - } - .addOnFailureListener { e -> - continuation.resume(Result.failure(LocationClient.LocationException(e.message ?: "Unknown error"))) - } - continuation.invokeOnCancellation { - // No need to cancel anything for lastLocation as it's a one-time operation + continuation.invokeOnCancellation { + // Cancel the Play Services location request when the coroutine is cancelled + cts.cancel() + } } - } override fun hasLocationPermission(): Boolean = context.hasLocationPermission() } diff --git a/app/src/main/java/bose/ankush/weatherify/base/location/LocationClient.kt b/app/src/main/java/bose/ankush/weatherify/base/location/LocationClient.kt index 63d83862..3d58b1c2 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/location/LocationClient.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/location/LocationClient.kt @@ -1,15 +1,19 @@ package bose.ankush.weatherify.base.location -import android.location.Location import kotlinx.coroutines.flow.Flow +/** + * Platform-agnostic location client interface. + * Uses [Coordinates] instead of android.location.Location to enable KMP compatibility. + */ interface LocationClient { + fun getLocationUpdates(interval: Long): Flow - fun getLocationUpdates(interval: Long): Flow - - suspend fun getCurrentLocation(): Result + suspend fun getCurrentLocation(): Result fun hasLocationPermission(): Boolean - class LocationException(message: String): Exception() + class LocationException( + message: String, + ) : Exception(message) } diff --git a/app/src/main/java/bose/ankush/weatherify/base/location/LocationPermissions.kt b/app/src/main/java/bose/ankush/weatherify/base/location/LocationPermissions.kt new file mode 100644 index 00000000..75e9b7d6 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/base/location/LocationPermissions.kt @@ -0,0 +1,10 @@ +package bose.ankush.weatherify.base.location + +/** + * Platform-agnostic location permission string constants. + * Avoids importing android.Manifest in shared/common ViewModel code. + */ +object LocationPermissions { + const val FINE_LOCATION = "android.permission.ACCESS_FINE_LOCATION" + const val COARSE_LOCATION = "android.permission.ACCESS_COARSE_LOCATION" +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/location/LocationService.kt b/app/src/main/java/bose/ankush/weatherify/base/location/LocationService.kt index 85de20d9..f4bb2202 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/location/LocationService.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/location/LocationService.kt @@ -2,7 +2,6 @@ package bose.ankush.weatherify.base.location import android.app.NotificationManager import android.app.Service -import android.content.Context import android.content.Intent import android.os.IBinder import androidx.core.app.NotificationCompat @@ -15,26 +14,23 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint class LocationService : Service() { - private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @Inject lateinit var locationClient: LocationClient - override fun onBind(intent: Intent?): IBinder? { - return null - } + override fun onBind(intent: Intent?): IBinder? = null - override fun onCreate() { - super.onCreate() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { when (intent?.action) { ACTION_START -> start() ACTION_STOP -> stop() @@ -43,21 +39,22 @@ class LocationService : Service() { } private fun start() { - val notification = NotificationCompat.Builder( - this, - NOTIFICATION_CHANNEL_ID - ) - .setContentTitle(NOTIFICATION_TITLE) - .setContentText("Location: null") - .setSmallIcon(R.drawable.ic_profile) - .setOngoing(true) + val notification = + NotificationCompat + .Builder( + this, + NOTIFICATION_CHANNEL_ID, + ).setContentTitle(NOTIFICATION_TITLE) + .setContentText("Location: null") + .setSmallIcon(R.drawable.ic_profile) + .setOngoing(true) val notificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + getSystemService(NOTIFICATION_SERVICE) as NotificationManager locationClient .getLocationUpdates(interval = 1000L) - .catch { exception -> println(exception.printStackTrace()) } + .catch { e -> Timber.e(e, "Location updates error") } .onEach { location -> val lat = location.latitude.toString().take(4) val long = location.longitude.toString().take(4) @@ -65,14 +62,13 @@ class LocationService : Service() { notificationManager.notify( NOTIFICATION_ID, - updatedNotification.build() + updatedNotification.build(), ) - } - .launchIn(serviceScope) + }.launchIn(serviceScope) startForeground( NOTIFICATION_ID, - notification.build() + notification.build(), ) } diff --git a/app/src/main/java/bose/ankush/weatherify/base/notification/NotificationHelper.kt b/app/src/main/java/bose/ankush/weatherify/base/notification/NotificationHelper.kt new file mode 100644 index 00000000..0a2a9566 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/base/notification/NotificationHelper.kt @@ -0,0 +1,31 @@ +package bose.ankush.weatherify.base.notification + +import android.content.Context +import androidx.core.app.NotificationCompat +import bose.ankush.weatherify.R +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NotificationHelper +@Inject +constructor( + private val context: Context, +) { + fun getNotificationBuilder( + channelId: String, + title: String, + message: String, + ): NotificationCompat.Builder = + NotificationCompat + .Builder(context, channelId) + .setSmallIcon(R.drawable.ic_home) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + + companion object { + const val DEFAULT_CHANNEL_ID = "weatherify_notifications" + } +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/notification/WeatherifyMessagingService.kt b/app/src/main/java/bose/ankush/weatherify/base/notification/WeatherifyMessagingService.kt new file mode 100644 index 00000000..5898fb67 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/base/notification/WeatherifyMessagingService.kt @@ -0,0 +1,97 @@ +package bose.ankush.weatherify.base.notification + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import androidx.core.app.NotificationCompat +import bose.ankush.weatherify.R +import bose.ankush.weatherify.presentation.MainActivity +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.ExperimentalCoroutinesApi +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class WeatherifyMessagingService : FirebaseMessagingService() { + @Inject + lateinit var notificationHelper: NotificationHelper + + private val notificationManager by lazy { + getSystemService(NOTIFICATION_SERVICE) as NotificationManager + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + Timber.d("Refreshed FCM token: $token") + // TODO: Send token to your server if needed + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + Timber.d("Message data payload: ${remoteMessage.data}") + + // Handle both notification and data messages + val title = + remoteMessage.notification?.title + ?: remoteMessage.data["title"] + ?: getString(R.string.app_name) + + val message = + remoteMessage.notification?.body + ?: remoteMessage.data["message"] + ?: remoteMessage.data.values.firstOrNull() + ?: "" + + // Handle data payload if needed + val customData = remoteMessage.data.filterKeys { it != "title" && it != "message" } + if (customData.isNotEmpty()) { + Timber.d("Custom data payload: $customData") + // Process your custom data here + } + + // Always show notification if there's a message + if (message.isNotBlank()) { + sendNotification(title, message) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun sendNotification( + title: String, + message: String, + ) { + val intent = + Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + // You can add extras here if needed + // putExtra("key", "value") + } + + val pendingIntent = + PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notificationBuilder = + notificationHelper + .getNotificationBuilder( + channelId = NotificationHelper.DEFAULT_CHANNEL_ID, + title = title, + message = message, + ).setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + + // Generate unique ID for each notification + val notificationId = System.currentTimeMillis().toInt() + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + companion object { + // Removed static NOTIFICATION_ID to allow multiple notifications + } +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/permissions/LocationPermissionTextProvider.kt b/app/src/main/java/bose/ankush/weatherify/base/permissions/LocationPermissionTextProvider.kt index 97bdd4f1..c262d516 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/permissions/LocationPermissionTextProvider.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/permissions/LocationPermissionTextProvider.kt @@ -1,23 +1,21 @@ package bose.ankush.weatherify.base.permissions class FineLocationPermissionTextProvider : PermissionTextProvider { - - override fun getDescription(isPermanentlyDeclined: Boolean): String { - return if (isPermanentlyDeclined) { - "It seems you have permanently declined fine location permission. You can go to app permission settings to enable it." + override fun getDescription(isPermanentlyDeclined: Boolean): String = + if (isPermanentlyDeclined) { + "It seems you have permanently declined fine location permission. " + + "You can go to app permission settings to enable it." } else { "Precise Location permissions are required to tracking your run path following precise location." } - } } class CoarseLocationPermissionTextProvider : PermissionTextProvider { - - override fun getDescription(isPermanentlyDeclined: Boolean): String { - return if (isPermanentlyDeclined) { - "It seems you have permanently declined coarse location permission. You can go to app permission settings to enable it." + override fun getDescription(isPermanentlyDeclined: Boolean): String = + if (isPermanentlyDeclined) { + "It seems you have permanently declined coarse location permission. " + + "You can go to app permission settings to enable it." } else { "Approximate location permissions are required to show weather & air quality of your approximate location." } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionAlertDialog.kt b/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionAlertDialog.kt deleted file mode 100644 index 4cf79d81..00000000 --- a/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionAlertDialog.kt +++ /dev/null @@ -1,27 +0,0 @@ -package bose.ankush.weatherify.base.permissions - -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable - -@Composable -fun PermissionAlertDialog( - permissionTextProvider: PermissionTextProvider, - isPermanentlyDeclined: Boolean, - onDismissClick: () -> Unit, - onOkClick: () -> Unit, - onGoToAppSettingClick: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismissClick, - title = { Text(text = "Permissions required") }, - text = { Text(text = permissionTextProvider.getDescription(isPermanentlyDeclined)) }, - confirmButton = { - TextButton(onClick = if (isPermanentlyDeclined) onGoToAppSettingClick else onOkClick) { - Text(text = if(isPermanentlyDeclined) "Grant Permission" else "Ok") - } - }, - dismissButton = { } - ) -} diff --git a/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionTextProvider.kt b/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionTextProvider.kt index fc7b7ffe..68e44495 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionTextProvider.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionTextProvider.kt @@ -2,4 +2,4 @@ package bose.ankush.weatherify.base.permissions interface PermissionTextProvider { fun getDescription(isPermanentlyDeclined: Boolean): String -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/data/mapper/AirQualityMapper.kt b/app/src/main/java/bose/ankush/weatherify/data/mapper/AirQualityMapper.kt index 12f46b8f..f2d3b610 100644 --- a/app/src/main/java/bose/ankush/weatherify/data/mapper/AirQualityMapper.kt +++ b/app/src/main/java/bose/ankush/weatherify/data/mapper/AirQualityMapper.kt @@ -1,18 +1,17 @@ package bose.ankush.weatherify.data.mapper -import bose.ankush.storage.room.AirQualityEntity as StorageAirQualityEntity import bose.ankush.weatherify.domain.model.AirQuality +import bose.ankush.storage.room.AirQualityEntity as StorageAirQualityEntity /** * Mapper class to convert between AirQualityEntity (data layer) and AirQuality (domain layer) */ object AirQualityMapper { - /** * Maps a storage AirQualityEntity to an AirQuality domain model */ - fun mapToDomain(entity: StorageAirQualityEntity): AirQuality { - return AirQuality( + fun mapToDomain(entity: StorageAirQualityEntity): AirQuality = + AirQuality( id = entity.id, aqi = entity.aqi ?: 0, co = entity.co ?: 0.0, @@ -20,7 +19,6 @@ object AirQualityMapper { o3 = entity.o3 ?: 0.0, so2 = entity.so2 ?: 0.0, pm10 = entity.pm10 ?: 0.0, - pm25 = entity.pm25 ?: 0.0 + pm25 = entity.pm25 ?: 0.0, ) - } } diff --git a/app/src/main/java/bose/ankush/weatherify/data/mapper/NetworkToStorageMapper.kt b/app/src/main/java/bose/ankush/weatherify/data/mapper/NetworkToStorageMapper.kt new file mode 100644 index 00000000..1f89a9b3 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/data/mapper/NetworkToStorageMapper.kt @@ -0,0 +1,149 @@ +package bose.ankush.weatherify.data.mapper + +import bose.ankush.storage.room.AirQualityEntity +import bose.ankush.storage.room.Weather +import bose.ankush.storage.room.WeatherEntity +import bose.ankush.network.model.AirQuality as NetworkAirQuality +import bose.ankush.network.model.WeatherForecast as NetworkWeatherForecast + +/** + * Mapper to convert Network layer models to Storage layer entities. + * + * This is the network โ†’ storage transformation layer. + * Maps API responses to persistent database models. + */ +object NetworkToStorageMapper { + /** + * Maps NetworkWeatherForecast (API model) to WeatherEntity (database model) + */ + fun mapWeatherToStorageEntity(weatherData: NetworkWeatherForecast): WeatherEntity { + val data = weatherData.data + + return WeatherEntity( + id = 0, + lastUpdated = System.currentTimeMillis(), + current = + data?.current?.let { current -> + WeatherEntity.Current( + clouds = current.clouds, + dt = current.dt, + feels_like = current.feelsLike, + humidity = current.humidity, + pressure = current.pressure, + sunrise = current.sunrise, + sunset = current.sunset, + temp = current.temp, + uvi = current.uvi, + weather = + current.weather?.mapNotNull { info -> + info?.let { + Weather( + description = it.description, + icon = it.icon, + id = it.id, + main = it.main, + ) + } + }, + wind_gust = current.windGust, + wind_speed = current.windSpeed, + ) + }, + daily = + data?.daily?.map { daily -> + daily?.let { + WeatherEntity.Daily( + clouds = it.clouds, + dew_point = it.dewPoint, + dt = it.dt, + humidity = it.humidity, + pressure = it.pressure, + rain = it.rain, + summary = it.summary, + sunrise = it.sunrise, + sunset = it.sunset, + temp = + it.temp?.let { temp -> + WeatherEntity.Daily.Temp( + day = temp.day, + eve = temp.eve, + max = temp.max, + min = temp.min, + morn = temp.morn, + night = temp.night, + ) + }, + uvi = it.uvi, + weather = + it.weather?.mapNotNull { info -> + info?.let { + Weather( + description = it.description, + icon = it.icon, + id = it.id, + main = it.main, + ) + } + }, + wind_gust = it.windGust, + wind_speed = it.windSpeed, + ) + } + }, + hourly = + data?.hourly?.map { hourly -> + hourly?.let { it -> + WeatherEntity.Hourly( + clouds = it.clouds, + dt = it.dt, + feels_like = it.feelsLike, + humidity = it.humidity, + temp = it.temp, + weather = + it.weather?.mapNotNull { info -> + info?.let { + Weather( + description = it.description, + icon = it.icon, + id = it.id, + main = it.main, + ) + } + }, + ) + } + }, + alerts = + data?.alerts?.mapNotNull { alert -> + alert?.let { + WeatherEntity.Alert( + description = it.description, + end = it.end, + event = it.event, + sender_name = it.senderName, + start = it.start, + ) + } + } ?: emptyList(), + ) + } + + /** + * Maps the air quality data embedded in the unified weather response to AirQualityEntity. + * When [airQualityData] is null (free tier โ€” air quality not included), stores a default + * entity so existing storage contracts are preserved. + */ + fun mapAirQualityToStorageEntity(airQualityData: NetworkAirQuality.Data?): AirQualityEntity { + val entry = airQualityData?.list?.firstOrNull() + return AirQualityEntity( + id = null, + aqi = entry?.main?.aqi, + co = entry?.components?.co, + no2 = entry?.components?.no2, + o3 = entry?.components?.o3, + so2 = entry?.components?.so2, + pm10 = entry?.components?.pm10, + pm25 = entry?.components?.pm25, + ) + } +} diff --git a/app/src/main/java/bose/ankush/weatherify/data/mapper/WeatherMapper.kt b/app/src/main/java/bose/ankush/weatherify/data/mapper/WeatherMapper.kt index 94fa5d07..adf75eab 100644 --- a/app/src/main/java/bose/ankush/weatherify/data/mapper/WeatherMapper.kt +++ b/app/src/main/java/bose/ankush/weatherify/data/mapper/WeatherMapper.kt @@ -1,116 +1,122 @@ package bose.ankush.weatherify.data.mapper -import bose.ankush.storage.room.Weather as StorageWeather -import bose.ankush.storage.room.WeatherEntity as StorageWeatherEntity +import bose.ankush.storage.room.WeatherEntity import bose.ankush.weatherify.domain.model.WeatherCondition import bose.ankush.weatherify.domain.model.WeatherForecast +import bose.ankush.storage.room.Weather as StorageWeather /** * Mapper class to convert between WeatherEntity (data layer) and WeatherForecast (domain layer) */ object WeatherMapper { - /** * Maps a Storage Weather entity to a WeatherCondition domain model */ - private fun mapStorageWeatherToDomain(weather: StorageWeather): WeatherCondition { - return WeatherCondition( - description = weather.description, - icon = weather.icon, + private fun mapStorageWeatherToDomain(weather: StorageWeather): WeatherCondition = + WeatherCondition( + description = weather.description ?: "", + icon = weather.icon ?: "", id = weather.id, - main = weather.main + main = weather.main ?: "", ) - } /** * Maps a Storage WeatherEntity to a WeatherForecast domain model */ - fun mapToDomain(entity: StorageWeatherEntity?): WeatherForecast? { + fun mapToDomain(entity: WeatherEntity?): WeatherForecast? { if (entity == null) return null return WeatherForecast( id = entity.id, - alerts = entity.alerts?.map { alert -> - alert?.let { - WeatherForecast.Alert( - description = it.description, - end = it.end, - event = it.event, - sender_name = it.sender_name, - start = it.start - ) - } - }, - current = entity.current?.let { current -> - WeatherForecast.Current( - clouds = current.clouds, - dt = current.dt, - feels_like = current.feels_like, - humidity = current.humidity, - pressure = current.pressure, - sunrise = current.sunrise, - sunset = current.sunset, - temp = current.temp, - uvi = current.uvi, - weather = current.weather?.map { weather -> - weather?.let { - mapStorageWeatherToDomain(it) - } - }, - wind_gust = current.wind_gust, - wind_speed = current.wind_speed - ) - }, - daily = entity.daily?.map { daily -> - daily?.let { - WeatherForecast.Daily( - clouds = it.clouds, - dew_point = it.dew_point, - dt = it.dt, - humidity = it.humidity, - pressure = it.pressure, - rain = it.rain, - summary = it.summary, - sunrise = it.sunrise, - sunset = it.sunset, - temp = it.temp?.let { temp -> - WeatherForecast.Daily.Temp( - day = temp.day, - eve = temp.eve, - max = temp.max, - min = temp.min, - morn = temp.morn, - night = temp.night - ) - }, - uvi = it.uvi, - weather = it.weather?.map { weather -> - weather?.let { - mapStorageWeatherToDomain(it) - } - }, - wind_gust = it.wind_gust, - wind_speed = it.wind_speed - ) - } - }, - hourly = entity.hourly?.map { hourly -> - hourly?.let { - WeatherForecast.Hourly( - clouds = it.clouds, - dt = it.dt, - feels_like = it.feels_like, - humidity = it.humidity, - temp = it.temp, - weather = it.weather?.map { weather -> - weather?.let { - mapStorageWeatherToDomain(it) - } - } + alerts = + entity.alerts?.map { alert -> + alert?.let { + WeatherForecast.Alert( + description = it.description, + end = it.end, + event = it.event, + sender_name = it.sender_name, + start = it.start, + ) + } + }, + current = + entity.current?.let { current -> + WeatherForecast.Current( + clouds = current.clouds, + dt = current.dt, + feels_like = current.feels_like, + humidity = current.humidity, + pressure = current.pressure, + sunrise = current.sunrise, + sunset = current.sunset, + temp = current.temp, + uvi = current.uvi, + weather = + current.weather?.map { weather -> + weather?.let { + mapStorageWeatherToDomain(it) + } + }, + wind_gust = current.wind_gust, + wind_speed = current.wind_speed, ) - } - }, - lastUpdated = entity.lastUpdated + }, + daily = + entity.daily?.map { daily -> + daily?.let { + WeatherForecast.Daily( + clouds = it.clouds, + dew_point = it.dew_point, + dt = it.dt, + humidity = it.humidity, + pressure = it.pressure, + rain = it.rain, + summary = it.summary, + sunrise = it.sunrise, + sunset = it.sunset, + temp = + it.temp?.let { temp -> + WeatherForecast.Daily.Temp( + day = temp.day, + eve = temp.eve, + max = temp.max, + min = temp.min, + morn = temp.morn, + night = temp.night, + ) + }, + uvi = it.uvi, + weather = + it.weather?.map { weather -> + weather?.let { + mapStorageWeatherToDomain(it) + } + }, + wind_gust = it.wind_gust, + wind_speed = it.wind_speed, + ) + } + }, + hourly = + entity.hourly?.map { hourly -> + hourly?.let { + WeatherForecast.Hourly( + clouds = it.clouds, + dt = it.dt, + feels_like = it.feels_like, + humidity = it.humidity, + temp = it.temp, + weather = + it.weather?.map { weather -> + weather?.let { + mapStorageWeatherToDomain(it) + } + }, + ) + } + }, + lastUpdated = entity.lastUpdated, ) } } diff --git a/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManager.kt b/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManager.kt deleted file mode 100644 index 9e49a549..00000000 --- a/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManager.kt +++ /dev/null @@ -1,31 +0,0 @@ -package bose.ankush.weatherify.data.preference - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.preferencesDataStore -import bose.ankush.weatherify.base.common.APP_PREFERENCE_KEY -import bose.ankush.weatherify.domain.preference.PreferenceManager -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Implementation of PreferenceManager that uses DataStore - */ -@Singleton -class PreferenceManagerImpl @Inject constructor(@ApplicationContext private val context: Context) : PreferenceManager { - - private val Context.dataStore: DataStore by preferencesDataStore(name = APP_PREFERENCE_KEY) - - override fun getLocationPreferenceFlow(): Flow = context.dataStore.data - - override suspend fun saveLocationPreferences(coordinates: Pair) { - context.dataStore.edit { preferences -> - preferences[PreferenceManager.USER_LAT_LOCATION] = coordinates.first - preferences[PreferenceManager.USER_LON_LOCATION] = coordinates.second - } - } -} diff --git a/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManagerImpl.kt b/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManagerImpl.kt new file mode 100644 index 00000000..77d6a557 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManagerImpl.kt @@ -0,0 +1,85 @@ +package bose.ankush.weatherify.data.preference + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import bose.ankush.weatherify.base.common.APP_PREFERENCE_KEY +import bose.ankush.weatherify.domain.preference.PreferenceManager +import bose.ankush.weatherify.domain.preference.UserPreferences +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implementation of PreferenceManager that uses DataStore + */ +@Singleton +class PreferenceManagerImpl +@Inject +constructor( + @get:ApplicationContext private val context: Context, +) : PreferenceManager { + private val Context.dataStore: DataStore by preferencesDataStore(name = APP_PREFERENCE_KEY) + + override fun getUserPreferencesFlow(): Flow = + context.dataStore.data.map { preferences -> + UserPreferences( + latitude = preferences[PreferenceManager.USER_LAT_LOCATION], + longitude = preferences[PreferenceManager.USER_LON_LOCATION], + isPremium = preferences[PreferenceManager.IS_PREMIUM] ?: false, + premiumExpiry = preferences[PreferenceManager.PREMIUM_EXPIRY], + isLocationOverridden = preferences[PreferenceManager.IS_LOCATION_OVERRIDDEN] + ?: false, + overrideLat = preferences[PreferenceManager.OVERRIDE_LAT], + overrideLon = preferences[PreferenceManager.OVERRIDE_LON], + overrideLocationName = preferences[PreferenceManager.OVERRIDE_LOCATION_NAME], + ) + } + + override suspend fun saveLocationPreferences(coordinates: Pair) { + context.dataStore.edit { preferences -> + preferences[PreferenceManager.USER_LAT_LOCATION] = coordinates.first + preferences[PreferenceManager.USER_LON_LOCATION] = coordinates.second + } + } + + override suspend fun savePremiumStatus( + isPremium: Boolean, + expiryMillis: Long?, + ) { + context.dataStore.edit { preferences -> + preferences[PreferenceManager.IS_PREMIUM] = isPremium + if (expiryMillis != null) { + preferences[PreferenceManager.PREMIUM_EXPIRY] = expiryMillis + } else { + preferences.remove(PreferenceManager.PREMIUM_EXPIRY) + } + } + } + + override suspend fun saveLocationOverride(lat: Double, lon: Double, name: String) { + context.dataStore.edit { preferences -> + preferences[PreferenceManager.OVERRIDE_LAT] = lat + preferences[PreferenceManager.OVERRIDE_LON] = lon + preferences[PreferenceManager.OVERRIDE_LOCATION_NAME] = name + preferences[PreferenceManager.IS_LOCATION_OVERRIDDEN] = true + } + } + + override suspend fun clearLocationOverride() { + context.dataStore.edit { preferences -> + preferences.remove(PreferenceManager.OVERRIDE_LAT) + preferences.remove(PreferenceManager.OVERRIDE_LON) + preferences.remove(PreferenceManager.OVERRIDE_LOCATION_NAME) + preferences[PreferenceManager.IS_LOCATION_OVERRIDDEN] = false + } + } + + override suspend fun clearAll() { + context.dataStore.edit { it.clear() } + } +} diff --git a/app/src/main/java/bose/ankush/weatherify/data/remote/dto/CityDto.kt b/app/src/main/java/bose/ankush/weatherify/data/remote/dto/CityDto.kt index e682b9c5..2ed2d9cb 100644 --- a/app/src/main/java/bose/ankush/weatherify/data/remote/dto/CityDto.kt +++ b/app/src/main/java/bose/ankush/weatherify/data/remote/dto/CityDto.kt @@ -5,11 +5,10 @@ import bose.ankush.weatherify.domain.model.CityName data class CityDto( val id: String? = "", val name: String = "", - val state: String? = "" + val state: String? = "", ) -fun CityDto.toCityName(): CityName { - return CityName( - name = name +fun CityDto.toCityName(): CityName = + CityName( + name = name, ) -} diff --git a/app/src/main/java/bose/ankush/weatherify/data/remote_config/FirebaseRemoteConfigService.kt b/app/src/main/java/bose/ankush/weatherify/data/remote_config/FirebaseRemoteConfigService.kt index 83f91b05..0b2b8b5a 100644 --- a/app/src/main/java/bose/ankush/weatherify/data/remote_config/FirebaseRemoteConfigService.kt +++ b/app/src/main/java/bose/ankush/weatherify/data/remote_config/FirebaseRemoteConfigService.kt @@ -2,10 +2,10 @@ package bose.ankush.weatherify.data.remote_config import bose.ankush.weatherify.R import bose.ankush.weatherify.domain.remote_config.RemoteConfigService -import com.google.firebase.ktx.Firebase +import com.google.firebase.Firebase import com.google.firebase.remoteconfig.FirebaseRemoteConfig -import com.google.firebase.remoteconfig.ktx.remoteConfig -import com.google.firebase.remoteconfig.ktx.remoteConfigSettings +import com.google.firebase.remoteconfig.remoteConfig +import com.google.firebase.remoteconfig.remoteConfigSettings import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -15,8 +15,9 @@ import javax.inject.Singleton * This class handles all interactions with Firebase Remote Config. */ @Singleton -class FirebaseRemoteConfigService @Inject constructor() : RemoteConfigService { - +class FirebaseRemoteConfigService +@Inject +constructor() : RemoteConfigService { private val remoteConfig: FirebaseRemoteConfig = Firebase.remoteConfig private val tag = "${FirebaseRemoteConfigService::class.simpleName} ->" @@ -25,9 +26,10 @@ class FirebaseRemoteConfigService @Inject constructor() : RemoteConfigService { * Sets default values from the XML resource file. */ override fun initialize() { - val configSettings = remoteConfigSettings { - minimumFetchIntervalInSeconds = DEFAULT_MINIMUM_FETCH_INTERVAL_SECONDS - } + val configSettings = + remoteConfigSettings { + minimumFetchIntervalInSeconds = DEFAULT_MINIMUM_FETCH_INTERVAL_SECONDS + } remoteConfig.apply { setConfigSettingsAsync(configSettings) @@ -43,14 +45,16 @@ class FirebaseRemoteConfigService @Inject constructor() : RemoteConfigService { * @param defaultValue The default value to return if the key is not found * @return The boolean value from Firebase Remote Config, or the default value if not found */ - override fun getBoolean(key: String, defaultValue: Boolean): Boolean { - return try { + override fun getBoolean( + key: String, + defaultValue: Boolean, + ): Boolean = + try { remoteConfig.getBoolean(key) } catch (e: Exception) { Timber.tag(tag).e(e, "Error getting boolean value for key: $key") defaultValue } - } companion object { private const val DEFAULT_MINIMUM_FETCH_INTERVAL_SECONDS = 3600L // 1 hour diff --git a/app/src/main/java/bose/ankush/weatherify/data/repository/CityRepositoryImpl.kt b/app/src/main/java/bose/ankush/weatherify/data/repository/CityRepositoryImpl.kt index 3568df42..823e318c 100644 --- a/app/src/main/java/bose/ankush/weatherify/data/repository/CityRepositoryImpl.kt +++ b/app/src/main/java/bose/ankush/weatherify/data/repository/CityRepositoryImpl.kt @@ -7,19 +7,21 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import javax.inject.Inject -class CityRepositoryImpl @Inject constructor( - private val context: Context +class CityRepositoryImpl +@Inject +constructor( + private val context: Context, ) : CityRepository { - override fun getCityNames(): List { - val jsonString: String = context.assets.open("city_names.json") - .bufferedReader() - .use { it.readText() } + val jsonString: String = + context.assets + .open("city_names.json") + .bufferedReader() + .use { it.readText() } val cityNameListType = object : TypeToken>() {}.type - return Gson().fromJson?>(jsonString, cityNameListType).sortedBy { it.name } + return (Gson().fromJson?>(jsonString, cityNameListType) + ?: emptyList()).sortedBy { it.name } } - } - diff --git a/app/src/main/java/bose/ankush/weatherify/data/repository/WeatherRepositoryImpl.kt b/app/src/main/java/bose/ankush/weatherify/data/repository/WeatherRepositoryImpl.kt index e77e6150..bf208943 100644 --- a/app/src/main/java/bose/ankush/weatherify/data/repository/WeatherRepositoryImpl.kt +++ b/app/src/main/java/bose/ankush/weatherify/data/repository/WeatherRepositoryImpl.kt @@ -2,63 +2,91 @@ package bose.ankush.weatherify.data.repository import bose.ankush.storage.api.WeatherStorage import bose.ankush.storage.room.AirQualityEntity -import bose.ankush.storage.room.WeatherEntity as StorageWeatherEntity +import bose.ankush.storage.room.WeatherEntity import bose.ankush.weatherify.base.dispatcher.DispatcherProvider import bose.ankush.weatherify.data.mapper.AirQualityMapper +import bose.ankush.weatherify.data.mapper.NetworkToStorageMapper import bose.ankush.weatherify.data.mapper.WeatherMapper import bose.ankush.weatherify.domain.model.AirQuality import bose.ankush.weatherify.domain.model.WeatherForecast import bose.ankush.weatherify.domain.repository.WeatherRepository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import javax.inject.Inject +import bose.ankush.network.repository.WeatherRepository as NetworkWeatherRepository /** - * Implementation of WeatherRepository that uses the KMM storage module - * for data access and refresh operations + * Domain-layer repository that orchestrates between network and storage modules. + * + * Responsibilities: + * - Fetch unified weather data from network (single /weather call) + * - Extract both weather and air quality from the unified response + * - Map network models to storage entities + * - Save to local storage (WeatherStorage) + * - Provide domain models to UI layer (via mappers) */ -class WeatherRepositoryImpl @Inject constructor( +class WeatherRepositoryImpl +@Inject +constructor( + private val networkRepository: NetworkWeatherRepository, private val weatherStorage: WeatherStorage, - private val dispatcher: DispatcherProvider + private val dispatcher: DispatcherProvider, ) : WeatherRepository { - override fun getAirQualityReport(coordinates: Pair): Flow = weatherStorage.getAirQualityReport(coordinates).map { entity -> - AirQualityMapper.mapToDomain(entity as AirQualityEntity) + (entity as? AirQualityEntity)?.let { AirQualityMapper.mapToDomain(it) } ?: AirQuality() } override fun getWeatherReport(location: Pair): Flow = weatherStorage.getWeatherReport(location).map { entity -> - WeatherMapper.mapToDomain(entity as StorageWeatherEntity) + (entity as? WeatherEntity)?.let { WeatherMapper.mapToDomain(it) } } /** - * Method used by view-model when UI sends refresh weather event. - * Delegates to the storage module for refreshing data. - * - * The app module controls when to refresh based on business rules - * (e.g., data staleness, user pull-to-refresh) + * Orchestrates data refresh: fetch unified response from network โ†’ extract weather + air + * quality โ†’ map โ†’ save to storage. + * + * Air quality is now embedded in the /weather response and may be null for free-tier users. + * In that case an empty AirQualityEntity is stored to satisfy the storage contract. */ - override suspend fun refreshWeatherData(coordinates: Pair) { + override suspend fun refreshWeatherData( + coordinates: Pair, + forceRefresh: Boolean, + ) { withContext(dispatcher.io) { - try { - // Check if data is stale (older than 1 hour) - val lastUpdateTime = weatherStorage.getLastWeatherUpdateTime() - val currentTime = System.currentTimeMillis() - val isDataStale = (currentTime - lastUpdateTime) > ONE_HOUR_IN_MILLIS + val lastUpdateTime = weatherStorage.getLastWeatherUpdateTime(coordinates) + val currentTime = System.currentTimeMillis() + val isDataStale = forceRefresh || (currentTime - lastUpdateTime) > ONE_HOUR_IN_MILLIS + + if (isDataStale) { + // Single unified API call โ€” includes air quality for premium users + networkRepository.refreshWeatherData(coordinates) - // Refresh data if it's stale or if this is a forced refresh - if (isDataStale) { - weatherStorage.refreshWeatherData(coordinates) + val weatherData = networkRepository.getWeatherReport(coordinates).firstOrNull() + + if (weatherData != null) { + val weatherEntity = + NetworkToStorageMapper.mapWeatherToStorageEntity(weatherData) + // Air quality is inside data.airQuality; null for free tier โ†’ stores defaults + val airQualityEntity = + NetworkToStorageMapper.mapAirQualityToStorageEntity( + weatherData.data?.airQuality, + ) + weatherStorage.saveWeatherData(weatherEntity, airQualityEntity) + weatherStorage.saveLastWeatherUpdateTime(coordinates, currentTime) } - } catch (e: Exception) { - // If there's an error, throw a more descriptive exception - throw Exception("Failed to refresh weather data: ${e.message}", e) } } } + override suspend fun clearAllData() { + withContext(dispatcher.io) { + weatherStorage.clearAllData() + } + } + companion object { private const val ONE_HOUR_IN_MILLIS = 60 * 60 * 1000L } diff --git a/app/src/main/java/bose/ankush/weatherify/di/AppModule.kt b/app/src/main/java/bose/ankush/weatherify/di/AppModule.kt index 710b4db5..422a1174 100644 --- a/app/src/main/java/bose/ankush/weatherify/di/AppModule.kt +++ b/app/src/main/java/bose/ankush/weatherify/di/AppModule.kt @@ -2,6 +2,12 @@ package bose.ankush.weatherify.di import android.app.Application import android.content.Context +import bose.ankush.weatherify.base.common.AndroidDeviceInfoProvider +import bose.ankush.weatherify.base.common.DeviceInfoProvider +import bose.ankush.weatherify.base.common.LoggerFactory +import bose.ankush.weatherify.base.common.TimberLoggerFactory +import bose.ankush.weatherify.base.config.AndroidAppConfig +import bose.ankush.weatherify.base.config.AppConfig import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -11,9 +17,19 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object AppModule { + @Provides + @Singleton + fun provideContext(application: Application): Context = application.applicationContext + + @Provides + @Singleton + fun provideLoggerFactory(): LoggerFactory = TimberLoggerFactory() + + @Provides + @Singleton + fun provideDeviceInfoProvider(): DeviceInfoProvider = AndroidDeviceInfoProvider() @Provides @Singleton - fun provideContext(application: Application): Context = - application.applicationContext + fun provideAppConfig(): AppConfig = AndroidAppConfig() } diff --git a/app/src/main/java/bose/ankush/weatherify/di/DispatcherModule.kt b/app/src/main/java/bose/ankush/weatherify/di/DispatcherModule.kt index 3143521e..d08b86a0 100644 --- a/app/src/main/java/bose/ankush/weatherify/di/DispatcherModule.kt +++ b/app/src/main/java/bose/ankush/weatherify/di/DispatcherModule.kt @@ -11,8 +11,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object DispatcherModule { - @Singleton @Provides fun provideDispatcherProvider(): DispatcherProvider = AppDispatcher() -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/di/LocationModule.kt b/app/src/main/java/bose/ankush/weatherify/di/LocationModule.kt index bc77f0d0..9a1f6901 100644 --- a/app/src/main/java/bose/ankush/weatherify/di/LocationModule.kt +++ b/app/src/main/java/bose/ankush/weatherify/di/LocationModule.kt @@ -14,7 +14,6 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object LocationModule { - @Singleton @Provides fun provideFusedLocationProviderClient(context: Context): FusedLocationProviderClient = @@ -24,7 +23,6 @@ object LocationModule { @Provides fun provideLocationClient( context: Context, - fusedLocationProviderClient: FusedLocationProviderClient - ) : LocationClient = - DeviceLocationClient(context, fusedLocationProviderClient) + fusedLocationProviderClient: FusedLocationProviderClient, + ): LocationClient = DeviceLocationClient(context, fusedLocationProviderClient) } diff --git a/app/src/main/java/bose/ankush/weatherify/di/NetworkModule.kt b/app/src/main/java/bose/ankush/weatherify/di/NetworkModule.kt index d02f75cc..668f4102 100644 --- a/app/src/main/java/bose/ankush/weatherify/di/NetworkModule.kt +++ b/app/src/main/java/bose/ankush/weatherify/di/NetworkModule.kt @@ -1,10 +1,23 @@ package bose.ankush.weatherify.di import android.content.Context +import bose.ankush.network.auth.repository.AuthRepository +import bose.ankush.network.auth.token.TokenManager import bose.ankush.network.common.AndroidNetworkConnectivity import bose.ankush.network.common.NetworkConnectivity +import bose.ankush.network.di.createAuthRepository +import bose.ankush.network.di.createFeedbackRepository +import bose.ankush.network.di.createLocationRepository +import bose.ankush.network.di.createServiceRepository +import bose.ankush.network.di.createTokenManager import bose.ankush.network.di.createWeatherRepository +import bose.ankush.network.domain.SavedLocationsUseCase +import bose.ankush.network.domain.SearchPlacesUseCase +import bose.ankush.network.repository.FeedbackRepository +import bose.ankush.network.repository.LocationRepository +import bose.ankush.network.repository.ServiceRepository import bose.ankush.network.repository.WeatherRepository +import bose.ankush.storage.api.TokenStorage import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,26 +31,84 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object NetworkModule { - /** * Provides NetworkConnectivity implementation */ @Provides @Singleton fun provideNetworkConnectivity( - @ApplicationContext context: Context - ): NetworkConnectivity { - return AndroidNetworkConnectivity(context) - } + @ApplicationContext context: Context, + ): NetworkConnectivity = AndroidNetworkConnectivity(context) /** * Provides WeatherRepository implementation from the network module + * Uses TokenStorage for JWT authentication in API requests */ @Provides @Singleton fun provideWeatherRepository( - networkConnectivity: NetworkConnectivity - ): WeatherRepository { - return createWeatherRepository(networkConnectivity) - } -} \ No newline at end of file + networkConnectivity: NetworkConnectivity, + tokenStorage: TokenStorage, + ): WeatherRepository = createWeatherRepository(networkConnectivity, tokenStorage) + + /** + * Provides AuthRepository implementation from the network module + */ + @Provides + @Singleton + fun provideAuthRepository(tokenStorage: TokenStorage): AuthRepository = + createAuthRepository(tokenStorage) + + /** + * Provides TokenManager singleton for use in ViewModels. + * Shares the same AuthRepository singleton used elsewhere in the app. + */ + @Provides + @Singleton + fun provideTokenManager( + tokenStorage: TokenStorage, + authRepository: AuthRepository, + ): TokenManager = createTokenManager(tokenStorage, authRepository) + + /** + * Provides FeedbackRepository implementation from the network module + */ + @Provides + @Singleton + fun provideFeedbackRepository( + networkConnectivity: NetworkConnectivity, + tokenStorage: TokenStorage, + ): FeedbackRepository = createFeedbackRepository(networkConnectivity, tokenStorage) + + /** + * Provides LocationRepository for saved favourite locations (premium feature). + */ + @Provides + @Singleton + fun provideLocationRepository(tokenStorage: TokenStorage): LocationRepository = + createLocationRepository(tokenStorage) + + /** + * Provides ServiceRepository for premium service subscriptions. + * Uses basic HTTP client; /services/public endpoint requires no authentication. + */ + @Provides + @Singleton + fun provideServiceRepository(): ServiceRepository = createServiceRepository() + + /** + * Provides SearchPlacesUseCase for searching places. + */ + @Provides + @Singleton + fun provideSearchPlacesUseCase(repository: LocationRepository): SearchPlacesUseCase = + SearchPlacesUseCase(repository) + + /** + * Provides SavedLocationsUseCase for managing saved locations. + */ + @Provides + @Singleton + fun provideSavedLocationsUseCase(repository: LocationRepository): SavedLocationsUseCase = + SavedLocationsUseCase(repository) +} diff --git a/app/src/main/java/bose/ankush/weatherify/di/PaymentKoinBridgeEntryPoint.kt b/app/src/main/java/bose/ankush/weatherify/di/PaymentKoinBridgeEntryPoint.kt new file mode 100644 index 00000000..83e10e73 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/di/PaymentKoinBridgeEntryPoint.kt @@ -0,0 +1,22 @@ +package bose.ankush.weatherify.di + +import bose.ankush.storage.api.TokenStorage +import bose.ankush.weatherify.base.config.AppConfig +import bose.ankush.weatherify.domain.preference.PreferenceManager +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * Hilt EntryPoint that exposes singletons needed to configure the Koin payment module. + * Used in WeatherifyApplication after Hilt has initialized (i.e. after super.onCreate()). + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface PaymentKoinBridgeEntryPoint { + fun tokenStorage(): TokenStorage + + fun preferenceManager(): PreferenceManager + + fun appConfig(): AppConfig +} diff --git a/app/src/main/java/bose/ankush/weatherify/di/PaymentKoinModule.kt b/app/src/main/java/bose/ankush/weatherify/di/PaymentKoinModule.kt new file mode 100644 index 00000000..17535937 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/di/PaymentKoinModule.kt @@ -0,0 +1,47 @@ +package bose.ankush.weatherify.di + +import android.content.Context +import bose.ankush.network.api.PaymentApiService +import bose.ankush.network.common.AndroidNetworkConnectivity +import bose.ankush.network.common.NetworkConnectivity +import bose.ankush.network.di.createPaymentApiService +import bose.ankush.payment.domain.config.PaymentConfig +import bose.ankush.payment.domain.store.PremiumStore +import bose.ankush.weatherify.payment.config.AppConfigPaymentConfig +import bose.ankush.weatherify.payment.store.PreferenceManagerPremiumStore +import dagger.hilt.android.EntryPointAccessors +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.Module +import org.koin.dsl.module + +/** + * Builds the app-level Koin module that provides platform-specific bindings required by + * [bose.ankush.payment.di.featurePaymentModules]. + * + * Called from [bose.ankush.weatherify.WeatherifyApplication] **after** Hilt is initialized + * (i.e. after super.onCreate()), so [EntryPointAccessors] is safe to use here. + */ +fun appPaymentKoinModule(context: Context): Module { + val bridge = + EntryPointAccessors.fromApplication( + context, + PaymentKoinBridgeEntryPoint::class.java, + ) + val tokenStorage = bridge.tokenStorage() + val preferenceManager = bridge.preferenceManager() + val appConfig = bridge.appConfig() + + return module { + // NetworkConnectivity: stateless โ€” safe to create a fresh instance for Koin + single { AndroidNetworkConnectivity(androidContext()) } + + // PaymentApiService: uses authenticated HTTP client from the network module + single { createPaymentApiService(tokenStorage) } + + // PremiumStore: wraps the Hilt-managed PreferenceManager singleton + single { PreferenceManagerPremiumStore(preferenceManager) } + + // PaymentConfig: wraps the Hilt-managed AppConfig singleton + single { AppConfigPaymentConfig(appConfig) } + } +} diff --git a/app/src/main/java/bose/ankush/weatherify/di/PreferenceModule.kt b/app/src/main/java/bose/ankush/weatherify/di/PreferenceModule.kt index 743714ce..6a9b77bc 100644 --- a/app/src/main/java/bose/ankush/weatherify/di/PreferenceModule.kt +++ b/app/src/main/java/bose/ankush/weatherify/di/PreferenceModule.kt @@ -14,13 +14,8 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class PreferenceModule { - - /** - * Binds PreferenceManagerImpl to PreferenceManager interface - */ + @Suppress("unused") @Binds @Singleton - abstract fun bindPreferenceManager( - preferenceManagerImpl: PreferenceManagerImpl - ): PreferenceManager -} \ No newline at end of file + abstract fun bindPreferenceManager(preferenceManagerImpl: PreferenceManagerImpl): PreferenceManager +} diff --git a/app/src/main/java/bose/ankush/weatherify/di/RemoteConfigModule.kt b/app/src/main/java/bose/ankush/weatherify/di/RemoteConfigModule.kt index 66fc3ce7..7bc13979 100644 --- a/app/src/main/java/bose/ankush/weatherify/di/RemoteConfigModule.kt +++ b/app/src/main/java/bose/ankush/weatherify/di/RemoteConfigModule.kt @@ -14,13 +14,8 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class RemoteConfigModule { - - /** - * Binds FirebaseRemoteConfigService to RemoteConfigService interface - */ + @Suppress("unused") @Binds @Singleton - abstract fun bindRemoteConfigService( - firebaseRemoteConfigService: FirebaseRemoteConfigService - ): RemoteConfigService -} \ No newline at end of file + abstract fun bindRemoteConfigService(firebaseRemoteConfigService: FirebaseRemoteConfigService): RemoteConfigService +} diff --git a/app/src/main/java/bose/ankush/weatherify/di/RepoModule.kt b/app/src/main/java/bose/ankush/weatherify/di/RepoModule.kt index 81770fb7..768c210a 100644 --- a/app/src/main/java/bose/ankush/weatherify/di/RepoModule.kt +++ b/app/src/main/java/bose/ankush/weatherify/di/RepoModule.kt @@ -12,27 +12,30 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton - +import bose.ankush.network.repository.WeatherRepository as NetworkWeatherRepository @Module @InstallIn(SingletonComponent::class) object RepoModule { - + /** + * Provides the domain-layer WeatherRepository that orchestrates network and storage. + * + * This is the single source of truth for weather data operations in the app. + */ @Singleton @Provides fun provideWeatherRepository( + networkRepository: NetworkWeatherRepository, weatherStorage: WeatherStorage, - dispatcherProvider: DispatcherProvider + dispatcherProvider: DispatcherProvider, ): WeatherRepository = WeatherRepositoryImpl( + networkRepository, weatherStorage, - dispatcherProvider + dispatcherProvider, ) @Singleton @Provides - fun provideCityRepository( - context: Context - ): CityRepository = - CityRepositoryImpl(context) + fun provideCityRepository(context: Context): CityRepository = CityRepositoryImpl(context) } diff --git a/app/src/main/java/bose/ankush/weatherify/domain/model/CityName.kt b/app/src/main/java/bose/ankush/weatherify/domain/model/CityName.kt index d768f491..3f2d824e 100644 --- a/app/src/main/java/bose/ankush/weatherify/domain/model/CityName.kt +++ b/app/src/main/java/bose/ankush/weatherify/domain/model/CityName.kt @@ -1,14 +1,15 @@ package bose.ankush.weatherify.domain.model data class CityName( - val name: String? + val name: String?, ) { fun doesMatchSearchQuery(query: String): Boolean { - val matchingCombinations = listOf( - "$name", - "${name?.first()}", - "${name?.last()}" - ) + val matchingCombinations = + listOf( + "$name", + "${name?.first()}", + "${name?.last()}", + ) return matchingCombinations.any { it.contains(query, ignoreCase = true) } diff --git a/app/src/main/java/bose/ankush/weatherify/domain/model/Country.kt b/app/src/main/java/bose/ankush/weatherify/domain/model/Country.kt index 80b2f4e9..02e05c1b 100644 --- a/app/src/main/java/bose/ankush/weatherify/domain/model/Country.kt +++ b/app/src/main/java/bose/ankush/weatherify/domain/model/Country.kt @@ -16,5 +16,5 @@ data class Country( val codeA2: String = "in", val defaultLanguage: String? = "en", val languages: List = listOf("en"), - val localCurrency: List = listOf("INR") -): Parcelable \ No newline at end of file + val localCurrency: List = listOf("INR"), +) : Parcelable diff --git a/app/src/main/java/bose/ankush/weatherify/domain/model/Weather.kt b/app/src/main/java/bose/ankush/weatherify/domain/model/Weather.kt deleted file mode 100644 index 287b4385..00000000 --- a/app/src/main/java/bose/ankush/weatherify/domain/model/Weather.kt +++ /dev/null @@ -1,12 +0,0 @@ -package bose.ankush.weatherify.domain.model - -data class Weather( - val cod: Int = 0, - val temp: Double? = 0.0, - val wind: Double? = 0.0, - val windAngle: Int? = 0, - val humidity: Int? = 0, - val name: String? = "", - val icon: String? = "", - val description: String? = "" -) \ No newline at end of file diff --git a/app/src/main/java/bose/ankush/weatherify/domain/model/WeatherForecast.kt b/app/src/main/java/bose/ankush/weatherify/domain/model/WeatherForecast.kt index bc7bdf32..7c1b8ba9 100644 --- a/app/src/main/java/bose/ankush/weatherify/domain/model/WeatherForecast.kt +++ b/app/src/main/java/bose/ankush/weatherify/domain/model/WeatherForecast.kt @@ -13,10 +13,10 @@ data class WeatherForecast( ) { data class Alert( val description: String?, - val end: Int?, + val end: Long?, val event: String?, val sender_name: String?, - val start: Int?, + val start: Long?, ) data class Current( @@ -25,13 +25,13 @@ data class WeatherForecast( val feels_like: Double?, val humidity: Int?, val pressure: Int?, - val sunrise: Int?, - val sunset: Int?, + val sunrise: Long?, + val sunset: Long?, val temp: Double?, val uvi: Double?, val weather: List? = listOf(), val wind_gust: Double?, - val wind_speed: Double? + val wind_speed: Double?, ) data class Daily( @@ -42,13 +42,13 @@ data class WeatherForecast( val pressure: Int?, val rain: Double?, val summary: String?, - val sunrise: Int?, - val sunset: Int?, + val sunrise: Long?, + val sunset: Long?, val temp: Temp?, val uvi: Double?, val weather: List? = listOf(), val wind_gust: Double?, - val wind_speed: Double? + val wind_speed: Double?, ) { data class Temp( val day: Double?, @@ -56,7 +56,7 @@ data class WeatherForecast( val max: Double?, val min: Double?, val morn: Double?, - val night: Double? + val night: Double?, ) } @@ -74,8 +74,8 @@ data class WeatherForecast( * Domain model for weather condition */ data class WeatherCondition( - val description: String, - val icon: String, + val description: String = "", + val icon: String = "", val id: Int, - val main: String + val main: String = "", ) diff --git a/app/src/main/java/bose/ankush/weatherify/domain/preference/PreferenceManager.kt b/app/src/main/java/bose/ankush/weatherify/domain/preference/PreferenceManager.kt index f21f4238..0d896675 100644 --- a/app/src/main/java/bose/ankush/weatherify/domain/preference/PreferenceManager.kt +++ b/app/src/main/java/bose/ankush/weatherify/domain/preference/PreferenceManager.kt @@ -1,6 +1,9 @@ package bose.ankush.weatherify.domain.preference -import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow /** @@ -8,9 +11,9 @@ import kotlinx.coroutines.flow.Flow */ interface PreferenceManager { /** - * Get the flow of location preferences + * Get the flow of user preferences */ - fun getLocationPreferenceFlow(): Flow + fun getUserPreferencesFlow(): Flow /** * Save location coordinates to preferences @@ -18,11 +21,55 @@ interface PreferenceManager { */ suspend fun saveLocationPreferences(coordinates: Pair) + /** + * Save premium subscription status and expiry + */ + suspend fun savePremiumStatus( + isPremium: Boolean, + expiryMillis: Long?, + ) + + /** + * Save a pinned location override that replaces live GPS as the weather source. + */ + suspend fun saveLocationOverride(lat: Double, lon: Double, name: String) + + /** + * Clear the pinned location override, reverting to live GPS. + */ + suspend fun clearLocationOverride() + + /** + * Clear all stored preferences (location, premium status, expiry). + * Must be called on logout so no stale state survives into the next session. + */ + suspend fun clearAll() + /** * Preference keys */ companion object PreferenceKeys { - val USER_LAT_LOCATION = androidx.datastore.preferences.core.doublePreferencesKey("latitude") - val USER_LON_LOCATION = androidx.datastore.preferences.core.doublePreferencesKey("longitude") + val USER_LAT_LOCATION = doublePreferencesKey("latitude") + val USER_LON_LOCATION = doublePreferencesKey("longitude") + val IS_PREMIUM = booleanPreferencesKey("is_premium") + val PREMIUM_EXPIRY = longPreferencesKey("premium_expiry") + val OVERRIDE_LAT = doublePreferencesKey("override_lat") + val OVERRIDE_LON = doublePreferencesKey("override_lon") + val OVERRIDE_LOCATION_NAME = stringPreferencesKey("override_location_name") + val IS_LOCATION_OVERRIDDEN = booleanPreferencesKey("is_location_overridden") } -} \ No newline at end of file +} + +/** + * Data class representing all user-specific preferences. + */ +data class UserPreferences( + val latitude: Double? = null, + val longitude: Double? = null, + val isPremium: Boolean = false, + val premiumExpiry: Long? = null, + val isLocationOverridden: Boolean = false, + val overrideLat: Double? = null, + val overrideLon: Double? = null, + val overrideLocationName: String? = null, +) diff --git a/app/src/main/java/bose/ankush/weatherify/domain/remote_config/RemoteConfigService.kt b/app/src/main/java/bose/ankush/weatherify/domain/remote_config/RemoteConfigService.kt index 5869fbf0..a9612945 100644 --- a/app/src/main/java/bose/ankush/weatherify/domain/remote_config/RemoteConfigService.kt +++ b/app/src/main/java/bose/ankush/weatherify/domain/remote_config/RemoteConfigService.kt @@ -6,7 +6,6 @@ package bose.ankush.weatherify.domain.remote_config * allowing for easier testing and flexibility in implementation. */ interface RemoteConfigService { - /** * Initializes the remote configuration service. * This should be called early in the application lifecycle. @@ -19,5 +18,8 @@ interface RemoteConfigService { * @param defaultValue The default value to return if the key is not found * @return The boolean value from remote configuration, or the default value if not found */ - fun getBoolean(key: String, defaultValue: Boolean = false): Boolean -} \ No newline at end of file + fun getBoolean( + key: String, + defaultValue: Boolean = false, + ): Boolean +} diff --git a/app/src/main/java/bose/ankush/weatherify/domain/repository/CityRepository.kt b/app/src/main/java/bose/ankush/weatherify/domain/repository/CityRepository.kt index 45d17bd0..f7eebb56 100644 --- a/app/src/main/java/bose/ankush/weatherify/domain/repository/CityRepository.kt +++ b/app/src/main/java/bose/ankush/weatherify/domain/repository/CityRepository.kt @@ -4,4 +4,4 @@ import bose.ankush.weatherify.data.remote.dto.CityDto interface CityRepository { fun getCityNames(): List -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/domain/repository/WeatherRepository.kt b/app/src/main/java/bose/ankush/weatherify/domain/repository/WeatherRepository.kt index 213f369a..ab2b89ce 100644 --- a/app/src/main/java/bose/ankush/weatherify/domain/repository/WeatherRepository.kt +++ b/app/src/main/java/bose/ankush/weatherify/domain/repository/WeatherRepository.kt @@ -10,10 +10,14 @@ Date: 05,May,2021 **/ interface WeatherRepository { - fun getAirQualityReport(coordinates: Pair): Flow fun getWeatherReport(location: Pair): Flow - suspend fun refreshWeatherData(coordinates: Pair) + suspend fun refreshWeatherData( + coordinates: Pair, + forceRefresh: Boolean = false, + ) + + suspend fun clearAllData() } diff --git a/app/src/main/java/bose/ankush/weatherify/domain/use_case/feedback/SubmitFeedback.kt b/app/src/main/java/bose/ankush/weatherify/domain/use_case/feedback/SubmitFeedback.kt new file mode 100644 index 00000000..cddd0a17 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/feedback/SubmitFeedback.kt @@ -0,0 +1,15 @@ +package bose.ankush.weatherify.domain.use_case.feedback + +import bose.ankush.network.model.FeedbackRequest +import bose.ankush.network.model.FeedbackResponse +import bose.ankush.network.repository.FeedbackRepository +import javax.inject.Inject + +class SubmitFeedback +@Inject +constructor( + private val repository: FeedbackRepository, +) { + suspend operator fun invoke(request: FeedbackRequest): Result = + repository.submitFeedback(request) +} diff --git a/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_air_quality/GetAirQuality.kt b/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_air_quality/GetAirQuality.kt index 7653f718..8b752460 100644 --- a/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_air_quality/GetAirQuality.kt +++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_air_quality/GetAirQuality.kt @@ -5,9 +5,13 @@ import bose.ankush.weatherify.domain.repository.WeatherRepository import kotlinx.coroutines.flow.Flow import javax.inject.Inject -class GetAirQuality @Inject constructor( - private val weatherRepository: WeatherRepository +class GetAirQuality +@Inject +constructor( + private val weatherRepository: WeatherRepository, ) { - operator fun invoke(lat: Double, lang: Double): Flow = - weatherRepository.getAirQualityReport(Pair(lat, lang)) + operator fun invoke( + lat: Double, + lang: Double, + ): Flow = weatherRepository.getAirQualityReport(Pair(lat, lang)) } diff --git a/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_cities/GetCityNames.kt b/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_cities/GetCityNames.kt index 29efc7a1..fe65f80b 100644 --- a/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_cities/GetCityNames.kt +++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_cities/GetCityNames.kt @@ -5,13 +5,16 @@ import bose.ankush.weatherify.domain.model.CityName import bose.ankush.weatherify.domain.repository.CityRepository import javax.inject.Inject -class GetCityNames @Inject constructor( - private val repository: CityRepository +class GetCityNames +@Inject +constructor( + private val repository: CityRepository, ) { - operator fun invoke(): List = try { - val cityNames = repository.getCityNames().map { it.toCityName() } - cityNames.ifEmpty { emptyList() } - } catch (e: Exception) { - emptyList() - } -} \ No newline at end of file + operator fun invoke(): List = + try { + val cityNames = repository.getCityNames().map { it.toCityName() } + cityNames.ifEmpty { emptyList() } + } catch (_: Exception) { + emptyList() + } +} diff --git a/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_weather_reports/GetWeatherReport.kt b/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_weather_reports/GetWeatherReport.kt index 457ab932..3bfe2268 100644 --- a/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_weather_reports/GetWeatherReport.kt +++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_weather_reports/GetWeatherReport.kt @@ -3,7 +3,10 @@ package bose.ankush.weatherify.domain.use_case.get_weather_reports import bose.ankush.weatherify.domain.repository.WeatherRepository import javax.inject.Inject -class GetWeatherReport @Inject constructor(private val repository: WeatherRepository) { - - suspend operator fun invoke(location: Pair) = repository.getWeatherReport(location) +class GetWeatherReport +@Inject +constructor( + private val repository: WeatherRepository, +) { + operator fun invoke(location: Pair) = repository.getWeatherReport(location) } diff --git a/app/src/main/java/bose/ankush/weatherify/domain/use_case/refresh_weather_reports/RefreshWeatherReport.kt b/app/src/main/java/bose/ankush/weatherify/domain/use_case/refresh_weather_reports/RefreshWeatherReport.kt index 4404165c..5f13d7a7 100644 --- a/app/src/main/java/bose/ankush/weatherify/domain/use_case/refresh_weather_reports/RefreshWeatherReport.kt +++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/refresh_weather_reports/RefreshWeatherReport.kt @@ -3,10 +3,15 @@ package bose.ankush.weatherify.domain.use_case.refresh_weather_reports import bose.ankush.weatherify.domain.repository.WeatherRepository import javax.inject.Inject -class RefreshWeatherReport @Inject constructor( - private val repository: WeatherRepository +class RefreshWeatherReport +@Inject +constructor( + private val repository: WeatherRepository, ) { - suspend operator fun invoke(coordinates: Pair) { - repository.refreshWeatherData(coordinates) + suspend operator fun invoke( + coordinates: Pair, + forceRefresh: Boolean = false, + ) { + repository.refreshWeatherData(coordinates, forceRefresh) } -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/payment/config/AppConfigPaymentConfig.kt b/app/src/main/java/bose/ankush/weatherify/payment/config/AppConfigPaymentConfig.kt new file mode 100644 index 00000000..06d29fcb --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/payment/config/AppConfigPaymentConfig.kt @@ -0,0 +1,14 @@ +package bose.ankush.weatherify.payment.config + +import bose.ankush.payment.domain.config.PaymentConfig +import bose.ankush.weatherify.base.config.AppConfig + +/** + * Bridges [AppConfig] (Hilt-managed) into the [PaymentConfig] interface + * consumed by [bose.ankush.payment.presentation.PaymentViewModel] (Koin-managed). + */ +internal class AppConfigPaymentConfig( + private val appConfig: AppConfig, +) : PaymentConfig { + override val razorpayKey: String get() = appConfig.razorpayKey +} diff --git a/app/src/main/java/bose/ankush/weatherify/payment/store/PreferenceManagerPremiumStore.kt b/app/src/main/java/bose/ankush/weatherify/payment/store/PreferenceManagerPremiumStore.kt new file mode 100644 index 00000000..41b9cb60 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/payment/store/PreferenceManagerPremiumStore.kt @@ -0,0 +1,28 @@ +package bose.ankush.weatherify.payment.store + +import bose.ankush.payment.domain.store.PremiumStatus +import bose.ankush.payment.domain.store.PremiumStore +import bose.ankush.weatherify.domain.preference.PreferenceManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Bridges [PreferenceManager] (Hilt-managed) into the [PremiumStore] interface + * consumed by [bose.ankush.payment.presentation.PaymentViewModel] (Koin-managed). + */ +internal class PreferenceManagerPremiumStore( + private val preferenceManager: PreferenceManager, +) : PremiumStore { + override fun observePremiumStatus(): Flow = + preferenceManager.getUserPreferencesFlow().map { prefs -> + PremiumStatus( + isPremium = prefs.isPremium, + expiryMillis = prefs.premiumExpiry, + ) + } + + override suspend fun savePremiumStatus( + isPremium: Boolean, + expiryMillis: Long?, + ) = preferenceManager.savePremiumStatus(isPremium, expiryMillis) +} diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/MainActivity.kt b/app/src/main/java/bose/ankush/weatherify/presentation/MainActivity.kt index 44cbc6bc..c986ef9e 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/MainActivity.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/MainActivity.kt @@ -2,28 +2,49 @@ package bose.ankush.weatherify.presentation import android.Manifest import android.content.Context +import android.location.LocationManager import android.os.Bundle import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.WindowCompat import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.WindowCompat +import bose.ankush.commonui.auth.LoginScreen +import bose.ankush.commonui.components.NotificationToast +import bose.ankush.commonui.components.ToastType +import bose.ankush.commonui.components.rememberToastAnchorState +import bose.ankush.commonui.permissions.PermissionAlertDialog +import bose.ankush.commonui.web.InAppWebView +import bose.ankush.payment.presentation.PaymentViewModel import bose.ankush.weatherify.base.common.ACCESS_NOTIFICATION -import bose.ankush.weatherify.base.common.ACCESS_PHONE_CALL -import bose.ankush.weatherify.base.common.Extension.callNumber import bose.ankush.weatherify.base.common.Extension.hasNotificationPermission import bose.ankush.weatherify.base.common.Extension.openAppSystemSettings import bose.ankush.weatherify.base.common.PERMISSIONS_TO_REQUEST @@ -31,23 +52,35 @@ import bose.ankush.weatherify.base.common.startInAppUpdate import bose.ankush.weatherify.base.location.LocationClient import bose.ankush.weatherify.base.permissions.CoarseLocationPermissionTextProvider import bose.ankush.weatherify.base.permissions.FineLocationPermissionTextProvider -import bose.ankush.weatherify.base.permissions.PermissionAlertDialog import bose.ankush.weatherify.presentation.navigation.AppNavigation import bose.ankush.weatherify.presentation.theme.WeatherifyTheme +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.razorpay.Checkout +import com.razorpay.PaymentData +import com.razorpay.PaymentResultWithDataListener import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.json.JSONObject import javax.inject.Inject +import org.koin.androidx.viewmodel.ext.android.viewModel as koinViewModel @ExperimentalCoroutinesApi @ExperimentalAnimationApi @AndroidEntryPoint -class MainActivity : AppCompatActivity() { - +class MainActivity : + AppCompatActivity(), + PaymentResultWithDataListener { private val viewModel: MainViewModel by viewModels() + // Koin-managed: owns payment state and Razorpay flow + private val paymentViewModel: PaymentViewModel by koinViewModel() + @Inject lateinit var locationClient: LocationClient + // Hold a reference to the Checkout instance only during payment + private var razorpayCheckout: Checkout? = null + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) @@ -57,119 +90,282 @@ class MainActivity : AppCompatActivity() { setContent { WeatherifyTheme { - val context: Context = LocalContext.current - val launchPhoneCallPermissionState = - viewModel.launchPhoneCallPermission.collectAsState() - val launchNotificationPermissionState = - viewModel.launchNotificationPermission.collectAsState() - if (locationClient.hasLocationPermission()) { - // if permission granted already then fetch and save location coordinates - viewModel.fetchAndSaveLocationCoordinates() - } else { - // request location permission - RequestLocationPermission(context) + val context = LocalContext.current + val isLoggedIn by viewModel.isLoggedIn.collectAsState() + val authState by viewModel.authState.collectAsState() + val isAuthInitialized by viewModel.isAuthInitialized.collectAsState() + + // Set status bar color and icon color based on background + @Suppress("DEPRECATION") + val systemUiController = rememberSystemUiController() + val bgColor = MaterialTheme.colorScheme.background + val useDarkIcons = bgColor.luminance() > 0.5f + SideEffect { + systemUiController.setStatusBarColor( + color = Color.Transparent, + darkIcons = useDarkIcons, + ) } - if (launchPhoneCallPermissionState.value) { - // request phone call permission - RequestPhoneCallPermission(context) + + // Toast anchor state โ€” auto-measures bottom bar height + val toastAnchorState = rememberToastAnchorState() + + // NotificationToast state + var toastVisible by remember { mutableStateOf(false) } + var toastMessage by remember { mutableStateOf("") } + var toastTitle by remember { mutableStateOf("") } + var toastType by remember { mutableStateOf(ToastType.ERROR) } + + fun showToast( + message: String, + title: String = "Error", + type: ToastType = ToastType.ERROR, + ) { + toastMessage = message + toastTitle = title + toastType = type + toastVisible = true } - if (launchNotificationPermissionState.value) { - // request notification permission - RequestNotificationPermission(context) + + // Handle authentication state changes + LaunchedEffect(authState) { + when (authState) { + is AuthState.Error -> { + showToast((authState as AuthState.Error).message.asString(this@MainActivity)) + viewModel.resetAuthState() + } + is AuthState.Success -> { + showToast("Authentication successful", "Success", ToastType.SUCCESS) + viewModel.resetAuthState() + } + else -> Unit + } } - /** - * For Settings screen: - * notification item should be invisible if notification permission is already granted. - */ - LaunchedEffect(key1 = launchNotificationPermissionState) { - if (!context.hasNotificationPermission()) { - viewModel.updateShowNotificationBannerState(true) - } else { - viewModel.updateShowNotificationBannerState(false) + // Listen for Unauthorized events + LaunchedEffect(Unit) { + bose.ankush.network.auth.events.AuthEventBus.events.collect { event -> + if (event is bose.ankush.network.auth.events.AuthEvent.Unauthorized) { + showToast( + message = + event.message.ifBlank { + "You need to log in again to continue using the app for security purposes." + }, + title = "Session Expired", + type = ToastType.WARNING, + ) + } } } - // main container holding all app composable screens + // Collect checkout params from PaymentViewModel and launch Razorpay + LaunchedEffect(Unit) { + paymentViewModel.checkoutParams.collect { params -> + try { + Checkout.preload(applicationContext) + razorpayCheckout = Checkout() + razorpayCheckout?.setKeyID(params.keyId) + val options = + JSONObject().apply { + put("name", params.name) + put("description", params.description) + put("order_id", params.orderId) + put("currency", params.currency) + put("amount", params.amount) + val prefill = + JSONObject().apply { + params.email?.let { put("email", it) } + params.contact?.let { put("contact", it) } + } + put("prefill", prefill) + } + razorpayCheckout?.open(this@MainActivity, options) + } catch (e: Exception) { + paymentViewModel.onPaymentFailed( + e.message ?: "Unable to open payment checkout", + ) + Checkout.clearUserData(context) + razorpayCheckout = null + } + } + } + + // Main content Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), + ), ) { - AppNavigation(viewModel) + when { + !isAuthInitialized -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + } + isLoggedIn -> { + // Only fetch location once after login, not on every recomposition + val launchNotificationPermissionState = + viewModel.launchNotificationPermission.collectAsState() + LaunchedEffect(isLoggedIn) { + if (locationClient.hasLocationPermission()) { + viewModel.fetchAndSaveLocationCoordinates() + } + } + // If location permission is missing, request it on first launch + if (!locationClient.hasLocationPermission()) { + RequestLocationPermission(context) + } + if (launchNotificationPermissionState.value) { + RequestNotificationPermission(context) + } + LaunchedEffect(launchNotificationPermissionState.value) { + viewModel.updateShowNotificationBannerState(!context.hasNotificationPermission()) + } + AppNavigation(viewModel, paymentViewModel, toastAnchorState) + } + else -> { + // State for web view + var currentWebUrl by remember { mutableStateOf(null) } + + // Show web view if a URL is selected + if (currentWebUrl != null) { + InAppWebView( + url = currentWebUrl!!, + onClose = { currentWebUrl = null }, + ) + } else { + // Only show login screen if not logged in and auth is initialized + LoginScreen( + onLoginClick = { email, password -> + viewModel.login( + email, + password, + ) + }, + onRegisterClick = { email, password -> + viewModel.register( + email, + password, + ) + }, + onWebUrlClick = { url -> currentWebUrl = url }, + isLoading = authState is AuthState.Loading, + ) + } + } + } + NotificationToast( + modifier = Modifier.align(Alignment.BottomCenter), + message = toastMessage, + title = toastTitle, + type = toastType, + isVisible = toastVisible, + onDismiss = { toastVisible = false }, + anchorState = toastAnchorState, + ) } } } } + /** + * Request location permissions using Compose dialog and launcher. + */ @Composable fun RequestLocationPermission(context: Context) { val permissionQueue = viewModel.permissionDialogQueue - val locationPermissionsResultLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestMultiplePermissions(), + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), onResult = { permissionMap -> PERMISSIONS_TO_REQUEST.forEach { permission -> viewModel.onPermissionResult( permission = permission, - isGranted = permissionMap[permission] == true + isGranted = permissionMap[permission] == true, ) } - }) + }, + ) permissionQueue.reversed().forEach { permission -> - PermissionAlertDialog(permissionTextProvider = when (permission) { - Manifest.permission.ACCESS_FINE_LOCATION -> FineLocationPermissionTextProvider() - Manifest.permission.ACCESS_COARSE_LOCATION -> CoarseLocationPermissionTextProvider() - else -> return@forEach - }, - isPermanentlyDeclined = shouldShowRequestPermissionRationale(permission), - onDismissClick = viewModel::dismissDialog, - onOkClick = { - viewModel.dismissDialog() - locationPermissionsResultLauncher.launch(PERMISSIONS_TO_REQUEST) - }, - onGoToAppSettingClick = { context.openAppSystemSettings() }) - } + val isPermanentlyDeclined = !shouldShowRequestPermissionRationale(permission) + val textProvider = + when (permission) { + Manifest.permission.ACCESS_FINE_LOCATION -> FineLocationPermissionTextProvider() + Manifest.permission.ACCESS_COARSE_LOCATION -> CoarseLocationPermissionTextProvider() + else -> return@forEach + } - LaunchedEffect(key1 = Unit) { - locationPermissionsResultLauncher.launch(PERMISSIONS_TO_REQUEST) - } - } + // Consumer owns back-press: exit the app when permanently declined + BackHandler(enabled = isPermanentlyDeclined) { finish() } - @Composable - fun RequestPhoneCallPermission(context: Context) { - val phoneCallPermissionResultLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), - onResult = { isGranted -> - // TODO: Hardcoded task to call phone number as it is triggered from 1 place [AppNavigation] - if (isGranted) context.callNumber() - } + PermissionAlertDialog( + descriptionText = textProvider.getDescription(isPermanentlyDeclined), + isPermanentlyDeclined = isPermanentlyDeclined, + onPositiveAction = + if (isPermanentlyDeclined) { + { context.openAppSystemSettings() } + } else { + { + viewModel.dismissDialog() + locationPermissionsResultLauncher.launch(PERMISSIONS_TO_REQUEST) + } + }, + onNegativeAction = { finish() }, + positiveButtonLabel = if (isPermanentlyDeclined) "Grant Permission" else "OK", + negativeButtonLabel = "Exit", ) + } - LaunchedEffect(key1 = Unit) { - phoneCallPermissionResultLauncher.launch(ACCESS_PHONE_CALL) + // Launch initial permission request if missing and queue is empty (first-launch scenario) + LaunchedEffect(Unit) { + if (permissionQueue.isEmpty() && !locationClient.hasLocationPermission()) { + locationPermissionsResultLauncher.launch(PERMISSIONS_TO_REQUEST) + } + } + + // Re-launch only when rationale should be shown (not permanently declined) + LaunchedEffect(permissionQueue.size) { + val hasRationalePermission = + permissionQueue.any { shouldShowRequestPermissionRationale(it) } + if (permissionQueue.isNotEmpty() && hasRationalePermission) { + locationPermissionsResultLauncher.launch(PERMISSIONS_TO_REQUEST) + } } } + /** + * Request notification permission using Compose launcher. + */ @Composable fun RequestNotificationPermission(context: Context) { val notificationPermissionResultLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), onResult = { isGranted -> + viewModel.updateShowNotificationBannerState(!isGranted) if (isGranted) { - Toast.makeText( - context, - "Notification permission granted", - Toast.LENGTH_SHORT - ).show() - // hide notification banner on settings screen - viewModel.updateShowNotificationBannerState(false) + Toast + .makeText( + context, + "Notification permission granted", + Toast.LENGTH_SHORT, + ).show() + } else { + val isPermanentlyDeclined = + !shouldShowRequestPermissionRationale(ACCESS_NOTIFICATION) + viewModel.updateNotificationPermissionPermanentlyDeclined( + isPermanentlyDeclined, + ) } - } + }, ) - - LaunchedEffect(key1 = Unit) { + LaunchedEffect(Unit) { notificationPermissionResultLauncher.launch(ACCESS_NOTIFICATION) } } @@ -177,5 +373,60 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() startInAppUpdate(this) + viewModel.refreshTokenOnForeground() + // If user granted a permission via system Settings and returned, clear it from the queue + val granted = + viewModel.permissionDialogQueue.filter { permission -> + checkSelfPermission(permission) == android.content.pm.PackageManager.PERMISSION_GRANTED + } + if (granted.isNotEmpty()) { + viewModel.removeGrantedPermissions(granted) + } + // If GPS was disabled and user returned from location settings, retry location fetch + if (viewModel.uiState.value.isGpsDisabled && locationClient.hasLocationPermission()) { + val locationManager = getSystemService(LOCATION_SERVICE) as LocationManager + val isLocationAvailable = + locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + if (isLocationAvailable) { + viewModel.fetchAndSaveLocationCoordinates() + } + } + } + + /** + * Razorpay payment success callback โ€” delegates to [PaymentViewModel]. + */ + override fun onPaymentSuccess( + razorpayPaymentID: String?, + paymentData: PaymentData?, + ) { + val orderId = paymentData?.orderId.orEmpty() + val paymentId = paymentData?.paymentId ?: razorpayPaymentID.orEmpty() + val signature = paymentData?.signature.orEmpty() + if (orderId.isNotBlank() && paymentId.isNotBlank() && signature.isNotBlank()) { + paymentViewModel.verifyPayment(orderId, paymentId, signature) + } else { + paymentViewModel.onPaymentFailed("Payment succeeded but missing data") + } + razorpayCheckout = null + } + + /** + * Razorpay payment error callback โ€” delegates to [PaymentViewModel]. + */ + override fun onPaymentError( + code: Int, + response: String?, + paymentData: PaymentData?, + ) { + val message = response ?: "Payment failed with code $code" + paymentViewModel.onPaymentFailed(message) + razorpayCheckout = null + } + + override fun onDestroy() { + super.onDestroy() + razorpayCheckout = null } } diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/MainViewModel.kt b/app/src/main/java/bose/ankush/weatherify/presentation/MainViewModel.kt index e21a662f..a1062e72 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/MainViewModel.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/MainViewModel.kt @@ -3,303 +3,822 @@ package bose.ankush.weatherify.presentation import androidx.compose.runtime.mutableStateListOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import bose.ankush.commonui.locations.PlaceSearchUiState +import bose.ankush.commonui.locations.SavedLocationsUiState +import bose.ankush.network.auth.events.AuthEvent +import bose.ankush.network.auth.events.AuthEventBus.emit +import bose.ankush.network.auth.model.AuthResponse +import bose.ankush.network.auth.repository.AuthRepository +import bose.ankush.network.auth.token.TokenManager +import bose.ankush.network.auth.token.TokenResult +import bose.ankush.network.auth.utils.isPremiumActive +import bose.ankush.network.domain.SavedLocationsUseCase +import bose.ankush.network.domain.SearchPlacesUseCase import bose.ankush.weatherify.R +import bose.ankush.weatherify.base.common.DeviceInfoProvider import bose.ankush.weatherify.base.common.ENABLE_NOTIFICATION +import bose.ankush.weatherify.base.common.LoggerFactory import bose.ankush.weatherify.base.common.UiText +import bose.ankush.weatherify.base.common.errorResponseFromException import bose.ankush.weatherify.base.dispatcher.DispatcherProvider import bose.ankush.weatherify.base.location.LocationClient +import bose.ankush.weatherify.base.location.LocationPermissions import bose.ankush.weatherify.domain.preference.PreferenceManager import bose.ankush.weatherify.domain.remote_config.RemoteConfigService +import bose.ankush.weatherify.domain.repository.WeatherRepository import bose.ankush.weatherify.domain.use_case.get_air_quality.GetAirQuality import bose.ankush.weatherify.domain.use_case.get_weather_reports.GetWeatherReport import bose.ankush.weatherify.domain.use_case.refresh_weather_reports.RefreshWeatherReport import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import timber.log.Timber +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import javax.inject.Inject /** - * Main ViewModel for the Weatherify application. - * - * This ViewModel is responsible for: - * - Managing the UI state for weather and air quality data - * - Handling location permissions and coordinates - * - Managing notification settings and permissions - * - Coordinating data loading from repositories + * Main ViewModel for Weatherify. + * Handles UI state, authentication, location, and notifications. * - * @property refreshWeatherReport Use case for refreshing weather data from remote source - * @property getWeatherReport Use case for retrieving weather data from local database - * @property getAirQuality Use case for retrieving air quality data - * @property locationClient Client for accessing device location - * @property preferenceManager Manager for user preferences storage - * @property dispatchers Provider for coroutine dispatchers - * @property remoteConfigService Service for accessing remote configuration + * Payment state and logic lives in [bose.ankush.payment.presentation.PaymentViewModel]. + * + * All platform-specific dependencies are injected via interfaces so this class + * is ready to be moved to a KMP commonMain source set with minimal changes. + * + * Remaining KMP TODO: [UiText.StringResource] still references Android R.string resources. + * When migrating UiText to a KMP-compatible text-resource system, replace the + * [UiText.StringResource] usages below with the new type. */ +@OptIn(FlowPreview::class) @HiltViewModel -class MainViewModel @Inject constructor( +class MainViewModel +@Inject +constructor( private val refreshWeatherReport: RefreshWeatherReport, private val getWeatherReport: GetWeatherReport, private val getAirQuality: GetAirQuality, + private val weatherRepository: WeatherRepository, private val locationClient: LocationClient, private val preferenceManager: PreferenceManager, private val dispatchers: DispatcherProvider, - private val remoteConfigService: RemoteConfigService + private val remoteConfigService: RemoteConfigService, + private val authRepository: AuthRepository, + private val tokenManager: TokenManager, + private val searchPlacesUseCase: SearchPlacesUseCase, + private val savedLocationsUseCase: SavedLocationsUseCase, + loggerFactory: LoggerFactory, + private val deviceInfoProvider: DeviceInfoProvider, ) : ViewModel() { + private val logger = loggerFactory.create("${MainViewModel::class.simpleName} ->") - /** - * Queue of permissions that need to be requested from the user. - * This is exposed to the UI to show appropriate permission dialogs. - */ + // Permission dialog queue for UI var permissionDialogQueue = mutableStateListOf() private set + // UI state flows private val _uiState = MutableStateFlow(UIState(isLoading = true)) - /** - * The current UI state containing weather data, air quality, and loading status. - */ val uiState = _uiState.asStateFlow() - private val _launchPhoneCallPermission = MutableStateFlow(false) - /** - * Flag indicating whether the phone call permission dialog should be shown. - */ - val launchPhoneCallPermission = _launchPhoneCallPermission.asStateFlow() - private val _launchNotificationPermission = MutableStateFlow(false) - /** - * Flag indicating whether the notification permission dialog should be shown. - */ val launchNotificationPermission = _launchNotificationPermission.asStateFlow() private val _showNotificationCardItem = MutableStateFlow(false) - /** - * Flag indicating whether the notification card should be shown in the UI. - */ val showNotificationCardItem = _showNotificationCardItem.asStateFlow() - /** - * Exception handler for data fetching operations. - * Updates the UI state with an error message when an exception occurs. - */ - private val dataFetchExceptionHandler = CoroutineExceptionHandler { _, e -> - if (e !is CancellationException) { - _uiState.update { UIState(error = UiText.DynamicText(e.message.toString())) } - } - } + private val _isNotificationPermissionPermanentlyDeclined = MutableStateFlow(false) + val isNotificationPermissionPermanentlyDeclined = + _isNotificationPermissionPermanentlyDeclined.asStateFlow() + + // Auth state flows + private val _authState = MutableStateFlow(AuthState.Initial) + val authState: StateFlow = _authState.asStateFlow() - private val tag = "${MainViewModel::class.simpleName} ->" + private val _isLoggedIn = MutableStateFlow(false) + val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() - // Track active jobs for proper cancellation + private val _isAuthInitialized = MutableStateFlow(false) + val isAuthInitialized: StateFlow = _isAuthInitialized.asStateFlow() + + // Location state flows + private val _savedLocationsState = MutableStateFlow(SavedLocationsUiState()) + val savedLocationsState: StateFlow = _savedLocationsState.asStateFlow() + + private val _placeSearchState = MutableStateFlow(PlaceSearchUiState()) + val placeSearchState: StateFlow = _placeSearchState.asStateFlow() + + private val _queryFlow = MutableStateFlow("") + + // Coroutine jobs private var notificationBannerJob: Job? = null private var locationJob: Job? = null private var dataLoadingJob: Job? = null - /** - * Dismisses the current permission dialog by removing it from the queue. - * This should be called when the user has responded to a permission request. - */ + // Exception handler for data fetch + private val dataFetchExceptionHandler = + CoroutineExceptionHandler { _, e -> + if (e !is CancellationException) { + val error = + if (e is Exception) { + errorResponseFromException(e) + } else { + UiText.StringResource(resId = R.string.general_error_txt) + } + _uiState.update { UIState(error = error) } + } + } + + init { + logger.d("MainViewModel initialized") + + viewModelScope.launch { + var initialized = false + authRepository.isLoggedIn().collectLatest { loggedIn -> + _isLoggedIn.value = loggedIn + logger.d("Auth state changed - isLoggedIn: $loggedIn") + if (!initialized) { + if (loggedIn) silentTokenRefresh() + _isAuthInitialized.value = true + initialized = true + logger.d("Auth initialization completed") + } + } + } + + // Reactively refresh weather data when premium tier changes (activation or expiry). + // drop(1) skips the initial emission so we only react to actual changes. + viewModelScope.launch { + preferenceManager + .getUserPreferencesFlow() + .map { it.isPremium } + .distinctUntilChanged() + .drop(1) + .collect { isPremium -> + logger.d("Premium status changed to $isPremium โ€” forcing weather data refresh") + performInitialDataLoading(forceRefresh = true) + } + } + + // Setup debounced place search + viewModelScope.launch(dispatchers.io) { + _queryFlow + .debounce(400L) + .filter { it.length >= 2 } + .distinctUntilChanged() + .collect { query -> fetchPlaceSuggestions(query) } + } + + // Load saved locations and premium status on init + viewModelScope.launch(dispatchers.io) { + preferenceManager.getUserPreferencesFlow().collect { prefs -> + val premiumActive = + isPremiumActive( + prefs.premiumExpiry?.let { millis -> + Instant.fromEpochMilliseconds(millis).toString() + }, + ) + val wasPremium = _savedLocationsState.value.isPremium + _savedLocationsState.update { it.copy(isPremium = premiumActive) } + if (premiumActive && !wasPremium) loadSavedLocations() + } + } + } + + /** Remove first permission dialog from queue. */ fun dismissDialog() { - permissionDialogQueue.removeAt(0) + if (permissionDialogQueue.isNotEmpty()) { + val dismissed = permissionDialogQueue.removeAt(0) + logger.d("Dismissed permission dialog: $dismissed") + } } - /** - * Handles the result of a permission request. - * If permission is denied, adds it to the dialog queue to show a rationale. - * If permission is granted, proceeds with fetching location coordinates. - * - * @param permission The permission that was requested - * @param isGranted Whether the permission was granted by the user - */ + /** Remove all permissions from the queue that have been granted (e.g. via system Settings). + * Also triggers location fetch if a location permission was among those granted. */ + fun removeGrantedPermissions(grantedPermissions: List) { + var locationGranted = false + grantedPermissions.forEach { permission -> + permissionDialogQueue.remove(permission) + logger.d("Removed granted permission from queue: $permission") + if (permission == LocationPermissions.FINE_LOCATION || + permission == LocationPermissions.COARSE_LOCATION + ) { + locationGranted = true + } + } + if (locationGranted) { + logger.d("Location permission granted via Settings, fetching location") + fetchAndSaveLocationCoordinates() + } + } + + /** Handle permission result, fetch location if granted. */ fun onPermissionResult( permission: String, isGranted: Boolean, ) { - if (!isGranted && !permissionDialogQueue.contains(permission)) { - permissionDialogQueue.add(permission) - } else { + logger.d("Permission result - permission: $permission, granted: $isGranted") + if (isGranted) { + logger.d("Permission granted, fetching location") fetchAndSaveLocationCoordinates() + } else if (!permissionDialogQueue.contains(permission)) { + permissionDialogQueue.add(permission) + logger.w("Permission denied: $permission, added to queue") } } - /** - * Updates the state of the phone call permission dialog. - * - * @param launchState True to show the permission dialog, false to hide it - */ - fun updatePhoneCallPermission(launchState: Boolean) { - _launchPhoneCallPermission.update { launchState } - } - - /** - * Updates the state of the notification permission dialog. - * - * @param launchState True to show the permission dialog, false to hide it - */ + /** Show/hide notification permission dialog. */ fun updateNotificationPermission(launchState: Boolean) { + logger.d("Updating notification permission dialog - show: $launchState") _launchNotificationPermission.update { launchState } } - /** - * Updates the state of the notification banner based on the remote configuration. - * If notifications are disabled, the banner visibility will be false. - * - * @param launchState True to show the notification banner if enabled in remote config, false to hide it - */ + /** Show/hide notification banner based on remote config. */ fun updateShowNotificationBannerState(launchState: Boolean) { - // Cancel previous job if it exists notificationBannerJob?.cancel() - - notificationBannerJob = viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) { - try { - if (remoteConfigService.getBoolean(ENABLE_NOTIFICATION)) { - _showNotificationCardItem.update { launchState } - Timber.tag(tag).d("Notification feature is enabled") - } else { - _showNotificationCardItem.update { false } - Timber.tag(tag).d("Notification feature is disabled") + notificationBannerJob = + viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) { + try { + val enabled = remoteConfigService.getBoolean(ENABLE_NOTIFICATION) + _showNotificationCardItem.update { enabled && launchState } + logger.d("Notification feature is ${if (enabled) "enabled" else "disabled"}") + } catch (_: CancellationException) { + throw CancellationException() + } catch (e: Exception) { + logger.e("Error updating notification banner state", e) + _uiState.update { it.copy(error = errorResponseFromException(e)) } } - } catch (e: CancellationException) { - throw e // Rethrow cancellation exceptions - } catch (e: Exception) { - Timber.tag(tag).e(e, "Error updating notification banner state") - _uiState.update { it.copy(error = UiText.DynamicText(e.message.toString())) } } - } } - /** - * Fetches the user's current location coordinates and saves them to preferences. - * Once coordinates are obtained, triggers initial data loading for weather and air quality. - * This method handles errors and updates the UI state accordingly. - */ + /** Update whether notification permission is permanently declined. */ + fun updateNotificationPermissionPermanentlyDeclined(isPermanentlyDeclined: Boolean) { + logger.d("Notification permission permanently declined: $isPermanentlyDeclined") + _isNotificationPermissionPermanentlyDeclined.update { isPermanentlyDeclined } + } + + /** Fetch and save user location, then load initial data. Skips GPS when override is active. */ fun fetchAndSaveLocationCoordinates() { - // Cancel previous job if it exists + logger.d("Starting location fetch") + _uiState.update { UIState(isLoading = true) } locationJob?.cancel() + locationJob = + viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) { + val prefs = preferenceManager.getUserPreferencesFlow().first() + if (prefs.isLocationOverridden) { + logger.d("Location override active โ€” skipping GPS, using saved location") + performInitialDataLoading() + return@launch + } + try { + locationClient.getCurrentLocation().fold( + onSuccess = { loc -> + logger.i("Location fetched successfully - lat: ${loc.latitude}, lon: ${loc.longitude}") + preferenceManager.saveLocationPreferences(loc.latitude to loc.longitude) + logger.d("Location preferences saved") + performInitialDataLoading() + }, + onFailure = { e -> + logger.e("Location fetch failed", e) + val isGpsDisabled = + e is LocationClient.LocationException && + e.message?.contains( + "GPS is disabled", + ignoreCase = true + ) == true + val error = + if (isGpsDisabled) { + UiText.StringResource(resId = R.string.gps_disabled_error_txt) + } else if (e is Exception) { + errorResponseFromException(e) + } else { + UiText.StringResource(resId = R.string.general_error_txt) + } + _uiState.update { + it.copy( + isLoading = false, + error = error, + isGpsDisabled = isGpsDisabled, + ) + } + }, + ) + } catch (_: CancellationException) { + logger.d("Location fetch cancelled") + } catch (e: Exception) { + logger.e("Error fetching location coordinates", e) + _uiState.update { + it.copy(isLoading = false, error = errorResponseFromException(e)) + } + } + } + } - locationJob = viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) { - try { - locationClient.getCurrentLocation().fold( - onSuccess = { location -> - val coordinates = Pair(first = location.latitude, second = location.longitude) - // storing location on shared preference - preferenceManager.saveLocationPreferences(coordinates) - // load initial data when coordinates received - performInitialDataLoading() - }, - onFailure = { e -> - _uiState.update { UIState(error = UiText.DynamicText(e.message.toString())) } + /** Refresh weather data without clearing the existing UI (pull-to-refresh). */ + fun refreshWeatherData() { + logger.d("Starting pull-to-refresh") + _uiState.update { it.copy(isRefreshing = true) } + locationJob?.cancel() + locationJob = + viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) { + val prefs = preferenceManager.getUserPreferencesFlow().first() + if (prefs.isLocationOverridden) { + logger.d("Location override active โ€” refreshing with saved location") + performInitialDataLoading(forceRefresh = true) + return@launch + } + try { + locationClient.getCurrentLocation().fold( + onSuccess = { loc -> + logger.i("Location fetched for refresh - lat: ${loc.latitude}, lon: ${loc.longitude}") + preferenceManager.saveLocationPreferences(loc.latitude to loc.longitude) + performInitialDataLoading(forceRefresh = true) + }, + onFailure = { e -> + logger.e("Location fetch failed during refresh", e) + val isGpsDisabled = + e is LocationClient.LocationException && + e.message?.contains( + "GPS is disabled", + ignoreCase = true + ) == true + val error = + if (isGpsDisabled) { + UiText.StringResource(resId = R.string.gps_disabled_error_txt) + } else if (e is Exception) { + errorResponseFromException(e) + } else { + UiText.StringResource(resId = R.string.general_error_txt) + } + _uiState.update { + it.copy( + isRefreshing = false, + error = error, + isGpsDisabled = isGpsDisabled + ) + } + }, + ) + } catch (_: CancellationException) { + logger.d("Pull-to-refresh cancelled") + } catch (e: Exception) { + logger.e("Error during pull-to-refresh", e) + _uiState.update { + it.copy(isRefreshing = false, error = errorResponseFromException(e)) + } + } + } + } + + /** Load weather and air quality data for UI. Uses override coordinates when active. */ + private fun performInitialDataLoading(forceRefresh: Boolean = false) { + logger.d("Starting initial data loading (forceRefresh=$forceRefresh)") + dataLoadingJob?.cancel() + dataLoadingJob = + viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) { + try { + val prefs = preferenceManager.getUserPreferencesFlow().first() + val isOverridden = prefs.isLocationOverridden && + prefs.overrideLat != null && prefs.overrideLon != null + val lat = if (isOverridden) prefs.overrideLat else prefs.latitude + val lon = if (isOverridden) prefs.overrideLon else prefs.longitude + val overrideName = if (isOverridden) prefs.overrideLocationName else null + + if (lat != null && lon != null) { + fetchWeatherData(lat, lon, isOverridden, overrideName, forceRefresh) + } else { + logger.w("Location coordinates not found in preferences") + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + error = UiText.StringResource(R.string.default_coordinates_txt), + ) + } } + } catch (_: CancellationException) { + logger.d("Initial data loading cancelled") + } catch (e: Exception) { + logger.e("Error in initial data loading", e) + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + error = errorResponseFromException(e) + ) + } + } + } + } + + /** Fetches weather + air quality for the given coordinates and updates [_uiState]. */ + private suspend fun fetchWeatherData( + lat: Double, + lon: Double, + isOverridden: Boolean, + overrideName: String?, + forceRefresh: Boolean, + ) { + val location = lat to lon + logger.d("Loading data for location - lat: $lat, lon: $lon, overridden: $isOverridden") + + refreshWeatherReport(location, forceRefresh) + logger.v("Refreshed weather report cache") + + getAirQuality(location.first, location.second) + .combine(getWeatherReport(location)) { air, weather -> + logger.d("Data loaded successfully") + UIState( + isLoading = false, + userLocation = location, + weatherData = weather, + airQualityData = air, + error = null, + isLocationOverridden = isOverridden, + activeLocationName = overrideName, ) - } catch (e: CancellationException) { - throw e // Rethrow cancellation exceptions + }.flowOn(dispatchers.io) + .catch { e -> + if (e is CancellationException) throw e + logger.e("Error loading weather data", e) + val error = + if (e is Exception) errorResponseFromException(e) + else UiText.StringResource(resId = R.string.general_error_txt) + _uiState.update { it.copy(isLoading = false, isRefreshing = false, error = error) } + }.collectLatest { state -> _uiState.value = state } + } + + /** Login with email and password. */ + fun login( + email: String, + password: String, + ) = launchAuth("Login", email) { + authRepository.login(email, password) + } + + /** Register with email and password. */ + fun register( + email: String, + password: String, + ) = launchAuth("Registration", email) { + authRepository.register( + email = email, + password = password, + timestampOfRegistration = deviceInfoProvider.getCurrentUtcTimestamp(), + deviceModel = deviceInfoProvider.getDeviceModel(), + operatingSystem = deviceInfoProvider.getOperatingSystem(), + osVersion = deviceInfoProvider.getOsVersion(), + appVersion = deviceInfoProvider.getAppVersion(), + registrationSource = deviceInfoProvider.getRegistrationSource(), + firebaseToken = deviceInfoProvider.getFirebaseToken(), + ) + } + + private fun launchAuth( + actionName: String, + email: String, + block: suspend () -> AuthResponse, + ) = viewModelScope.launch(dispatchers.io) { + logger.d("$actionName attempt for email: $email") + _authState.value = AuthState.Loading + try { + handleAuthResponse(block()) + } catch (e: Exception) { + if (e is CancellationException) throw e + logger.e("$actionName failed for email: $email", e) + _authState.value = + AuthState.Error(UiText.DynamicText(e.message ?: "$actionName failed")) + } + } + + /** Logout user. */ + fun logout() = + viewModelScope.launch(dispatchers.io) { + logger.d("Logout initiated") + _authState.value = AuthState.LogoutLoading + authRepository.logout().fold( + onSuccess = { + logger.i("Logout successful") + weatherRepository.clearAllData() + preferenceManager.clearAll() + _authState.value = AuthState.LoggedOut + }, + onFailure = { e -> + logger.e("Logout failed", e) + _authState.value = + AuthState.Error(UiText.DynamicText(e.message ?: "Logout failed")) + }, + ) + } + + private suspend fun silentTokenRefresh() = + withContext(dispatchers.io) { + logger.d("Starting silent token refresh") + when (val result = tokenManager.refreshToken()) { + is TokenResult.Valid -> logger.i("Token refreshed successfully") + is TokenResult.NoToken -> { + tokenManager.forceLogout() + emit(AuthEvent.Unauthorized("Your session has expired. Please log in again.")) + } + + is TokenResult.InvalidToken -> { + tokenManager.forceLogout() + emit(AuthEvent.Unauthorized("Your session has expired. Please log in again.")) + } + + is TokenResult.Error -> { + logger.e("Silent token refresh error", result.exception) + emit(AuthEvent.Unauthorized("Your session has expired. Please log in again.")) + } + } + } + + /** Called on every app foreground to sync token and premium status with the server. */ + fun refreshTokenOnForeground() = + viewModelScope.launch(dispatchers.io) { + try { + val response = authRepository.refreshToken() ?: return@launch + if (response.isSuccess()) { + val expiryMillis = response.data?.premiumExpiresAt?.let { parseIsoToMillis(it) } + val active = isPremiumActive(response.data?.premiumExpiresAt) + preferenceManager.savePremiumStatus( + isPremium = active, + expiryMillis = expiryMillis + ) + } else { + // 400 Bad Request โ€” token is invalid, force logout + tokenManager.forceLogout() + emit(AuthEvent.Unauthorized("Your session has expired. Please log in again.")) + } + } catch (_: CancellationException) { + // ignore } catch (e: Exception) { - Timber.tag(tag).e(e, "Error fetching location coordinates") - _uiState.update { it.copy(error = UiText.DynamicText(e.message.toString())) } + logger.e("Foreground token refresh error", e) + } + } + + private fun handleAuthResponse(response: AuthResponse) { + val data = + response.data + ?.takeIf { response.isSuccess() && it.token.isNotBlank() } + ?: run { + logger.w("Authentication failed - success: ${response.isSuccess()}") + _authState.value = + AuthState.Error( + UiText.DynamicText( + response.message ?: "Authentication failed" + ) + ) + return + } + + logger.i("Authentication successful") + + val premiumActive = isPremiumActive(data.premiumExpiresAt) + val expiryMillis = data.premiumExpiresAt?.let { parseIsoToMillis(it) } + + if (!premiumActive) { + viewModelScope.launch(dispatchers.io) { + preferenceManager.savePremiumStatus(isPremium = false, expiryMillis = expiryMillis) + } + _authState.value = AuthState.Success + return + } + + // Save premium status to preferences so PaymentViewModel observes the update reactively. + viewModelScope.launch(dispatchers.io) { + logger.i("User is premium, saving premium status") + val millis = + expiryMillis + ?: (Clock.System.now().toEpochMilliseconds() + 365L * 24 * 60 * 60 * 1000) + preferenceManager.savePremiumStatus(isPremium = true, expiryMillis = millis) + withContext(dispatchers.main) { + _authState.value = AuthState.Success } } } + private fun parseIsoToMillis(isoDate: String): Long? = + try { + Instant.parse(isoDate).toEpochMilliseconds() + } catch (_: Exception) { + null + } - /** - * Performs initial data loading to prepare weather and air quality data for the UI. - * - * This method: - * 1. Retrieves user location coordinates from preferences - * 2. Refreshes weather data from remote source and saves to local database - * 3. Combines air quality and weather data streams - * 4. Updates the UI state with the combined data - * - * The method handles various error cases: - * - Missing coordinates - * - Network errors - * - Data processing errors - */ - private fun performInitialDataLoading() { - // Cancel previous job if it exists - dataLoadingJob?.cancel() + /** Reset authentication state. */ + fun resetAuthState() { + logger.d("Auth state reset to Initial") + _authState.value = AuthState.Initial + } - dataLoadingJob = viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) { - try { - // Get coordinates from preference - val preferences = preferenceManager.getLocationPreferenceFlow().first() - val latitude = preferences[PreferenceManager.USER_LAT_LOCATION] - val longitude = preferences[PreferenceManager.USER_LON_LOCATION] - - if (latitude != null && longitude != null) { - val location = Pair(latitude, longitude) - - // fetch and save weather report from remote to ROOM DB - refreshWeatherReport(location) - - // zip both data streams and collect to populate on UI state data class. - // Also update UI state about user's location coordinates - getAirQuality(location.first, location.second) - .combine(getWeatherReport.invoke(location)) { air, weather -> - UIState( + // ============ Location Management ============ + + /** Load saved locations for the current user. */ + fun loadSavedLocations() { + viewModelScope.launch(dispatchers.io) { + _savedLocationsState.update { it.copy(isLoading = true, error = null) } + savedLocationsUseCase.getSavedLocations().fold( + onSuccess = { locations -> + _savedLocationsState.update { + it.copy( + isLoading = false, + locations = locations, + ) + } + }, + onFailure = { e -> + if (e !is CancellationException) { + _savedLocationsState.update { + it.copy( isLoading = false, - userLocation = location, - weatherData = weather, - airQualityData = air, - error = null + error = "Unable to load saved locations. Please try again later.", ) } - .flowOn(dispatchers.io) - .catch { e -> - if (e is CancellationException) throw e - Timber.tag(tag).e(e, "Error loading weather data") - _uiState.update { - it.copy( - isLoading = false, - error = UiText.DynamicText(e.message.toString()) - ) - } + } + }, + ) + } + } + + /** Save a new location. */ + fun saveLocation( + name: String, + lat: Double, + lon: Double, + ) { + viewModelScope.launch(dispatchers.io) { + _savedLocationsState.update { it.copy(isLoading = true, error = null) } + savedLocationsUseCase.saveLocation(name, lat, lon).fold( + onSuccess = { + _savedLocationsState.update { + it.copy( + isLoading = false, + successMessage = "Location saved successfully", + ) + } + loadSavedLocations() + }, + onFailure = { e -> + if (e !is CancellationException) { + _savedLocationsState.update { + it.copy( + isLoading = false, + error = "Unable to save location. Please try again later.", + ) } - .onEach { newState -> _uiState.update { newState } } - .launchIn(this) - } else { - // in case we don't have coordinates, update UI state with appropriate error message - _uiState.update { - UIState( - isLoading = false, - error = UiText.StringResource(R.string.default_coordinates_txt) - ) } - } - } catch (e: CancellationException) { - throw e // Rethrow cancellation exceptions + }, + ) + } + } + + /** Delete a saved location by ID. */ + fun deleteLocation(id: String) { + viewModelScope.launch(dispatchers.io) { + _savedLocationsState.update { it.copy(isLoading = true, error = null) } + savedLocationsUseCase.deleteLocation(id).fold( + onSuccess = { + _savedLocationsState.update { + it.copy( + isLoading = false, + successMessage = "Location deleted successfully", + ) + } + loadSavedLocations() + }, + onFailure = { e -> + if (e !is CancellationException) { + _savedLocationsState.update { + it.copy( + isLoading = false, + error = "Unable to delete location. Please try again later.", + ) + } + } + }, + ) + } + } + + /** Update place search query. */ + fun onPlaceSearchQueryChanged(query: String) { + _placeSearchState.update { it.copy(searchQuery = query, error = null) } + _queryFlow.value = query + if (query.length < 2) { + _placeSearchState.update { it.copy(results = emptyList(), isLoading = false) } + } + } + + /** Clear place search results. */ + fun clearPlaceSearch() { + _placeSearchState.value = PlaceSearchUiState() + _queryFlow.value = "" + } + + /** Clear location success/error messages. */ + fun clearLocationMessage() { + _savedLocationsState.update { it.copy(error = null, successMessage = null) } + } + + /** Pin a saved location as the default weather source and reload weather data. */ + fun setDefaultLocation(lat: Double, lon: Double, name: String) { + _uiState.update { UIState(isLoading = true) } + dataLoadingJob?.cancel() + dataLoadingJob = viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) { + try { + logger.i("Setting default location override: $name ($lat, $lon)") + preferenceManager.saveLocationOverride(lat, lon, name) + // Use coordinates directly โ€” avoids DataStore re-read race condition + fetchWeatherData( + lat, + lon, + isOverridden = true, + overrideName = name, + forceRefresh = true + ) + } catch (_: CancellationException) { + logger.d("setDefaultLocation cancelled") } catch (e: Exception) { - Timber.tag(tag).e(e, "Error in initial data loading") - _uiState.update { + logger.e("Error setting default location", e) + _uiState.update { it.copy( isLoading = false, - error = UiText.DynamicText(e.message.toString()) - ) + error = errorResponseFromException(e) + ) } } } } - /** - * Cleans up resources when the ViewModel is cleared. - * Cancels all active coroutine jobs to prevent memory leaks and unnecessary work. - */ + /** Clear the pinned location override and revert to live GPS. */ + fun clearLocationOverride() { + viewModelScope.launch(dispatchers.io) { + logger.i("Clearing location override โ€” reverting to GPS") + preferenceManager.clearLocationOverride() + withContext(dispatchers.main) { + fetchAndSaveLocationCoordinates() + } + } + } + + /** Fetch place suggestions for given query. */ + private suspend fun fetchPlaceSuggestions(query: String) { + _placeSearchState.update { it.copy(isLoading = true, error = null) } + searchPlacesUseCase(query).fold( + onSuccess = { suggestions -> + _placeSearchState.update { it.copy(isLoading = false, results = suggestions) } + }, + onFailure = { e -> + if (e !is CancellationException) { + _placeSearchState.update { + it.copy( + isLoading = false, + error = "Unable to fetch places. Please try again.", + ) + } + } + }, + ) + } + override fun onCleared() { super.onCleared() - // Cancel all active jobs when ViewModel is cleared + logger.d("MainViewModel cleared - cancelling all jobs") notificationBannerJob?.cancel() locationJob?.cancel() dataLoadingJob?.cancel() } } + +/** Authentication state. */ +sealed class AuthState { + object Initial : AuthState() + + object Loading : AuthState() + + object LogoutLoading : AuthState() + + object Success : AuthState() + + object LoggedOut : AuthState() + + data class Error( + val message: UiText, + ) : AuthState() +} diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/SettingsViewModel.kt b/app/src/main/java/bose/ankush/weatherify/presentation/SettingsViewModel.kt new file mode 100644 index 00000000..11c9d445 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/presentation/SettingsViewModel.kt @@ -0,0 +1,78 @@ +package bose.ankush.weatherify.presentation + +import androidx.lifecycle.ViewModel +import bose.ankush.commonui.settings.SettingsScreenState +import bose.ankush.commonui.viewmodel.ServiceSubscriptionViewModel +import bose.ankush.network.repository.ServiceRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +sealed class SettingsEvent { + data object OpenPremiumSheet : SettingsEvent() + + data object ClosePremiumSheet : SettingsEvent() + + data object OpenLogoutDialog : SettingsEvent() + + data object CloseLogoutDialog : SettingsEvent() + + data object DismissPremiumToast : SettingsEvent() + + data class OpenWebUrl( + val url: String, + ) : SettingsEvent() + + data object CloseWebView : SettingsEvent() +} + +@HiltViewModel +class SettingsViewModel +@Inject +constructor( + private val serviceRepository: ServiceRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(SettingsScreenState()) + val uiState: StateFlow = _uiState + + val serviceSubscriptionViewModel by lazy { + ServiceSubscriptionViewModel(repository = serviceRepository) + } + + fun handleEvent(event: SettingsEvent) { + when (event) { + SettingsEvent.OpenPremiumSheet -> { + _uiState.value = _uiState.value.copy(showPremiumBottomSheet = true) + } + + SettingsEvent.ClosePremiumSheet -> { + _uiState.value = _uiState.value.copy(showPremiumBottomSheet = false) + } + + SettingsEvent.OpenLogoutDialog -> { + _uiState.value = _uiState.value.copy(showLogoutDialog = true) + } + + SettingsEvent.CloseLogoutDialog -> { + _uiState.value = _uiState.value.copy(showLogoutDialog = false) + } + + SettingsEvent.DismissPremiumToast -> { + _uiState.value = _uiState.value.copy(showPremiumActivationToast = false) + } + + is SettingsEvent.OpenWebUrl -> { + _uiState.value = _uiState.value.copy(currentWebUrl = event.url) + } + + SettingsEvent.CloseWebView -> { + _uiState.value = _uiState.value.copy(currentWebUrl = null) + } + } + } + + fun showPremiumActivationToast() { + _uiState.value = _uiState.value.copy(showPremiumActivationToast = true) + } +} diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/UIState.kt b/app/src/main/java/bose/ankush/weatherify/presentation/UIState.kt index 0c2b4966..27e54b51 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/UIState.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/UIState.kt @@ -1,8 +1,8 @@ package bose.ankush.weatherify.presentation import bose.ankush.weatherify.base.common.UiText -import bose.ankush.weatherify.domain.model.WeatherForecast import bose.ankush.weatherify.domain.model.AirQuality +import bose.ankush.weatherify.domain.model.WeatherForecast /** * Data class representing the UI state for the weather application. @@ -16,8 +16,12 @@ import bose.ankush.weatherify.domain.model.AirQuality */ data class UIState( val isLoading: Boolean = false, + val isRefreshing: Boolean = false, val userLocation: Pair? = null, val weatherData: WeatherForecast? = null, val airQualityData: AirQuality? = null, - val error: UiText? = null + val error: UiText? = null, + val isGpsDisabled: Boolean = false, + val isLocationOverridden: Boolean = false, + val activeLocationName: String? = null, ) diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesListScreen.kt b/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesListScreen.kt index 12e4b33a..ec686e74 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesListScreen.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesListScreen.kt @@ -1,9 +1,19 @@ package bose.ankush.weatherify.presentation.cities -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -21,11 +31,9 @@ import bose.ankush.weatherify.presentation.home.state.ShowLoading import bose.ankush.weatherify.presentation.navigation.Screen @Composable -fun CitiesListScreen( - navController: NavController, -) { +fun CitiesListScreen(navController: NavController) { Box( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { Scaffold( topBar = { @@ -36,11 +44,11 @@ fun CitiesListScreen( }, content = { innerPadding -> Column( - modifier = Modifier.padding(innerPadding) + modifier = Modifier.padding(innerPadding), ) { CityNameSearchBarWithList(navController) } - } + }, ) } } @@ -53,34 +61,38 @@ private fun CityNameSearchBarWithList(navController: NavController) { val cityName by viewModels.cityName.collectAsState() Column( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(10.dp), ) { TextField( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)), value = searchText, onValueChange = viewModels::onSearchTextChange, placeholder = { Text(text = stringResource(id = R.string.select_city) + "...") }, - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.onSecondaryContainer, - focusedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, - focusedPlaceholderColor = MaterialTheme.colorScheme.onSecondaryContainer - ) + colors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.onSecondaryContainer, + focusedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, + focusedPlaceholderColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), ) Spacer(modifier = Modifier.height(10.dp)) if (isSearching) { ShowLoading(modifier = Modifier.fillMaxSize()) } else { LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f) + modifier = + Modifier + .fillMaxWidth() + .weight(1f), ) { items(cityName.size) { CityListItem(cityNameList = cityName, position = it) { _, name -> diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesViewModel.kt b/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesViewModel.kt index 3194b130..4dfdf036 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesViewModel.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesViewModel.kt @@ -6,14 +6,22 @@ import bose.ankush.weatherify.domain.model.CityName import bose.ankush.weatherify.domain.use_case.get_cities.GetCityNames import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import javax.inject.Inject @HiltViewModel -class CitiesViewModel @Inject constructor( - getCityNames: GetCityNames +class CitiesViewModel +@Inject +constructor( + getCityNames: GetCityNames, ) : ViewModel() { - var searchText = MutableStateFlow("") private set @@ -23,21 +31,24 @@ class CitiesViewModel @Inject constructor( private val cityNameList = MutableStateFlow(getCityNames()) @OptIn(FlowPreview::class) - val cityName: StateFlow> = searchText - .debounce(500L) - .onEach { isSearching.update { true } } - .combine(cityNameList) { text, city -> - if (text.isBlank()) city - else city.filter { it.doesMatchSearchQuery(text) } - } - .onEach { isSearching.update { false } } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - cityNameList.value - ) + val cityName: StateFlow> = + searchText + .debounce(500L) + .onEach { isSearching.update { true } } + .combine(cityNameList) { text, city -> + if (text.isBlank()) { + city + } else { + city.filter { it.doesMatchSearchQuery(text) } + } + }.onEach { isSearching.update { false } } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + cityNameList.value, + ) fun onSearchTextChange(text: String) { searchText.value = text } -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/cities/component/CityListItem.kt b/app/src/main/java/bose/ankush/weatherify/presentation/cities/component/CityListItem.kt index 700dc1b8..4f330468 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/cities/component/CityListItem.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/cities/component/CityListItem.kt @@ -6,7 +6,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -17,23 +21,25 @@ import bose.ankush.weatherify.domain.model.CityName internal fun CityListItem( cityNameList: List, position: Int, - onItemClick: (Int, String) -> Unit + onItemClick: (Int, String) -> Unit, ) { var selectedItem: Int? by remember { mutableStateOf(null) } val cityName = cityNameList[position].name ?: DEFAULT_CITY_NAME + val bgColor = + if (selectedItem != position) Color.Transparent else MaterialTheme.colorScheme.inversePrimary Text( text = cityName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier - .fillMaxWidth() - .padding(start = 13.dp, end = 16.dp) - .clickable { - selectedItem = position - onItemClick(position, cityName) - } - .background(if (selectedItem != position) Color.Transparent else MaterialTheme.colorScheme.inversePrimary) - .padding(start = 3.dp, top = 10.dp, bottom = 10.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(start = 13.dp, end = 16.dp) + .clickable { + selectedItem = position + onItemClick(position, cityName) + } + .background(bgColor) + .padding(start = 3.dp, top = 10.dp, bottom = 10.dp), ) - -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/AirQualityDetailsScreen.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/AirQualityDetailsScreen.kt deleted file mode 100644 index 5f68d6d7..00000000 --- a/app/src/main/java/bose/ankush/weatherify/presentation/home/AirQualityDetailsScreen.kt +++ /dev/null @@ -1,56 +0,0 @@ -package bose.ankush.weatherify.presentation.home - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.navigation.NavController -import bose.ankush.weatherify.R -import bose.ankush.weatherify.base.common.component.ScreenTopAppBar -import bose.ankush.weatherify.presentation.MainViewModel - -@Composable -internal fun AirQualityDetailsScreen( - viewModel: MainViewModel, - navController: NavController -) { - val userLocation by rememberSaveable { mutableStateOf(viewModel.uiState.value.userLocation) } - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() - ) { - Scaffold( - topBar = { - ScreenTopAppBar( - headlineId = R.string.air_quality, - navIconAction = { navController.popBackStack() } - ) - }, - content = { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (userLocation != null) { - Text( - text = "Your current location coordinate is: ${userLocation?.first}, ${userLocation?.second}", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - } - } - } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/HomeScreen.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/HomeScreen.kt index 77a40016..1a32f5e8 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/home/HomeScreen.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/HomeScreen.kt @@ -12,24 +12,40 @@ import androidx.compose.animation.slideInVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import bose.ankush.sunriseui.components.SunriseSunsetCombinedAnimation +import bose.ankush.commonui.components.SunriseSunsetCombinedAnimation +import bose.ankush.commonui.components.ToastAnchorState +import bose.ankush.commonui.permissions.PermissionAlertDialog import bose.ankush.weatherify.R +import bose.ankush.weatherify.base.common.Extension.openLocationSettings import bose.ankush.weatherify.base.common.UiText import bose.ankush.weatherify.presentation.MainViewModel import bose.ankush.weatherify.presentation.UIState @@ -37,6 +53,8 @@ import bose.ankush.weatherify.presentation.home.component.BriefAirQualityReportC import bose.ankush.weatherify.presentation.home.component.CurrentWeatherReportLayout import bose.ankush.weatherify.presentation.home.component.DailyWeatherForecastReportLayout import bose.ankush.weatherify.presentation.home.component.HourlyWeatherForecastReportLayout +import bose.ankush.weatherify.presentation.home.component.WeatherAlertLayout +import bose.ankush.weatherify.presentation.home.state.ErrorBackgroundAnimation import bose.ankush.weatherify.presentation.home.state.ShowError import bose.ankush.weatherify.presentation.home.state.ShowLoading import bose.ankush.weatherify.presentation.navigation.AppBottomBar @@ -45,25 +63,44 @@ import kotlinx.coroutines.delay @Composable fun HomeScreen( viewModel: MainViewModel, - navController: NavController + navController: NavController, + toastAnchorState: ToastAnchorState? = null, ) { val context: Context = LocalContext.current val uiState: UIState = viewModel.uiState.collectAsState().value + val showNotificationCard = viewModel.showNotificationCardItem.collectAsState().value + val isNotificationPermissionPermanentlyDeclined = + viewModel.isNotificationPermissionPermanentlyDeclined.collectAsState().value // reacting as per response state change when { !uiState.error?.asString(context).isNullOrEmpty() -> { // Screen error handler HandleScreenError( - context, - uiState.error + context = context, + errorText = uiState.error, + isLoading = uiState.isLoading, + isGpsDisabled = uiState.isGpsDisabled, ) { viewModel.fetchAndSaveLocationCoordinates() } } - uiState.weatherData?.current?.weather?.isNotEmpty() == true || + uiState.weatherData + ?.current + ?.weather + ?.isNotEmpty() == true || uiState.airQualityData != null -> { // Show data on UI - ShowUIContainer(uiState, navController) + ShowUIContainer( + uiState = uiState, + navController = navController, + toastAnchorState = toastAnchorState, + showNotificationCard = showNotificationCard, + isNotificationPermissionPermanentlyDeclined = isNotificationPermissionPermanentlyDeclined, + onEnableNotificationClick = { viewModel.updateNotificationPermission(true) }, + onDismissNotificationClick = { viewModel.updateShowNotificationBannerState(false) }, + onRefresh = { viewModel.refreshWeatherData() }, + onResetLocationOverride = { viewModel.clearLocationOverride() }, + ) } else -> { @@ -87,28 +124,57 @@ fun HandleScreenLoading() { fun HandleScreenError( context: Context, errorText: UiText?, - onErrorAction: () -> Unit + isLoading: Boolean = false, + isGpsDisabled: Boolean = false, + onErrorAction: () -> Unit, ) { - ShowError( - modifier = Modifier - .fillMaxSize() - .padding(all = 16.dp), - msg = errorText?.asString(context), - buttonText = stringResource(id = R.string.retry_btn_txt), - buttonAction = onErrorAction - ) + Box(modifier = Modifier.fillMaxSize()) { + ErrorBackgroundAnimation() + + ShowError( + modifier = + Modifier + .fillMaxSize() + .padding(all = 16.dp), + msg = errorText?.asString(context), + buttonText = + if (isGpsDisabled) { + stringResource(id = R.string.enable_gps_btn_txt) + } else { + stringResource(id = R.string.retry_btn_txt) + }, + isLoading = isLoading, + buttonAction = + if (isGpsDisabled) { + { context.openLocationSettings() } + } else { + onErrorAction + }, + ) + } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ShowUIContainer( uiState: UIState, - navController: NavController + navController: NavController, + toastAnchorState: ToastAnchorState? = null, + showNotificationCard: Boolean = false, + isNotificationPermissionPermanentlyDeclined: Boolean = false, + onEnableNotificationClick: () -> Unit = {}, + onDismissNotificationClick: () -> Unit = {}, + onRefresh: () -> Unit = {}, + onResetLocationOverride: () -> Unit = {}, ) { val weatherReports = uiState.weatherData val airQualityReports = uiState.airQualityData + val pullToRefreshState = rememberPullToRefreshState() + // Create transition states for animations val currentWeatherTransitionState = remember { MutableTransitionState(false) } + val alertsTransitionState = remember { MutableTransitionState(false) } val airQualityTransitionState = remember { MutableTransitionState(false) } val hourlyForecastTransitionState = remember { MutableTransitionState(false) } val dailyForecastTransitionState = remember { MutableTransitionState(false) } @@ -117,6 +183,7 @@ private fun ShowUIContainer( LaunchedEffect(weatherReports, airQualityReports) { // Reset states first currentWeatherTransitionState.targetState = false + alertsTransitionState.targetState = false airQualityTransitionState.targetState = false hourlyForecastTransitionState.targetState = false dailyForecastTransitionState.targetState = false @@ -125,13 +192,16 @@ private fun ShowUIContainer( delay(100) // Small initial delay currentWeatherTransitionState.targetState = true - delay(200) // Delay for air quality + delay(150) // Delay for alerts (prioritize showing alerts early) + alertsTransitionState.targetState = true + + delay(150) // Delay for air quality airQualityTransitionState.targetState = true - delay(300) // Delay for hourly forecast + delay(150) // Delay for hourly forecast hourlyForecastTransitionState.targetState = true - delay(400) // Delay for daily forecast + delay(150) // Delay for daily forecast dailyForecastTransitionState.targetState = true } @@ -139,99 +209,182 @@ private fun ShowUIContainer( // Add the SunriseSunsetCombinedAnimation as a full-screen background weatherReports?.current?.let { currentWeather -> SunriseSunsetCombinedAnimation( - sunriseTimestamp = currentWeather.sunrise?.toLong(), - sunsetTimestamp = currentWeather.sunset?.toLong(), - currentTimestamp = System.currentTimeMillis() / 1000 + sunriseTimestamp = currentWeather.sunrise, + sunsetTimestamp = currentWeather.sunset, + currentTimestamp = System.currentTimeMillis() / 1000, + ) + } + + if (showNotificationCard) { + PermissionAlertDialog( + descriptionText = stringResource(R.string.notification_permission_message), + isPermanentlyDeclined = isNotificationPermissionPermanentlyDeclined, + onPositiveAction = onEnableNotificationClick, + onNegativeAction = onDismissNotificationClick, + positiveButtonLabel = stringResource(R.string.enable_notification_btn), + negativeButtonLabel = stringResource(R.string.cancel_btn_txt), ) } Scaffold( containerColor = Color.Transparent, // Make the scaffold background transparent content = { innerPadding -> - LazyColumn( + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = onRefresh, + state = pullToRefreshState, modifier = Modifier.fillMaxSize(), - contentPadding = innerPadding, - verticalArrangement = Arrangement.spacedBy(8.dp), - // Add state key to prevent unnecessary recompositions - state = rememberLazyListState() ) { - // Show current weather report - prioritize loading this first - item(key = "current_weather") { - weatherReports?.current?.let { - AnimatedVisibility( - visibleState = currentWeatherTransitionState, - enter = fadeIn(animationSpec = tween(durationMillis = 500)) + - slideInVertically( - animationSpec = tween(durationMillis = 500), - initialOffsetY = { it / 3 } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = innerPadding, + verticalArrangement = Arrangement.spacedBy(8.dp), + // Add state key to prevent unnecessary recompositions + state = rememberLazyListState(), + ) { + // Show override chip when a saved location is pinned + if (uiState.isLocationOverridden && uiState.activeLocationName != null) { + item(key = "location_override_chip") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + AssistChip( + onClick = onResetLocationOverride, + label = { + Text( + text = "${uiState.activeLocationName} ยท ${ + stringResource( + R.string.location_override_reset_btn + ) + }", + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = stringResource( + R.string.location_override_chip_content_desc, + uiState.activeLocationName, + ), + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + colors = AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + leadingIconContentColor = MaterialTheme.colorScheme.onSecondaryContainer, ), - exit = fadeOut() - ) { - CurrentWeatherReportLayout( - it, - uiState.userLocation, - weatherReports.daily?.firstOrNull()?.summary - ) + ) + } } } - } - // Show brief air quality report - item(key = "air_quality") { - airQualityReports?.let { - AnimatedVisibility( - visibleState = airQualityTransitionState, - enter = fadeIn(animationSpec = tween(durationMillis = 500)) + - slideInVertically( - animationSpec = tween(durationMillis = 500), - initialOffsetY = { it / 3 } - ), - exit = fadeOut() - ) { - BriefAirQualityReportCardLayout(airQualityReports, navController) + // Show current weather report - prioritize loading this first + item(key = "current_weather") { + weatherReports?.current?.let { + AnimatedVisibility( + visibleState = currentWeatherTransitionState, + enter = + fadeIn(animationSpec = tween(durationMillis = 500)) + + slideInVertically( + animationSpec = tween(durationMillis = 500), + initialOffsetY = { it / 3 }, + ), + exit = fadeOut(), + ) { + CurrentWeatherReportLayout( + it, + uiState.userLocation, + weatherReports.daily?.firstOrNull()?.summary, + ) + } } } - } - // Show hourly weather forecast report - item(key = "hourly_forecast") { - weatherReports?.hourly?.let { - AnimatedVisibility( - visibleState = hourlyForecastTransitionState, - enter = fadeIn(animationSpec = tween(durationMillis = 500)) + - slideInVertically( - animationSpec = tween(durationMillis = 500), - initialOffsetY = { it / 3 } - ), - exit = fadeOut() - ) { - HourlyWeatherForecastReportLayout(it) + // Show weather alerts if available + item(key = "weather_alerts") { + weatherReports?.alerts?.let { alerts -> + AnimatedVisibility( + visibleState = alertsTransitionState, + enter = + fadeIn(animationSpec = tween(durationMillis = 500)) + + slideInVertically( + animationSpec = tween(durationMillis = 500), + initialOffsetY = { it / 3 }, + ), + exit = fadeOut(), + ) { + WeatherAlertLayout(alerts = alerts) + } } } - } - // Show next 8 day's weather forecast report - item(key = "daily_forecast") { - weatherReports?.daily?.let { list -> - AnimatedVisibility( - visibleState = dailyForecastTransitionState, - enter = fadeIn(animationSpec = tween(durationMillis = 500)) + - slideInVertically( - animationSpec = tween(durationMillis = 500), - initialOffsetY = { it / 3 } - ), - exit = fadeOut() - ) { - DailyWeatherForecastReportLayout(list) + // Show brief air quality report + item(key = "air_quality") { + airQualityReports?.takeIf { it.aqi > 0 }?.let { aq -> + AnimatedVisibility( + visibleState = airQualityTransitionState, + enter = + fadeIn(animationSpec = tween(durationMillis = 500)) + + slideInVertically( + animationSpec = tween(durationMillis = 500), + initialOffsetY = { it / 3 }, + ), + exit = fadeOut(), + ) { + BriefAirQualityReportCardLayout(aq) + } + } + } + + // Show hourly weather forecast report + item(key = "hourly_forecast") { + weatherReports?.hourly?.let { + AnimatedVisibility( + visibleState = hourlyForecastTransitionState, + enter = + fadeIn(animationSpec = tween(durationMillis = 500)) + + slideInVertically( + animationSpec = tween(durationMillis = 500), + initialOffsetY = { it / 3 }, + ), + exit = fadeOut(), + ) { + HourlyWeatherForecastReportLayout(it) + } + } + } + + // Show next 8 day's weather forecast report + item(key = "daily_forecast") { + weatherReports?.daily?.let { list -> + AnimatedVisibility( + visibleState = dailyForecastTransitionState, + enter = + fadeIn(animationSpec = tween(durationMillis = 500)) + + slideInVertically( + animationSpec = tween(durationMillis = 500), + initialOffsetY = { it / 3 }, + ), + exit = fadeOut(), + ) { + DailyWeatherForecastReportLayout(list) + } } } } - } - }, bottomBar = { + } // end PullToRefreshBox + }, + bottomBar = { AppBottomBar( isVisible = rememberSaveable { mutableStateOf(true) }, - navController = navController + navController = navController, + toastAnchorState = toastAnchorState, ) - }) + }, + ) } } diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/BriefAirQualityReportCardLayout.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/BriefAirQualityReportCardLayout.kt index cbd63494..f205b2bc 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/BriefAirQualityReportCardLayout.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/BriefAirQualityReportCardLayout.kt @@ -1,37 +1,62 @@ package bose.ankush.weatherify.presentation.home.component import android.annotation.SuppressLint +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.navigation.NavController -import bose.ankush.weatherify.R import bose.ankush.weatherify.base.common.AirQualityIndexAnalyser.getAQIAnalysedText import bose.ankush.weatherify.base.common.AirQualityIndexAnalyser.getFormattedAQI import bose.ankush.weatherify.domain.model.AirQuality -import bose.ankush.weatherify.presentation.navigation.Screen + +private data class AqiUiState( + val statusText: String, + val qualityColor: Color, + val formattedAqi: String, +) + +@Composable +private fun rememberAqiUiState(aqi: Int): AqiUiState = + remember(aqi) { + val (fullStatusText, _) = getAQIAnalysedText(aqi) + // Convert OpenWeatherMap 1-6 scale to EPA 0-500 scale for color mapping + val epaAqi = convertOwmAqiToEpa(aqi) + AqiUiState( + statusText = fullStatusText.split(" at").firstOrNull() ?: "", + qualityColor = getAirQualityColor(epaAqi), + formattedAqi = aqi.getFormattedAQI(), + ) + } /** * This composable is response to show air quality card on HomeScreen. @@ -39,174 +64,223 @@ import bose.ankush.weatherify.presentation.navigation.Screen */ @SuppressLint("MissingPermission") @Composable -internal fun BriefAirQualityReportCardLayout( - airQuality: AirQuality, - navController: NavController -) { - ShowUI( - aq = airQuality, - onItemClick = { navController.navigate(Screen.AirQualityDetailsScreen.route) } - ) -} - -/** - * Air quality UI composable - * This composable has onClick listener, with action to navigate to AirQualityDetailsScreen, - * and carry latitude and longitude as navigation arguments - */ -@Composable -private fun ShowUI( - aq: AirQuality, onItemClick: () -> Unit -) { - // Pre-calculate values that don't change during composition - // Use remember to cache these values based on aq.aqi - val (fullStatusText, _) = remember(aq.aqi) { getAQIAnalysedText(aq.aqi) } - val qualityColor = remember(aq.aqi) { getAirQualityColor(aq.aqi) } - val statusText = remember(fullStatusText) { fullStatusText.split(" at")[0] } - val qualityColorAlpha = remember(qualityColor) { qualityColor.copy(alpha = 0.2f) } - - // Pre-calculate pollutant values - val pm25Value = remember(aq.pm25) { "${aq.pm25.toInt()} ฮผg/mยณ" } - val coValue = remember(aq.co) { "${aq.co.toInt()} ฮผg/mยณ" } - val o3Value = remember(aq.o3) { "${aq.o3.toInt()} ฮผg/mยณ" } - - // Pre-calculate formatted AQI - val formattedAQI = remember(aq.aqi) { aq.aqi.getFormattedAQI() } +internal fun BriefAirQualityReportCardLayout(airQuality: AirQuality) { + val aqiUiState = rememberAqiUiState(airQuality.aqi) + var isExpanded by remember { mutableStateOf(false) } Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .clickable { onItemClick() }, - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) - ) + onClick = { isExpanded = !isExpanded }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .animateContentSize(), + shape = RoundedCornerShape(24.dp), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { - // Air Quality Status Indicator - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Box( - modifier = Modifier - .size(16.dp) - .clip(CircleShape) - .background(qualityColor) - ) - - Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = "Air Quality", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium - ) - } - + AqiSummary(aqiUiState = aqiUiState, isExpanded = isExpanded) Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider( + Modifier, + DividerDefaults.Thickness, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + ) + Spacer(modifier = Modifier.height(16.dp)) + if (isExpanded) { + ExpandedPollutantsDetails(airQuality = airQuality) + } else { + KeyPollutants(airQuality = airQuality) + } + } + } +} - // AQI Value and Status - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - text = formattedAQI, - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - - Text( - text = "AQI", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - } +@Composable +private fun AqiSummary( + aqiUiState: AqiUiState, + isExpanded: Boolean, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = + Modifier + .size(72.dp) + .background(aqiUiState.qualityColor, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = aqiUiState.formattedAqi, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = contentColorFor(backgroundColor = aqiUiState.qualityColor), + ) + } - Surface( - color = qualityColorAlpha, - shape = RoundedCornerShape(8.dp) - ) { - Text( - text = statusText, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = qualityColor, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) - ) - } - } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "Air Quality", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = aqiUiState.statusText, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = if (isExpanded) "Collapse" else "Expand", + modifier = Modifier.rotate(if (isExpanded) 180f else 0f), + ) + } +} - Spacer(modifier = Modifier.height(16.dp)) +@Composable +private fun KeyPollutants(airQuality: AirQuality) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + ) { + PollutantItem(name = "PM2.5", value = airQuality.pm25.toInt().toString()) + PollutantItem(name = "CO", value = airQuality.co.toInt().toString()) + PollutantItem(name = "Oโ‚ƒ", value = airQuality.o3.toInt().toString()) + } + } +} - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - PollutantItem(name = "PM2.5", value = pm25Value) - PollutantItem(name = "CO", value = coValue) - PollutantItem(name = "Oโ‚ƒ", value = o3Value) - } +@Composable +fun ExpandedPollutantsDetails(airQuality: AirQuality) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PollutantItem( + modifier = Modifier.weight(1f), + name = "CO", + value = airQuality.co.toInt().toString(), + ) + PollutantItem( + modifier = Modifier.weight(1f), + name = "NOโ‚‚", + value = airQuality.no2.toInt().toString(), + ) } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PollutantItem( + modifier = Modifier.weight(1f), + name = "Oโ‚ƒ", + value = airQuality.o3.toInt().toString(), + ) + PollutantItem( + modifier = Modifier.weight(1f), + name = "SOโ‚‚", + value = airQuality.so2.toInt().toString(), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PollutantItem( + modifier = Modifier.weight(1f), + name = "PM10", + value = airQuality.pm10.toInt().toString(), + ) + PollutantItem( + modifier = Modifier.weight(1f), + name = "PM2.5", + value = airQuality.pm25.toInt().toString(), + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Concentration in ฮผg/mยณ", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) } } +/** + * Converts OpenWeatherMap AQI scale (1-6) to EPA AQI scale (0-500) + * + * OWM Scale: + * - 1: Good + * - 2: Fair + * - 3: Moderate + * - 4: Poor + * - 5: Very Poor + * - 6: Extreme + * + * EPA Scale: + * - 0-50: Good (Green) + * - 51-100: Moderate (Yellow) + * - 101-150: Unhealthy for Sensitive Groups (Orange) + * - 151-200: Unhealthy (Red) + * - 201-300: Very Unhealthy (Purple) + * - 301+: Hazardous (Dark Red) + */ +private fun convertOwmAqiToEpa(owmAqi: Int): Int = + when (owmAqi) { + 1 -> 25 // Good + 2 -> 75 // Fair -> Moderate + 3 -> 125 // Moderate -> Unhealthy for Sensitive Groups + 4 -> 175 // Poor -> Unhealthy + 5 -> 250 // Very Poor -> Very Unhealthy + 6 -> 425 // Extreme -> Hazardous + else -> owmAqi.coerceIn(0, 500) // Fallback for invalid values + } + /** * Returns a color based on the air quality index value * Not a composable function since it doesn't use any composable functions */ -private fun getAirQualityColor(aqi: Int): Color { - return when (aqi) { - in 0..50 -> Color(0xFF4CAF50) // Good - Green - in 51..100 -> Color(0xFFFFEB3B) // Moderate - Yellow - in 101..150 -> Color(0xFFFF9800) // Unhealthy for sensitive groups - Orange - in 151..200 -> Color(0xFFE53935) // Unhealthy - Red - in 201..300 -> Color(0xFF9C27B0) // Very Unhealthy - Purple - else -> Color(0xFF7E0023) // Hazardous - Dark Red +private fun getAirQualityColor(aqi: Int): Color = + when (aqi) { + in 0..50 -> Color(0xFF4CAF50) // Good - Green + in 51..100 -> Color(0xFFFFEB3B) // Moderate - Yellow + in 101..150 -> Color(0xFFFF9800) // Unhealthy for sensitive groups - Orange + in 151..200 -> Color(0xFFE53935) // Unhealthy - Red + in 201..300 -> Color(0xFF9C27B0) // Very Unhealthy - Purple + else -> Color(0xFF7E0023) // Hazardous - Dark Red } -} /** * Displays a single pollutant item with name and value - * Optimized to use Box instead of Surface for better performance */ @Composable -private fun PollutantItem(name: String, value: String) { +private fun PollutantItem( + name: String, + value: String, + modifier: Modifier = Modifier, +) { Column( - horizontalAlignment = Alignment.CenterHorizontally + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - // Use Box instead of Surface for better performance - Box( - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Text( - text = name, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - Text( text = value, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = name, + style = MaterialTheme.typography.bodySmall, // smaller for de-emphasis + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/CurrentWeatherReportLayout.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/CurrentWeatherReportLayout.kt index 70844f24..2754b060 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/CurrentWeatherReportLayout.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/CurrentWeatherReportLayout.kt @@ -1,5 +1,6 @@ package bose.ankush.weatherify.presentation.home.component +import android.annotation.SuppressLint import android.location.Geocoder import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -45,6 +46,7 @@ import bose.ankush.weatherify.domain.model.WeatherForecast import coil.compose.AsyncImage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import timber.log.Timber import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -53,21 +55,24 @@ import java.util.Locale internal fun CurrentWeatherReportLayout( currentWeather: WeatherForecast.Current, userLocation: Pair? = null, - summary: String? = null + summary: String? = null, ) { Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp), + ), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { // Location and date @@ -94,21 +99,23 @@ internal fun CurrentWeatherReportLayout( Spacer(modifier = Modifier.height(16.dp)) Card( shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp) - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp), + ), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = "Today's Forecast", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(8.dp)) @@ -118,7 +125,7 @@ internal fun CurrentWeatherReportLayout( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } @@ -131,7 +138,7 @@ internal fun CurrentWeatherReportLayout( @Composable private fun LocationAndDateHeader( currentWeather: WeatherForecast.Current, - userLocation: Pair? = null + userLocation: Pair? = null, ) { val context = LocalContext.current // Use remember to avoid recreating the state on each recomposition @@ -142,52 +149,55 @@ private fun LocationAndDateHeader( if (userLocation != null) { try { // Use IO dispatcher for background processing - val result = withContext(Dispatchers.IO) { - val geocoder = Geocoder(context, Locale.getDefault()) - - @Suppress("DEPRECATION") - val addresses = geocoder.getFromLocation( - userLocation.first, - userLocation.second, - 1 - ) - - if (!addresses.isNullOrEmpty()) { - val address = addresses.firstOrNull() - val cityName = address?.locality ?: address?.subAdminArea - val countryName = address?.countryName - - when { - cityName != null -> "$cityName, $countryName" - else -> countryName ?: "Current Location" + val result = + withContext(Dispatchers.IO) { + val geocoder = Geocoder(context, Locale.getDefault()) + + @Suppress("DEPRECATION") + val addresses = + geocoder.getFromLocation( + userLocation.first, + userLocation.second, + 1, + ) + + if (!addresses.isNullOrEmpty()) { + val address = addresses.firstOrNull() + val cityName = address?.locality ?: address?.subAdminArea + val countryName = address?.countryName + + when { + cityName != null -> "$cityName, $countryName" + else -> countryName ?: "Current Location" + } + } else { + "Current Location" } - } else { - "Current Location" } - } // Update state only once after background processing is complete locationName = result } catch (e: Exception) { // If geocoding fails, keep the default "Current Location" - e.printStackTrace() + Timber.e(e, "Geocoding failed; using default location label") } } } // Pre-calculate the formatted date to avoid doing it during composition - val formattedDate = remember(currentWeather.dt) { - DateTimeUtils.getFormattedDateTimeFromEpoch(currentWeather.dt) - } + val formattedDate = + remember(currentWeather.dt) { + DateTimeUtils.getFormattedDateTimeFromEpoch(currentWeather.dt) + } Column( - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { // Display the location name Text( text = locationName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(4.dp)) @@ -205,38 +215,41 @@ private fun LocationAndDateHeader( private fun CurrentWeatherVisualization(currentWeather: WeatherForecast.Current) { // Cache the first weather condition to avoid multiple get(0) calls and potential crashes val firstWeather = currentWeather.weather?.firstOrNull() - val weatherDescription = (firstWeather?.description ?: stringResource(id = R.string.not_available)) - .formatTextCapitalization() + val weatherDescription = + (firstWeather?.description ?: stringResource(id = R.string.not_available)) + .formatTextCapitalization() val weatherIconUrl = firstWeather?.icon?.getIconUrl() Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { // Temperature display Column( horizontalAlignment = Alignment.Start, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text( - text = stringResource( - id = R.string.degree, - currentWeather.temp?.toCelsius() ?: stringResource(id = R.string.not_available) - ), + text = + stringResource( + id = R.string.degree, + currentWeather.temp?.toCelsius() + ?: stringResource(id = R.string.not_available), + ), style = MaterialTheme.typography.displayLarge, fontSize = 80.sp, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Row( - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( text = "Feels like ${currentWeather.feels_like?.toCelsius()}ยฐ", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), ) } } @@ -244,20 +257,21 @@ private fun CurrentWeatherVisualization(currentWeather: WeatherForecast.Current) // Weather icon and description Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Surface( shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), - modifier = Modifier.size(100.dp) + modifier = Modifier.size(100.dp), ) { AsyncImage( model = weatherIconUrl, placeholder = painterResource(id = R.drawable.ic_sunny), contentDescription = stringResource(id = R.string.weather_icon_content), - modifier = Modifier - .padding(16.dp) - .size(64.dp) + modifier = + Modifier + .padding(16.dp) + .size(64.dp), ) } @@ -268,7 +282,7 @@ private fun CurrentWeatherVisualization(currentWeather: WeatherForecast.Current) style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } @@ -277,37 +291,39 @@ private fun CurrentWeatherVisualization(currentWeather: WeatherForecast.Current) @Composable private fun WeatherMetricsGrid(weatherData: WeatherForecast.Current) { // First row metrics - val firstRowMetrics = listOf( - WeatherMetric( - icon = R.drawable.ic_humidity, - value = "${weatherData.humidity}%", - label = "Humidity" - ), - WeatherMetric( - icon = R.drawable.ic_wind, - value = "${weatherData.wind_speed} m/s", - label = "Wind" - ), - WeatherMetric( - icon = R.drawable.ic_uv, - value = "${weatherData.uvi}", - label = "UV Index" + val firstRowMetrics = + listOf( + WeatherMetric( + icon = R.drawable.ic_humidity, + value = "${weatherData.humidity}%", + label = "Humidity", + ), + WeatherMetric( + icon = R.drawable.ic_wind, + value = "${weatherData.wind_speed} m/s", + label = "Wind", + ), + WeatherMetric( + icon = R.drawable.ic_uv, + value = "${weatherData.uvi}", + label = "UV Index", + ), ) - ) // Second row metrics - val secondRowMetrics = mutableListOf( - WeatherMetric( - icon = R.drawable.ic_humidity, // Using humidity icon for pressure as it's more appropriate than sunny - value = "${weatherData.pressure} hPa", - label = "Pressure" - ), - WeatherMetric( - icon = R.drawable.ic_humidity, // Using humidity icon for clouds as it's more appropriate than sunny - value = "${weatherData.clouds}%", - label = "Clouds" + val secondRowMetrics = + mutableListOf( + WeatherMetric( + icon = R.drawable.ic_humidity, // Using humidity icon for pressure as it's more appropriate than sunny + value = "${weatherData.pressure} hPa", + label = "Pressure", + ), + WeatherMetric( + icon = R.drawable.ic_humidity, // Using humidity icon for clouds as it's more appropriate than sunny + value = "${weatherData.clouds}%", + label = "Clouds", + ), ) - ) // Add wind gust if available if (weatherData.wind_gust != null) { @@ -315,8 +331,8 @@ private fun WeatherMetricsGrid(weatherData: WeatherForecast.Current) { WeatherMetric( icon = R.drawable.ic_wind, value = "${weatherData.wind_gust} m/s", - label = "Wind Gust" - ) + label = "Wind Gust", + ), ) } @@ -333,23 +349,23 @@ private fun WeatherMetricItem( icon: Int, value: String, label: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, - modifier = modifier.padding(horizontal = 4.dp) + modifier = modifier.padding(horizontal = 4.dp), ) { Surface( shape = CircleShape, color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.size(40.dp) + modifier = Modifier.size(40.dp), ) { Icon( painter = painterResource(id = icon), contentDescription = label, tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.padding(8.dp) + modifier = Modifier.padding(8.dp), ) } @@ -359,13 +375,13 @@ private fun WeatherMetricItem( text = value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Text( text = label, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), ) } } @@ -374,78 +390,87 @@ private fun WeatherMetricItem( private fun SunriseSunsetInfo(weatherData: WeatherForecast.Current) { Card( shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp) - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp), + ), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { // Sunrise Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text( text = "Sunrise", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), ) Text( - text = formatTimeWithAmPm( - weatherData.sunrise, - true - ), // Force AM for sunrise + text = + formatTimeWithAmPm( + weatherData.sunrise, + true, + ), + // Force AM for sunrise style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) } // Divider Box( - modifier = Modifier - .height(40.dp) - .width(1.dp) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) + modifier = + Modifier + .height(40.dp) + .width(1.dp) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)), ) // Sunset Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text( text = "Sunset", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), ) Text( text = formatTimeWithAmPm(weatherData.sunset, false), // Force PM for sunset style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) } } } } +@SuppressLint("ConstantLocale") private val hourMinuteFormatter = SimpleDateFormat("h:mm", Locale.getDefault()) @Composable -private fun formatTimeWithAmPm(timestamp: Int?, isSunrise: Boolean): String { +private fun formatTimeWithAmPm( + timestamp: Long?, + isSunrise: Boolean, +): String { if (timestamp == null) return "N/A" // Use remember to cache the formatted time based on the timestamp and isSunrise flag return remember(timestamp, isSunrise) { - val date = Date(timestamp.toLong() * 1000) + val date = Date(timestamp * 1000) val timeWithoutAmPm = hourMinuteFormatter.format(date) // Force AM for sunrise, PM for sunset @@ -461,24 +486,24 @@ private fun formatTimeWithAmPm(timestamp: Int?, isSunrise: Boolean): String { private data class WeatherMetric( val icon: Int, val value: String, - val label: String + val label: String, ) @Composable private fun MetricsRow( metrics: List, - fillEmptySpace: Boolean = false + fillEmptySpace: Boolean = false, ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, ) { metrics.forEach { metric -> WeatherMetricItem( icon = metric.icon, value = metric.value, label = metric.label, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/DailyWeatherForecastReportLayout.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/DailyWeatherForecastReportLayout.kt index 8daf5c85..300cb49c 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/DailyWeatherForecastReportLayout.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/DailyWeatherForecastReportLayout.kt @@ -11,8 +11,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import bose.ankush.sunriseui.components.AnimatedWeatherIcon -import bose.ankush.sunriseui.components.WeatherDayCard +import bose.ankush.commonui.components.AnimatedWeatherIcon +import bose.ankush.commonui.components.WeatherDayCard import bose.ankush.weatherify.R import bose.ankush.weatherify.base.DateTimeUtils.dayName import bose.ankush.weatherify.base.common.Extension.toCelsius @@ -27,20 +27,21 @@ internal fun DailyWeatherForecastReportLayout(list: List if (list.isNotEmpty()) { Column( horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Text( text = stringResource(id = R.string.daily_forecast_heading_txt), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp), ) // Use Column instead of LazyColumn to avoid nested scrollable containers Column( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { list.forEachIndexed { index, _ -> DailyWeatherForecastItem(list, index) @@ -56,7 +57,10 @@ internal fun DailyWeatherForecastReportLayout(list: List * Uses the WeatherDayCard from the sunriseui module. */ @Composable -internal fun DailyWeatherForecastItem(list: List, item: Int) { +internal fun DailyWeatherForecastItem( + list: List, + item: Int, +) { val dayName = list[item]?.dt?.dayName() ?: stringResource(id = R.string.not_available) val minTemperature = "${list[item]?.temp?.min?.toCelsius()}ยฐ" val maxTemperature = "${list[item]?.temp?.max?.toCelsius()}ยฐ" @@ -72,8 +76,8 @@ internal fun DailyWeatherForecastItem(list: List, item: iconContent = { AnimatedWeatherIcon( weatherDescription = weatherDescription, - modifier = Modifier.padding(4.dp) + modifier = Modifier.padding(4.dp), ) - } + }, ) } diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/HourlyWeatherForecastReportLayout.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/HourlyWeatherForecastReportLayout.kt index 623b7421..6647aa17 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/HourlyWeatherForecastReportLayout.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/HourlyWeatherForecastReportLayout.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import bose.ankush.sunriseui.components.WeatherHourCard +import bose.ankush.commonui.components.WeatherHourCard import bose.ankush.weatherify.R import bose.ankush.weatherify.base.DateTimeUtils.toFormattedTime import bose.ankush.weatherify.base.common.Extension.formatTextCapitalization @@ -36,38 +36,42 @@ import bose.ankush.weatherify.domain.model.WeatherForecast import coil.compose.AsyncImage @Composable -internal fun HourlyWeatherForecastReportLayout( - hourlyWeatherForecasts: List -) { +internal fun HourlyWeatherForecastReportLayout(hourlyWeatherForecasts: List) { if (hourlyWeatherForecasts.isNotEmpty()) { Column( horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Text( text = stringResource(id = R.string.hourly_forecast_heading_txt), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp), ) Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp), + ), ) { Box( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), ) { - FutureForecastListItem(hourlyWeatherForecasts) { /* Item click action will be implemented in future */ } + FutureForecastListItem(hourlyWeatherForecasts) { + // Item click action will be implemented in future + } } } } @@ -76,42 +80,45 @@ internal fun HourlyWeatherForecastReportLayout( } } - @Composable private fun FutureForecastListItem( weatherForecast: List, - onItemClick: (Int) -> Unit + onItemClick: (Int) -> Unit, ) { var selectedItem by remember { mutableStateOf(0) } // Limit the number of items to display for better performance - val limitedForecast = remember(weatherForecast) { - weatherForecast.take(24) // Show only 24 hours - } + val limitedForecast = + remember(weatherForecast) { + weatherForecast.take(24) // Show only 24 hours + } LazyRow( - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp, end = 8.dp, top = 16.dp), - state = rememberLazyListState() // Add state to prevent unnecessary recompositions + modifier = + Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, top = 16.dp), + state = rememberLazyListState(), // Add state to prevent unnecessary recompositions ) { items( items = limitedForecast, - key = { item -> item?.dt ?: 0 } // Use unique key for each item + key = { item -> item?.dt ?: 0 }, // Use unique key for each item ) { item -> val index = limitedForecast.indexOf(item) val isSelected = selectedItem == index val time = item?.dt?.toFormattedTime() ?: stringResource(id = R.string.not_available) - val temperature = stringResource( - id = R.string.celsius, - item?.temp?.toCelsius() ?: stringResource(id = R.string.not_available) - ) + val temperature = + stringResource( + id = R.string.celsius, + item?.temp?.toCelsius() ?: stringResource(id = R.string.not_available), + ) val firstWeather = item?.weather?.firstOrNull() val description = (firstWeather?.description ?: stringResource(id = R.string.not_available)) - .wrapText().formatTextCapitalization() + .wrapText() + .formatTextCapitalization() val weatherIconUrl = firstWeather?.icon?.getIconUrl() WeatherHourCard( @@ -130,7 +137,7 @@ private fun FutureForecastListItem( error = painterResource(id = R.drawable.ic_sunny), contentDescription = stringResource(id = R.string.weather_icon_content), ) - } + }, ) } } diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/WeatherAlertLayout.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/WeatherAlertLayout.kt new file mode 100644 index 00000000..0697c0c3 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/WeatherAlertLayout.kt @@ -0,0 +1,36 @@ +package bose.ankush.weatherify.presentation.home.component + +import androidx.compose.runtime.Composable +import bose.ankush.commonui.components.WeatherAlertCard +import bose.ankush.weatherify.domain.model.WeatherForecast + +/** + * This composable is responsible for displaying weather alerts on the HomeScreen. + * It handles the case when there are no alerts by not rendering anything. + * + * @param alerts The list of alerts from the WeatherForecast data + * @param onReadMoreClick Optional callback for when the "Read More" button is clicked + */ +@Composable +fun WeatherAlertLayout( + alerts: List?, + onReadMoreClick: (() -> Unit)? = null, +) { + // If the alerts list is null or empty, don't render anything + if (alerts.isNullOrEmpty()) { + return + } + + // Get the first alert (most recent/important) + val firstAlert = alerts.firstOrNull() ?: return + + // Render the alert card + WeatherAlertCard( + title = firstAlert.event, + description = firstAlert.description, + startTime = firstAlert.start, + endTime = firstAlert.end, + source = firstAlert.sender_name, + onReadMoreClick = onReadMoreClick, + ) +} diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/state/ErrorComponent.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/state/ErrorComponent.kt index 5a5c42d2..e3b97396 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/home/state/ErrorComponent.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/state/ErrorComponent.kt @@ -1,12 +1,18 @@ package bose.ankush.weatherify.presentation.home.state +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -20,47 +26,93 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import bose.ankush.weatherify.R +/** + * Creates a simple background for the error screen + */ +@Composable +fun ErrorBackgroundAnimation() { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) +} + +/** + * Displays an error message with a retry button + * + * @param modifier Modifier for the container + * @param msg Error message to display + * @param buttonText Text for the retry button + * @param isLoading Whether the retry operation is in progress + * @param buttonAction Action to perform when the retry button is clicked + */ @Composable fun ShowError( modifier: Modifier, msg: String?, - buttonText: String = stringResource(id = R.string.go_back), - buttonAction: () -> Unit + buttonText: String = stringResource(id = R.string.retry_btn_txt), + isLoading: Boolean = false, + buttonAction: () -> Unit, ) { Box( modifier = modifier, - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(horizontal = 24.dp), ) { + // Error icon Icon( painter = painterResource(id = R.drawable.ic_error), contentDescription = stringResource(id = R.string.error_icon_content), - modifier = Modifier.size(36.dp), - tint = MaterialTheme.colorScheme.error + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.error, ) + + Spacer(modifier = Modifier.padding(top = 16.dp)) + + // Main error message Text( text = msg ?: stringResource(id = R.string.general_error_txt), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 16.dp) ) + + Spacer(modifier = Modifier.padding(top = 8.dp)) + + // Retry button Button( onClick = buttonAction, - colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), modifier = Modifier.padding(top = 16.dp), - elevation = ButtonDefaults.buttonElevation( - disabledElevation = 0.dp, - defaultElevation = 30.dp, - pressedElevation = 10.dp - ) + enabled = !isLoading, ) { - Text(text = buttonText, color = MaterialTheme.colorScheme.onError) + if (isLoading) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.onError, + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = buttonText) + } + } else { + Text(text = buttonText) + } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/state/LoadingComponent.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/state/LoadingComponent.kt index bfe064c9..409cee5c 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/home/state/LoadingComponent.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/state/LoadingComponent.kt @@ -10,15 +10,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable -fun ShowLoading( - modifier: Modifier -) { +fun ShowLoading(modifier: Modifier) { Box(modifier = modifier) { CircularProgressIndicator( - modifier = Modifier - .size(26.dp) - .align(Alignment.Center), - color = MaterialTheme.colorScheme.primary + modifier = + Modifier + .size(26.dp) + .align(Alignment.Center), + color = MaterialTheme.colorScheme.primary, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppBottomBar.kt b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppBottomBar.kt index 7c8d9b7a..d349bf1e 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppBottomBar.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppBottomBar.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar @@ -17,8 +19,6 @@ import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow @@ -30,65 +30,78 @@ import androidx.navigation.NavController import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.currentBackStackEntryAsState +import bose.ankush.commonui.components.ToastAnchorState +import bose.ankush.commonui.components.toastAnchor import bose.ankush.weatherify.R @Composable fun AppBottomBar( isVisible: MutableState, - navController: NavController + navController: NavController, + toastAnchorState: ToastAnchorState? = null, ) { val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination - val selectedItem = remember { mutableIntStateOf(0) } - val screenItems = listOf( - Screen.HomeNestedNav, - Screen.ProfileNestedNav - ) + val screenItems = + listOf( + Screen.HomeNestedNav, + Screen.SavedLocationsNestedNav, + Screen.ProfileNestedNav, + ) AnimatedVisibility( + modifier = if (toastAnchorState != null) Modifier.toastAnchor(toastAnchorState) else Modifier, visible = isVisible.value, enter = slideInVertically(initialOffsetY = { it }), exit = slideOutVertically(targetOffsetY = { it }), ) { // Enhanced Glassmorphic Navigation Bar NavigationBar( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)) - .height(64.dp) - .shadow( - elevation = 6.dp, - shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), - spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) - ) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp).copy(alpha = 0.8f) - ) - .border( - width = 0.5.dp, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), - shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) - ), - containerColor = Color.Transparent // Make the container transparent to show our custom background + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)) + .height(64.dp) + .shadow( + elevation = 6.dp, + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + ) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp).copy(alpha = 0.8f), + ) + .border( + width = 0.5.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + ), + containerColor = Color.Transparent, // Make the container transparent to show our custom background ) { - screenItems.forEachIndexed { index, screen -> + screenItems.forEachIndexed { _, screen -> NavigationBarItem( icon = { when (screen.resourceId) { - R.string.home_nested_nav -> Icon( - painter = painterResource(id = R.drawable.ic_home), - contentDescription = stringResource(id = screen.resourceId) - ) + R.string.home_nested_nav -> + Icon( + painter = painterResource(id = R.drawable.ic_home), + contentDescription = stringResource(id = screen.resourceId), + ) + + R.string.saved_locations_nested_nav -> + Icon( + imageVector = Icons.Outlined.BookmarkBorder, + contentDescription = stringResource(id = R.string.saved_locations_icon_content), + ) - R.string.profile_nested_nav -> Icon( - painter = painterResource(id = R.drawable.ic_profile), - contentDescription = stringResource(id = screen.resourceId) - ) + R.string.profile_nested_nav -> + Icon( + painter = painterResource(id = R.drawable.ic_profile), + contentDescription = stringResource(id = screen.resourceId), + ) } }, selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, onClick = { - selectedItem.intValue = index navController.navigate(screen.route) { popUpTo(navController.graph.findStartDestination().id) { saveState = true @@ -97,11 +110,12 @@ fun AppBottomBar( restoreState = true } }, - colors = NavigationBarItemDefaults.colors( - indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f), - selectedIconColor = MaterialTheme.colorScheme.primary, - unselectedIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) + colors = + NavigationBarItemDefaults.colors( + indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f), + selectedIconColor = MaterialTheme.colorScheme.primary, + unselectedIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ), ) } } diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppNavigation.kt b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppNavigation.kt index c4a5d556..5fa4c9eb 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppNavigation.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppNavigation.kt @@ -1,50 +1,72 @@ package bose.ankush.weatherify.presentation.navigation import android.annotation.SuppressLint +import android.widget.Toast import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import androidx.navigation.navigation +import bose.ankush.commonui.components.ToastAnchorState +import bose.ankush.commonui.locations.SavedLocationsScreen +import bose.ankush.commonui.locations.SavedLocationsStrings +import bose.ankush.commonui.settings.SettingsScreen +import bose.ankush.commonui.settings.SettingsScreenStrings import bose.ankush.language.presentation.LanguageScreen -import bose.ankush.weatherify.base.common.Extension.callNumber +import bose.ankush.payment.presentation.PaymentViewModel +import bose.ankush.weatherify.BuildConfig +import bose.ankush.weatherify.R +import bose.ankush.weatherify.base.LocaleConfigMapper import bose.ankush.weatherify.base.common.Extension.hasNotificationPermission import bose.ankush.weatherify.base.common.Extension.isDeviceSDKAndroid13OrAbove import bose.ankush.weatherify.base.common.Extension.openAppLocaleSettings +import bose.ankush.weatherify.presentation.AuthState import bose.ankush.weatherify.presentation.MainViewModel +import bose.ankush.weatherify.presentation.SettingsEvent +import bose.ankush.weatherify.presentation.SettingsViewModel import bose.ankush.weatherify.presentation.cities.CitiesListScreen -import bose.ankush.weatherify.presentation.home.AirQualityDetailsScreen import bose.ankush.weatherify.presentation.home.HomeScreen -import bose.ankush.weatherify.presentation.settings.SettingsScreen const val LANGUAGE_ARGUMENT_KEY = "country_config" @SuppressLint("NewApi") @ExperimentalAnimationApi @Composable -fun AppNavigation(viewModel: MainViewModel) { +fun AppNavigation( + viewModel: MainViewModel, + paymentViewModel: PaymentViewModel, + toastAnchorState: ToastAnchorState? = null, +) { val navController = rememberNavController() val context = LocalContext.current NavHost( navController = navController, - startDestination = Screen.HomeNestedNav.route + startDestination = Screen.HomeNestedNav.route, ) { - /*Home Screens*/ + // Home Screens navigation( startDestination = Screen.HomeScreen.route, - route = Screen.HomeNestedNav.route + route = Screen.HomeNestedNav.route, ) { composable( route = Screen.HomeScreen.route, ) { HomeScreen( viewModel = viewModel, - navController = navController + navController = navController, + toastAnchorState = toastAnchorState, ) } composable( @@ -52,78 +74,184 @@ fun AppNavigation(viewModel: MainViewModel) { enterTransition = { slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Down, - animationSpec = tween(500) + animationSpec = tween(500), ) }, popEnterTransition = { slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Down, - animationSpec = tween(500) + animationSpec = tween(500), ) }, exitTransition = { slideOutOfContainer( towards = AnimatedContentTransitionScope.SlideDirection.Up, - animationSpec = tween(500) + animationSpec = tween(500), ) }, popExitTransition = { slideOutOfContainer( towards = AnimatedContentTransitionScope.SlideDirection.Up, - animationSpec = tween(500) + animationSpec = tween(500), ) }, ) { CitiesListScreen(navController = navController) } - composable( - route = Screen.AirQualityDetailsScreen.route, - enterTransition = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(500) - ) - }, - popEnterTransition = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(500) - ) - }, - exitTransition = { - slideOutOfContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(500) - ) - }, - popExitTransition = { - slideOutOfContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(500) - ) - } - ) { - AirQualityDetailsScreen( - viewModel = viewModel, - navController = navController + } + + // Saved Locations (Premium Feature) + navigation( + startDestination = Screen.SavedLocationsScreen.route, + route = Screen.SavedLocationsNestedNav.route, + ) { + composable(route = Screen.SavedLocationsScreen.route) { + val locationsState = viewModel.savedLocationsState.collectAsState().value + val searchState = viewModel.placeSearchState.collectAsState().value + val noResultsTemplate = stringResource(R.string.place_search_no_results) + val setAsDefaultBodyTemplate = stringResource(R.string.set_as_default_dialog_body) + + SavedLocationsScreen( + locationsState = locationsState, + searchState = searchState, + onQueryChanged = { query -> viewModel.onPlaceSearchQueryChanged(query) }, + onClearSearch = { viewModel.clearPlaceSearch() }, + onSaveLocation = { name, lat, lon -> viewModel.saveLocation(name, lat, lon) }, + onDeleteLocation = { id -> viewModel.deleteLocation(id) }, + onLocationSelected = { location -> + viewModel.setDefaultLocation(location.lat, location.lon, location.name) + }, + onMessageShown = { viewModel.clearLocationMessage() }, + strings = + SavedLocationsStrings( + title = stringResource(R.string.saved_locations_title), + premiumTitle = stringResource(R.string.saved_locations_premium_title), + premiumDesc = stringResource(R.string.saved_locations_premium_desc), + emptyText = stringResource(R.string.saved_locations_empty_txt), + searchHint = stringResource(R.string.place_search_hint), + searchDialogTitle = stringResource(R.string.place_search_dialog_title), + noResults = { query -> noResultsTemplate.replace("%1\$s", query) }, + deleteContentDesc = stringResource(R.string.delete_icon_content), + addContentDesc = stringResource(R.string.add_icon_content), + cancelBtn = stringResource(R.string.cancel_btn_txt), + saveSuccessMsg = stringResource(R.string.saved_locations_save_success), + deleteSuccessMsg = stringResource(R.string.saved_locations_delete_success), + setAsDefaultDialogTitle = stringResource(R.string.set_as_default_dialog_title), + setAsDefaultDialogBody = { name -> + setAsDefaultBodyTemplate.replace("%1\$s", name) + }, + setAsDefaultDialogWarning = stringResource(R.string.set_as_default_dialog_warning), + setAsDefaultConfirmBtn = stringResource(R.string.set_as_default_confirm_btn), + ), + bottomBar = { + AppBottomBar( + isVisible = rememberSaveable { mutableStateOf(true) }, + navController = navController, + toastAnchorState = toastAnchorState, + ) + }, ) } } - /*Account/Profile Screens*/ + // Account/Profile Screens navigation( startDestination = Screen.SettingsScreen.route, - route = Screen.ProfileNestedNav.route + route = Screen.ProfileNestedNav.route, ) { composable( route = Screen.SettingsScreen.route, ) { + val authState = viewModel.authState.collectAsState().value + val paymentUiState = paymentViewModel.uiState.collectAsState().value + val localeErrorMessage = stringResource(R.string.locale_config_error_txt) + val showLocaleError = remember { mutableStateOf(false) } + val languageList = + remember(context) { + try { + LocaleConfigMapper.getAvailableLanguagesFromJson( + jsonFile = "countryConfig.json", + context = context, + ) + } catch (_: Exception) { + showLocaleError.value = true + emptyArray() + } + } + + LaunchedEffect(showLocaleError.value) { + if (showLocaleError.value) { + Toast.makeText(context, localeErrorMessage, Toast.LENGTH_SHORT).show() + showLocaleError.value = false + } + } + + val settingsViewModel: SettingsViewModel = hiltViewModel() + val settingsUiState = settingsViewModel.uiState.collectAsState().value + val serviceSubscriptionBottomSheetUiState = + settingsViewModel.serviceSubscriptionViewModel.uiState + .collectAsState() + .value + val isBottomBarVisible = rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(paymentUiState.stage) { + if (paymentUiState.stage == bose.ankush.payment.presentation.PaymentStage.Success) { + settingsViewModel.showPremiumActivationToast() + } + } + SettingsScreen( - viewModel = viewModel, - navController = navController, - onLanguageNavAction = { + paymentUiState = paymentUiState, + isLoggingOut = authState is AuthState.LogoutLoading, + isLoggedOut = authState is AuthState.LoggedOut, + versionName = BuildConfig.VERSION_NAME, + shouldShowNotificationItem = isDeviceSDKAndroid13OrAbove() && !context.hasNotificationPermission(), + languageList = languageList, + uiState = settingsUiState, + strings = + SettingsScreenStrings( + profileTitle = stringResource(R.string.profile_title), + logout = stringResource(R.string.logout_btn_txt), + logoutConfirmation = stringResource(R.string.logout_confirmation_txt), + confirm = stringResource(R.string.confirm_btn_txt), + cancel = stringResource(R.string.cancel_btn_txt), + getPremium = stringResource(R.string.premium_get_txt), + processing = stringResource(R.string.premium_processing_txt), + processingDescription = stringResource(R.string.premium_processing_desc_txt), + unlockDescription = stringResource(R.string.premium_unlock_desc_txt), + upgradeNow = stringResource(R.string.premium_upgrade_btn_txt), + premiumActive = stringResource(R.string.premium_active_txt), + premiumExpires = stringResource(R.string.premium_expires_txt), + premiumActiveStatus = stringResource(R.string.premium_active_status_txt), + notificationsTitle = stringResource(R.string.settings_notifications_txt), + languageTitle = stringResource(R.string.settings_language_txt), + privacyPolicy = stringResource(R.string.legal_privacy_policy_txt), + termsOfUse = stringResource(R.string.legal_terms_of_use_txt), + appVersion = stringResource(R.string.legal_app_version_txt), + backButtonDesc = stringResource(R.string.back_button_content), + arrowRightDesc = stringResource(R.string.arrow_right_icon_content), + premiumActivatedTitle = stringResource(R.string.premium_activated_title_txt), + premiumActivatedMessage = stringResource(R.string.premium_activated_msg_txt), + ), + serviceSubscriptionBottomSheetUiState = serviceSubscriptionBottomSheetUiState, + onLogout = { viewModel.logout() }, + onLoggedOutHandled = { viewModel.resetAuthState() }, + onStartPayment = { amountPaise -> paymentViewModel.startPayment(amountPaise) }, + onLoadServices = { settingsViewModel.serviceSubscriptionViewModel.loadServices() }, + onServiceSelected = { service -> + settingsViewModel.serviceSubscriptionViewModel.selectService( + service, + ) + }, + onTierSelected = { tier -> + settingsViewModel.serviceSubscriptionViewModel.selectPricingTier( + tier, + ) + }, + onBackNavAction = { navController.popBackStack() }, + onLanguageNavAction = { list -> if (isDeviceSDKAndroid13OrAbove()) { - navController.navigate(Screen.LanguageScreen.withArgs(it)) + navController.navigate(Screen.LanguageScreen.withArgs(list)) } else { context.openAppLocaleSettings() } @@ -133,49 +261,95 @@ fun AppNavigation(viewModel: MainViewModel) { viewModel.updateNotificationPermission(launchState = true) } }, - onAvatarNavAction = { - if (!context.callNumber()) { - viewModel.updatePhoneCallPermission(launchState = true) + onStateChange = { newState -> + when { + newState.showPremiumBottomSheet != settingsUiState.showPremiumBottomSheet -> { + if (!newState.showPremiumBottomSheet) { + settingsViewModel.serviceSubscriptionViewModel.resetState() + } + settingsViewModel.handleEvent( + if (newState.showPremiumBottomSheet) { + SettingsEvent.OpenPremiumSheet + } else { + SettingsEvent.ClosePremiumSheet + }, + ) + } + + newState.showLogoutDialog != settingsUiState.showLogoutDialog -> + settingsViewModel.handleEvent( + if (newState.showLogoutDialog) { + SettingsEvent.OpenLogoutDialog + } else { + SettingsEvent.CloseLogoutDialog + }, + ) + + newState.showPremiumActivationToast != settingsUiState.showPremiumActivationToast -> + settingsViewModel.handleEvent(SettingsEvent.DismissPremiumToast) + + newState.currentWebUrl != settingsUiState.currentWebUrl -> { + val url = newState.currentWebUrl + if (url != null) { + settingsViewModel.handleEvent(SettingsEvent.OpenWebUrl(url)) + } else { + settingsViewModel.handleEvent(SettingsEvent.CloseWebView) + } + } } - } + }, + onBottomBarVisibilityChange = { isVisible -> + isBottomBarVisible.value = isVisible + }, + toastAnchorState = toastAnchorState, + bottomBar = { + AppBottomBar( + isVisible = isBottomBarVisible, + navController = navController, + toastAnchorState = toastAnchorState, + ) + }, ) } composable( route = Screen.LanguageScreen.route + "/{$LANGUAGE_ARGUMENT_KEY}", - arguments = listOf(navArgument(LANGUAGE_ARGUMENT_KEY) { - type = StringListType() - nullable = false - }), + arguments = + listOf( + navArgument(LANGUAGE_ARGUMENT_KEY) { + type = StringListType() + nullable = false + }, + ), enterTransition = { slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(500) + animationSpec = tween(500), ) }, popEnterTransition = { slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(500) + animationSpec = tween(500), ) }, exitTransition = { slideOutOfContainer( towards = AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(500) + animationSpec = tween(500), ) }, popExitTransition = { slideOutOfContainer( towards = AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(500) + animationSpec = tween(500), ) - } + }, ) { entry -> entry.arguments?.let { it.getStringArray(LANGUAGE_ARGUMENT_KEY)?.let { listOfString -> LanguageScreen( languages = listOfString, - navAction = { navController.popBackStack() } + navAction = { navController.popBackStack() }, ) } } diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/CustomNavType.kt b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/CustomNavType.kt index 8b1a224c..5f925841 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/CustomNavType.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/CustomNavType.kt @@ -3,27 +3,22 @@ package bose.ankush.weatherify.presentation.navigation import android.os.Bundle import androidx.navigation.NavType -class DoubleNavType : NavType(isNullableAllowed = false) { - override fun get(bundle: Bundle, key: String): Double = bundle.getDouble(key) - - override fun parseValue(value: String): Double = value.toDouble() - - override fun put(bundle: Bundle, key: String, value: Double) { - bundle.putDouble(key, value) - } -} - class StringListType : NavType>(isNullableAllowed = false) { - override fun get(bundle: Bundle, key: String): List { + override fun get( + bundle: Bundle, + key: String, + ): List { val stringArray = bundle.getStringArray(key) return stringArray?.toList() ?: emptyList() } - override fun parseValue(value: String): List { - return value.split(",").map { it.trim() } - } + override fun parseValue(value: String): List = value.split(",").map { it.trim() } - override fun put(bundle: Bundle, key: String, value: List) { + override fun put( + bundle: Bundle, + key: String, + value: List, + ) { bundle.putStringArray(key, value.toTypedArray()) } } diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/Screen.kt b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/Screen.kt index 0c6f9b73..f91c2fe4 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/Screen.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/Screen.kt @@ -3,34 +3,44 @@ package bose.ankush.weatherify.presentation.navigation import androidx.annotation.StringRes import bose.ankush.weatherify.R -sealed class Screen(val route: String, @StringRes val resourceId: Int) { - - /*Home Screens*/ +sealed class Screen( + val route: String, + @StringRes val resourceId: Int, +) { + // Home Screens data object HomeNestedNav : Screen("home_nav", R.string.home_nested_nav) + data object HomeScreen : Screen("home_screen", R.string.home_screen) + data object CitiesListScreen : Screen("city_list_screen", R.string.city_screen) - data object AirQualityDetailsScreen : Screen("air_quality_details_screen", R.string.aq_screen) - /*Account/Profile Screens*/ + // Saved Locations (Premium) + data object SavedLocationsNestedNav : + Screen("saved_locations_nav", R.string.saved_locations_nested_nav) + + data object SavedLocationsScreen : + Screen("saved_locations_screen", R.string.saved_locations_screen) + + // Account/Profile Screens data object ProfileNestedNav : Screen("profile_nav", R.string.profile_nested_nav) + data object SettingsScreen : Screen("settings_screen", R.string.settings_screen) + data object LanguageScreen : Screen("language_screen", R.string.account_screen) - fun withArgs(vararg args: String?): String { - return buildString { + fun withArgs(vararg args: String?): String = + buildString { append(route) args.forEach { arg -> append("/$arg") } } - } - fun withArgs(vararg args: Array): String { - return buildString { + fun withArgs(vararg args: Array): String = + buildString { append(route) args.forEach { arg -> append("/${arg.joinToString(",")}") } } - } } diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/profile/ProfileScreen.kt b/app/src/main/java/bose/ankush/weatherify/presentation/profile/ProfileScreen.kt deleted file mode 100644 index 3a2b9005..00000000 --- a/app/src/main/java/bose/ankush/weatherify/presentation/profile/ProfileScreen.kt +++ /dev/null @@ -1,8 +0,0 @@ -package bose.ankush.weatherify.presentation.profile - -import androidx.compose.runtime.Composable - -@Composable -fun ProfileScreen() { - -} \ No newline at end of file diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/settings/SettingsScreen.kt b/app/src/main/java/bose/ankush/weatherify/presentation/settings/SettingsScreen.kt deleted file mode 100644 index 186eedd0..00000000 --- a/app/src/main/java/bose/ankush/weatherify/presentation/settings/SettingsScreen.kt +++ /dev/null @@ -1,613 +0,0 @@ -package bose.ankush.weatherify.presentation.settings - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowRight -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.RichTooltipBox -import androidx.compose.material3.RichTooltipState -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import bose.ankush.weatherify.R -import bose.ankush.weatherify.base.LocaleConfigMapper -import bose.ankush.weatherify.presentation.MainViewModel -import bose.ankush.weatherify.presentation.navigation.AppBottomBar -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun SettingsScreen( - viewModel: MainViewModel, - navController: NavController, - onLanguageNavAction: (Array) -> Unit, - onNotificationNavAction: () -> Unit, - onAvatarNavAction: () -> Unit, -) { - val isNotificationBannerVisible = viewModel.showNotificationCardItem.collectAsState().value - val scope = rememberCoroutineScope() - val languageList = LocaleConfigMapper.getAvailableLanguagesFromJson( - jsonFile = "countryConfig.json", - context = LocalContext.current - ) - - // State for Premium bottom sheet - val showPremiumBottomSheet = remember { mutableStateOf(false) } - val bottomSheetState = rememberModalBottomSheetState() - - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - ScreenHeader( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 50.dp), - onAvatarNavAction = onAvatarNavAction, - scope = scope - ) - }, - content = { innerPadding -> - Column(modifier = Modifier.padding(innerPadding)) { - // Notification block - if (isNotificationBannerVisible) { - // Create a transition state for the animation - val transitionState = remember { MutableTransitionState(false) } - - // Start the animation when the component is first displayed - LaunchedEffect(Unit) { - delay(100) // Small delay for better visual effect - transitionState.targetState = true - } - - AnimatedVisibility( - visibleState = transitionState, - enter = fadeIn(animationSpec = tween(durationMillis = 500)) + - slideInVertically( - animationSpec = tween(durationMillis = 500), - initialOffsetY = { it / 2 } - ), - exit = fadeOut() - ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 30.dp), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(all = 20.dp), - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.Start - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Box( - modifier = Modifier - .size(16.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - ) - - Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = "Notification", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - modifier = Modifier.padding(top = 8.dp), - text = "Turn on notification permission to get weather updates on the go.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) - - Button( - modifier = Modifier - .padding(top = 16.dp) - .align(Alignment.End), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - shape = RoundedCornerShape(8.dp), - onClick = { onNotificationNavAction.invoke() } - ) { - Text( - text = "Turn on", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Medium - ) - } - } - } - } - } - - // Language block - // Create a transition state for the animation - val languageTransitionState = remember { MutableTransitionState(false) } - - // Start the animation when the component is first displayed - LaunchedEffect(Unit) { - delay(200) // Small delay for staggered effect - languageTransitionState.targetState = true - } - - AnimatedVisibility( - visibleState = languageTransitionState, - enter = fadeIn(animationSpec = tween(durationMillis = 500)) + - slideInVertically( - animationSpec = tween(durationMillis = 500), - initialOffsetY = { it / 2 } - ), - exit = fadeOut() - ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp) - .clickable { onLanguageNavAction.invoke(languageList) }, - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(all = 20.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(16.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.secondary) - ) - - Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = "Language", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - modifier = Modifier.padding(start = 24.dp), - text = "Select your preferred language for a personalized experience.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) - } - - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.size(36.dp) - ) { - Icon( - imageVector = Icons.Filled.KeyboardArrowRight, - contentDescription = "Navigate to language selection", - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.padding(8.dp) - ) - } - } - } - } - - // Get Premium block - // Create a transition state for the animation - val premiumTransitionState = remember { MutableTransitionState(false) } - - // Start the animation when the component is first displayed - LaunchedEffect(Unit) { - delay(300) // Small delay for staggered effect - premiumTransitionState.targetState = true - } - - AnimatedVisibility( - visibleState = premiumTransitionState, - enter = fadeIn(animationSpec = tween(durationMillis = 500)) + - slideInVertically( - animationSpec = tween(durationMillis = 500), - initialOffsetY = { it / 2 } - ), - exit = fadeOut() - ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp) - .clickable { showPremiumBottomSheet.value = true }, - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(all = 20.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(16.dp) - .clip(CircleShape) - .background(Color(0xFFFFB74D)) - ) - - Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = "Get Premium", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - modifier = Modifier.padding(start = 24.dp), - text = "Upgrade to Premium and unlock exclusive features, priority support, and an ad-free experience.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) - } - - Surface( - shape = CircleShape, - color = Color(0xFFFFB74D).copy(alpha = 0.2f), - modifier = Modifier.size(36.dp) - ) { - Icon( - imageVector = Icons.Filled.KeyboardArrowRight, - contentDescription = "Show premium information", - tint = Color(0xFFFFB74D), - modifier = Modifier.padding(8.dp) - ) - } - } - } - - // Premium Bottom Sheet - if (showPremiumBottomSheet.value) { - ModalBottomSheet( - onDismissRequest = { showPremiumBottomSheet.value = false }, - sheetState = bottomSheetState, - containerColor = MaterialTheme.colorScheme.surface, - dragHandle = { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .width(40.dp) - .height(4.dp) - .background( - color = MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.3f - ), - shape = RoundedCornerShape(2.dp) - ) - ) - } - } - ) { - PremiumBottomSheetContent( - onDismiss = { showPremiumBottomSheet.value = false } - ) - } - } - } - } - }, - bottomBar = { - AppBottomBar( - isVisible = rememberSaveable { mutableStateOf(true) }, - navController = navController - ) - } - ) -} - -@Composable -private fun PremiumBottomSheetContent( - onDismiss: () -> Unit -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Simplified Header - Text( - text = "Premium", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Condensed Features List - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) - ), - shape = RoundedCornerShape(12.dp) - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - SimplePremiumFeature("Ad-Free Experience") - SimplePremiumFeature("Extended 15-day Forecasts") - SimplePremiumFeature("Severe Weather Alerts") - SimplePremiumFeature("Detailed Air Quality Data") - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Simplified Pricing - Text( - text = "$4.99/month", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - - Text( - text = "7-day free trial, cancel anytime", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - modifier = Modifier.padding(top = 4.dp) - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // Subscribe Button - Button( - onClick = { onDismiss() }, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFFFB74D) - ), - shape = RoundedCornerShape(8.dp) - ) { - Text( - text = "Subscribe", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = Color.White - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Cancel Button - Text( - text = "No Thanks", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - .clickable { onDismiss() } - .padding(vertical = 8.dp) - ) - } -} - -@Composable -private fun SimplePremiumFeature( - feature: String -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(6.dp) - .clip(CircleShape) - .background(Color(0xFFFFB74D)) - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - text = feature, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ScreenHeader( - modifier: Modifier = Modifier, - onAvatarNavAction: () -> Unit, - scope: CoroutineScope, -) { - val tooltipState = remember { RichTooltipState() } - - // Create a transition state for the animation - val headerTransitionState = remember { MutableTransitionState(false) } - - // Start the animation when the component is first displayed - LaunchedEffect(Unit) { - headerTransitionState.targetState = true - } - - AnimatedVisibility( - visibleState = headerTransitionState, - enter = fadeIn(animationSpec = tween(durationMillis = 500)) + - slideInVertically( - animationSpec = tween(durationMillis = 500), - initialOffsetY = { -it / 2 } - ), - exit = fadeOut() - ) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = stringResource(id = R.string.settings_screen), - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground - ) - - Text( - text = "Customize your app experience", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier.padding(top = 4.dp) - ) - } - - RichTooltipBox( - tooltipState = tooltipState, - title = { - Text( - text = "Hi Maa,", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - }, - text = { - Text( - text = "Baba sends you love, kisses and hug โค\uFE0F", - style = MaterialTheme.typography.bodyMedium - ) - }, - action = { - Text( - text = "Call him", - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - modifier = Modifier - .padding(top = 8.dp, bottom = 8.dp, end = 16.dp) - .clickable { - scope.launch { - tooltipState.dismiss() - onAvatarNavAction.invoke() - } - } - ) - } - ) { - Surface( - shape = CircleShape, - modifier = Modifier - .size(48.dp) - .shadow(elevation = 4.dp, shape = CircleShape) - ) { - Image( - painter = painterResource(id = R.drawable.zobo), - contentDescription = "Profile avatar", - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(CircleShape) - .clickable { scope.launch { tooltipState.show() } } - ) - } - } - } - } -} diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/theme/Theme.kt b/app/src/main/java/bose/ankush/weatherify/presentation/theme/Theme.kt index 978c0106..be21f9f9 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/theme/Theme.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/theme/Theme.kt @@ -1,5 +1,6 @@ package bose.ankush.weatherify.presentation.theme +import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -10,15 +11,17 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color.Companion.Transparent import androidx.compose.ui.platform.LocalContext -import com.google.accompanist.systemuicontroller.rememberSystemUiController +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat @Composable fun WeatherifyTheme( isDynamicColor: Boolean = true, darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { // Cache dynamic color check to avoid recalculating it val dynamicColor = isDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S @@ -26,41 +29,38 @@ fun WeatherifyTheme( // Cache the color scheme calculation to avoid recalculating it on each recomposition // Only recalculate when darkTheme or dynamicColor changes - val colors = remember(darkTheme, dynamicColor) { - when { - darkTheme && dynamicColor -> dynamicDarkColorScheme(context) - darkTheme -> darkColorPalette - dynamicColor -> dynamicLightColorScheme(context) - else -> lightColorPalette + val colors = + remember(darkTheme, dynamicColor) { + when { + darkTheme && dynamicColor -> dynamicDarkColorScheme(context) + darkTheme -> darkColorPalette + dynamicColor -> dynamicLightColorScheme(context) + else -> lightColorPalette + } } - } - - // Cache the system UI controller to avoid recreating it - val systemUiController = rememberSystemUiController() - // Only update system UI colors when colors or darkTheme changes - SideEffect { - with(systemUiController) { - // Set both status bar and navigation bar in a single batch update - setSystemBarsColor( - color = Transparent, - darkIcons = !darkTheme - ) - isNavigationBarVisible = false + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + val controller = WindowCompat.getInsetsController(window, view) + controller.isAppearanceLightStatusBars = !darkTheme + controller.isAppearanceLightNavigationBars = !darkTheme + controller.hide(WindowInsetsCompat.Type.navigationBars()) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } } MaterialTheme( colorScheme = colors, typography = AppTypography, - content = content + content = content, ) } -private val darkColorPalette = darkColorScheme( - -) - -private val lightColorPalette = lightColorScheme( +private val darkColorPalette = + darkColorScheme() -) +private val lightColorPalette = + lightColorScheme() diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/theme/Type.kt b/app/src/main/java/bose/ankush/weatherify/presentation/theme/Type.kt index 7e635ab9..e8cd9cbb 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/theme/Type.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/theme/Type.kt @@ -2,116 +2,142 @@ package bose.ankush.weatherify.presentation.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +import bose.ankush.weatherify.R -// Define the Typography with the system default font (San Francisco on iOS, Roboto on Android) -// This is a modern approach used by many contemporary apps -val AppTypography = Typography( - displayLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 57.sp, - lineHeight = 64.sp, - letterSpacing = (-0.25).sp - ), - displayMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 45.sp, - lineHeight = 52.sp, - letterSpacing = 0.sp - ), - displaySmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, - fontSize = 36.sp, - lineHeight = 44.sp, - letterSpacing = 0.sp - ), - headlineLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.SemiBold, - fontSize = 32.sp, - lineHeight = 40.sp, - letterSpacing = 0.sp - ), - headlineMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.SemiBold, - fontSize = 28.sp, - lineHeight = 36.sp, - letterSpacing = 0.sp - ), - headlineSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.SemiBold, - fontSize = 24.sp, - lineHeight = 32.sp, - letterSpacing = 0.sp - ), - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.SemiBold, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - titleMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.15.sp - ), - titleSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp - ), - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.15.sp - ), - bodyMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.25.sp - ), - bodySmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.4.sp - ), - labelLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp - ), - labelMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp +// App typography aligned to the design mock: clean, friendly sans-serif similar to the screenshot. +// We use the bundled Inter font to achieve a modern look consistently across the app. +private val InterFamily = + FontFamily( + Font(R.font.inter_regular, FontWeight.Normal), + Font(R.font.inter_regular, FontWeight.Medium), + Font(R.font.inter_regular, FontWeight.SemiBold), + Font(R.font.inter_regular, FontWeight.Bold), + ) + +val AppTypography = + Typography( + displayLarge = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Bold, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Bold, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + headlineLarge = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleLarge = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.1.sp, + ), + titleSmall = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + bodyMedium = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.2.sp, + ), + bodySmall = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.3.sp, + ), + labelLarge = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelSmall = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), ) -) diff --git a/app/src/main/res/drawable/zobo.png b/app/src/main/res/drawable/zobo.png deleted file mode 100644 index be40e5e1..00000000 Binary files a/app/src/main/res/drawable/zobo.png and /dev/null differ diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml new file mode 100644 index 00000000..a1130e4c --- /dev/null +++ b/app/src/main/res/values-bn/strings.xml @@ -0,0 +1,119 @@ + + + + เฆธเงเฆชเงเฆฒเงเฆฏเฆพเฆถ + เฆนเง‹เฆฎ + เฆฌเฆพเฆฏเฆผเง เฆฎเฆพเฆจ + เฆถเฆนเฆฐเฆ—เงเฆฒเง‹ + เฆชเงเฆฐเง‹เฆซเฆพเฆ‡เฆฒ + เฆธเง‡เฆŸเฆฟเฆ‚เฆธ + + + home_nested_nav + profile_nested_nav + + + Weatherify + เฆชเงเฆจเฆฐเฆพเฆฏเฆผ เฆšเง‡เฆทเงเฆŸเฆพ เฆ•เฆฐเงเฆจ + เฆ˜เฆฃเงเฆŸเฆพ เฆ…เฆจเงเฆฏเฆพเฆฏเฆผเง€ เฆชเง‚เฆฐเงเฆฌเฆพเฆญเฆพเฆธ + เฆฆเงˆเฆจเฆฟเฆ• เฆชเง‚เฆฐเงเฆฌเฆพเฆญเฆพเฆธ + %1$sเฆ•เฆฟเฆฎเฆฟ/เฆ˜ + %1$sยฐ เฆเฆฐ เฆฎเฆคเง‹ เฆฎเฆจเง‡ เฆนเฆšเงเฆ›เง‡ + เฆถเฆนเฆฐ เฆจเฆฟเฆฐเงเฆฌเฆพเฆšเฆจ เฆ•เฆฐเงเฆจ + เฆฌเฆพเฆฏเฆผเง เฆฎเฆพเฆจ + เฆซเฆฟเฆฐเง‡ เฆฏเฆพเฆจ + เฆฌเงเฆเง‡เฆ›เฆฟ + + + เฆ…เฆจเฆจเงเฆฎเง‹เฆฆเฆฟเฆค เฆ…เงเฆฏเฆพเฆ•เงเฆธเง‡เฆธ! + เฆถเฆนเฆฐ เฆชเฆพเฆ“เฆฏเฆผเฆพ เฆฏเฆพเฆฏเฆผเฆจเฆฟ! + เฆธเฆพเฆฐเงเฆญเฆพเฆฐเง‡ เฆธเฆฎเฆธเงเฆฏเฆพ เฆนเฆšเงเฆ›เง‡ เฆฎเฆจเง‡ เฆนเฆšเงเฆ›เง‡! + เฆ‡เฆจเงเฆŸเฆพเฆฐเฆจเง‡เฆŸ เฆธเฆ‚เฆฏเง‹เฆ— เฆชเงเฆจเฆฐเฆพเฆฏเฆผ เฆชเฆฐเง€เฆ•เงเฆทเฆพ เฆ•เฆฐเฆคเง‡ เฆชเฆพเฆฐเฆฌเง‡เฆจ! + เฆ‡เฆจเงเฆŸเฆพเฆฐเฆจเง‡เฆŸ เฆธเฆ‚เฆฏเง‹เฆ— เฆชเงเฆจเฆฐเฆพเฆฏเฆผ เฆชเฆฐเง€เฆ•เงเฆทเฆพ เฆ•เฆฐเฆคเง‡ เฆชเฆพเฆฐเฆฌเง‡เฆจ! + เฆ…เฆจเงเฆฐเง‹เฆงเง‡เฆฐ เฆธเฆฎเฆฏเฆผ เฆถเง‡เฆท เฆนเฆฏเฆผเง‡ เฆ—เง‡เฆ›เง‡เฅค เฆชเฆฐเง‡ เฆ†เฆฌเฆพเฆฐ เฆšเง‡เฆทเงเฆŸเฆพ เฆ•เฆฐเงเฆจเฅค + เฆ†เฆฐเง‡!.. เฆ•เฆฟเฆ›เง เฆเฆ•เฆŸเฆพ เฆญเงเฆฒ เฆนเฆฏเฆผเง‡เฆ›เง‡เฅค + เฆฒเง‹เฆ•เง‡เฆถเฆจ เฆธเง‡เฆฌเฆพ เฆฌเฆจเงเฆง เฆ†เฆ›เง‡เฅค เฆธเงเฆฅเฆพเฆจเง€เฆฏเฆผ เฆ†เฆฌเฆนเฆพเฆ“เฆฏเฆผเฆพ เฆชเง‡เฆคเง‡ GPS เฆšเฆพเฆฒเง เฆ•เฆฐเงเฆจเฅค + GPS เฆšเฆพเฆฒเง เฆ•เฆฐเงเฆจ + เฆ…เฆฌเฆธเงเฆฅเฆพเฆจ เฆธเงเฆฅเฆพเฆจเฆพเฆ™เงเฆ• เฆเฆ–เฆจเง‹ เฆ†เฆชเฆกเง‡เฆŸ เฆนเฆฏเฆผเฆจเฆฟเฅค + เฆเฆ‡ เฆฎเงเฆนเง‚เฆฐเงเฆคเง‡ เฆ•เง‹เฆจเง‹ เฆถเฆนเฆฐ เฆชเฆพเฆ“เฆฏเฆผเฆพ เฆฏเฆพเฆฏเฆผเฆจเฆฟเฅค เฆชเฆฐเง‡ เฆฆเง‡เฆ–เงเฆจ + เฆ•เง‹เฆจเง‹ เฆฌเฆฟเฆฌเฆฐเฆฃ เฆชเฆพเฆ“เฆฏเฆผเฆพ เฆฏเฆพเฆฏเฆผเฆจเฆฟเฅค เฆ•เฆฟเฆ›เงเฆ•เงเฆทเฆฃ เฆชเฆฐเง‡ เฆšเง‡เฆทเงเฆŸเฆพ เฆ•เฆฐเงเฆจเฅค + + + เฆชเงเฆฐเง‹เฆซเฆพเฆ‡เฆฒ + เฆฒเฆ— เฆ†เฆ‰เฆŸ + เฆ†เฆชเฆจเฆฟ เฆ•เฆฟ เฆจเฆฟเฆถเงเฆšเฆฟเฆคเฆญเฆพเฆฌเง‡ เฆฒเฆ— เฆ†เฆ‰เฆŸ เฆ•เฆฐเฆคเง‡ เฆšเฆพเฆจ? + เฆ†เฆชเฆจเฆพเฆฐ เฆ…เงเฆฏเฆพเฆ•เฆพเฆ‰เฆจเงเฆŸ เฆ…เงเฆฏเฆพเฆ•เงเฆธเง‡เฆธ เฆ•เฆฐเฆคเง‡ เฆ†เฆฌเฆพเฆฐ เฆฒเฆ—เฆ‡เฆจ เฆ•เฆฐเฆคเง‡ เฆนเฆฌเง‡เฅค + เฆจเฆฟเฆถเงเฆšเฆฟเฆค เฆ•เฆฐเงเฆจ + เฆฌเฆพเฆคเฆฟเฆฒ เฆ•เฆฐเงเฆจ + + Get Premium + Processingโ€ฆ + Please wait while we activate your premium subscription. + Unlock all features and enjoy an ad-free experience. + Upgrade Now + You are a Premium User + Expires %1$s + Active + เฆฌเฆฟเฆœเงเฆžเฆชเงเฆคเฆฟ + เฆญเฆพเฆทเฆพ + เฆ—เง‹เฆชเฆจเง€เฆฏเฆผเฆคเฆพ เฆจเง€เฆคเฆฟ + เฆฌเงเฆฏเฆฌเฆนเฆพเฆฐเง‡เฆฐ เฆถเฆฐเงเฆคเฆพเฆฌเฆฒเง€ + เฆ…เงเฆฏเฆพเฆช เฆธเฆ‚เฆธเงเฆ•เฆฐเฆฃ + เฆธเง‡เฆŸเฆฟเฆ‚เฆธ + เฆ†เฆ‡เฆจเฆฟ + + + เฆ†เฆฌเฆนเฆพเฆ“เฆฏเฆผเฆพ เฆ…เฆฌเฆธเงเฆฅเฆพเฆฐ เฆ†เฆ‡เฆ•เฆจ + เฆญเฆพเฆทเฆพ เฆ†เฆ‡เฆ•เฆจ + เฆฎเง‡เฆจเง เฆ†เฆ‡เฆ•เฆจ + เฆฌเฆพเฆฏเฆผเง เฆ†เฆ‡เฆ•เฆจ + เฆ†เฆฐเงเฆฆเงเฆฐเฆคเฆพ เฆ†เฆ‡เฆ•เฆจ + เฆคเงเฆฐเงเฆŸเฆฟ เฆ†เฆ‡เฆ•เฆจ + เฆฌเฆจเงเฆง เฆ•เฆฐเงเฆจ เฆ†เฆ‡เฆ•เฆจ + เฆชเง‡เฆ›เฆจเง‡ เฆฌเฆพเฆŸเฆจ + เฆฌเฆฟเฆœเงเฆžเฆชเงเฆคเฆฟ เฆธเง‡เฆŸเฆฟเฆ‚เฆธ เฆ†เฆ‡เฆ•เฆจ + เฆ—เง‹เฆชเฆจเง€เฆฏเฆผเฆคเฆพ เฆจเง€เฆคเฆฟ เฆ†เฆ‡เฆ•เฆจ + เฆฌเงเฆฏเฆฌเฆนเฆพเฆฐเง‡เฆฐ เฆถเฆฐเงเฆคเฆพเฆฌเฆฒเง€ เฆ†เฆ‡เฆ•เฆจ + เฆคเฆฅเงเฆฏ เฆ†เฆ‡เฆ•เฆจ + เฆชเฆฐเฆฌเฆฐเงเฆคเง€ เฆธเงเฆ•เงเฆฐเฆฟเฆจเง‡ เฆจเง‡เฆญเฆฟเฆ—เง‡เฆŸ เฆ•เฆฐเงเฆจ + เฆญเฆพเฆทเฆพ เฆ•เฆจเฆซเฆฟเฆ—เฆพเฆฐเง‡เฆถเฆจ เฆฒเง‹เฆก เฆ•เฆฐเฆคเง‡ เฆฌเงเฆฏเฆฐเงเฆฅ เฆนเฆฏเฆผเง‡เฆ›เง‡เฅค เฆกเฆฟเฆซเฆฒเงเฆŸ เฆญเฆพเฆทเฆพ เฆฌเงเฆฏเฆฌเฆนเฆพเฆฐ เฆ•เฆฐเฆพ เฆนเฆšเงเฆ›เง‡เฅค + + + เฆ†เฆชเฆกเง‡เฆŸ เฆฅเฆพเฆ•เงเฆจ + เฆ†เฆฌเฆนเฆพเฆ“เฆฏเฆผเฆพ เฆธเฆคเฆฐเงเฆ•เฆคเฆพเฆฐ เฆœเฆจเงเฆฏ เฆฌเฆฟเฆœเงเฆžเฆชเงเฆคเฆฟ เฆธเฆ•เงเฆฐเฆฟเฆฏเฆผ เฆ•เฆฐเงเฆจ + เฆธเฆ•เงเฆฐเฆฟเฆฏเฆผ เฆ•เฆฐเงเฆจ + + + เฆชเงเฆฐเฆฟเฆฎเฆฟเฆฏเฆผเฆพเฆฎ เฆธเฆ•เงเฆฐเฆฟเฆฏเฆผ + เฆ†เฆชเฆจเฆพเฆฐ เฆชเงเฆฐเฆฟเฆฎเฆฟเฆฏเฆผเฆพเฆฎ เฆธเฆพเฆฌเฆธเงเฆ•เงเฆฐเฆฟเฆชเฆถเฆจ เฆเฆ–เฆจ เฆธเฆ•เงเฆฐเฆฟเฆฏเฆผ! + + + เฆธเฆ‚เฆฐเฆ•เงเฆทเฆฟเฆค เฆ…เฆฌเฆธเงเฆฅเฆพเฆจ + saved_locations_nested_nav + เฆธเฆ‚เฆฐเฆ•เงเฆทเฆฟเฆค เฆ…เฆฌเฆธเงเฆฅเฆพเฆจ + เฆเฆ–เฆจเง‹ เฆ•เง‹เฆจเง‹ เฆธเฆ‚เฆฐเฆ•เงเฆทเฆฟเฆค เฆ…เฆฌเฆธเงเฆฅเฆพเฆจ เฆจเง‡เฆ‡เฅค เฆฏเง‹เฆ— เฆ•เฆฐเฆคเง‡ + เฆŸเงเฆฏเฆพเฆช เฆ•เฆฐเงเฆจเฅค + เฆ…เฆฌเฆธเงเฆฅเฆพเฆจ เฆฏเง‹เฆ— เฆ•เฆฐเงเฆจ + เฆฎเงเฆ›เงเฆจ + เฆธเฆ‚เฆฐเฆ•เงเฆทเฆฃ เฆ•เฆฐเงเฆจ + เฆ…เฆฌเฆธเงเฆฅเฆพเฆจเง‡เฆฐ เฆจเฆพเฆฎ (เฆฏเง‡เฆฎเฆจ เฆฌเฆพเฆกเฆผเฆฟ) + เฆชเงเฆฐเฆฟเฆฎเฆฟเฆฏเฆผเฆพเฆฎ เฆฌเงˆเฆถเฆฟเฆทเงเฆŸเงเฆฏ + เฆ†เฆชเฆจเฆพเฆฐ เฆชเงเฆฐเฆฟเฆฏเฆผ เฆ…เฆฌเฆธเงเฆฅเฆพเฆจเฆ—เงเฆฒเฆฟ เฆธเฆ‚เฆฐเฆ•เงเฆทเฆฃ เฆ•เฆฐเงเฆจ เฆเฆฌเฆ‚ เฆคเฆพเงŽเฆ•เงเฆทเฆฃเฆฟเฆ•เฆญเฆพเฆฌเง‡ เฆ…เงเฆฏเฆพเฆ•เงเฆธเง‡เฆธ เฆ•เฆฐเงเฆจเฅค เฆเฆ‡ เฆฌเงˆเฆถเฆฟเฆทเงเฆŸเงเฆฏเฆŸเฆฟ เฆ†เฆจเฆฒเฆ• เฆ•เฆฐเฆคเง‡ เฆชเงเฆฐเฆฟเฆฎเฆฟเฆฏเฆผเฆพเฆฎเง‡ เฆ†เฆชเฆ—เงเฆฐเง‡เฆก เฆ•เฆฐเงเฆจเฅค + เฆธเฆ‚เฆฐเฆ•เงเฆทเฆฟเฆค เฆ…เฆฌเฆธเงเฆฅเฆพเฆจ เฆฒเง‹เฆก เฆ•เฆฐเฆคเง‡ เฆฌเงเฆฏเฆฐเงเฆฅเฅค + เฆ…เฆฌเฆธเงเฆฅเฆพเฆจ เฆธเฆซเฆฒเฆญเฆพเฆฌเง‡ เฆธเฆ‚เฆฐเฆ•เงเฆทเฆฟเฆค เฆนเฆฏเฆผเง‡เฆ›เง‡เฅค + เฆ…เฆฌเฆธเงเฆฅเฆพเฆจ เฆ…เฆชเฆธเฆพเฆฐเฆฃ เฆ•เฆฐเฆพ เฆนเฆฏเฆผเง‡เฆ›เง‡เฅค + เฆธเฆ‚เฆฐเฆ•เงเฆทเฆฟเฆค เฆ…เฆฌเฆธเงเฆฅเฆพเฆจ + เฆจเฆคเงเฆจ เฆ…เฆฌเฆธเงเฆฅเฆพเฆจ เฆฏเง‹เฆ— เฆ•เฆฐเงเฆจ + เฆ…เฆฌเฆธเงเฆฅเฆพเฆจ เฆฎเงเฆ›เงเฆจ + + + เฆ•เง‹เฆจเง‹ เฆธเงเฆฅเฆพเฆจ เฆ–เงเฆเฆœเงเฆจ + เฆถเฆนเฆฐ เฆฌเฆพ เฆ เฆฟเฆ•เฆพเฆจเฆพ เฆŸเฆพเฆ‡เฆช เฆ•เฆฐเงเฆจโ€ฆ + \'%1$s\'เฆเฆฐ เฆœเฆจเงเฆฏ เฆ•เง‹เฆจเง‹ เฆธเงเฆฅเฆพเฆจ เฆชเฆพเฆ“เฆฏเฆผเฆพ เฆฏเฆพเฆฏเฆผเฆจเฆฟ + เฆ†เฆฌเฆนเฆพเฆ“เฆฏเฆผเฆพเฆฐ เฆ…เฆฌเฆธเงเฆฅเฆพเฆจ เฆนเฆฟเฆธเง‡เฆฌเง‡ เฆฌเงเฆฏเฆฌเฆนเฆพเฆฐ เฆ•เฆฐเฆฌเง‡เฆจ? + เฆ†เฆชเฆจเฆพเฆฐ เฆฌเฆฐเงเฆคเฆฎเฆพเฆจ GPS เฆ…เฆฌเฆธเงเฆฅเฆพเฆจเง‡เฆฐ เฆชเฆฐเฆฟเฆฌเฆฐเงเฆคเง‡ %1$s-เฆเฆฐ เฆœเฆจเงเฆฏ เฆ†เฆฌเฆนเฆพเฆ“เฆฏเฆผเฆพ เฆคเฆฅเงเฆฏ เฆฆเง‡เฆ–เฆพเฆฌเง‡เฅค + เฆเฆŸเฆฟ เฆธเฆ•เงเฆฐเฆฟเฆฏเฆผ เฆฅเฆพเฆ•เฆพ เฆ…เฆฌเฆธเงเฆฅเฆพเฆฏเฆผ เฆ†เฆชเฆจเฆพเฆฐ เฆฒเฆพเฆ‡เฆญ GPS เฆ…เฆฌเฆธเงเฆฅเฆพเฆจ เฆ†เฆชเฆกเง‡เฆŸ เฆนเฆฌเง‡ เฆจเฆพเฅค + เฆกเฆฟเฆซเฆฒเงเฆŸ เฆนเฆฟเฆธเง‡เฆฌเง‡ เฆธเง‡เฆŸ เฆ•เฆฐเงเฆจ + %1$s เฆฌเงเฆฏเฆฌเฆนเฆพเฆฐ เฆ•เฆฐเฆพ เฆนเฆšเงเฆ›เง‡ + GPS-เฆ เฆฐเฆฟเฆธเง‡เฆŸ เฆ•เฆฐเงเฆจ + เฆฌเฆฐเงเฆคเฆฎเฆพเฆจเง‡ %1$s-เฆเฆฐ เฆ†เฆฌเฆนเฆพเฆ“เฆฏเฆผเฆพ เฆฆเง‡เฆ–เฆพเฆจเง‹ เฆนเฆšเงเฆ›เง‡เฅค GPS-เฆ เฆฐเฆฟเฆธเง‡เฆŸ เฆ•เฆฐเฆคเง‡ เฆŸเงเฆฏเฆพเฆช เฆ•เฆฐเงเฆจเฅค + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 76e03445..77be5556 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -34,11 +34,39 @@ เคถเคนเคฐ เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพ! เคฒเค—เคคเคพ เคนเฅˆ เคธเคฐเฅเคตเคฐ เคฎเฅ‡เค‚ เคธเคฎเคธเฅเคฏเคพ เคนเฅˆ! เค•เฅเคฏเคพ เค†เคช เค‡เค‚เคŸเคฐเคจเฅ‡เคŸ เค•เคจเฅ‡เค•เฅเคŸเคฟเคตเคฟเคŸเฅ€ เค•เฅ€ เคฆเฅ‹เคฌเคพเคฐเคพ เคœเคพเค‚เคš เค•เคฐ เคธเค•เคคเฅ‡ เคนเฅˆเค‚! + เค•เฅเคฏเคพ เค†เคช เค‡เค‚เคŸเคฐเคจเฅ‡เคŸ เค•เคจเฅ‡เค•เฅเคŸเคฟเคตเคฟเคŸเฅ€ เค•เฅ€ เคฆเฅ‹เคฌเคพเคฐเคพ เคœเคพเค‚เคš เค•เคฐ เคธเค•เคคเฅ‡ เคนเฅˆเค‚! + เค…เคจเฅเคฐเฅ‹เคง เค•เคพ เคธเคฎเคฏ เคธเคฎเคพเคชเฅเคค เคนเฅ‹ เค—เคฏเคพเฅค เค•เฅƒเคชเคฏเคพ เคฌเคพเคฆ เคฎเฅ‡เค‚ เคชเฅเคจเคƒ เคชเฅเคฐเคฏเคพเคธ เค•เคฐเฅ‡เค‚เฅค เค“เคน! เค•เฅเค› เค—เคฒเคค เคนเฅ‹ เค—เคฏเคพ เคนเฅˆเฅค + เคธเฅเคฅเคพเคจ เคธเฅ‡เคตเคพเคเค‚ เคฌเค‚เคฆ เคนเฅˆเค‚เฅค เค…เคชเคจเคพ เคธเฅเคฅเคพเคจเฅ€เคฏ เคฎเฅŒเคธเคฎ เคชเคพเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค GPS เคšเคพเคฒเฅ‚ เค•เคฐเฅ‡เค‚เฅค + GPS เคšเคพเคฒเฅ‚ เค•เคฐเฅ‡เค‚ เคธเฅเคฅเคพเคจ เคจเคฟเคฐเฅเคฆเฅ‡เคถเคพเค‚เค• เค…เคญเฅ€ เคคเค• เค…เคชเคกเฅ‡เคŸ เคจเคนเฅ€เค‚ เค•เคฟเค เค—เค เคนเฅˆเค‚เฅค เค“เคน เคคเฅ‡เคฐเฅ€! เค‡เคธ เคธเคฎเคฏ เค•เฅ‹เคˆ เคถเคนเคฐ เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพเฅค เคฌเคพเคฆ เคฎเฅ‡เค‚ เคœเคพเค‚เคšเฅ‡เค‚ เค•เฅ‹เคˆ เคตเคฟเคตเคฐเคฃ เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพเฅค เค•เฅเค› เคฆเฅ‡เคฐ เคฌเคพเคฆ เค•เฅ‹เคถเคฟเคถ เค•เคฐเฅ‡เค‚เฅค + + เคชเฅเคฐเฅ‹เคซเคผเคพเค‡เคฒ + เคฒเฅ‰เค—เค†เค‰เคŸ + เค•เฅเคฏเคพ เค†เคช เคฒเฅ‰เค—เค†เค‰เคŸ เค•เคฐเคจเคพ เคšเคพเคนเคคเฅ‡ เคนเฅˆเค‚? + เค†เคชเค•เฅ‹ เค…เคชเคจเฅ‡ เค–เคพเคคเฅ‡ เค•เฅ‹ เคเค•เฅเคธเฅ‡เคธ เค•เคฐเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค เคซเคฟเคฐ เคธเฅ‡ เคฒเฅ‰เค—เคฟเคจ เค•เคฐเคจเคพ เคนเฅ‹เค—เคพเฅค + เคชเฅเคทเฅเคŸเคฟ เค•เคฐเฅ‡เค‚ + เคฐเคฆเฅเคฆ เค•เคฐเฅ‡เค‚ + + เคชเฅเคฐเฅ€เคฎเคฟเคฏเคฎ เคชเฅเคฐเคพเคชเฅเคค เค•เคฐเฅ‡เค‚ + เคชเฅเคฐเค•เฅเคฐเคฟเคฏเคพ เคฎเฅ‡เค‚โ€ฆ + เค•เฅƒเคชเคฏเคพ เคชเฅเคฐเคคเฅ€เค•เฅเคทเคพ เค•เคฐเฅ‡เค‚ เคœเคฌเค•เคฟ เคนเคฎ เค†เคชเค•เฅ€ เคชเฅเคฐเฅ€เคฎเคฟเคฏเคฎ เคธเคฆเคธเฅเคฏเคคเคพ เคธเค•เฅเคฐเคฟเคฏ เค•เคฐเคคเฅ‡ เคนเฅˆเค‚เฅค + เคธเคญเฅ€ เคธเฅเคตเคฟเคงเคพเค“เค‚ เค•เฅ‹ เค…เคจเคฒเฅ‰เค• เค•เคฐเฅ‡เค‚ เค”เคฐ เคตเคฟเคœเฅเคžเคพเคชเคจ-เคฎเฅเค•เฅเคค เค…เคจเฅเคญเคต เค•เคพ เค†เคจเค‚เคฆ เคฒเฅ‡เค‚เฅค + เค…เคญเฅ€ เค…เคชเค—เฅเคฐเฅ‡เคก เค•เคฐเฅ‡เค‚ + เค†เคช เคชเฅเคฐเฅ€เคฎเคฟเคฏเคฎ เค‰เคชเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เคนเฅˆเค‚ + %1$s เค•เฅ‹ เคธเคฎเคพเคชเฅเคค เคนเฅ‹เคคเคพ เคนเฅˆ + เคธเค•เฅเคฐเคฟเคฏ + เคธเฅ‚เคšเคจเคพเคเค‚ + เคญเคพเคทเคพ + เค—เฅ‹เคชเคจเฅ€เคฏเคคเคพ เคจเฅ€เคคเคฟ + เค‰เคชเคฏเฅ‹เค— เค•เฅ€ เคถเคฐเฅเคคเฅ‡เค‚ + เคเคช เคธเค‚เคธเฅเค•เคฐเคฃ + เคธเฅ‡เคŸเคฟเค‚เค—เฅเคธ + เค•เคพเคจเฅ‚เคจเฅ€ + เคฎเฅŒเคธเคฎ เค•เฅ€ เคธเฅเคฅเคฟเคคเคฟ เค†เค‡เค•เคจ เคญเคพเคทเคพ เคชเคฐเคฟเคตเคฐเฅเคคเคจ เคšเคฟเคนเฅเคจ @@ -48,4 +76,50 @@ เคคเฅเคฐเฅเคŸเคฟ เคšเคฟเคนเฅ เคฌเค‚เคฆ เค•เคฐเฅ‡เค‚ เคšเคฟเคนเฅ เคฆเฅˆเคจเคฟเค• เคชเฅ‚เคฐเฅเคตเคพเคจเฅเคฎเคพเคจ + เคตเคพเคชเคธ เคฌเคŸเคจ + เคธเฅ‚เคšเคจเคพ เคธเฅ‡เคŸเคฟเค‚เค—เฅเคธ เค†เค‡เค•เคจ + เค—เฅ‹เคชเคจเฅ€เคฏเคคเคพ เคจเฅ€เคคเคฟ เค†เค‡เค•เคจ + เค‰เคชเคฏเฅ‹เค— เค•เฅ€ เคถเคฐเฅเคคเฅ‡เค‚ เค†เค‡เค•เคจ + เคœเคพเคจเค•เคพเคฐเฅ€ เค†เค‡เค•เคจ + เค…เค—เคฒเฅ€ เคธเฅเค•เฅเคฐเฅ€เคจ เคชเคฐ เคจเฅ‡เคตเคฟเค—เฅ‡เคŸ เค•เคฐเฅ‡เค‚ + เคญเคพเคทเคพ เค•เฅ‰เคจเฅเคซเคผเคฟเค—เคฐเฅ‡เคถเคจ เคฒเฅ‹เคก เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคตเคฟเคซเคฒเฅค เคกเคฟเคซเคผเฅ‰เคฒเฅเคŸ เคญเคพเคทเคพ เค•เคพ เค‰เคชเคฏเฅ‹เค— เค•เคฐ เคฐเคนเฅ‡ เคนเฅˆเค‚เฅค + + + เค…เคชเคกเฅ‡เคŸ เคฐเคนเฅ‡เค‚ + เคฎเฅŒเคธเคฎ เคธเคคเคฐเฅเค•เคคเคพ เค•เฅ‡ เคฒเคฟเค เคธเฅ‚เคšเคจเคพเคเค‚ เคธเค•เฅเคทเคฎ เค•เคฐเฅ‡เค‚ + เคธเค•เฅเคทเคฎ เค•เคฐเฅ‡เค‚ + + + เคชเฅเคฐเฅ€เคฎเคฟเคฏเคฎ เคธเค•เฅเคฐเคฟเคฏ + เค†เคชเค•เฅ€ เคชเฅเคฐเฅ€เคฎเคฟเคฏเคฎ เคธเคฆเคธเฅเคฏเคคเคพ เค…เคฌ เคธเค•เฅเคฐเคฟเคฏ เคนเฅˆ! + + + เคธเคนเฅ‡เคœเฅ‡ เค—เค เคธเฅเคฅเคพเคจ + saved_locations_nested_nav + เคธเคนเฅ‡เคœเฅ‡ เค—เค เคธเฅเคฅเคพเคจ + เค•เฅ‹เคˆ เคธเคนเฅ‡เคœเคพ เค—เคฏเคพ เคธเฅเคฅเคพเคจ เคจเคนเฅ€เค‚ เคนเฅˆเฅค เคœเฅ‹เคกเคผเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค + เคŸเฅˆเคช เค•เคฐเฅ‡เค‚เฅค + เคธเฅเคฅเคพเคจ เคœเฅ‹เคกเคผเฅ‡เค‚ + เคนเคŸเคพเคเค‚ + เคธเคนเฅ‡เคœเฅ‡เค‚ + เคธเฅเคฅเคพเคจ เค•เคพ เคจเคพเคฎ (เคœเฅˆเคธเฅ‡ เค˜เคฐ) + เคชเฅเคฐเฅ€เคฎเคฟเคฏเคฎ เคธเฅเคตเคฟเคงเคพ + เค…เคชเคจเฅ‡ เคชเคธเค‚เคฆเฅ€เคฆเคพ เคธเฅเคฅเคพเคจเฅ‹เค‚ เค•เฅ‹ เคธเคนเฅ‡เคœเฅ‡เค‚ เค”เคฐ เคคเฅเคฐเค‚เคค เคเค•เฅเคธเฅ‡เคธ เค•เคฐเฅ‡เค‚เฅค เค‡เคธ เคธเฅเคตเคฟเคงเคพ เค•เฅ‹ เค…เคจเคฒเฅ‰เค• เค•เคฐเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค เคชเฅเคฐเฅ€เคฎเคฟเคฏเคฎ เคฎเฅ‡เค‚ เค…เคชเค—เฅเคฐเฅ‡เคก เค•เคฐเฅ‡เค‚เฅค + เคธเคนเฅ‡เคœเฅ‡ เค—เค เคธเฅเคฅเคพเคจ เคฒเฅ‹เคก เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคตเคฟเคซเคฒเฅค + เคธเฅเคฅเคพเคจ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคธเคนเฅ‡เคœเคพ เค—เคฏเคพเฅค + เคธเฅเคฅเคพเคจ เคนเคŸเคพ เคฆเคฟเคฏเคพ เค—เคฏเคพเฅค + เคธเคนเฅ‡เคœเฅ‡ เค—เค เคธเฅเคฅเคพเคจ + เคจเคฏเคพ เคธเฅเคฅเคพเคจ เคœเฅ‹เคกเคผเฅ‡เค‚ + เคธเฅเคฅเคพเคจ เคนเคŸเคพเคเค‚ + + + เค•เคฟเคธเฅ€ เคธเฅเคฅเคพเคจ เค•เฅ‹ เค–เฅ‹เคœเฅ‡เค‚ + เคถเคนเคฐ เคฏเคพ เคชเคคเคพ เคŸเคพเค‡เคช เค•เคฐเฅ‡เค‚โ€ฆ + \'%1$s\' เค•เฅ‡ เคฒเคฟเค เค•เฅ‹เคˆ เคธเฅเคฅเคพเคจ เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพ + เคฎเฅŒเคธเคฎ เคธเฅเคฅเคพเคจ เค•เฅ‡ เคฐเฅ‚เคช เคฎเฅ‡เค‚ เค‰เคชเคฏเฅ‹เค— เค•เคฐเฅ‡เค‚? + เค†เคชเค•เฅ€ เคตเคฐเฅเคคเคฎเคพเคจ GPS เคธเฅเคฅเคฟเคคเคฟ เค•เฅ‡ เคฌเคœเคพเคฏ %1$s เค•เคพ เคฎเฅŒเคธเคฎ เคกเฅ‡เคŸเคพ เคฆเคฟเค–เคพเคฏเคพ เคœเคพเคเค—เคพเฅค + เค‡เคธ เคฆเฅŒเคฐเคพเคจ เค†เคชเค•เฅ€ เคฒเคพเค‡เคต GPS เคธเฅเคฅเคพเคจ เค…เคชเคกเฅ‡เคŸ เคจเคนเฅ€เค‚ เคนเฅ‹เค—เฅ€เฅค + เคกเคฟเคซเคผเฅ‰เคฒเฅเคŸ เค•เฅ‡ เคฐเฅ‚เคช เคฎเฅ‡เค‚ เคธเฅ‡เคŸ เค•เคฐเฅ‡เค‚ + %1$s เค•เคพ เค‰เคชเคฏเฅ‹เค— เคนเฅ‹ เคฐเคนเคพ เคนเฅˆ + GPS เคชเคฐ เคฐเฅ€เคธเฅ‡เคŸ เค•เคฐเฅ‡เค‚ + เคตเคฐเฅเคคเคฎเคพเคจ เคฎเฅ‡เค‚ %1$s เค•เคพ เคฎเฅŒเคธเคฎ เคฆเคฟเค–เคพเคฏเคพ เคœเคพ เคฐเคนเคพ เคนเฅˆเฅค GPS เคชเคฐ เคฐเฅ€เคธเฅ‡เคŸ เค•เคฐเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค เคŸเฅˆเคช เค•เคฐเฅ‡เค‚เฅค diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 978b0392..aa5cdcd5 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -34,11 +34,30 @@ ื”ืขื™ืจ ืœื ื ืžืฆืื”! ืžืชืžื•ื“ื“ ืขื ื‘ืขื™ื” ื‘ืฉืจืช ื”ืื ืชื•ื›ืœ ืœื‘ื“ื•ืง ืžื—ื“ืฉ ืืช ื—ื™ื‘ื•ืจ ื”ืื™ื ื˜ืจื ื˜! + ื”ืื ืชื•ื›ืœ ืœื‘ื“ื•ืง ืžื—ื“ืฉ ืืช ื—ื™ื‘ื•ืจ ื”ืื™ื ื˜ืจื ื˜! + ืคื’ ื”ื–ืžืŸ ื”ืงืฆื•ื‘ ืœื‘ืงืฉื”. ืื ื ื ืกื” ืฉื•ื‘ ืžืื•ื—ืจ ื™ื•ืชืจ. ืื•ืคืก!..ืžืฉื”ื• ื”ืฉืชื‘ืฉ. + ืฉื™ืจื•ืชื™ ื”ืžื™ืงื•ื ื›ื‘ื•ื™ื™ื. ื”ืคืขืœ GPS ื›ื“ื™ ืœืงื‘ืœ ืืช ืžื–ื’ ื”ืื•ื•ื™ืจ ื”ืžืงื•ืžื™. + ื”ืคืขืœ GPS ืงื•ืื•ืจื“ื™ื ื˜ื•ืช ื”ืžื™ืงื•ื ืขื“ื™ื™ืŸ ืœื ืขื•ื“ื›ื ื•. ื’ื™ืฉื” ืœื ืžื•ืจืฉื™ืช! ืœื ื ืžืฆืื• ืคืจื˜ื™ื. ื ืกื” ืฉื•ื‘ ืžืื•ื—ืจ ื™ื•ืชืจ + + ืคืจื•ืคื™ืœ + ื”ืชื ืชืงื•ืช + ื”ืื ืืชื” ื‘ื˜ื•ื— ืฉื‘ืจืฆื•ื ืš ืœื”ืชื ืชืง? + ื™ื”ื™ื” ืขู„ูŠืš ืœื”ืชื—ื‘ืจ ืฉื•ื‘ ื›ื“ื™ ืœื’ืฉืช ืœื—ืฉื‘ื•ื ืš. + ืื™ืฉื•ืจ + ื‘ื™ื˜ื•ืœ + ื”ื•ื“ืขื•ืช + ืฉืคื” + ืžื“ื™ื ื™ื•ืช ืคืจื˜ื™ื•ืช + ืชื ืื™ ืฉื™ืžื•ืฉ + ื’ืจืกื” ืืคืœื™ืงืฆื™ื” + ื”ื’ื“ืจื•ืช + ืžืฉืคื˜ื™ + ืกืžืœ ืžืฆื‘ ืžื–ื’ ื”ืื•ื•ื™ืจ ืกืžืœ ืฉืคื” @@ -48,4 +67,58 @@ ืกืžืœ ืฉื’ื™ืื” ืกืžืœ ืกื’ื™ืจื” ืชื—ื–ื™ืช ื™ื•ืžื™ืช + ื›ืคืชื•ืจ ื—ื–ืจื” + ืกืžืœ ื”ื’ื“ืจื•ืช ื”ืชืจืื” + ืกืžืœ ืžื“ื™ื ื™ื•ืช ืคืจื˜ื™ื•ืช + ืกืžืœ ืชื ืื™ ื”ืฉื™ืžื•ืฉ + ืกืžืœ ืžื™ื“ืข + ื ื•ื•ื˜ ืœืžืกืš ื”ื‘ื + ื›ื™ืฉืœื•ืŸ ื‘ื˜ืขื™ื ืช ืชืฆื•ืจืช ืฉืคื”. ืฉื™ืžื•ืฉ ื‘ืฉืคื” ื‘ืจื™ืจืช ืžื—ื“ืœ. + + + ืœื”ื™ืฉืืจ ืžืขื•ื“ื›ืŸ + ื”ืคื•ืš ื”ืชืจืื•ืช ื—ื“ืฉื•ืช ืžื–ื’ ืื•ื•ื™ืจ + ื”ืคื•ืš + + + ืงื‘ืœ ืคืจื™ืžื™ื•ื + ื‘ืขื™ื‘ื•ื“โ€ฆ + ืื ื ื”ืžืชืŸ ื‘ื–ืžืŸ ืฉืื ื• ืžืคืขื™ืœื™ื ืืช ืžื ื•ื™ ื”ืคืจื™ืžื™ื•ื ืฉืœืš. + ื‘ื˜ืœ ืืช ื ืขื™ืœืช ื›ืœ ื”ืชื›ื•ื ื•ืช ื•ื”ื ื”ื ื” ืžื—ื•ื•ื™ื” ืœืœื ืžื•ื“ืขื•ืช. + ืฉื“ืจื’ ื›ืขืช + ืืชื” ืžืฉืชืžืฉ ืคืจื™ืžื™ื•ื + ืคื’ ื‘ืชื•ืงืฃ %1$s + ืคืขื™ืœ + ืคืจื™ืžื™ื•ื ื”ื•ืคืขืœ + ืžื ื•ื™ ื”ืคืจื™ืžื™ื•ื ืฉืœืš ืคืขื™ืœ ื›ืขืช! + + + ืžื™ืงื•ืžื™ื ืฉืžื•ืจื™ื + saved_locations_nested_nav + ืžื™ืงื•ืžื™ื ืฉืžื•ืจื™ื + ืื™ืŸ ืžื™ืงื•ืžื™ื ืฉืžื•ืจื™ื ืขื“ื™ื™ืŸ. ื”ืงืฉ + ื›ื“ื™ ืœื”ื•ืกื™ืฃ ืื—ื“. + ื”ื•ืกืฃ ืžื™ืงื•ื + ืžื—ืง + ืฉืžื•ืจ + ืฉื ืžื™ืงื•ื (ืœืžืฉืœ ื‘ื™ืช) + ืชื›ื•ื ืช ืคืจื™ืžื™ื•ื + ืฉืžื•ืจ ื•ื’ืฉ ืืœ ืžื™ืงื•ืžื™ืš ื”ืžื•ืขื“ืคื™ื ื‘ืื•ืคืŸ ืžื™ื™ื“ื™. ืฉื“ืจื’ ืœืคืจื™ืžื™ื•ื ื›ื“ื™ ืœื”ืฉื™ื’ ื ืขื™ืœืช ืชื›ื•ื ื” ื–ื•. + ื›ื™ืฉืœื•ืŸ ื‘ื˜ืขื™ื ืช ืžื™ืงื•ืžื™ื ืฉืžื•ืจื™ื. + ืžื™ืงื•ื ื ืฉืžืจ ื‘ื”ืฆืœื—ื”. + ืžื™ืงื•ื ื”ื•ืกืจ. + ืžื™ืงื•ืžื™ื ืฉืžื•ืจื™ื + ื”ื•ืกืฃ ืžื™ืงื•ื ื—ื“ืฉ + ืžื—ืง ืžื™ืงื•ื + + + ื—ืคืฉ ืžื™ืงื•ื + ื”ืงืœื“ ืขื™ืจ ืื• ื›ืชื•ื‘ืชโ€ฆ + ืœื ื ืžืฆืื• ืžื™ืงื•ืžื™ื ืขื‘ื•ืจ \'%1$s\' + ืœื”ืฉืชืžืฉ ื›ืžื™ืงื•ื ืžื–ื’ ื”ืื•ื•ื™ืจ? + ื ืชื•ื ื™ ืžื–ื’ ื”ืื•ื•ื™ืจ ื™ื•ืฆื’ื• ืขื‘ื•ืจ %1$s ื‘ืžืงื•ื ืžื™ืงื•ื ื”-GPS ื”ื ื•ื›ื—ื™ ืฉืœืš. + ืžื™ืงื•ื ื”-GPS ื”ื—ื™ ืฉืœืš ืœื ื™ืชืขื“ื›ืŸ ื›ืœ ืขื•ื“ ื–ื” ืคืขื™ืœ. + ื”ื’ื“ืจ ื›ื‘ืจื™ืจืช ืžื—ื“ืœ + ืžืฉืชืžืฉ ื‘-%1$s + ืืคืก ืœ-GPS + ื›ืจื’ืข ืžื•ืฆื’ ืžื–ื’ ื”ืื•ื•ื™ืจ ืขื‘ื•ืจ %1$s. ื”ืงืฉ ื›ื“ื™ ืœืืคืก ืœ-GPS. diff --git a/app/src/main/res/values-kn/strings.xml b/app/src/main/res/values-kn/strings.xml new file mode 100644 index 00000000..4c784975 --- /dev/null +++ b/app/src/main/res/values-kn/strings.xml @@ -0,0 +1,118 @@ + + + + เฒธเณเฒชเณเฒฒเณเฒฏเฒพเฒทเณ + เฒฎเณเฒ–เฒชเณเฒŸ + เฒตเฒพเฒฏเณ เฒ—เณเฒฃเฒฎเฒŸเณเฒŸ + เฒจเฒ—เฒฐเฒ—เฒณเณ + เฒชเณเฒฐเณŠเฒซเณˆเฒฒเณ + เฒธเณ†เฒŸเณเฒŸเฒฟเฒ‚เฒ—เณโ€Œเฒ—เฒณเณ + + + home_nested_nav + profile_nested_nav + + + Weatherify + เฒฎเฒคเณเฒคเณ† เฒชเณเฒฐเฒฏเฒคเณเฒจเฒฟเฒธเฒฟ + เฒ—เฒ‚เฒŸเณ†เฒ—เฒŸเณเฒŸเฒฒเณ† เฒฎเณเฒจเณเฒธเณ‚เฒšเฒจเณ† + เฒฆเณˆเฒจเฒ‚เฒฆเฒฟเฒจ เฒฎเณเฒจเณเฒธเณ‚เฒšเฒจเณ† + %1$sเฒ•เฒฟเฒฎเณ€/เฒ— + %1$sยฐ เฒŽเฒ‚เฒฆเณ เฒ…เฒจเฒฟเฒธเณเฒคเณเฒคเฒฆเณ† + เฒจเฒ—เฒฐ เฒ†เฒฏเณเฒ•เณ†เฒฎเฒพเฒกเฒฟ + เฒตเฒพเฒฏเณ เฒ—เณเฒฃเฒฎเฒŸเณเฒŸ + เฒนเฒฟเฒ‚เฒฆเณ† เฒนเณ‹เฒ—เฒฟ + เฒ…เฒฐเณเฒฅเฒตเฒพเฒฏเฒฟเฒคเณ + + + เฒ…เฒจเฒงเฒฟเฒ•เณƒเฒค เฒชเณเฒฐเฒตเณ‡เฒถ! + เฒจเฒ—เฒฐ เฒ•เฒ‚เฒกเณเฒฌเฒฐเฒฒเฒฟเฒฒเณเฒฒ! + เฒธเฒฐเณเฒตเฒฐเณ เฒธเฒฎเฒธเณเฒฏเณ† เฒ•เฒ‚เฒกเณเฒฌเฒฐเณเฒคเณเฒคเฒฆเณ†! + เฒ‡เฒ‚เฒŸเฒฐเณเฒจเณ†เฒŸเณ เฒธเฒ‚เฒชเฒฐเณเฒ• เฒฎเฒฐเณ-เฒชเฒฐเฒฟเฒถเณ€เฒฒเฒฟเฒธเฒฌเฒนเณเฒฆเณ‡! + เฒ‡เฒ‚เฒŸเฒฐเณเฒจเณ†เฒŸเณ เฒธเฒ‚เฒชเฒฐเณเฒ• เฒฎเฒฐเณ-เฒชเฒฐเฒฟเฒถเณ€เฒฒเฒฟเฒธเฒฌเฒนเณเฒฆเณ‡! + เฒตเฒฟเฒจเฒ‚เฒคเฒฟเฒฏ เฒธเฒฎเฒฏ เฒฎเณ€เฒฐเฒฟเฒคเณ. เฒฆเฒฏเฒตเฒฟเฒŸเณเฒŸเณ เฒจเฒ‚เฒคเฒฐ เฒฎเฒคเณเฒคเณ† เฒชเณเฒฐเฒฏเฒคเณเฒจเฒฟเฒธเฒฟ. + เฒ…เฒฏเณเฒฏเณ‹!.. เฒเฒจเณ‹ เฒคเฒชเณเฒชเฒพเฒ—เฒฟเฒฆเณ†. + เฒธเณเฒฅเฒณ เฒธเณ‡เฒตเณ†เฒ—เฒณเณ เฒ†เฒซเณ เฒ†เฒ—เฒฟเฒฆเณ†. เฒธเณเฒฅเฒณเณ€เฒฏ เฒนเฒตเฒพเฒฎเฒพเฒจ เฒชเฒกเณ†เฒฏเฒฒเณ GPS เฒ†เฒจเณ เฒฎเฒพเฒกเฒฟ. + GPS เฒ†เฒจเณ เฒฎเฒพเฒกเฒฟ + เฒธเณเฒฅเฒณ เฒจเฒฟเฒฐเณเฒฆเณ‡เฒถเฒพเฒ‚เฒ•เฒ—เฒณเฒจเณเฒจเณ เฒ‡เฒจเณเฒจเณ‚ เฒจเฒตเณ€เฒ•เฒฐเฒฟเฒธเฒฒเฒพเฒ—เฒฟเฒฒเณเฒฒ. + เฒˆ เฒ•เณเฒทเฒฃ เฒฏเฒพเฒต เฒจเฒ—เฒฐเฒ—เฒณเณ‚ เฒ•เฒ‚เฒกเณเฒฌเฒฐเฒฒเฒฟเฒฒเณเฒฒ. เฒจเฒ‚เฒคเฒฐ เฒฎเฒคเณเฒคเณŠเฒฎเณเฒฎเณ† เฒชเฒฐเฒฟเฒถเณ€เฒฒเฒฟเฒธเฒฟ + เฒตเฒฟเฒตเฒฐเฒ—เฒณเณ เฒ•เฒ‚เฒกเณเฒฌเฒฐเฒฒเฒฟเฒฒเณเฒฒ. เฒธเณเฒตเฒฒเณเฒช เฒธเฒฎเฒฏเฒฆ เฒจเฒ‚เฒคเฒฐ เฒชเณเฒฐเฒฏเฒคเณเฒจเฒฟเฒธเฒฟ. + + + เฒชเณเฒฐเณŠเฒซเณˆเฒฒเณ + เฒฒเฒพเฒ—เณ เฒ”เฒŸเณ + เฒจเณ€เฒตเณ เฒ–เฒšเฒฟเฒคเฒตเฒพเฒ—เฒฟ เฒฒเฒพเฒ—เณ เฒ”เฒŸเณ เฒฎเฒพเฒกเฒฒเณ เฒฌเฒฏเฒธเณเฒตเฒฟเฒฐเฒพ? + เฒจเฒฟเฒฎเณเฒฎ เฒ–เฒพเฒคเณ† เฒชเณเฒฐเฒตเณ‡เฒถเฒฟเฒธเฒฒเณ เฒฎเฒคเณเฒคเณ† เฒฒเฒพเฒ—เฒฟเฒจเณ เฒฎเฒพเฒกเฒฌเณ‡เฒ•เฒพเฒ—เณเฒคเณเฒคเฒฆเณ†. + เฒฆเณƒเฒขเณ€เฒ•เฒฐเฒฟเฒธเฒฟ + เฒฐเฒฆเณเฒฆเณเฒฎเฒพเฒกเฒฟ + เฒ…เฒงเฒฟเฒธเณ‚เฒšเฒจเณ†เฒ—เฒณเณ + เฒญเฒพเฒทเณ† + เฒ—เณ‹เฒชเณเฒฏเฒคเฒพ เฒจเณ€เฒคเฒฟ + เฒฌเฒณเฒ•เณ† เฒจเฒฟเฒฏเฒฎเฒ—เฒณเณ + เฒ†เณเฒฏเฒชเณ เฒ†เฒตเณƒเฒคเณเฒคเฒฟ + เฒธเณ†เฒŸเณเฒŸเฒฟเฒ‚เฒ—เณโ€Œเฒ—เฒณเณ + เฒ•เฒพเฒจเณ‚เฒจเณ + + + เฒนเฒตเฒพเฒฎเฒพเฒจ เฒธเณเฒฅเฒฟเฒคเฒฟ เฒเฒ•เฒพเฒจเณ + เฒญเฒพเฒทเณ† เฒเฒ•เฒพเฒจเณ + เฒฎเณ†เฒจเณ เฒเฒ•เฒพเฒจเณ + เฒ—เฒพเฒณเฒฟ เฒเฒ•เฒพเฒจเณ + เฒ†เฒฐเณเฒฆเณเฒฐเฒคเณ† เฒเฒ•เฒพเฒจเณ + เฒฆเณ‹เฒท เฒเฒ•เฒพเฒจเณ + เฒฎเณเฒšเณเฒšเฒฟ เฒเฒ•เฒพเฒจเณ + เฒนเฒฟเฒ‚เฒฆเณ† เฒฌเฒŸเฒจเณ + เฒ…เฒงเฒฟเฒธเณ‚เฒšเฒจเณ† เฒธเณ†เฒŸเณเฒŸเฒฟเฒ‚เฒ—เณโ€Œเฒ—เฒณ เฒเฒ•เฒพเฒจเณ + เฒ—เณ‹เฒชเณเฒฏเฒคเฒพ เฒจเณ€เฒคเฒฟ เฒเฒ•เฒพเฒจเณ + เฒฌเฒณเฒ•เณ† เฒจเฒฟเฒฏเฒฎเฒ—เฒณ เฒเฒ•เฒพเฒจเณ + เฒฎเฒพเฒนเฒฟเฒคเฒฟ เฒเฒ•เฒพเฒจเณ + เฒฎเณเฒ‚เฒฆเฒฟเฒจ เฒชเฒฐเฒฆเณ†เฒ—เณ† เฒจเณเฒฏเฒพเฒตเฒฟเฒ—เณ‡เฒŸเณ เฒฎเฒพเฒกเฒฟ + เฒญเฒพเฒทเฒพ เฒธเฒ‚เฒฐเฒšเฒจเณ† เฒฒเณ‹เฒกเณ เฒฎเฒพเฒกเฒฒเณ เฒตเฒฟเฒซเฒฒเฒตเฒพเฒ—เฒฟเฒฆเณ†. เฒกเฒฟเฒซเฒพเฒฒเณเฒŸเณ เฒญเฒพเฒทเณ† เฒฌเฒณเฒธเฒฒเฒพเฒ—เณเฒคเณเฒคเฒฟเฒฆเณ†. + + + เฒจเฒตเณ€เฒ•เฒฐเฒฟเฒคเฒฐเฒพเฒ—เฒฟเฒฐเฒฟ + เฒนเฒตเฒพเฒฎเฒพเฒจ เฒŽเฒšเณเฒšเฒฐเฒฟเฒ•เณ†เฒ—เฒณเฒฟเฒ—เฒพเฒ—เฒฟ เฒ…เฒงเฒฟเฒธเณ‚เฒšเฒจเณ†เฒ—เฒณเฒจเณเฒจเณ เฒธเฒ•เณเฒฐเฒฟเฒฏเฒ—เณŠเฒณเฒฟเฒธเฒฟ + เฒธเฒ•เณเฒฐเฒฟเฒฏเฒ—เณŠเฒณเฒฟเฒธเฒฟ + + + เฒชเณเฒฐเณ€เฒฎเฒฟเฒฏเฒ‚ เฒชเฒกเณ†เฒฏเฒฟเฒฐเฒฟ + เฒธเฒ‚เฒธเณเฒ•เฒฐเฒฃเณ†เฒฏเฒฒเณเฒฒเฒฟโ€ฆ + เฒ†เฒชเณเฒค เฒชเณเฒฐเณ€เฒฎเฒฟเฒฏเฒ‚ เฒ—เณเฒฐเฒพเฒนเฒ•เฒคเณเฒตเฒตเฒจเณเฒจเณ เฒธเฒ•เณเฒฐเฒฟเฒฏเฒ—เณŠเฒณเฒฟเฒธเณเฒตเฒพเฒ— เฒฆเฒฏเฒตเฒฟเฒŸเณเฒŸเณ เฒคเฒกเณ†เฒฆเณเฒ•เณŠเฒณเณเฒณเฒฟ. + เฒŽเฒฒเณเฒฒเฒพ เฒตเณˆเฒถเฒฟเฒทเณเฒŸเณเฒฏเฒ—เฒณเฒจเณเฒจเณ เฒ…เฒจเณโ€Œเฒฒเฒพเฒ•เณ เฒฎเฒพเฒกเฒฟ เฒฎเฒคเณเฒคเณ เฒตเฒฟเฒœเณเฒžเฒพเฒชเฒจ-เฒฎเณเฒ•เณเฒค เฒ…เฒจเณเฒญเฒตเฒตเฒจเณเฒจเณ เฒ†เฒจเฒ‚เฒฆเฒฟเฒธเฒฟ. + เฒˆเฒ— เฒ…เฒชเณโ€Œเฒ—เณเฒฐเณ‡เฒกเณ เฒฎเฒพเฒกเฒฟ + เฒจเณ€เฒตเณ เฒชเณเฒฐเณ€เฒฎเฒฟเฒฏเฒ‚ เฒฌเฒณเฒ•เณ†เฒฆเฒพเฒฐ + %1$s เฒ—เฒพเฒ—เฒฟ เฒฎเณเฒ•เณเฒคเฒพเฒฏ + เฒธเฒ•เณเฒฐเฒฟเฒฏ + เฒชเณเฒฐเณ€เฒฎเฒฟเฒฏเฒ‚ เฒธเฒ•เณเฒฐเฒฟเฒฏเฒ—เณŠเฒณเฒฟเฒธเฒฒเฒพเฒ—เฒฟเฒฆเณ† + เฒจเฒฟเฒฎเณเฒฎ เฒชเณเฒฐเณ€เฒฎเฒฟเฒฏเฒ‚ เฒ—เณเฒฐเฒพเฒนเฒ•เฒคเณเฒต เฒˆเฒ— เฒธเฒ•เณเฒฐเฒฟเฒฏเฒตเฒพเฒ—เฒฟเฒฆเณ†! + + + เฒ‰เฒณเฒฟเฒธเฒฒเฒพเฒฆ เฒธเณเฒฅเฒณเฒ—เฒณเณ + saved_locations_nested_nav + เฒ‰เฒณเฒฟเฒธเฒฒเฒพเฒฆ เฒธเณเฒฅเฒณเฒ—เฒณเณ + เฒ‡เฒจเณเฒจเณ‚ เฒธเฒ‚เฒฐเฒ•เณเฒทเฒฟเฒค เฒธเณเฒฅเฒณเฒ—เฒณเฒฟเฒฒเณเฒฒ. เฒธเณ‡เฒฐเฒฟเฒธเฒฒเณ + เฒŸเณเฒฏเฒพเฒชเณ เฒฎเฒพเฒกเฒฟ. + เฒธเณเฒฅเฒณ เฒธเณ‡เฒฐเฒฟเฒธเฒฟ + เฒ…เฒณเฒฟเฒธเฒฟ + เฒ‰เฒณเฒฟเฒธเฒฟ + เฒธเณเฒฅเฒณ เฒนเณ†เฒธเฒฐเณ (เฒ‰เฒฆเฒพ. เฒฎเฒจเณ†) + เฒชเณเฒฐเณ€เฒฎเฒฟเฒฏเฒ‚ เฒตเณˆเฒถเฒฟเฒทเณเฒŸเณเฒฏ + เฒจเฒฟเฒฎเณเฒฎ เฒฎเณ†เฒšเณเฒšเณเฒต เฒธเณเฒฅเฒณเฒ—เฒณเฒจเณเฒจเณ เฒ‰เฒณเฒฟเฒธเฒฟ เฒฎเฒคเณเฒคเณ เฒคเฒ•เณเฒทเฒฃ เฒชเณเฒฐเฒตเณ‡เฒถเฒฟเฒธเฒฟ. เฒˆ เฒตเณˆเฒถเฒฟเฒทเณเฒŸเณเฒฏเฒตเฒจเณเฒจเณ เฒ…เฒจเณโ€Œเฒฒเฒพเฒ•เณ เฒฎเฒพเฒกเฒฒเณ เฒชเณเฒฐเณ€เฒฎเฒฟเฒฏเฒ‚เฒ—เณ† เฒ…เฒชเณโ€Œเฒ—เณเฒฐเณ‡เฒกเณ เฒฎเฒพเฒกเฒฟ. + เฒ‰เฒณเฒฟเฒธเฒฒเฒพเฒฆ เฒธเณเฒฅเฒณเฒ—เฒณเฒจเณเฒจเณ เฒฒเณ‹เฒกเณ เฒฎเฒพเฒกเฒฒเณ เฒตเฒฟเฒซเฒฒเฒตเฒพเฒ—เฒฟเฒฆเณ†. + เฒธเณเฒฅเฒณ เฒฏเฒถเฒธเณเฒตเฒฟเฒฏเฒพเฒ—เฒฟ เฒ‰เฒณเฒฟเฒธเฒฒเฒพเฒ—เฒฟเฒฆเณ†. + เฒธเณเฒฅเฒณ เฒคเณ†เฒ—เณ†เฒฆเณเฒนเฒพเฒ•เฒฒเฒพเฒ—เฒฟเฒฆเณ†. + เฒ‰เฒณเฒฟเฒธเฒฒเฒพเฒฆ เฒธเณเฒฅเฒณเฒ—เฒณเณ + เฒนเณŠเฒธ เฒธเณเฒฅเฒณ เฒธเณ‡เฒฐเฒฟเฒธเฒฟ + เฒธเณเฒฅเฒณ เฒ…เฒณเฒฟเฒธเฒฟ + + + เฒธเณเฒฅเฒณเฒตเฒจเณเฒจเณ เฒนเณเฒกเณเฒ•เฒฟ + เฒชเฒŸเณเฒŸเฒฃ เฒ…เฒฅเฒตเฒพ เฒตเฒฟเฒณเฒพเฒธ เฒŸเณˆเฒชเณ เฒฎเฒพเฒกเฒฟโ€ฆ + \'%1$s\'เฒ—เณ† เฒฏเฒพเฒตเณเฒฆเณ‡ เฒธเณเฒฅเฒณเฒ—เฒณเณ เฒ•เฒ‚เฒกเณเฒฌเฒ‚เฒฆเฒฟเฒฒเณเฒฒ + เฒนเฒตเฒพเฒฎเฒพเฒจ เฒธเณเฒฅเฒณเฒตเฒพเฒ—เฒฟ เฒฌเฒณเฒธเณเฒตเฒฟเฒฐเฒพ? + เฒจเฒฟเฒฎเณเฒฎ เฒชเณเฒฐเฒธเณเฒคเณเฒค GPS เฒธเณเฒฅเฒณเฒฆ เฒฌเฒฆเฒฒเฒฟเฒ—เณ† %1$s เฒ—เฒพเฒ—เฒฟ เฒนเฒตเฒพเฒฎเฒพเฒจ เฒกเณ‡เฒŸเฒพ เฒคเณ‹เฒฐเฒฟเฒธเฒฒเฒพเฒ—เณเฒคเณเฒคเฒฆเณ†. + เฒ‡เฒฆเณ เฒธเฒ•เณเฒฐเฒฟเฒฏเฒตเฒพเฒ—เฒฟเฒฐเณเฒตเฒพเฒ— เฒจเฒฟเฒฎเณเฒฎ เฒฒเณˆเฒตเณ GPS เฒธเณเฒฅเฒณ เฒ…เฒชเณโ€Œเฒกเณ‡เฒŸเณ เฒ†เฒ—เณเฒตเณเฒฆเฒฟเฒฒเณเฒฒ. + เฒกเฒฟเฒซเฒพเฒฒเณเฒŸเณ เฒ†เฒ—เฒฟ เฒนเณŠเฒ‚เฒฆเฒฟเฒธเฒฟ + %1$s เฒฌเฒณเฒธเฒฒเฒพเฒ—เณเฒคเณเฒคเฒฟเฒฆเณ† + GPS เฒ—เณ† เฒฐเณ€เฒธเณ†เฒŸเณ เฒฎเฒพเฒกเฒฟ + เฒชเณเฒฐเฒธเณเฒคเณเฒค %1$s เฒ—เฒพเฒ—เฒฟ เฒนเฒตเฒพเฒฎเฒพเฒจ เฒคเณ‹เฒฐเฒฟเฒธเฒฒเฒพเฒ—เณเฒคเณเฒคเฒฟเฒฆเณ†. GPS เฒ—เณ† เฒฐเณ€เฒธเณ†เฒŸเณ เฒฎเฒพเฒกเฒฒเณ เฒŸเณเฒฏเฒพเฒชเณ เฒฎเฒพเฒกเฒฟ. + diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml new file mode 100644 index 00000000..8af37519 --- /dev/null +++ b/app/src/main/res/values-ml/strings.xml @@ -0,0 +1,118 @@ + + + + เดธเตโ€Œเดชเตเดฒเดพเดทเต + เดนเต‹เด‚ + เดตเดพเดฏเต เด—เตเดฃเดจเดฟเดฒเดตเดพเดฐเด‚ + เดจเด—เดฐเด™เตเด™เตพ + เดชเตเดฐเตŠเดซเตˆเตฝ + เด•เตเดฐเดฎเต€เด•เดฐเดฃเด™เตเด™เตพ + + + home_nested_nav + profile_nested_nav + + + Weatherify + เดตเต€เดฃเตเดŸเตเด‚ เดถเตเดฐเดฎเดฟเด•เตเด•เตเด• + เดฎเดฃเดฟเด•เตเด•เต‚เตผ เดคเต‹เดฑเตเด‚ เดชเตเดฐเดตเดšเดจเด‚ + เดฆเตˆเดจเด‚เดฆเดฟเดจ เดชเตเดฐเดตเดšเดจเด‚ + %1$sเด•เดฟ.เดฎเต€/เดฎ + %1$sยฐ เดชเต‹เดฒเต† เดคเต‹เดจเตเดจเตเดจเตเดจเต + เดจเด—เดฐเด‚ เดคเดฟเดฐเดžเตเดžเต†เดŸเตเด•เตเด•เตเด• + เดตเดพเดฏเต เด—เตเดฃเดจเดฟเดฒเดตเดพเดฐเด‚ + เดคเดฟเดฐเดฟเดšเตเดšเตเดชเต‹เด•เตเด• + เดฎเดจเดธเตเดธเดฟเดฒเดพเดฏเดฟ + + + เด…เดจเดงเดฟเด•เตƒเดค เด†เด•เตเดธเดธเต! + เดจเด—เดฐเด‚ เด•เดฃเตเดŸเต†เดคเตเดคเดฟเดฏเดฟเดฒเตเดฒ! + เดธเตผเดตเตผ เดชเตเดฐเดถเตโ€Œเดจเด‚ เด‰เดณเตเดณเดคเดพเดฏเดฟ เดคเต‹เดจเตเดจเตเดจเตเดจเต! + เด‡เดจเตเดฑเตผเดจเต†เดฑเตเดฑเต เด•เดฃเด•เตเดฑเตเดฑเดฟเดตเดฟเดฑเตเดฑเดฟ เดตเต€เดฃเตเดŸเตเด‚ เดชเดฐเดฟเดถเต‹เดงเดฟเด•เตเด•เดพเดฎเต‹! + เด‡เดจเตเดฑเตผเดจเต†เดฑเตเดฑเต เด•เดฃเด•เตเดฑเตเดฑเดฟเดตเดฟเดฑเตเดฑเดฟ เดตเต€เดฃเตเดŸเตเด‚ เดชเดฐเดฟเดถเต‹เดงเดฟเด•เตเด•เดพเดฎเต‹! + เด…เดญเตเดฏเตผเดฅเดจเดฏเตเดŸเต† เดธเดฎเดฏเด‚ เด•เดดเดฟเดžเตเดžเต. เดฆเดฏเดตเดพเดฏเดฟ เดชเดฟเดจเตเดจเต€เดŸเต เดตเต€เดฃเตเดŸเตเด‚ เดถเตเดฐเดฎเดฟเด•เตเด•เตเด•. + เด…เดฏเตเดฏเต‹!.. เดŽเดจเตเดคเต‹ เดคเด•เดฐเดพเตผ เดธเด‚เดญเดตเดฟเดšเตเดšเต. + เดฒเตŠเด•เตเด•เต‡เดทเตป เดธเต‡เดตเดจเด™เตเด™เตพ เด“เดซเต เด†เดฃเต. เดชเตเดฐเดพเดฆเต‡เดถเดฟเด• เด•เดพเดฒเดพเดตเดธเตเดฅ เดฒเดญเดฟเด•เตเด•เดพเตป GPS เด“เตบ เดšเต†เดฏเตเดฏเตเด•. + GPS เด“เตบ เดšเต†เดฏเตเดฏเตเด• + เดฒเตŠเด•เตเด•เต‡เดทเตป เด•เต‹เตผเดกเดฟเดจเต‡เดฑเตเดฑเตเด•เตพ เด‡เดคเตเดตเดฐเต† เด…เดชเตโ€Œเดกเต‡เดฑเตเดฑเต เดšเต†เดฏเตเดคเดฟเดŸเตเดŸเดฟเดฒเตเดฒ. + เด‡เดชเตเดชเต‹เตพ เด’เดฐเต เดจเด—เดฐเดตเตเด‚ เด•เดฃเตเดŸเต†เดคเตเดคเดฟเดฏเดฟเดฒเตเดฒ. เดชเดฟเดจเตเดจเต€เดŸเต เดชเดฐเดฟเดถเต‹เดงเดฟเด•เตเด•เตเด• + เดตเดฟเดตเดฐเด™เตเด™เตพ เด•เดฃเตเดŸเต†เดคเตเดคเดฟเดฏเดฟเดฒเตเดฒ. เด•เตเดฑเดšเตเดšเต เดธเดฎเดฏเด‚ เด•เดดเดฟเดžเตเดžเต เดถเตเดฐเดฎเดฟเด•เตเด•เตเด•. + + + เดชเตเดฐเตŠเดซเตˆเตฝ + เดฒเต‹เด—เต เด”เดŸเตเดŸเต + เดจเดฟเด™เตเด™เตพเด•เตเด•เต เด‰เดฑเดชเตเดชเดพเดฏเตเด‚ เดฒเต‹เด—เต เด”เดŸเตเดŸเต เดšเต†เดฏเตเดฏเดฃเต‹? + เดจเดฟเด™เตเด™เดณเตเดŸเต† เด…เด•เตเด•เต—เดฃเตเดŸเต เด†เด•เตเดธเดธเต เดšเต†เดฏเตเดฏเดพเตป เดตเต€เดฃเตเดŸเตเด‚ เดฒเต‹เด—เดฟเตป เดšเต†เดฏเตเดฏเต‡เดฃเตเดŸเดคเดพเดฏเดฟ เดตเดฐเตเด‚. + เด‰เดฑเดชเตเดชเดพเด•เตเด•เตเด• + เดฑเดฆเตเดฆเดพเด•เตเด•เตเด• + เด…เดฑเดฟเดฏเดฟเดชเตเดชเตเด•เตพ + เดญเดพเดท + เดธเตเดตเด•เดพเดฐเตเดฏเดคเดพ เดจเดฏเด‚ + เด‰เดชเดฏเต‹เด— เดจเดฟเดฌเดจเตเดงเดจเด•เตพ + เด†เดชเตเดชเต เดชเดคเดฟเดชเตเดชเต + เด•เตเดฐเดฎเต€เด•เดฐเดฃเด™เตเด™เตพ + เดจเดฟเดฏเดฎเด‚ + + + เด•เดพเดฒเดพเดตเดธเตเดฅ เดเด•เตเด•เตบ + เดญเดพเดท เดเด•เตเด•เตบ + เดฎเต†เดจเต เดเด•เตเด•เตบ + เด•เดพเดฑเตเดฑเต เดเด•เตเด•เตบ + เดˆเตผเดชเตเดชเด‚ เดเด•เตเด•เตบ + เดชเดฟเดถเด•เต เดเด•เตเด•เตบ + เด…เดŸเดฏเตโ€Œเด•เตเด•เตเด• เดเด•เตเด•เตบ + เดชเดฟเดฑเด•เต‹เดŸเตเดŸเต เดฌเดŸเตเดŸเตบ + เด…เดฑเดฟเดฏเดฟเดชเตเดชเต เด•เตเดฐเดฎเต€เด•เดฐเดฃ เดเด•เตเด•เตบ + เดธเตเดตเด•เดพเดฐเตเดฏเดคเดพ เดจเดฏ เดเด•เตเด•เตบ + เด‰เดชเดฏเต‹เด— เดจเดฟเดฌเดจเตเดงเดจเด•เตพ เดเด•เตเด•เตบ + เดตเดฟเดตเดฐเด‚ เดเด•เตเด•เตบ + เด…เดŸเตเดคเตเดค เดธเตโ€Œเด•เตเดฐเต€เดจเดฟเดฒเต‡เด•เตเด•เต เดจเดพเดตเดฟเด—เต‡เดฑเตเดฑเต เดšเต†เดฏเตเดฏเตเด• + เดญเดพเดท เด•เต‹เตบเดซเดฟเด—เดฑเต‡เดทเตป เดฒเต‹เดกเต เดšเต†เดฏเตเดฏเตเดจเตเดจเดคเดฟเตฝ เดชเดฐเดพเดœเดฏเดชเตเดชเต†เดŸเตเดŸเต. เดกเดฟเดซเต‹เตพเดŸเตเดŸเต เดญเดพเดท เด‰เดชเดฏเต‹เด—เดฟเด•เตเด•เตเดจเตเดจเต. + + + เด…เดชเตโ€Œเดกเต‡เดฑเตเดฑเต เด†เดฏเดฟเดฐเดฟเด•เตเด•เตเด• + เด•เดพเดฒเดพเดตเดธเตเดฅ เด…เดฒเต‡เตผเดŸเตเดŸเตเด•เตพเด•เตเด•เต เด…เดฑเดฟเดฏเดฟเดชเตเดชเตเด•เตพ เด“เตบ เดšเต†เดฏเตเดฏเตเด• + เด“เตบ เดšเต†เดฏเตเดฏเตเด• + + + เดชเตเดฐเดฟเดฎเดฟเดฏเด‚ เดจเต‡เดŸเตเด• + เดชเตเดฐเต‹เดธเต†เดธเตเดธเดฟเด‚เด—เตโ€ฆ + เดžเด™เตเด™เตพ เดจเดฟเด™เตเด™เดณเตเดŸเต† เดชเตเดฐเดฟเดฎเดฟเดฏเด‚ เดธเดฌเตโ€Œเดธเตเด•เตเดฐเดฟเดชเตเดทเตป เดธเดœเตเดœเดฎเดพเด•เตเด•เตเดจเตเดจเดคเดฟเดจเดพเดฏเดฟ เดฆเดฏเดตเดพเดฏเดฟ เด•เดพเดคเตเดคเดฟเดฐเดฟเด•เตเด•เตเด•. + เดŽเดฒเตเดฒเดพ เดซเต€เดšเตเดšเดฑเตเด•เตพ เด…เตบเดฒเต‹เด•เตเด•เต เดšเต†เดฏเตเดฏเตเด• เด•เต‚เดŸเดพเดคเต† เดชเดฐเดธเตเดฏเดฐเดนเดฟเดค เด…เดจเตเดญเดตเด‚ เด†เดธเตเดตเดฆเดฟเด•เตเด•เตเด•. + เด‡เดชเตเดชเต‹เตพ เด…เดชเตโ€Œเด—เตเดฐเต‡เดกเต เดšเต†เดฏเตเดฏเตเด• + เดจเดฟเด™เตเด™เตพ เดชเตเดฐเดฟเดฎเดฟเดฏเด‚ เด‰เดชเดฏเต‹เด•เตเดคเดพเดตเดพเดฃเต + %1$s เด†เดฏเดฟ เดธเตเดซเตเดŸเดฟเด•เตเด•เตเดจเตเดจเต + เดธเดœเต€เดต + เดชเตเดฐเดฟเดฎเดฟเดฏเด‚ เดธเดœเต€เดตเดฎเดพเดฃเต + เดจเดฟเด™เตเด™เดณเตเดŸเต† เดชเตเดฐเดฟเดฎเดฟเดฏเด‚ เดธเดฌเตโ€Œเดธเตเด•เตเดฐเดฟเดชเตเดทเตป เด‡เดชเตเดชเต‹เตพ เดธเดœเต€เดตเดฎเดพเดฃเต! + + + เดธเด‚เดฐเด•เตเดทเดฟเดค เดธเตเดฅเดพเดจเด™เตเด™เตพ + saved_locations_nested_nav + เดธเด‚เดฐเด•เตเดทเดฟเดค เดธเตเดฅเดพเดจเด™เตเด™เตพ + เด‡เดคเตเดตเดฐเต† เดธเด‚เดฐเด•เตเดทเดฟเดค เดธเตเดฅเดพเดจเด™เตเด™เตพ เด‡เดฒเตเดฒ. เด’เดจเตเดจเต เดšเต‡เตผเด•เตเด•เดพเตป + เดŸเดพเดชเตเดชเต เดšเต†เดฏเตเดฏเตเด•. + เดธเตเดฅเดพเดจเด‚ เดšเต‡เตผเด•เตเด•เตเด• + เด‡เดฒเตเดฒเดพเดคเดพเด•เตเด•เตเด• + เดธเด‚เดฐเด•เตเดทเดฟเด•เตเด•เตเด• + เดธเตเดฅเดพเดจเดคเตเดคเดฟเดจเตเดฑเต† เดชเต‡เดฐเต (เด‰เดฆเดพ. เดตเต€เดŸเต) + เดชเตเดฐเดฟเดฎเดฟเดฏเด‚ เดซเต€เดšเตเดšเตผ + เดจเดฟเด™เตเด™เดณเตเดŸเต† เดชเตเดฐเดฟเดฏเดชเตเดชเต†เดŸเตเดŸ เดธเตเดฅเดพเดจเด™เตเด™เตพ เดธเด‚เดฐเด•เตเดทเดฟเด•เตเด•เตเด• เด•เต‚เดŸเดพเดคเต† เด‰เดŸเดจเดŸเดฟ เด†เด•เตเดธเดธเต เดšเต†เดฏเตเดฏเตเด•. เดˆ เดซเต€เดšเตเดšเตผ เด…เตบเดฒเต‹เด•เตเด•เต เดšเต†เดฏเตเดฏเดพเตป เดชเตเดฐเดฟเดฎเดฟเดฏเดฎเดพเดฏเดฟ เด…เดชเตโ€Œเด—เตเดฐเต‡เดกเต เดšเต†เดฏเตเดฏเตเด•. + เดธเด‚เดฐเด•เตเดทเดฟเดค เดธเตเดฅเดพเดจเด™เตเด™เตพ เดฒเต‹เดกเต เดšเต†เดฏเตเดฏเดพเตป เดชเดฐเดพเดœเดฏเดชเตเดชเต†เดŸเตเดŸเต. + เดธเตเดฅเดพเดจเด‚ เดตเดฟเดœเดฏเด•เดฐเดฎเดพเดฏเดฟ เดธเด‚เดฐเด•เตเดทเดฟเด•เตเด•เดชเตเดชเต†เดŸเตเดŸเต. + เดธเตเดฅเดพเดจเด‚ เดจเต€เด•เตเด•เด‚ เดšเต†เดฏเตเดฏเดชเตเดชเต†เดŸเตเดŸเต. + เดธเด‚เดฐเด•เตเดทเดฟเดค เดธเตเดฅเดพเดจเด™เตเด™เตพ + เดชเตเดคเดฟเดฏ เดธเตเดฅเดพเดจเด‚ เดšเต‡เตผเด•เตเด•เตเด• + เดธเตเดฅเดพเดจเด‚ เด‡เดฒเตเดฒเดพเดคเดพเด•เตเด•เตเด• + + + เด’เดฐเต เดธเตเดฅเดพเดจเด‚ เดคเดฟเดฐเดฏเตเด• + เดจเด—เดฐเด‚ เด…เดฒเตเดฒเต†เด™เตเด•เดฟเตฝ เดตเดฟเดฒเดพเดธเด‚ เดŸเตˆเดชเตเดชเต เดšเต†เดฏเตเดฏเตเด•โ€ฆ + \'%1$s\'เด•เตเด•เดพเดฏเดฟ เด’เดฐเต เดธเตเดฅเดพเดจเดตเตเด‚ เด•เดฃเตเดŸเต†เดคเตเดคเดฟเดฏเดฟเดฒเตเดฒ + เด•เดพเดฒเดพเดตเดธเตเดฅ เดธเตเดฅเดพเดจเดฎเดพเดฏเดฟ เด‰เดชเดฏเต‹เด—เดฟเด•เตเด•เดฃเต‹? + เดจเดฟเด™เตเด™เดณเตเดŸเต† เดจเดฟเดฒเดตเดฟเดฒเต† GPS เดธเตเดฅเดพเดจเดคเตเดคเดฟเดจเต เดชเด•เดฐเด‚ %1$s-เดจเตเดฑเต† เด•เดพเดฒเดพเดตเดธเตเดฅ เดกเดพเดฑเตเดฑ เด•เดพเดฃเดฟเด•เตเด•เตเด‚. + เด‡เดคเต เดธเดœเต€เดตเดฎเดพเดฏเดฟเดฐเดฟเด•เตเด•เตเดฎเตเดชเต‹เตพ เดจเดฟเด™เตเด™เดณเตเดŸเต† เดฒเตˆเดตเต GPS เดธเตเดฅเดพเดจเด‚ เด…เดชเตโ€Œเดกเต‡เดฑเตเดฑเต เด†เด•เดฟเดฒเตเดฒ. + เดกเดฟเดซเต‹เตพเดŸเตเดŸเดพเดฏเดฟ เดธเดœเตเดœเดฎเดพเด•เตเด•เตเด• + %1$s เด‰เดชเดฏเต‹เด—เดฟเด•เตเด•เตเดจเตเดจเต + GPS-เดฒเต‡เด•เตเด•เต เดฑเต€เดธเต†เดฑเตเดฑเต เดšเต†เดฏเตเดฏเตเด• + เดจเดฟเดฒเดตเดฟเตฝ %1$s-เดจเตเดฑเต† เด•เดพเดฒเดพเดตเดธเตเดฅ เด•เดพเดฃเดฟเด•เตเด•เตเดจเตเดจเต. GPS-เดฒเต‡เด•เตเด•เต เดฑเต€เดธเต†เดฑเตเดฑเต เดšเต†เดฏเตเดฏเดพเตป เดŸเดพเดชเตเดชเต เดšเต†เดฏเตเดฏเตเด•. + diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml new file mode 100644 index 00000000..2bd960cd --- /dev/null +++ b/app/src/main/res/values-ta/strings.xml @@ -0,0 +1,118 @@ + + + + เฎธเฏเฎชเฎฟเฎณเฎพเฎทเฏ + เฎฎเฏเฎ•เฎชเฏเฎชเฏ + เฎ•เฎพเฎฑเฏเฎฑเฎฟเฎฉเฏ เฎคเฎฐเฎฎเฏ + เฎจเฎ•เฎฐเฎ™เฏเฎ•เฎณเฏ + เฎšเฏเฎฏเฎตเฎฟเฎตเฎฐเฎฎเฏ + เฎ…เฎฎเฏˆเฎชเฏเฎชเฏเฎ•เฎณเฏ + + + home_nested_nav + profile_nested_nav + + + Weatherify + เฎฎเฏ€เฎฃเฏเฎŸเฏเฎฎเฏ เฎฎเฏเฎฏเฎฑเฏเฎšเฎฟ เฎšเฏ†เฎฏเฏเฎ• + เฎฎเฎฃเฎฟเฎจเฏ‡เฎฐ เฎฎเฏเฎฉเฏเฎฉเฎฑเฎฟเฎตเฎฟเฎชเฏเฎชเฏ + เฎคเฎฟเฎฉเฎšเฎฐเฎฟ เฎฎเฏเฎฉเฏเฎฉเฎฑเฎฟเฎตเฎฟเฎชเฏเฎชเฏ + %1$sเฎ•เฎฟเฎฎเฏ€/เฎฎเฎฃเฎฟ + %1$sยฐ เฎชเฏ‹เฎฒเฏ เฎ‰เฎฃเฎฐเฏเฎ•เฎฟเฎฑเฎคเฏ + เฎจเฎ•เฎฐเฎคเฏเฎคเฏˆ เฎคเฏ‡เฎฐเฏเฎจเฏเฎคเฏ†เฎŸเฏเฎ•เฏเฎ•เฎตเฏเฎฎเฏ + เฎ•เฎพเฎฑเฏเฎฑเฎฟเฎฉเฏ เฎคเฎฐเฎฎเฏ + เฎคเฎฟเฎฐเฏเฎฎเฏเฎชเฎฟ เฎšเฏ†เฎฒเฏเฎฒเฎตเฏเฎฎเฏ + เฎชเฏเฎฐเฎฟเฎจเฏเฎคเฎคเฏ + + + เฎ…เฎ™เฏเฎ•เฏ€เฎ•เฎฐเฎฟเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฎพเฎค เฎ…เฎฃเฏเฎ•เฎฒเฏ! + เฎจเฎ•เฎฐเฎฎเฏ เฎ•เฎฃเฏเฎŸเฏเฎชเฎฟเฎŸเฎฟเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฎตเฎฟเฎฒเฏเฎฒเฏˆ! + เฎšเฎฐเฏเฎตเฎฐเฏ เฎšเฎฟเฎ•เฏเฎ•เฎฒเฏ เฎ‡เฎฐเฏเฎชเฏเฎชเฎคเฏ เฎชเฏ‹เฎฒเฏ เฎคเฏ†เฎฐเฎฟเฎ•เฎฟเฎฑเฎคเฏ! + เฎ‡เฎฃเฏˆเฎฏ เฎ‡เฎฃเฏˆเฎชเฏเฎชเฏˆ เฎฎเฏ€เฎฃเฏเฎŸเฏเฎฎเฏ เฎšเฎฐเฎฟเฎชเฎพเฎฐเฏเฎ•เฏเฎ• เฎฎเฏเฎŸเฎฟเฎฏเฏเฎฎเฎพ! + เฎ‡เฎฃเฏˆเฎฏ เฎ‡เฎฃเฏˆเฎชเฏเฎชเฏˆ เฎฎเฏ€เฎฃเฏเฎŸเฏเฎฎเฏ เฎšเฎฐเฎฟเฎชเฎพเฎฐเฏเฎ•เฏเฎ• เฎฎเฏเฎŸเฎฟเฎฏเฏเฎฎเฎพ! + เฎ•เฏ‹เฎฐเฎฟเฎ•เฏเฎ•เฏˆ เฎจเฏ‡เฎฐ เฎตเฎฐเฎฎเฏเฎชเฏ เฎฎเฏ€เฎฑเฎฟเฎฏเฎคเฏ. เฎชเฎฟเฎฑเฎ•เฏ เฎฎเฏ€เฎฃเฏเฎŸเฏเฎฎเฏ เฎฎเฏเฎฏเฎฑเฏเฎšเฎฟเฎ•เฏเฎ•เฎตเฏเฎฎเฏ. + เฎ…เฎšเฏเฎšเฏ‹!.. เฎเฎคเฏ‹ เฎคเฎตเฎฑเฏ เฎจเฎŸเฎจเฏเฎคเฎคเฏ. + เฎ‡เฎŸ เฎšเฏ‡เฎตเฏˆเฎ•เฎณเฏ เฎ…เฎฃเฏˆเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฏเฎŸเฏเฎณเฏเฎณเฎฉ. เฎ‰เฎณเฏเฎณเฏ‚เฎฐเฏ เฎตเฎพเฎฉเฎฟเฎฒเฏˆ เฎชเฏ†เฎฑ GPS เฎ‡เฎฏเฎ•เฏเฎ•เฎตเฏเฎฎเฏ. + GPS เฎ‡เฎฏเฎ•เฏเฎ•เฏ + เฎ‡เฎŸ เฎ†เฎฏเฎ™เฏเฎ•เฎณเฏ เฎ‡เฎฉเฏเฎฉเฏเฎฎเฏ เฎชเฏเฎคเฏเฎชเฏเฎชเฎฟเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฎตเฎฟเฎฒเฏเฎฒเฏˆ. + เฎ‡เฎชเฏเฎชเฏ‹เฎคเฏ เฎŽเฎจเฏเฎค เฎจเฎ•เฎฐเฎฎเฏเฎฎเฏ เฎ•เฎฃเฏเฎŸเฏเฎชเฎฟเฎŸเฎฟเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฎตเฎฟเฎฒเฏเฎฒเฏˆ. เฎชเฎฟเฎฑเฎ•เฏ เฎšเฎฐเฎฟเฎชเฎพเฎฐเฏเฎ•เฏเฎ•เฎตเฏเฎฎเฏ + เฎตเฎฟเฎตเฎฐเฎ™เฏเฎ•เฎณเฏ เฎ•เฎฃเฏเฎŸเฏเฎชเฎฟเฎŸเฎฟเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฎตเฎฟเฎฒเฏเฎฒเฏˆ. เฎšเฎฟเฎฑเฎฟเฎคเฏ เฎจเฏ‡เฎฐเฎฎเฏ เฎ•เฎดเฎฟเฎคเฏเฎคเฏ เฎฎเฏเฎฏเฎฑเฏเฎšเฎฟเฎ•เฏเฎ•เฎตเฏเฎฎเฏ. + + + เฎšเฏเฎฏเฎตเฎฟเฎตเฎฐเฎฎเฏ + เฎตเฏ†เฎณเฎฟเฎฏเฏ‡เฎฑเฏ + เฎจเฏ€เฎ™เฏเฎ•เฎณเฏ เฎจเฎฟเฎšเฏเฎšเฎฏเฎฎเฎพเฎ• เฎตเฏ†เฎณเฎฟเฎฏเฏ‡เฎฑ เฎตเฎฟเฎฐเฏเฎฎเฏเฎชเฏเฎ•เฎฟเฎฑเฏ€เฎฐเฏเฎ•เฎณเฎพ? + เฎ‰เฎ™เฏเฎ•เฎณเฏ เฎ•เฎฃเฎ•เฏเฎ•เฏˆ เฎ…เฎฃเฏเฎ• เฎฎเฏ€เฎฃเฏเฎŸเฏเฎฎเฏ เฎ‰เฎณเฏเฎจเฏเฎดเฏˆเฎฏ เฎตเฏ‡เฎฃเฏเฎŸเฏเฎฎเฏ. + เฎ‰เฎฑเฏเฎคเฎฟเฎชเฏเฎชเฎŸเฏเฎคเฏเฎคเฏ + เฎฐเฎคเฏเฎคเฏ เฎšเฏ†เฎฏเฏ + เฎ…เฎฑเฎฟเฎตเฎฟเฎชเฏเฎชเฏเฎ•เฎณเฏ + เฎฎเฏŠเฎดเฎฟ + เฎคเฎฉเฎฟเฎฏเฏเฎฐเฎฟเฎฎเฏˆเฎ•เฏ เฎ•เฏŠเฎณเฏเฎ•เฏˆ + เฎชเฎฏเฎฉเฏเฎชเฎพเฎŸเฏเฎŸเฏ เฎตเฎฟเฎคเฎฟเฎฎเฏเฎฑเฏˆเฎ•เฎณเฏ + เฎ†เฎชเฏ เฎชเฎคเฎฟเฎชเฏเฎชเฏ + เฎ…เฎฎเฏˆเฎชเฏเฎชเฏเฎ•เฎณเฏ + เฎšเฎŸเฏเฎŸเฎฎเฏ + + + เฎตเฎพเฎฉเฎฟเฎฒเฏˆ เฎจเฎฟเฎฒเฏˆ เฎšเฎฟเฎฉเฏเฎฉเฎฎเฏ + เฎฎเฏŠเฎดเฎฟ เฎšเฎฟเฎฉเฏเฎฉเฎฎเฏ + เฎฎเฏ†เฎฉเฏ เฎšเฎฟเฎฉเฏเฎฉเฎฎเฏ + เฎ•เฎพเฎฑเฏเฎฑเฏ เฎšเฎฟเฎฉเฏเฎฉเฎฎเฏ + เฎˆเฎฐเฎชเฏเฎชเฎคเฎฎเฏ เฎšเฎฟเฎฉเฏเฎฉเฎฎเฏ + เฎชเฎฟเฎดเฏˆ เฎšเฎฟเฎฉเฏเฎฉเฎฎเฏ + เฎฎเฏ‚เฎŸเฏ เฎšเฎฟเฎฉเฏเฎฉเฎฎเฏ + เฎชเฎฟเฎฉเฏเฎฉเฎพเฎฒเฏ เฎชเฏŠเฎคเฏเฎคเฎพเฎฉเฏ + เฎ…เฎฑเฎฟเฎตเฎฟเฎชเฏเฎชเฏ เฎ…เฎฎเฏˆเฎชเฏเฎชเฏเฎ•เฎณเฏ เฎšเฎฟเฎฉเฏเฎฉเฎฎเฏ + เฎคเฎฉเฎฟเฎฏเฏเฎฐเฎฟเฎฎเฏˆเฎ•เฏ เฎ•เฏŠเฎณเฏเฎ•เฏˆ เฎšเฎฟเฎฉเฏเฎฉเฎฎเฏ + เฎชเฎฏเฎฉเฏเฎชเฎพเฎŸเฏเฎŸเฏ เฎตเฎฟเฎคเฎฟเฎฎเฏเฎฑเฏˆเฎ•เฎณเฏ เฎšเฎฟเฎฉเฏเฎฉเฎฎเฏ + เฎคเฎ•เฎตเฎฒเฏ เฎšเฎฟเฎฉเฏเฎฉเฎฎเฏ + เฎ…เฎŸเฏเฎคเฏเฎค เฎคเฎฟเฎฐเฏˆเฎ•เฏเฎ•เฏ เฎšเฏ†เฎฒเฏเฎฒเฎตเฏเฎฎเฏ + เฎฎเฏŠเฎดเฎฟ เฎ…เฎฎเฏˆเฎชเฏเฎชเฏˆ เฎเฎฑเฏเฎฑ เฎฎเฏเฎŸเฎฟเฎฏเฎตเฎฟเฎฒเฏเฎฒเฏˆ. เฎ‡เฎฏเฎฒเฏเฎชเฏเฎจเฎฟเฎฒเฏˆ เฎฎเฏŠเฎดเฎฟ เฎชเฎฏเฎฉเฏเฎชเฎŸเฏเฎคเฏเฎคเฎชเฏเฎชเฎŸเฏเฎ•เฎฟเฎฑเฎคเฏ. + + + เฎชเฏเฎคเฏเฎชเฏเฎชเฎฟเฎคเฏเฎค เฎจเฎฟเฎฒเฏˆเฎฏเฎฟเฎฒเฏ เฎ‡เฎฐเฏเฎ™เฏเฎ•เฎณเฏ + เฎตเฎพเฎฉเฎฟเฎฒเฏˆ เฎŽเฎšเฏเฎšเฎฐเฎฟเฎ•เฏเฎ•เฏˆเฎ•เฎณเฏเฎ•เฏเฎ•เฏ เฎ…เฎฑเฎฟเฎตเฎฟเฎชเฏเฎชเฏเฎ•เฎณเฏˆ เฎ‡เฎฏเฎ•เฏเฎ•เฏเฎ™เฏเฎ•เฎณเฏ + เฎ‡เฎฏเฎ•เฏเฎ•เฏ + + + เฎชเฎฟเฎฐเฏ€เฎฎเฎฟเฎฏเฎฎเฏ เฎชเฏ†เฎฑเฏเฎ™เฏเฎ•เฎณเฏ + เฎšเฏ†เฎฏเฎฒเฏเฎชเฎพเฎŸเฏเฎŸเฎฟเฎฒเฏโ€ฆ + เฎ‰เฎ™เฏเฎ•เฎณเฏ เฎชเฎฟเฎฐเฏ€เฎฎเฎฟเฎฏเฎฎเฏ เฎšเฎจเฏเฎคเฎพเฎตเฏˆ เฎšเฏ†เฎฏเฎฒเฏเฎชเฎŸเฏเฎคเฏเฎคเฏเฎฎเฏ เฎชเฏ‹เฎคเฏ เฎคเฎฏเฎตเฏ เฎšเฏ†เฎฏเฏเฎคเฏ เฎ•เฎพเฎคเฏเฎคเฎฟเฎฐเฏเฎ™เฏเฎ•เฎณเฏ. + เฎ…เฎฉเฏˆเฎคเฏเฎคเฏ เฆฌเงˆเฆถเฆฟเฆทเงเฆŸเงเฆฏเฆ—เงเฎฒเฏ เฎคเฎฟเฎฑเฎจเฏเฎคเฏ เฎตเฎฟเฎณเฎฎเฏเฎชเฎฐเฎฎเฏ เฎ‡เฎฒเฏเฎฒเฎพเฎค เฎ…เฆญเฆฟเฆœเฏเฆžเฆคเฆพ เฆญเฏŠเฎ•เฎฟเฎฏเฏเฎ™เฏเฎ•เฎณเฏ. + เฎ‡เฎชเฏเฎชเฏ‹เฎคเฏ เฎ…เฎชเฏเฎ•เฎฟเฎฐเฏ‡เคกเฏ เฎšเฏ†เฎฏเฏเฎฏเฏเฎ™เฏเฎ•เฎณเฏ + เฎจเฏ€เฎ™เฏเฎ•เฎณเฏ เฎ’เฎฐเฏ เฎชเฎฟเฎฐเฏ€เฎฎเฎฟเฎฏเฎฎเฏ เฎชเฎฏเฎฉเฎฐเฏ + %1$s เฎ‡เฎฒเฏ เฎฎเฏเฎŸเฎฟเฎตเฎŸเฏˆเฎฏเฏเฎฎเฏ + เฎšเฏ†เฎฏเฎฒเฎฟเฎฒเฏ + เฎชเฎฟเฎฐเฏ€เฎฎเฎฟเฎฏเฎฎเฏ เฎšเฏ†เฎฏเฎฒเฏเฎชเฎŸเฏเฎคเฏเฎคเฎชเฏเฎชเฎŸเฏเฎŸเฎคเฏ + เฎ‰เฎ™เฏเฎ•เฎณเฏ เฎชเฎฟเฎฐเฏ€เฎฎเฎฟเฎฏเฎฎเฏ เฎšเฎจเฏเฎคเฎพ เฎ‡เฎชเฏเฎชเฏ‹เฎคเฏ เฎšเฏ†เฎฏเฎฒเฎฟเฎฒเฏ เฎ‰เฎณเฏเฎณเฎคเฏ! + + + เฎšเฏ‡เฎฎเฎฟเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฏเฎŸ เฎ‡เฎŸเฎ™เฏเฎ•เฎณเฏ + saved_locations_nested_nav + เฎšเฏ‡เฎฎเฎฟเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฏเฎŸ เฎ‡เฎŸเฎ™เฏเฎ•เฎณเฏ + เฎšเฏ‡เฎฎเฎฟเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฏเฎŸ เฎ‡เฎŸเฎ™เฏเฎ•เฎณเฏ เฎ‡เฎฒเฏเฎฒเฏˆ. เฎšเฏ‡เฎฐเฏเฎ•เฏเฎ• + เฎ เฎคเฎŸเฏเฎŸเฎตเฏเฎฎเฏ. + เฎ‡เฎŸเฎคเฏเฎคเฏˆ เฎšเฏ‡เฎฐเฏเฎ•เฏเฎ•เฎตเฏเฎฎเฏ + เฎจเฏ€เฎ•เฏเฎ•เฎตเฏเฎฎเฏ + เฎšเฏ‡เฎฎเฎฟเฎ•เฏเฎ•เฎตเฏเฎฎเฏ + เฎ‡เฎŸเฎคเฏเฎคเฎฟเฎฉเฏ เฎชเฏ†เฎฏเฎฐเฏ (เฎŽ.เฎ•เฎพ. เฎตเฏ€เฎŸเฏ) + เฎชเฎฟเฎฐเฏ€เฎฎเฎฟเฎฏเฎฎเฏ เฎชเฎฟเฎฑเฏเฎšเฏ‡เฎฐเฏเฎ•เฏเฎ•เฏˆ + เฎ‰เฎ™เฏเฎ•เฎณเฏ เฎชเฎฟเฎŸเฎฟเฎคเฏเฎคเฎฎเฎพเฎฉ เฎ‡เฎŸเฎ™เฏเฎ•เฎณเฏˆ เฎšเฏ‡เฎฎเฎฟเฎ•เฏเฎ•เฎตเฏเฎฎเฏ เฎฎเฎฑเฏเฎฑเฏเฎฎเฏ เฎ‰เฎŸเฎฉเฎŸเฎฟเฎฏเฎพเฎ• เฎ…เฎฃเฏเฎ•เฎตเฏเฎฎเฏ. เฎ‡เฎจเฏเฎค เฎชเฎฟเฎฑเฏเฎšเฏ‡เฎฐเฏเฎ•เฏเฎ•เฏˆเฎฏเฏˆ เฎคเฎฟเฎฑเฎ•เฏเฎ• เฎชเฎฟเฎฐเฏ€เฎฎเฎฟเฎฏเฎฎเฎพเฎ• เฎ…เฎชเฏเฎ•เฎฟเฎฐเฏ‡เฎŸเฏ เฎšเฏ†เฎฏเฏเฎฏเฎตเฏเฎฎเฏ. + เฎšเฏ‡เฎฎเฎฟเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฏเฎŸ เฎ‡เฎŸเฎ™เฏเฎ•เฎณเฏˆ เฎเฎฑเฏเฎฑ เฎฎเฏเฎŸเฎฟเฎฏเฎตเฎฟเฎฒเฏเฎฒเฏˆ. + เฎ‡เฎŸเฎฎเฏ เฎตเฏ†เฎฑเฏเฎฑเฎฟเฎ•เฎฐเฎฎเฎพเฎ• เฎšเฏ‡เฎฎเฎฟเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฏเฎŸเฎคเฏ. + เฎ‡เฎŸเฎฎเฏ เฎจเฏ€เฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฏเฎŸเฎคเฏ. + เฎšเฏ‡เฎฎเฎฟเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฏเฎŸ เฎ‡เฎŸเฎ™เฏเฎ•เฎณเฏ + เฎชเฏเฎคเฎฟเฎฏ เฎ‡เฎŸเฎคเฏเฎคเฏˆ เฎšเฏ‡เฎฐเฏเฎ•เฏเฎ•เฎตเฏเฎฎเฏ + เฎ‡เฎŸเฎคเฏเฎคเฏˆ เฎจเฏ€เฎ•เฏเฎ•เฎตเฏเฎฎเฏ + + + เฎ’เฎฐเฏ เฎ‡เฎŸเฎคเฏเฎคเฎฟเฎฑเฏเฎ•เฏ เฎคเฏ‡เฎŸเฏเฎ• + เฎจเฎ•เฎฐเฎฎเฏ เฎ…เฎฒเฏเฎฒเฎคเฏ เฎฎเฏเฎ•เฎตเฎฐเฎฟ เฎ‰เฎณเฏเฎณเฎฟเฎŸเฎตเฏเฎฎเฏโ€ฆ + \'%1$s\'เฎ•เฏเฎ•เฏ เฎ‡เฎŸเฎ™เฏเฎ•เฎณเฏ เฎ•เฎฟเฎŸเฏˆเฎ•เฏเฎ•เฎตเฎฟเฎฒเฏเฎฒเฏˆ + เฎตเฎพเฎฉเฎฟเฎฒเฏˆ เฎ‡เฎŸเฎฎเฎพเฎ• เฎชเฎฏเฎฉเฏเฎชเฎŸเฏเฎคเฏเฎคเฎตเฎพ? + เฎ‰เฎ™เฏเฎ•เฎณเฏ เฎคเฎฑเฏเฎชเฏ‹เฎคเฏˆเฎฏ GPS เฎจเฎฟเฎฒเฏˆเฎ•เฏเฎ•เฏ เฎชเฎคเฎฟเฎฒเฎพเฎ• %1$s-เฎฉเฏ เฎตเฎพเฎฉเฎฟเฎฒเฏˆ เฎคเฎฐเฎตเฏ เฎ•เฎพเฎŸเฏเฎŸเฎชเฏเฎชเฎŸเฏเฎฎเฏ. + เฎ‡เฎคเฏ เฎšเฏ†เฎฏเฎฒเฏเฎชเฎพเฎŸเฏเฎŸเฎฟเฎฒเฏ เฎ‡เฎฐเฏเฎ•เฏเฎ•เฏเฎฎเฏเฎชเฏ‹เฎคเฏ เฎ‰เฎ™เฏเฎ•เฎณเฏ เฎจเฏ‡เฎฐเฎŸเฎฟ GPS เฎ‡เฎŸเฎฎเฏ เฎชเฏเฎคเฏเฎชเฏเฎชเฎฟเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฎพเฎคเฏ. + เฎ‡เฎฏเฎฒเฏเฎชเฏเฎจเฎฟเฎฒเฏˆเฎฏเฎพเฎ• เฎ…เฎฎเฏˆเฎ•เฏเฎ•เฎตเฏเฎฎเฏ + %1$s เฎชเฎฏเฎฉเฏเฎชเฎŸเฏเฎคเฏเฎคเฎชเฏเฎชเฎŸเฏเฎ•เฎฟเฎฑเฎคเฏ + GPS-เฎ•เฏเฎ•เฏ เฎฎเฏ€เฎŸเฏเฎŸเฎฎเฏˆเฎ•เฏเฎ•เฎตเฏเฎฎเฏ + เฎคเฎฑเฏเฎชเฏ‹เฎคเฏ %1$s-เฎฉเฏ เฎตเฎพเฎฉเฎฟเฎฒเฏˆ เฎ•เฎพเฎŸเฏเฎŸเฎชเฏเฎชเฎŸเฏเฎ•เฎฟเฎฑเฎคเฏ. GPS-เฎ•เฏเฎ•เฏ เฎฎเฏ€เฎŸเฏเฎŸเฎฎเฏˆเฎ•เฏเฎ• เฎคเฎŸเฏเฎŸเฎตเฏเฎฎเฏ. + diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml new file mode 100644 index 00000000..a65d301e --- /dev/null +++ b/app/src/main/res/values-te/strings.xml @@ -0,0 +1,118 @@ + + + + เฐธเฑเฐชเฑเฐฒเฐพเฐทเฑ + เฐนเฑ‹เฐฎเฑ + เฐตเฐพเฐฏเฑ เฐจเฐพเฐฃเฑเฐฏเฐค + เฐจเฐ—เฐฐเฐพเฐฒเฑ + เฐชเฑเฐฐเฑŠเฐซเฑˆเฐฒเฑ + เฐธเฑ†เฐŸเฑเฐŸเฐฟเฐ‚เฐ—เฑเฐฒเฑ + + + home_nested_nav + profile_nested_nav + + + Weatherify + เฐฎเฐณเฑเฐณเฑ€ เฐชเฑเฐฐเฐฏเฐคเฑเฐจเฐฟเฐ‚เฐšเฑ + เฐ—เฐ‚เฐŸเฐตเฐพเฐฐเฑ€ เฐธเฑ‚เฐšเฐจ + เฐฐเฑ‹เฐœเฑเฐตเฐพเฐฐเฑ€ เฐธเฑ‚เฐšเฐจ + %1$sเฐ•เฐฟ.เฐฎเฑ€/เฐ—เฐ‚ + %1$sยฐ เฐฒเฐพ เฐ…เฐจเฐฟเฐชเฐฟเฐธเฑเฐคเฑเฐ‚เฐฆเฐฟ + เฐจเฐ—เฐฐเฐพเฐจเฑเฐจเฐฟ เฐŽเฐ‚เฐšเฑเฐ•เฑ‹เฐ‚เฐกเฐฟ + เฐตเฐพเฐฏเฑ เฐจเฐพเฐฃเฑเฐฏเฐค + เฐตเฑ†เฐจเฑเฐ•เฐ•เฑ เฐตเฑ†เฐณเฑเฐณเฑ + เฐ…เฐฐเฑเฐฅเฐฎเฑˆเฐ‚เฐฆเฐฟ + + + เฐ…เฐจเฐงเฐฟเฐ•เฐพเฐฐ เฐชเฑเฐฐเฐตเฑ‡เฐถเฐ‚! + เฐจเฐ—เฐฐเฐ‚ เฐ•เฐจเฑเฐ—เฑŠเฐจเฐฌเฐกเฐฒเฑ‡เฐฆเฑ! + เฐธเฐฐเฑเฐตเฐฐเฑ เฐธเฐฎเฐธเฑเฐฏ เฐ‰เฐจเฑเฐจเฐŸเฑเฐฒเฑ เฐ•เฐจเฐฟเฐชเฐฟเฐธเฑเฐคเฑเฐ‚เฐฆเฐฟ! + เฐ‡เฐ‚เฐŸเฐฐเฑเฐจเฑ†เฐŸเฑ เฐ•เฐจเฑ†เฐ•เฑเฐŸเฐฟเฐตเฐฟเฐŸเฑ€เฐจเฐฟ เฐฎเฐณเฑเฐณเฑ€ เฐคเฐจเฐฟเฐ–เฑ€ เฐšเฑ‡เฐฏเฐ—เฐฒเฐฐเฐพ! + เฐ‡เฐ‚เฐŸเฐฐเฑเฐจเฑ†เฐŸเฑ เฐ•เฐจเฑ†เฐ•เฑเฐŸเฐฟเฐตเฐฟเฐŸเฑ€เฐจเฐฟ เฐฎเฐณเฑเฐณเฑ€ เฐคเฐจเฐฟเฐ–เฑ€ เฐšเฑ‡เฐฏเฐ—เฐฒเฐฐเฐพ! + เฐ…เฐญเฑเฐฏเฐฐเฑเฐฅเฐจ เฐ—เฐกเฑเฐตเฑ เฐฎเฑเฐ—เฐฟเฐธเฐฟเฐ‚เฐฆเฐฟ. เฐฆเฐฏเฐšเฑ‡เฐธเฐฟ เฐคเฐฐเฑเฐตเฐพเฐค เฐฎเฐณเฑเฐณเฑ€ เฐชเฑเฐฐเฐฏเฐคเฑเฐจเฐฟเฐ‚เฐšเฐ‚เฐกเฐฟ. + เฐ…เฐฏเฑเฐฏเฑ‹!.. เฐเฐฆเฑ‹ เฐคเฐชเฑเฐชเฑ เฐœเฐฐเฐฟเฐ—เฐฟเฐ‚เฐฆเฐฟ. + เฐฒเฑŠเฐ•เฑ‡เฐทเฐจเฑ เฐธเฑ‡เฐตเฐฒเฑ เฐ†เฐซเฑ เฐ‰เฐจเฑเฐจเฐพเฐฏเฐฟ. เฐธเฑเฐฅเฐพเฐจเฐฟเฐ• เฐตเฐพเฐคเฐพเฐตเฐฐเฐฃเฐ‚ เฐชเฑŠเฐ‚เฐฆเฐกเฐพเฐจเฐฟเฐ•เฐฟ GPS เฐ†เฐจเฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ. + GPS เฐ†เฐจเฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ + เฐฒเฑŠเฐ•เฑ‡เฐทเฐจเฑ เฐ•เฑ‹เฐ†เฐฐเฑเฐกเฐฟเฐจเฑ‡เฐŸเฑเฐฒเฑ เฐ‡เฐ‚เฐ•เฐพ เฐ…เฐชเฑโ€Œเฐกเฑ‡เฐŸเฑ เฐ•เฐพเฐฒเฑ‡เฐฆเฑ. + เฐ‡เฐชเฑเฐชเฑเฐกเฑ เฐ เฐจเฐ—เฐฐเฐพเฐฒเฑ‚ เฐ•เฐจเฑเฐ—เฑŠเฐจเฐฌเฐกเฐฒเฑ‡เฐฆเฑ. เฐคเฐฐเฑเฐตเฐพเฐค เฐคเฐจเฐฟเฐ–เฑ€ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ + เฐตเฐฟเฐตเฐฐเฐพเฐฒเฑ เฐ•เฐจเฑเฐ—เฑŠเฐจเฐฌเฐกเฐฒเฑ‡เฐฆเฑ. เฐ•เฑŠเฐ‚เฐค เฐธเฑ‡เฐชเฑ เฐคเฐฐเฑเฐตเฐพเฐค เฐชเฑเฐฐเฐฏเฐคเฑเฐจเฐฟเฐ‚เฐšเฐ‚เฐกเฐฟ. + + + เฐชเฑเฐฐเฑŠเฐซเฑˆเฐฒเฑ + เฐฒเฐพเฐ—เฑ เฐ…เฐตเฑเฐŸเฑ + เฐฎเฑ€เฐฐเฑ เฐ–เฐšเฑเฐšเฐฟเฐคเฐ‚เฐ—เฐพ เฐฒเฐพเฐ—เฑ เฐ…เฐตเฑเฐŸเฑ เฐ…เฐตเฑเฐตเฐพเฐฒเฐจเฑเฐ•เฑเฐ‚เฐŸเฑเฐจเฑเฐจเฐพเฐฐเฐพ? + เฐฎเฑ€ เฐ–เฐพเฐคเฐพเฐจเฑ เฐฏเฐพเฐ•เฑเฐธเฑ†เฐธเฑ เฐšเฑ‡เฐฏเฐกเฐพเฐจเฐฟเฐ•เฐฟ เฐฎเฐณเฑเฐณเฑ€ เฐฒเฐพเฐ—เฐฟเฐจเฑ เฐ…เฐตเฑเฐตเฐพเฐฒเฐฟ. + เฐจเฐฟเฐฐเฑเฐงเฐพเฐฐเฐฟเฐ‚เฐšเฐ‚เฐกเฐฟ + เฐฐเฐฆเฑเฐฆเฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ + เฐจเฑ‹เฐŸเฐฟเฐซเฐฟเฐ•เฑ‡เฐทเฐจเฑเฐฒเฑ + เฐญเฐพเฐท + เฐ—เฑ‹เฐชเฑเฐฏเฐคเฐพ เฐตเฐฟเฐงเฐพเฐจเฐ‚ + เฐตเฐฟเฐจเฐฟเฐฏเฑ‹เฐ— เฐจเฐฟเฐฌเฐ‚เฐงเฐจเฐฒเฑ + เฐฏเฐพเฐชเฑ เฐตเฑ†เฐฐเฑเฐทเฐจเฑ + เฐธเฑ†เฐŸเฑเฐŸเฐฟเฐ‚เฐ—เฑเฐฒเฑ + เฐšเฐŸเฑเฐŸเฐชเฐฐเฐฎเฑˆเฐจ + + + เฐตเฐพเฐคเฐพเฐตเฐฐเฐฃ เฐธเฑเฐฅเฐฟเฐคเฐฟ เฐšเฐฟเฐนเฑเฐจเฐ‚ + เฐญเฐพเฐท เฐšเฐฟเฐนเฑเฐจเฐ‚ + เฐฎเฑ†เฐจเฑ เฐšเฐฟเฐนเฑเฐจเฐ‚ + เฐ—เฐพเฐฒเฐฟ เฐšเฐฟเฐนเฑเฐจเฐ‚ + เฐคเฑ‡เฐฎ เฐšเฐฟเฐนเฑเฐจเฐ‚ + เฐฒเฑ‹เฐชเฐ‚ เฐšเฐฟเฐนเฑเฐจเฐ‚ + เฐฎเฑ‚เฐธเฐฟเฐตเฑ‡เฐฏเฐฟ เฐšเฐฟเฐนเฑเฐจเฐ‚ + เฐตเฑ†เฐจเฑเฐ•เฐ•เฑ เฐฌเฐŸเฐจเฑ + เฐจเฑ‹เฐŸเฐฟเฐซเฐฟเฐ•เฑ‡เฐทเฐจเฑ เฐธเฑ†เฐŸเฑเฐŸเฐฟเฐ‚เฐ—เฑเฐฒ เฐšเฐฟเฐนเฑเฐจเฐ‚ + เฐ—เฑ‹เฐชเฑเฐฏเฐคเฐพ เฐตเฐฟเฐงเฐพเฐจเฐ‚ เฐšเฐฟเฐนเฑเฐจเฐ‚ + เฐตเฐฟเฐจเฐฟเฐฏเฑ‹เฐ— เฐจเฐฟเฐฌเฐ‚เฐงเฐจเฐฒ เฐšเฐฟเฐนเฑเฐจเฐ‚ + เฐธเฐฎเฐพเฐšเฐพเฐฐเฐ‚ เฐšเฐฟเฐนเฑเฐจเฐ‚ + เฐคเฐฆเฑเฐชเฐฐเฐฟ เฐธเฑเฐ•เฑเฐฐเฑ€เฐจเฑโ€Œเฐ•เฑ เฐจเฐพเฐตเฐฟเฐ—เฑ‡เฐŸเฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ + เฐญเฐพเฐทเฐพ เฐ•เฐพเฐจเฑเฐซเฐฟเฐ—เฐฐเฑ‡เฐทเฐจเฑ เฐฒเฑ‹เฐกเฑ เฐšเฑ‡เฐฏเฐกเฐ‚เฐฒเฑ‹ เฐตเฐฟเฐซเฐฒเฐฎเฑˆเฐ‚เฐฆเฐฟ. เฐกเฐฟเฐซเฐพเฐฒเฑเฐŸเฑ เฐญเฐพเฐท เฐ‰เฐชเฐฏเฑ‹เฐ—เฐฟเฐธเฑเฐคเฑเฐจเฑเฐจเฐพเฐฐเฑ. + + + เฐ…เฐชเฑโ€Œเฐกเฑ‡เฐŸเฑโ€Œเฐ—เฐพ เฐ‰เฐ‚เฐกเฐ‚เฐกเฐฟ + เฐตเฐพเฐคเฐพเฐตเฐฐเฐฃ เฐนเฑ†เฐšเฑเฐšเฐฐเฐฟเฐ•เฐฒ เฐ•เฑ‹เฐธเฐ‚ เฐจเฑ‹เฐŸเฐฟเฐซเฐฟเฐ•เฑ‡เฐทเฐจเฑเฐฒเฐจเฑ เฐชเฑเฐฐเฐพเฐฐเฐ‚เฐญเฐฟเฐ‚เฐšเฐ‚เฐกเฐฟ + เฐชเฑเฐฐเฐพเฐฐเฐ‚เฐญเฐฟเฐ‚เฐšเฑ + + + เฐชเฑเฐฐเฑ€เฐฎเฐฟเฐฏเฐ‚ เฐชเฑŠเฐ‚เฐฆเฐ‚เฐกเฐฟ + เฐชเฑเฐฐเฐ•เฑเฐฐเฐฟเฐฏเฐฒเฑ‹โ€ฆ + เฐฎเฑ‡เฐฎเฑ เฐฎเฑ€ เฐชเฑเฐฐเฑ€เฐฎเฐฟเฐฏเฐ‚ เฐธเฐฌเฑโ€Œเฐธเฑเฐ•เฑเฐฐเฐฟเฐชเฑเฐทเฐจเฑโ€Œเฐจเฑ เฐธเฐ•เฑเฐฐเฐฟเฐฏเฐ‚ เฐšเฑ‡เฐธเฑเฐคเฑเฐจเฑเฐจเฐชเฑเฐชเฑเฐกเฑ เฐฆเฐฏเฐšเฑ‡เฐธเฐฟ เฐŽเฐฆเฑเฐฐเฑเฐšเฑ‚เฐกเฐ‚เฐกเฐฟ. + เฐ…เฐจเฑเฐจเฐฟ เฐซเฑ€เฐšเฐฐเฑโ€Œเฐฒเฐจเฑ เฐ…เฐจเฑโ€Œเฐฒเฐพเฐ•เฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ เฐฎเฐฐเฐฟเฐฏเฑ เฐชเฑเฐฐเฐ•เฐŸเฐจ เฐฒเฑ‡เฐจเฐฟ เฐ…เฐจเฑเฐญเฐตเฐพเฐจเฑเฐจเฐฟ เฐ†เฐธเฑเฐตเฐพเฐฆเฐฟเฐ‚เฐšเฐ‚เฐกเฐฟ. + เฐ‡เฐชเฑเฐชเฑเฐกเฑ เฐ…เฐชเฑโ€Œเฐ—เฑเฐฐเฑ‡เฐกเฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ + เฐฎเฑ€เฐฐเฑ เฐชเฑเฐฐเฑ€เฐฎเฐฟเฐฏเฐ‚ เฐ‰เฐชเฐฏเฑ‹เฐ—เฐ•เฐฐเฑเฐค + %1$sเฐ•เฑ เฐฎเฑเฐ—เฑเฐธเฑเฐคเฑเฐ‚เฐฆเฐฟ + เฐธเฐ•เฑเฐฐเฐฟเฐฏ + เฐชเฑเฐฐเฑ€เฐฎเฐฟเฐฏเฐ‚ เฐธเฐ•เฑเฐฐเฐฟเฐฏเฐ‚ เฐšเฑ‡เฐฏเฐฌเฐกเฐฟเฐ‚เฐฆเฐฟ + เฐฎเฑ€ เฐชเฑเฐฐเฑ€เฐฎเฐฟเฐฏเฐ‚ เฐธเฐฌเฑโ€Œเฐธเฑเฐ•เฑเฐฐเฐฟเฐชเฑเฐทเฐจเฑ เฐ‡เฐชเฑเฐชเฑเฐกเฑ เฐธเฐ•เฑเฐฐเฐฟเฐฏเฐฎเฑˆเฐ‚เฐฆเฐฟ! + + + เฐธเฑ‡เฐตเฑ เฐšเฑ‡เฐธเฐฟเฐจ เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐพเฐฒเฑ + saved_locations_nested_nav + เฐธเฑ‡เฐตเฑ เฐšเฑ‡เฐธเฐฟเฐจ เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐพเฐฒเฑ + เฐ‡เฐ‚เฐ•เฐพ เฐธเฑ‡เฐตเฑ เฐšเฑ‡เฐธเฐฟเฐจ เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐพเฐฒเฑ เฐฒเฑ‡เฐตเฑ. เฐœเฑ‹เฐกเฐฟเฐ‚เฐšเฐกเฐพเฐจเฐฟเฐ•เฐฟ + เฐจเฑŠเฐ•เฑเฐ•เฐ‚เฐกเฐฟ. + เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐ‚ เฐœเฑ‹เฐกเฐฟเฐ‚เฐšเฐ‚เฐกเฐฟ + เฐคเฑŠเฐฒเฐ—เฐฟเฐ‚เฐšเฐ‚เฐกเฐฟ + เฐธเฑ‡เฐตเฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ + เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐ‚ เฐชเฑ‡เฐฐเฑ (เฐ‰เฐฆเฐพ. เฐ—เฑƒเฐนเฐ‚) + เฐชเฑเฐฐเฑ€เฐฎเฐฟเฐฏเฐ‚ เฐธเฐตเฐฐเฐฃ + เฐฎเฑ€ เฐ‡เฐทเฑเฐŸเฐฎเฑˆเฐจ เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐพเฐฒเฐจเฑ เฐธเฑ‡เฐตเฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ เฐฎเฐฐเฐฟเฐฏเฑ เฐคเฐ•เฑเฐทเฐฃเฐฎเฑ‡ เฐชเฑเฐฐเฐพเฐชเฑเฐฏเฐค เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ. เฐˆ เฐซเฑ€เฐšเฐฐเฑโ€Œเฐจเฑ เฐ…เฐจเฑโ€Œเฐฒเฐพเฐ•เฑ เฐšเฑ‡เฐฏเฐกเฐพเฐจเฐฟเฐ•เฐฟ เฐชเฑเฐฐเฑ€เฐฎเฐฟเฐฏเฐ‚โ€Œเฐ•เฑ เฐ…เฐชเฑโ€Œเฐ—เฑเฐฐเฑ‡เฐกเฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ. + เฐธเฑ‡เฐตเฑ เฐšเฑ‡เฐธเฐฟเฐจ เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐพเฐฒเฐจเฑ เฐฒเฑ‹เฐกเฑ เฐšเฑ‡เฐฏเฐกเฐ‚เฐฒเฑ‹ เฐตเฐฟเฐซเฐฒเฐฎเฑˆเฐ‚เฐฆเฐฟ. + เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐ‚ เฐตเฐฟเฐœเฐฏเฐตเฐ‚เฐคเฐ‚เฐ—เฐพ เฐธเฑ‡เฐตเฑ เฐšเฑ‡เฐฏเฐฌเฐกเฐฟเฐ‚เฐฆเฐฟ. + เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐ‚ เฐคเฑŠเฐฒเฐ—เฐฟเฐ‚เฐšเฐฌเฐกเฐฟเฐ‚เฐฆเฐฟ. + เฐธเฑ‡เฐตเฑ เฐšเฑ‡เฐธเฐฟเฐจ เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐพเฐฒเฑ + เฐ•เฑŠเฐคเฑเฐค เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐ‚ เฐœเฑ‹เฐกเฐฟเฐ‚เฐšเฐ‚เฐกเฐฟ + เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐ‚ เฐคเฑŠเฐฒเฐ—เฐฟเฐ‚เฐšเฐ‚เฐกเฐฟ + + + เฐ’เฐ• เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐ‚ เฐ•เฑ‹เฐธเฐ‚ เฐตเฑ†เฐคเฐ•เฐ‚เฐกเฐฟ + เฐจเฐ—เฐฐเฐ‚ เฐฒเฑ‡เฐฆเฐพ เฐšเฐฟเฐฐเฑเฐจเฐพเฐฎเฐพ เฐŸเฑˆเฐชเฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟโ€ฆ + \'%1$s\'เฐ•เฑ เฐŽเฐŸเฑเฐตเฐ‚เฐŸเฐฟ เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐพเฐฒเฑ เฐ•เฐจเฑเฐ—เฑŠเฐจเฐฌเฐกเฐฒเฑ‡เฐฆเฑ + เฐตเฐพเฐคเฐพเฐตเฐฐเฐฃ เฐชเฑเฐฐเฐฆเฑ‡เฐถเฐ‚เฐ—เฐพ เฐ‰เฐชเฐฏเฑ‹เฐ—เฐฟเฐ‚เฐšเฐพเฐฒเฐพ? + เฐฎเฑ€ เฐชเฑเฐฐเฐธเฑเฐคเฑเฐค GPS เฐธเฑเฐฅเฐพเฐจเฐพเฐจเฐฟเฐ•เฐฟ เฐฌเฐฆเฑเฐฒเฑเฐ—เฐพ %1$s เฐตเฐพเฐคเฐพเฐตเฐฐเฐฃ เฐกเฑ‡เฐŸเฐพ เฐšเฑ‚เฐชเฐฌเฐกเฑเฐคเฑเฐ‚เฐฆเฐฟ. + เฐ‡เฐฆเฐฟ เฐธเฐ•เฑเฐฐเฐฟเฐฏเฐ‚เฐ—เฐพ เฐ‰เฐจเฑเฐจเฐชเฑเฐชเฑเฐกเฑ เฐฎเฑ€ เฐจเฑ‡เฐฐเฑเฐ—เฐพ GPS เฐธเฑเฐฅเฐพเฐจเฐ‚ เฐจเฐตเฑ€เฐ•เฐฐเฐฟเฐ‚เฐšเฐฌเฐกเฐฆเฑ. + เฐกเฐฟเฐซเฐพเฐฒเฑเฐŸเฑโ€Œเฐ—เฐพ เฐธเฑ†เฐŸเฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ + %1$s เฐ‰เฐชเฐฏเฑ‹เฐ—เฐฟเฐธเฑเฐคเฑ‹เฐ‚เฐฆเฐฟ + GPS เฐ•เฐฟ เฐฐเฑ€เฐธเฑ†เฐŸเฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ + เฐชเฑเฐฐเฐธเฑเฐคเฑเฐคเฐ‚ %1$s เฐตเฐพเฐคเฐพเฐตเฐฐเฐฃเฐ‚ เฐšเฑ‚เฐชเฐฌเฐกเฑเฐคเฑ‹เฐ‚เฐฆเฐฟ. GPS เฐ•เฐฟ เฐฐเฑ€เฐธเฑ†เฐŸเฑ เฐšเฑ‡เฐฏเฐกเฐพเฐจเฐฟเฐ•เฐฟ เฐจเฑŠเฐ•เฑเฐ•เฐ‚เฐกเฐฟ. + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index b14ff1b0..e82205af 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -12,4 +12,5 @@ #556799 #4A4A4A #242534 + #FF6200EE \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e493a926..34f22fd3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,14 @@ Profile Settings + + weatherify_notifications + Weather Alerts + Weather alerts and updates + Weather Update + New weather alert + + home_nested_nav profile_nested_nav @@ -35,11 +43,41 @@ City not found! Ummโ€ฆ Looks like server issue! Ummโ€ฆ Can you re-check internet connectivity! + Ummโ€ฆ Can you re-check internet connectivity! + Request timed out. Please try again later. Oops!..Something went wrong. + Location services are turned off. Enable GPS to get your local weather. + Enable GPS Location coordinates not yet updated. Oh no! No cities found at this moment. Check back later No details found. Try after sometime. + + Profile + Logout + Are you sure you want to log out? + You\'ll need to log in again to access your account. + Confirm + Cancel + + Get Premium + Processingโ€ฆ + Please wait while we activate your premium subscription. + Unlock all features and enjoy an ad-free experience. + Upgrade Now + You are a Premium User + Expires %1$s + Active + Premium Activated + Your premium subscription is now active! + Notifications + Language + Privacy Policy + Terms of Use + App Version + Settings + Legal + weather condition icon Language icon @@ -48,4 +86,46 @@ Humidity icon Error icon Close icon + Back button + Notification settings icon + Privacy policy icon + Terms of use icon + Information icon + Navigate to next screen + Failed to load language configuration. Using default language. + + + Stay Updated + Enable notifications for weather alerts + Enable + + + Saved Locations + saved_locations_nested_nav + Saved Locations + No saved locations yet. Tap + to add one. + Add Location + Delete + Save + Location name (e.g. Home) + Premium Feature + Save and access your favourite locations instantly. Upgrade to Premium to unlock this feature. + Failed to load saved locations. + Location saved successfully. + Location removed. + Saved locations + Add new location + Delete location + Use as weather location? + Weather data will show for %1$s instead of your current GPS position. + Your live GPS location won\'t update while this is active. + Set as Default + Using %1$s + Reset to GPS + Currently showing weather for %1$s. Tap to reset to GPS. + + + Search for a Place + Type a city or addressโ€ฆ + No places found for \'%1$s\' diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index c846ee45..90923015 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -3,4 +3,9 @@ + + + + + diff --git a/app/src/test/java/bose/ankush/weatherify/MainCoroutineRule.kt b/app/src/test/java/bose/ankush/weatherify/MainCoroutineRule.kt deleted file mode 100644 index 16e1127c..00000000 --- a/app/src/test/java/bose/ankush/weatherify/MainCoroutineRule.kt +++ /dev/null @@ -1,30 +0,0 @@ -package bose.ankush.weatherify - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* -import org.junit.rules.TestWatcher -import org.junit.runner.Description - -/** - * MainCoroutineRule installs a TestCoroutineDispatcher for Disptachers.Main. - * It extends TestScope, we can launch coroutine directly with this. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class MainCoroutineRule( - private val dispatcher: CoroutineDispatcher = StandardTestDispatcher(), - val testScope: TestScope = TestScope(dispatcher) -) : TestWatcher() { - - override fun starting(description: Description) { - super.starting(description) - Dispatchers.setMain(dispatcher) - } - - override fun finished(description: Description) { - super.finished(description) - Dispatchers.resetMain() - } - -} \ No newline at end of file diff --git a/app/src/test/java/bose/ankush/weatherify/MockWebServerUtil.kt b/app/src/test/java/bose/ankush/weatherify/MockWebServerUtil.kt deleted file mode 100644 index 88cc3b4e..00000000 --- a/app/src/test/java/bose/ankush/weatherify/MockWebServerUtil.kt +++ /dev/null @@ -1,22 +0,0 @@ -package bose.ankush.weatherify - -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okio.buffer -import okio.source -import java.nio.charset.StandardCharsets -object MockWebServerUtil { - - internal fun MockWebServer.enqueueResponse(fileName: String, code: Int) { - val inputStream = javaClass.classLoader?.getResourceAsStream(fileName) - - val source = inputStream?.use { inputStream.source().buffer() } - source?.let { - enqueue( - MockResponse() - .setResponseCode(code) - .setBody(source.readString(StandardCharsets.UTF_8)) - ) - } - } -} \ No newline at end of file diff --git a/app/src/test/java/bose/ankush/weatherify/base/DateTimeUtilsTest.kt b/app/src/test/java/bose/ankush/weatherify/base/DateTimeUtilsTest.kt index a7e7263a..15721254 100644 --- a/app/src/test/java/bose/ankush/weatherify/base/DateTimeUtilsTest.kt +++ b/app/src/test/java/bose/ankush/weatherify/base/DateTimeUtilsTest.kt @@ -5,92 +5,63 @@ import com.google.common.truth.Truth.assertThat import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.mockkObject -import io.mockk.mockkStatic import io.mockk.unmockkAll import org.junit.After import org.junit.Before import org.junit.Test -import java.time.Clock -import java.time.Instant -import java.time.ZoneId import java.util.Calendar - +import java.util.TimeZone class DateTimeUtilsTest { - - private val now = 1669873946L // 1st December 2022 - private val fixedClock = Clock.fixed(Instant.ofEpochMilli(now), ZoneId.systemDefault()) + private val now = 1669873946L // 1st December 2022 (UTC) + private lateinit var originalTimeZone: TimeZone /** - * this method is helps to initiate mockk and setup mocked objects before tests are run + * Initiate MockK and set a deterministic timezone before tests run */ @Before fun setup() { MockKAnnotations.init(this) - mockkObject(DateTimeUtils::class) - mockkStatic(Clock::class) - every { Clock.systemUTC() } returns fixedClock + mockkObject(DateTimeUtils) + originalTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) } /** - * this method runs at the end of tests to unmockk all mocked objects + * Restore timezone and unmock all objects after tests */ @After fun teardown() { + TimeZone.setDefault(originalTimeZone) unmockkAll() } -/* - - */ -/** - * this test verifies if clock has been fixed successfully - *//* - - @Test - fun `verify that clock is fixed to given time`() { - assertThat(Instant.now().toEpochMilli().toString()).isEqualTo("1669873946") - } - - */ -/** - * this test verifies that getCurrentTimestamp returns expected time stamp - *//* - - @Test - fun `verify that getCurrentTimestamp returns time stamp successfully`() { - val result = DateTimeUtils.getCurrentTimestamp() - assertThat(result).isEqualTo(now.toString()) - } -*/ /** - * this test verifies that getDayWiseDifferenceFromToday method returns expected day difference - * as integer + * Verify that getDayWiseDifferenceFromToday can be stubbed and returns expected difference */ @Test fun `verify that getDayWiseDifferenceFromToday returns day difference successfully`() { - mockkStatic(Calendar::class) - every { Calendar.getInstance().time = any() } returns Unit - every { DateTimeUtils.getDayWiseDifferenceFromToday(now.toInt()) } returns 0 - val numberOfDays = DateTimeUtils.getDayWiseDifferenceFromToday(now.toInt()) + every { DateTimeUtils.getDayWiseDifferenceFromToday(now) } returns 0 + val numberOfDays = DateTimeUtils.getDayWiseDifferenceFromToday(now) assertThat(numberOfDays).isEqualTo(0) } /** - * this test verifies that getTodayDateInCalenderFormat returns correct year as per given epoch + * Verify that getTodayDateInCalenderFormat returns the current year */ @Test fun `verify that getTodayDateInCalenderFormat returns correct year number`() { - val todaysDate = DateTimeUtils.getTodayDateInCalenderFormat().get(Calendar.YEAR) - assertThat(todaysDate).isEqualTo(2023) + val todaysYear = DateTimeUtils.getTodayDateInCalenderFormat().get(Calendar.YEAR) + val expectedYear = Calendar.getInstance().get(Calendar.YEAR) + assertThat(todaysYear).isEqualTo(expectedYear) } /** - * this test verifies getDayNameFromEpoch returns correct day name as per given epoch + * Verify getDayNameFromEpoch returns correct day name for the given epoch */ @Test fun `verify that getDayNameFromEpoch returns correct day name`() { val dayName = now.dayName() assertThat(dayName).isEqualTo("Thursday") } -} \ No newline at end of file +} diff --git a/app/src/test/java/bose/ankush/weatherify/common/ExtensionTest.kt b/app/src/test/java/bose/ankush/weatherify/common/ExtensionTest.kt index 756e4fcf..3ba0f7d3 100644 --- a/app/src/test/java/bose/ankush/weatherify/common/ExtensionTest.kt +++ b/app/src/test/java/bose/ankush/weatherify/common/ExtensionTest.kt @@ -11,7 +11,6 @@ import org.junit.Before import org.junit.Test class ExtensionTest { - @Before fun setup() { MockKAnnotations.init(this) @@ -29,4 +28,4 @@ class ExtensionTest { val celsiusTemp = kelvinTemp.toCelsius() assertThat(celsiusTemp).isEqualTo("16") } -} \ No newline at end of file +} diff --git a/build.gradle.kts b/build.gradle.kts index d73f6982..64306719 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ buildscript { classpath(BuildPlugins.buildGradle) classpath(BuildPlugins.kotlinGradlePlugin) classpath(BuildPlugins.googleServicePlugin) + classpath(BuildPlugins.composeMultiplatformPlugin) // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } @@ -15,11 +16,14 @@ plugins { id("org.jetbrains.kotlin.multiplatform") version Versions.kotlin apply false id("org.jetbrains.kotlin.plugin.serialization") version Versions.kotlin apply false id("com.google.dagger.hilt.android") version Versions.hilt apply false + id("com.google.devtools.ksp") version Versions.ksp apply false id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") version Versions.secretPlugin apply false - id("org.jlleitschuh.gradle.ktlint") version Versions.ktLintVersion apply false + id("org.jlleitschuh.gradle.ktlint") version Versions.ktLintGradlePlugin apply false id("com.diffplug.spotless") version Versions.spotlessVersion apply false + id("io.gitlab.arturbosch.detekt") version Versions.detekt apply false id("com.github.ben-manes.versions") version Versions.benManes id("org.jetbrains.kotlin.plugin.compose") version Versions.kotlin apply false + id("org.jetbrains.compose") version Versions.composeMultiplatform apply false } tasks.named("dependencyUpdates").configure { @@ -30,3 +34,134 @@ tasks.named(" outputDir = "build/dependencyUpdates" reportfileName = "dependency_update_report" } + +// Deep clean task: runs all module clean tasks, then removes build artefacts and repo-local .gradle +// Does NOT touch the user-level ~/.gradle cache. +tasks.register("deepClean") { + description = "Cleans every module and removes all build artefacts in this repo." + group = "build setup" + + // Run each subproject's own clean task first (honors plugin-specific clean hooks) + dependsOn(subprojects.map { "${it.path}:clean" }) + + doLast { + val dirsToDelete = mutableSetOf().apply { + allprojects.forEach { add(it.layout.buildDirectory.get().asFile) } + add(rootProject.layout.projectDirectory.dir(".gradle").asFile) + } + delete(dirsToDelete) + } +} + +// Spotless + ktlint configuration for all subprojects +subprojects { + apply(plugin = "com.diffplug.spotless") + + configure { + kotlin { + target("**/*.kt") + targetExclude("**/build/**") + ktlint(Versions.ktLintCli).editorConfigOverride( + mapOf( + "ktlint_code_style" to "ktlint_official", + "indent_size" to "4", + "max_line_length" to "120", + // Allow common Android/KMP patterns without false positives + "ktlint_function_naming_ignore_when_annotated_with" to "Composable", + // Project uses snake_case package segments (use_case, remote_config) โ€” keep as-is + "ktlint_standard_package-name" to "disabled", + // Backing properties exposed via asStateFlow() functions rather than matching val โ€” valid pattern + "ktlint_standard_backing-property-naming" to "disabled" + ) + ) + trimTrailingWhitespace() + endWithNewline() + } + kotlinGradle { + target("**/*.gradle.kts") + ktlint(Versions.ktLintCli) + } + } +} + +// Detekt configuration for all subprojects +subprojects { + apply(plugin = "io.gitlab.arturbosch.detekt") + + extensions.configure("detekt") { + buildUponDefaultConfig = true + allRules = false + ignoreFailures = true + autoCorrect = false + parallel = true + } + + // Applies to both `detekt` and `detektAutoCorrect` tasks + tasks.withType().configureEach { + jvmTarget = "17" + reports { + xml.required.set(false) + txt.required.set(false) + sarif.required.set(false) + md.required.set(false) + html.required.set(true) + } + } + + // Auto-correct variant โ€” fixes the subset of rules detekt can patch automatically + tasks.register("detektAutoCorrect", io.gitlab.arturbosch.detekt.Detekt::class.java) { + description = "Runs detekt with auto-correct enabled" + group = "verification" + autoCorrect = true + buildUponDefaultConfig = true + ignoreFailures = true + parallel = true + setSource(files("src")) + include("**/*.kt", "**/*.kts") + exclude("**/build/**") + } + + // Ensure detekt auto-correct runs after spotless has already formatted the files + tasks.named("detektAutoCorrect") { mustRunAfter("spotlessApply") } +} + +// Aggregator tasks +tasks.register("spotlessCheckAll") { + group = "verification" + description = "Runs spotlessCheck in all subprojects" + dependsOn(subprojects.map { "${it.path}:spotlessCheck" }) +} + +tasks.register("spotlessApplyAll") { + group = "formatting" + description = "Runs spotlessApply in all subprojects" + dependsOn(subprojects.map { "${it.path}:spotlessApply" }) +} + +tasks.register("detektAll") { + group = "verification" + description = "Runs detekt in all subprojects" + dependsOn(subprojects.map { "${it.path}:detekt" }) +} + +tasks.register("detektAllAutoCorrect") { + group = "formatting" + description = "Runs detekt with auto-correct in all subprojects" + dependsOn(subprojects.map { "${it.path}:detektAutoCorrect" }) +} + +// Single command: audit all style and lint issues without modifying files +tasks.register("codeCheck") { + group = "verification" + description = "Checks formatting (spotless) and runs detekt across all subprojects" + dependsOn("spotlessCheckAll", "detektAll") +} + +// Single command: apply all auto-fixable formatting and lint corrections +tasks.register("codeFormat") { + group = "formatting" + description = "Applies spotless formatting and detekt auto-corrections across all subprojects" + dependsOn("spotlessApplyAll", "detektAllAutoCorrect") +} + +tasks.named("detektAllAutoCorrect") { mustRunAfter("spotlessApplyAll") } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 20ea48c8..82ac6685 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,4 +1,6 @@ import org.gradle.kotlin.dsl.`kotlin-dsl` +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `kotlin-dsl` @@ -9,8 +11,8 @@ repositories { mavenCentral() } -tasks.withType().configureEach { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) } } diff --git a/buildSrc/src/main/java/ConfigData.kt b/buildSrc/src/main/java/ConfigData.kt index d7b6b423..9c40aa1c 100644 --- a/buildSrc/src/main/java/ConfigData.kt +++ b/buildSrc/src/main/java/ConfigData.kt @@ -1,9 +1,8 @@ object ConfigData { - const val compileSdkVersion = 34 - const val buildToolsVersion = "30.0.3" + const val compileSdkVersion = 36 const val minSdkVersion = 26 - const val targetSdkVersion = 34 + const val targetSdkVersion = 36 const val versionCode = 101 const val versionName = "1.1" const val multiDexEnabled = true diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index de2204c0..c7165f14 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -3,6 +3,7 @@ object BuildPlugins { val buildGradle by lazy { "com.android.tools.build:gradle:${Versions.buildGradle}" } val kotlinGradlePlugin by lazy { "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}" } val googleServicePlugin by lazy { "com.google.gms:google-services:${Versions.googleServices}" } + val composeMultiplatformPlugin by lazy { "org.jetbrains.compose:compose-gradle-plugin:${Versions.composeMultiplatform}" } } // Dependencies @@ -20,6 +21,7 @@ object Deps { val systemUIController by lazy { "com.google.accompanist:accompanist-systemuicontroller:${Versions.accompanist}" } val dataStore by lazy { "androidx.datastore:datastore-preferences:${Versions.dataStore}" } val splashScreen by lazy { "androidx.core:core-splashscreen:${Versions.splashScreen}" } + val securityCrypto by lazy { "androidx.security:security-crypto:${Versions.securityCrypto}" } // Compose val composeBom by lazy { "androidx.compose:compose-bom:${Versions.composeBom}" } @@ -28,6 +30,7 @@ object Deps { val composeUi by lazy { "androidx.compose.ui:ui" } val composeUiTooling by lazy { "androidx.compose.ui:ui-tooling" } val composeUiToolingPreview by lazy { "androidx.compose.ui:ui-tooling-preview" } + val composeIconsExtended by lazy { "androidx.compose.material:material-icons-extended" } // Unit Testing val junit by lazy { "junit:junit:${Versions.junit}" } @@ -55,28 +58,33 @@ object Deps { // Firebase val firebaseBom by lazy { "com.google.firebase:firebase-bom:${Versions.firebaseBom}" } - val firebaseConfig by lazy { "com.google.firebase:firebase-config-ktx" } - val firebaseAnalytics by lazy { "com.google.firebase:firebase-analytics-ktx" } + // Firebase dependencies will use versions from BOM - no explicit versions needed + val firebaseConfig by lazy { "com.google.firebase:firebase-config" } + val firebaseAnalytics by lazy { "com.google.firebase:firebase-analytics" } val firebasePerformanceMonitoring by lazy { "com.google.firebase:firebase-perf" } + val firebaseMessaging by lazy { "com.google.firebase:firebase-messaging" } // Coroutines val coroutinesCore by lazy { "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutines}" } val coroutinesAndroid by lazy { "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutines}" } + // Date/Time (KMP-compatible, replaces java.time) + val kotlinxDatetime by lazy { "org.jetbrains.kotlinx:kotlinx-datetime:${Versions.kotlinxDatetime}" } + // Dependency Injection val hilt by lazy { "com.google.dagger:hilt-android:${Versions.hilt}" } val hiltTesting by lazy { "com.google.dagger:hilt-android-testing:${Versions.hilt}" } val hiltDaggerAndroidCompiler by lazy { "com.google.dagger:hilt-android-compiler:${Versions.hilt}" } val hiltNavigationCompose by lazy { "androidx.hilt:hilt-navigation-compose:${Versions.hiltCompose}" } + val hiltAndroidXCompiler by lazy { "androidx.hilt:hilt-compiler:${Versions.hiltCompose}" } // Miscellaneous val timber by lazy { "com.jakewharton.timber:timber:${Versions.timber}" } - val lottieCompose by lazy { "com.airbnb.android:lottie-compose:${Versions.lottie}" } val coilCompose by lazy { "io.coil-kt:coil-compose:${Versions.coilCompose}" } // Memory Leak val leakCanary by lazy { "com.squareup.leakcanary:leakcanary-android:${Versions.leakCanary}" } - /*For Payment module*/ + // For Payment module val razorPay by lazy { "com.razorpay:checkout:${Versions.razorPay}" } } diff --git a/buildSrc/src/main/java/Versions.kt b/buildSrc/src/main/java/Versions.kt index d91a74ea..d1073841 100644 --- a/buildSrc/src/main/java/Versions.kt +++ b/buildSrc/src/main/java/Versions.kt @@ -1,70 +1,77 @@ - object Versions { // Kotlin - const val kotlin = "2.0.20" - const val kotlinCompiler = "1.9" + const val kotlin = "2.2.21" // Compose - const val composeBom = "2023.08.00" + const val composeBom = "2025.06.01" + const val composeMultiplatform = "1.8.0" // for Compose Multiplatform (iOS + Android) // Plugins - const val buildGradle = "8.11.1" - const val navigation = "2.7.0" + const val buildGradle = "8.12.0" + const val navigation = "2.7.7" const val secretPlugin = "2.0.1" const val benManes = "0.52.0" const val spotlessVersion = "6.25.0" - const val ktLintVersion = "13.0.0" - const val googleServices = "4.3.15" + const val ksp = "2.2.21-2.0.5" + + // KtLint versions: separate plugin and CLI to avoid resolution confusion + const val ktLintGradlePlugin = "12.1.1" + const val ktLintCli = "1.7.1" + const val detekt = "1.23.6" + const val googleServices = "4.4.1" // Testing const val junit = "4.13.2" - const val extJunit = "1.1.5" - const val truth = "1.1.3" - const val turbine = "0.13.0" - const val coroutineTest = "1.7.1" + const val extJunit = "1.3.0" + const val truth = "1.4.5" + const val turbine = "1.2.1" + const val coroutineTest = "1.10.2" const val coreTesting = "2.2.0" - const val espresso = "3.5.1" + const val espresso = "3.7.0" const val mockitoInline = "5.2.0" const val mockitoNhaarman = "2.2.0" - const val mockWebServer = "4.9.3" - const val mockk = "1.13.5" + const val mockWebServer = "4.12.0" + const val mockk = "1.14.9" // Core - const val androidCore = "1.9.0" - const val appCompat = "1.6.1" - const val androidMaterial = "1.7.0" - const val lifecycle = "2.6.1" + const val androidCore = "1.13.1" + const val appCompat = "1.7.0" + const val androidMaterial = "1.11.0" + const val lifecycle = "2.7.0" const val googlePlayCore = "2.1.0" - const val googlePlayLocation = "21.0.1" - const val accompanist = "0.28.0" - const val dataStore = "1.0.0" - const val splashScreen = "1.0.1" + const val googlePlayLocation = "21.3.0" + const val accompanist = "0.36.0" + const val dataStore = "1.1.1" + const val splashScreen = "1.2.0" + const val securityCrypto = "1.1.0-alpha06" // Room - const val room = "2.5.2" + const val room = "2.8.4" // Networking - const val gson = "2.13.1" + const val gson = "2.13.2" // Firebase - const val firebaseBom = "32.2.0" + const val firebaseBom = "34.10.0" // Coroutines - const val coroutines = "1.6.4" + const val coroutines = "1.10.2" + + // Kotlinx Date/Time (KMP-compatible, replaces java.time) + const val kotlinxDatetime = "0.6.2" // Dependency Injection - const val hilt = "2.52" - const val hiltCompose = "1.0.0" + const val hilt = "2.58" + const val hiltCompose = "1.2.0" // Miscellaneous const val timber = "5.0.1" - const val lottie = "6.0.0" - const val coilCompose = "2.4.0" + const val coilCompose = "2.7.0" // Memory leak - const val leakCanary = "2.12" + const val leakCanary = "2.13" /*For Payment module*/ - const val razorPay = "1.6.30" + const val razorPay = "1.6.41" } diff --git a/buildSrc/src/main/kotlin/KmmDeps.kt b/buildSrc/src/main/kotlin/KmmDeps.kt index 08751443..514a0a12 100644 --- a/buildSrc/src/main/kotlin/KmmDeps.kt +++ b/buildSrc/src/main/kotlin/KmmDeps.kt @@ -1,11 +1,35 @@ import org.gradle.api.artifacts.dsl.DependencyHandler +// Compose Multiplatform runtime dependencies for the common-ui KMP module. +// The version MUST match your Kotlin version. Check the table at: +// https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html +// Known mapping: Kotlin 2.1.x โ†’ CMP 1.7.x | Update below for your exact Kotlin version. +object CmpVersions { + const val composeMultiplatform = "1.7.3" +} + +object CmpDeps { + const val runtime = "org.jetbrains.compose.runtime:runtime:${CmpVersions.composeMultiplatform}" + const val ui = "org.jetbrains.compose.ui:ui:${CmpVersions.composeMultiplatform}" + const val foundation = + "org.jetbrains.compose.foundation:foundation:${CmpVersions.composeMultiplatform}" + const val material3 = + "org.jetbrains.compose.material3:material3:${CmpVersions.composeMultiplatform}" + const val animation = + "org.jetbrains.compose.animation:animation:${CmpVersions.composeMultiplatform}" + const val components = + "org.jetbrains.compose.components:components-resources:${CmpVersions.composeMultiplatform}" + const val uiTooling = "org.jetbrains.compose.ui:ui-tooling:${CmpVersions.composeMultiplatform}" +} + object KmmVersions { const val ktor = "2.3.13" - const val kotlinxSerialization = "1.6.0" - const val kotlinxCoroutines = "1.7.3" + const val kotlinxSerialization = "1.7.3" + const val kotlinxCoroutines = "1.9.0" const val koin = "3.5.6" - const val kotlinxDateTime = "0.4.1" + const val koinAndroidCompose = "3.5.6" + const val kotlinxDateTime = "0.6.1" + const val kmpLifecycleViewModel = "2.8.4" } object KmmDeps { @@ -15,24 +39,30 @@ object KmmDeps { const val ktorContentNegotiation = "io.ktor:ktor-client-content-negotiation:${KmmVersions.ktor}" const val ktorJson = "io.ktor:ktor-serialization-kotlinx-json:${KmmVersions.ktor}" const val ktorLogging = "io.ktor:ktor-client-logging:${KmmVersions.ktor}" - + // Platform-specific Ktor engines const val ktorAndroid = "io.ktor:ktor-client-android:${KmmVersions.ktor}" const val ktorIOS = "io.ktor:ktor-client-darwin:${KmmVersions.ktor}" - + // Kotlinx Serialization const val kotlinxSerialization = "org.jetbrains.kotlinx:kotlinx-serialization-json:${KmmVersions.kotlinxSerialization}" - + // Kotlinx Coroutines const val kotlinxCoroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${KmmVersions.kotlinxCoroutines}" - + // Koin const val koinCore = "io.insert-koin:koin-core:${KmmVersions.koin}" - + const val koinAndroid = "io.insert-koin:koin-android:${KmmVersions.koin}" + const val koinAndroidCompose = "io.insert-koin:koin-androidx-compose:${KmmVersions.koinAndroidCompose}" + // DateTime const val kotlinxDateTime = "org.jetbrains.kotlinx:kotlinx-datetime:${KmmVersions.kotlinxDateTime}" + + // KMP-compatible ViewModel (JetBrains port of AndroidX lifecycle-viewmodel) + const val kmpLifecycleViewModel = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:${KmmVersions.kmpLifecycleViewModel}" } +@Suppress("unused") fun DependencyHandler.addKmmCommonDependencies() { implementation(KmmDeps.ktorCore) implementation(KmmDeps.ktorSerialization) @@ -45,10 +75,12 @@ fun DependencyHandler.addKmmCommonDependencies() { implementation(KmmDeps.kotlinxDateTime) } +@Suppress("unused") fun DependencyHandler.addKmmAndroidDependencies() { implementation(KmmDeps.ktorAndroid) } +@Suppress("unused") fun DependencyHandler.addKmmIOSDependencies() { implementation(KmmDeps.ktorIOS) } diff --git a/common-ui/build.gradle.kts b/common-ui/build.gradle.kts new file mode 100644 index 00000000..e23fca3b --- /dev/null +++ b/common-ui/build.gradle.kts @@ -0,0 +1,86 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.library") + kotlin("multiplatform") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.compose") +} + +kotlin { + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { + it.binaries.framework { + baseName = "common_ui" + isStatic = true + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib") + // Compose Multiplatform โ€” works on Android + iOS + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.materialIconsExtended) + // Payment UI state types (PaymentUiState, PaymentStage) used in SettingsScreen + implementation(project(":feature-payment")) + // Location models (SavedLocation, PlaceSuggestion) and repositories for SavedLocationsScreen + implementation(project(":network")) + // Date/time utilities for KMP + implementation(KmmDeps.kotlinxDateTime) + } + } + + @Suppress("UNUSED_VARIABLE") + val androidMain by getting { + dependencies { + implementation(KmmDeps.kotlinxCoroutinesCore) + // BackHandler support for InAppWebView + implementation("androidx.activity:activity-compose:1.13.0") + } + } + + val iosX64Main by getting + val iosArm64Main by getting + val iosSimulatorArm64Main by getting + + @Suppress("UNUSED_VARIABLE") + val iosMain by creating { + dependsOn(commonMain) + iosX64Main.dependsOn(this) + iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) + } + } +} + +android { + namespace = "bose.ankush.commonui" + compileSdk = ConfigData.compileSdkVersion + + defaultConfig { + minSdk = ConfigData.minSdkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } +} diff --git a/common-ui/src/androidMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt b/common-ui/src/androidMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt new file mode 100644 index 00000000..8ccec05c --- /dev/null +++ b/common-ui/src/androidMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt @@ -0,0 +1,13 @@ +package bose.ankush.commonui.util + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +actual fun formatDate( + millis: Long, + pattern: String, +): String { + val df = SimpleDateFormat(pattern, Locale.getDefault()) + return df.format(Date(millis)) +} diff --git a/common-ui/src/androidMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt b/common-ui/src/androidMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt new file mode 100644 index 00000000..f2e59444 --- /dev/null +++ b/common-ui/src/androidMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt @@ -0,0 +1,461 @@ +package bose.ankush.commonui.web + +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.util.Log +import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.SslErrorHandler +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.OpenInBrowser +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.net.toUri + +/** Android actual: security-hardened WebView wrapped in AndroidView. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun InAppWebView( + url: String, + modifier: Modifier, + onClose: () -> Unit, +) { + val context = LocalContext.current + + val pageTitle = remember { mutableStateOf("") } + var progress by remember { mutableIntStateOf(0) } + var loadError by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + var isInitialLoad by remember { mutableStateOf(true) } + val currentUrl = remember { mutableStateOf(url) } + + // Keep a single WebView instance across recompositions + val webView = + remember(context) { + WebView(context).apply { + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + } + + BackHandler(enabled = true) { + if (webView.canGoBack()) webView.goBack() else onClose() + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = pageTitle.value.ifBlank { "Weatherify" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = { + if (webView.canGoBack()) webView.goBack() else onClose() + }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + ) + } + }, + actions = { + IconButton(onClick = { webView.reload() }) { + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = "Refresh page", + ) + } + IconButton(onClick = { + val shareIntent = + Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, currentUrl.value) + type = "text/plain" + } + val chooser = Intent.createChooser(shareIntent, "Share URL") + try { + context.startActivity(chooser) + } catch (_: ActivityNotFoundException) { + // No app available to handle share + } + }) { + Icon( + imageVector = Icons.Outlined.Share, + contentDescription = "Share page", + ) + } + IconButton(onClick = { + try { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + currentUrl.value.toUri() + ) + ) + } catch (_: ActivityNotFoundException) { + // No browser available + } + }) { + Icon( + imageVector = Icons.Outlined.OpenInBrowser, + contentDescription = "Open in browser", + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) + }, + ) { paddingValues -> + Column( + modifier = + modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(paddingValues), + ) { + if (progress in 1..99) { + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = + Modifier + .height(2.dp) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = ProgressIndicatorDefaults.linearTrackColor, + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) + } + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + AndroidView( + factory = { ctx -> + webView.apply { + configureWebView( + view = this, + onTitle = { pageTitle.value = it }, + onProgress = { progress = it }, + onExternalIntent = { intent -> + try { + ctx.startActivity(intent) + } catch (_: ActivityNotFoundException) { + // No handler available + } + }, + onError = { message -> + loadError = true + errorMessage = message + }, + onPageFinished = { + isInitialLoad = false + }, + ) + } + }, + update = { view -> + if (view.url != url) { + currentUrl.value = url + view.loadUrl(url) + } + }, + ) + + // Loading overlay during initial page load + if (isInitialLoad && progress < 100) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.8f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + // Error overlay when page fails to load + if (loadError) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.95f)), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Outlined.ErrorOutline, + contentDescription = "Error", + modifier = + Modifier + .size(64.dp) + .padding(bottom = 16.dp), + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = "Failed to load page", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(bottom = 8.dp), + ) + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + modifier = Modifier.padding(bottom = 24.dp), + ) + } + Button( + onClick = { + loadError = false + errorMessage = "" + isInitialLoad = true + webView.reload() + }, + ) { + Text("Retry") + } + } + } + } + } + } + } + + // Clean up WebView resources when composable leaves composition + DisposableEffect(Unit) { + onDispose { + try { + webView.stopLoading() + webView.clearHistory() + webView.removeAllViews() + webView.destroy() + } catch (_: Exception) { + } + } + } +} + +@SuppressLint("SetJavaScriptEnabled") +private fun configureWebView( + view: WebView, + onTitle: (String) -> Unit, + onProgress: (Int) -> Unit, + onExternalIntent: (Intent) -> Unit, + onError: (String) -> Unit = {}, + onPageFinished: () -> Unit = {}, +) { + with(view.settings) { + // SECURITY: Disable JavaScript to prevent XSS attacks in legal documents + javaScriptEnabled = false + // SECURITY: Disable DOM storage to prevent credential/token theft + domStorageEnabled = false + @Suppress("DEPRECATION") + databaseEnabled = false + // SECURITY: Never allow mixed content (HTTP on HTTPS) to prevent MITM attacks + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + cacheMode = WebSettings.LOAD_DEFAULT + builtInZoomControls = true + displayZoomControls = false + useWideViewPort = true + loadWithOverviewMode = true + // SECURITY: Disable multiple windows to prevent popup injection attacks + setSupportMultipleWindows(false) + // SECURITY: Require user gesture for media playback to prevent unwanted autoplay + mediaPlaybackRequiresUserGesture = true + } + + // SECURITY: Only accept cookies from trusted legal content domains + CookieManager.getInstance().setAcceptCookie(false) + + view.webViewClient = + object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + val uri = request?.url ?: return false + val scheme = uri.scheme ?: "" + return handleUrl(view, uri, scheme, onExternalIntent) + } + + override fun onPageFinished( + view: WebView?, + url: String?, + ) { + super.onPageFinished(view, url) + onPageFinished() + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + super.onReceivedError(view, request, error) + // Only report errors for main-frame loads, ignore subresource failures + if (request?.isForMainFrame == true) { + val errorDesc = error?.description?.toString() ?: "Unknown error" + onError("Failed to load: $errorDesc") + } + } + + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: android.webkit.WebResourceResponse?, + ) { + super.onReceivedHttpError(view, request, errorResponse) + // Only report errors for main-frame loads, ignore subresource failures + if (request?.isForMainFrame == true) { + val statusCode = errorResponse?.statusCode ?: 0 + val reason = errorResponse?.reasonPhrase ?: "Unknown error" + onError("HTTP Error $statusCode: $reason") + } + } + + override fun onReceivedSslError( + view: WebView?, + handler: SslErrorHandler?, + error: android.net.http.SslError?, + ) { + super.onReceivedSslError(view, handler, error) + handler?.cancel() + val errorMsg = + when (error?.primaryError) { + android.net.http.SslError.SSL_EXPIRED -> "SSL certificate expired" + android.net.http.SslError.SSL_IDMISMATCH -> "SSL certificate hostname mismatch" + android.net.http.SslError.SSL_NOTYETVALID -> "SSL certificate not yet valid" + android.net.http.SslError.SSL_UNTRUSTED -> "SSL certificate not trusted" + else -> "SSL certificate error" + } + onError(errorMsg) + } + } + + view.webChromeClient = + object : WebChromeClient() { + override fun onProgressChanged( + view: WebView?, + newProgress: Int, + ) { + super.onProgressChanged(view, newProgress) + onProgress(newProgress) + } + + override fun onReceivedTitle( + view: WebView?, + title: String?, + ) { + super.onReceivedTitle(view, title) + if (!title.isNullOrBlank()) onTitle(title) + } + } +} + +private fun handleUrl( + webView: WebView?, + uri: Uri, + scheme: String, + onExternalIntent: (Intent) -> Unit, +): Boolean { + when (scheme.lowercase()) { + "http", "https" -> { + if (isWhitelistedUrl(uri)) { + webView?.loadUrl(uri.toString()) + } else { + // Reject URLs from untrusted domains + Log.w("InAppWebView", "Blocked untrusted URL: $uri") + } + } + "tel", "mailto", "geo", "sms", "intent" -> { + onExternalIntent(Intent(Intent.ACTION_VIEW, uri)) + } + else -> { + onExternalIntent(Intent(Intent.ACTION_VIEW, uri)) + } + } + // Always return true to indicate we handled the URL loading + return true +} + +/** + * Validate URL against whitelist of trusted domains. + * Only allows loading content from whitelisted legal document hosts. + */ +private fun isWhitelistedUrl(uri: Uri): Boolean { + val host = uri.host?.lowercase() ?: return false + val whitelistedDomains = + setOf( + "data.androidplay.in", // Terms, Privacy Policy + ) + return whitelistedDomains.any { trustedDomain -> + host == trustedDomain || host.endsWith(".$trustedDomain") + } +} diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/auth/LoginScreen.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/auth/LoginScreen.kt new file mode 100644 index 00000000..5137b27d --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/auth/LoginScreen.kt @@ -0,0 +1,404 @@ +package bose.ankush.commonui.auth + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp + +// Multiplatform-safe email regex (replaces android.util.Patterns) +private val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$") + +/** + * Login Screen composable that displays a login form with email and password fields, + * login/register toggle, and terms & conditions link. + * + * CMP-compatible: works on Android and iOS via Compose Multiplatform. + * + * @param onLoginClick Callback when the login button is clicked + * @param onRegisterClick Callback when the register button is clicked + * @param onWebUrlClick Callback when a web URL (terms/privacy) link is clicked + * @param isLoading Whether the screen is in loading state + */ +@Composable +fun LoginScreen( + onLoginClick: (email: String, password: String) -> Unit, + onRegisterClick: (email: String, password: String) -> Unit, + onWebUrlClick: (url: String) -> Unit = {}, + isLoading: Boolean = false, +) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var isPasswordVisible by remember { mutableStateOf(false) } + var isLoginMode by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + + var isTitleClicked by remember { mutableStateOf(false) } + var isSubtitleClicked by remember { mutableStateOf(false) } + + val focusManager = LocalFocusManager.current + + val isEmailValid = { input: String -> EMAIL_REGEX.matches(input) } + val isPasswordValid = { input: String -> input.length >= 6 } + + val validateInputs = { + when { + email.isBlank() -> { + errorMessage = "Email cannot be empty" + false + } + !isEmailValid(email) -> { + errorMessage = "Please enter a valid email address" + false + } + password.isBlank() -> { + errorMessage = "Password cannot be empty" + false + } + !isPasswordValid(password) -> { + errorMessage = "Password must be at least 6 characters" + false + } + else -> { + errorMessage = null + true + } + } + } + + val handleSubmit = { + if (!isLoading && validateInputs()) { + if (isLoginMode) onLoginClick(email, password) else onRegisterClick(email, password) + } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .imePadding() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + ) { + // Header + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 80.dp), + ) { + val titleScale by animateFloatAsState( + targetValue = if (isTitleClicked) 1.1f else 1.0f, + animationSpec = spring(dampingRatio = 0.4f, stiffness = 300f), + label = "titleScale", + ) + val titleColor = + if (isTitleClicked) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.primary + } + + Text( + text = if (isLoginMode) "Welcome Back" else "Create Account", + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), + color = titleColor, + textAlign = TextAlign.Start, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + .scale(titleScale) + .clickable { isTitleClicked = !isTitleClicked }, + ) + + val subtitleScale by animateFloatAsState( + targetValue = if (isSubtitleClicked) 1.1f else 1.0f, + animationSpec = + tween( + durationMillis = 300, + easing = androidx.compose.animation.core.FastOutSlowInEasing, + ), + label = "subtitleScale", + ) + val subtitleColor = + if (isSubtitleClicked) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) + } + + Text( + text = if (isLoginMode) "Sign in to continue" else "Join our community", + style = MaterialTheme.typography.bodyLarge, + color = subtitleColor, + textAlign = TextAlign.Start, + modifier = + Modifier + .fillMaxWidth() + .scale(subtitleScale) + .clickable { isSubtitleClicked = !isSubtitleClicked }, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Form + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + value = email, + onValueChange = { + email = it + errorMessage = null + }, + label = { Text("Email address") }, + singleLine = true, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + shape = RoundedCornerShape(12.dp), + ) + + OutlinedTextField( + value = password, + onValueChange = { + password = it + errorMessage = null + }, + label = { Text("Password") }, + singleLine = true, + visualTransformation = + if (isPasswordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onDone = { + focusManager.clearFocus() + handleSubmit() + }, + ), + trailingIcon = { + TextButton( + onClick = { isPasswordVisible = !isPasswordVisible }, + enabled = !isLoading, + contentPadding = ButtonDefaults.TextButtonWithIconContentPadding, + ) { + Text( + text = if (isPasswordVisible) "Hide" else "Show", + color = MaterialTheme.colorScheme.primary, + ) + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + shape = RoundedCornerShape(12.dp), + ) + + if (errorMessage != null) { + Text( + text = errorMessage!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { handleSubmit() }, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + enabled = !isLoading, + shape = RoundedCornerShape(12.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text( + text = if (isLoginMode) "Sign In" else "Create Account", + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + ), + ) + } + } + } + + // Footer + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TextButton( + onClick = { isLoginMode = !isLoginMode }, + enabled = !isLoading, + ) { + Text( + text = + if (isLoginMode) { + "Don't have an account? Register" + } else { + "Already registered? Login" + }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium, + ) + } + + val termsText = + buildAnnotatedString { + append("By continuing, you agree to our ") + pushStringAnnotation(tag = "terms", annotation = "terms") + withStyle( + style = + SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + ) { append("Terms & Conditions") } + pop() + append(" & ") + pushStringAnnotation(tag = "privacy", annotation = "privacy") + withStyle( + style = + SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + ) { append("Privacy Policy") } + pop() + } + + var textLayoutResult by remember { mutableStateOf(null) } + + BasicText( + text = termsText, + style = + MaterialTheme.typography.bodySmall.copy( + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + ), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .pointerInput(isLoading) { + if (!isLoading) { + detectTapGestures { offsetPosition -> + textLayoutResult?.let { layoutResult -> + val offset = + layoutResult.getOffsetForPosition(offsetPosition) + termsText + .getStringAnnotations( + start = offset, + end = offset, + ).firstOrNull() + ?.let { annotation -> + when (annotation.tag) { + "terms" -> + onWebUrlClick( + "https://data.androidplay.in/wfy/terms-and-conditions", + ) + + "privacy" -> + onWebUrlClick( + "https://data.androidplay.in/wfy/privacy-policy", + ) + } + } + } + } + } + }, + onTextLayout = { textLayoutResult = it }, + ) + } + } + } + } +} diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/NotificationToast.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/NotificationToast.kt new file mode 100644 index 00000000..2689601e --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/NotificationToast.kt @@ -0,0 +1,182 @@ +package bose.ankush.commonui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +enum class ToastType { SUCCESS, WARNING, ERROR } + +/** + * Holds the measured height of an anchor component (e.g. bottom nav bar) so + * [NotificationToast] can automatically position itself above it. + */ +@Stable +class ToastAnchorState internal constructor( + private val density: Float, +) { + var anchorHeight: Dp by mutableStateOf(0.dp) + internal set + + internal fun updateHeight(heightPx: Int) { + anchorHeight = (heightPx / density).dp + } +} + +@Composable +fun rememberToastAnchorState(): ToastAnchorState { + val density = LocalDensity.current + return remember { ToastAnchorState(density.density) } +} + +/** + * Attach to the component above which the toast should appear (e.g. bottom nav bar). + * Measures the component's height and reports it to [ToastAnchorState]. + */ +fun Modifier.toastAnchor(state: ToastAnchorState): Modifier = + this.onSizeChanged { size -> state.updateHeight(size.height) } + +/** + * Multiplatform toast notification overlay. + * + * Displays an animated toast message at the bottom of the screen, auto-dismissing after + * [durationMillis]. Position can be adjusted via [anchorState] (measures a bottom component's + * height) or a manual [bottomOffset]. + * + * Supports Android and iOS via Compose Multiplatform. + */ +@Composable +fun NotificationToast( + modifier: Modifier = Modifier, + message: String, + title: String, + type: ToastType, + isVisible: Boolean, + onDismiss: () -> Unit, + durationMillis: Long = 3000, + bottomOffset: Dp = 0.dp, + anchorState: ToastAnchorState? = null, +) { + LaunchedEffect(isVisible) { + if (isVisible) { + delay(durationMillis) + onDismiss() + } + } + + val (backgroundColor, icon, iconColor) = + when (type) { + ToastType.SUCCESS -> + Triple( + MaterialTheme.colorScheme.primaryContainer, + Icons.Filled.CheckCircle, + MaterialTheme.colorScheme.primary, + ) + + ToastType.WARNING -> + Triple( + MaterialTheme.colorScheme.tertiaryContainer, + Icons.Filled.Warning, + MaterialTheme.colorScheme.tertiary, + ) + + ToastType.ERROR -> + Triple( + MaterialTheme.colorScheme.errorContainer, + Icons.Filled.Close, + MaterialTheme.colorScheme.error, + ) + } + + Box( + modifier = + modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(bottom = 16.dp + (anchorState?.anchorHeight ?: bottomOffset)), + contentAlignment = Alignment.BottomCenter, + ) { + AnimatedVisibility( + visible = isVisible, + enter = + fadeIn(animationSpec = tween(300, easing = FastOutSlowInEasing)) + + slideInVertically( + animationSpec = tween(300, easing = FastOutSlowInEasing), + initialOffsetY = { it }, + ), + exit = + fadeOut(animationSpec = tween(300, easing = FastOutSlowInEasing)) + + slideOutVertically( + animationSpec = tween(300, easing = FastOutSlowInEasing), + targetOffsetY = { it }, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/ServiceSubscriptionBottomSheet.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/ServiceSubscriptionBottomSheet.kt new file mode 100644 index 00000000..19ed7e94 --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/ServiceSubscriptionBottomSheet.kt @@ -0,0 +1,510 @@ +package bose.ankush.commonui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import bose.ankush.commonui.viewmodel.ServiceSubscriptionUiState +import bose.ankush.network.model.Feature +import bose.ankush.network.model.PricingTier +import bose.ankush.network.model.Service + +@Composable +fun ServiceSubscriptionBottomSheet( + uiState: ServiceSubscriptionUiState, + loadService: () -> Unit, + onServiceSelected: (Service) -> Unit, + onTierSelected: (PricingTier) -> Unit, + onDismiss: () -> Unit, + onSubscribe: (service: Service, tier: PricingTier) -> Unit, + modifier: Modifier = Modifier, +) { + LaunchedEffect(Unit) { + loadService() + } + + Column( + modifier = + modifier + .fillMaxWidth() + .fillMaxHeight(0.7f) + .background(MaterialTheme.colorScheme.surface) + .navigationBarsPadding() + .imePadding() + .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)), + ) { + AnimatedVisibility( + visible = uiState.isLoading, + enter = fadeIn(), + exit = fadeOut(), + ) { + ShimmerBottomSheetSkeleton( + modifier = Modifier.padding(bottom = 20.dp), + ) + } + + AnimatedVisibility( + visible = !uiState.isLoading && uiState.error != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + ErrorContent( + error = uiState.error ?: "Unknown error", + onDismiss = onDismiss, + onRetry = loadService, + ) + } + + AnimatedVisibility( + visible = !uiState.isLoading && uiState.services.isNotEmpty() && uiState.error == null, + enter = fadeIn(), + exit = fadeOut(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + ) { + CloseButton(onClose = onDismiss) + + if (uiState.selectedService != null && uiState.selectedTier != null) { + PlanHeader( + service = uiState.selectedService, + tier = uiState.selectedTier, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + ServiceSelector( + services = uiState.services, + selectedService = uiState.selectedService, + onServiceSelected = onServiceSelected, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + FeaturesSection( + features = uiState.selectedService.features, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + TierSelector( + service = uiState.selectedService, + selectedTier = uiState.selectedTier, + onTierSelected = onTierSelected, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + onSubscribe(uiState.selectedService, uiState.selectedTier) + }, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 24.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + shape = RoundedCornerShape(12.dp), + ) { + Text( + text = "Subscribe Now", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + } + } + } + } + } +} + +@Composable +private fun CloseButton(onClose: () -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.End, + ) { + IconButton(onClick = onClose, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +private fun PlanHeader( + service: Service, + tier: PricingTier, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = service.displayName, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = tier.getDisplayPrice(), + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + + Text( + text = "for ${tier.getDisplayDuration()}", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ServiceSelector( + services: List, + selectedService: Service, + onServiceSelected: (Service) -> Unit, +) { + if (services.size <= 1) return + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + Text( + text = "Select Plan", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 12.dp), + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + services.forEach { service -> + ServiceOptionCard( + service = service, + isSelected = service.id == selectedService.id, + onClick = { onServiceSelected(service) }, + ) + } + } + } +} + +@Composable +private fun ServiceOptionCard( + service: Service, + isSelected: Boolean, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background( + if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ).clickable(onClick = onClick) + .padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = service.displayName.take(10), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + + if (isSelected) { + Spacer(modifier = Modifier.height(4.dp)) + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + } + } + } +} + +@Composable +private fun FeaturesSection(features: List) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + Text( + text = "Features", + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp), + ) + + features.take(8).forEach { feature -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color(0xFF4CAF50), + modifier = + Modifier + .size(16.dp) + .padding(top = 1.dp), + ) + + Text( + text = feature.description, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + maxLines = 1, + ) + } + } + + if (features.size > 8) { + Text( + text = "+ ${features.size - 8} more features", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +@Composable +private fun TierSelector( + service: Service, + selectedTier: PricingTier, + onTierSelected: (PricingTier) -> Unit, +) { + if (service.pricingTiers.size <= 1) return + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + Text( + text = "Select Duration", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 12.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + service.pricingTiers.forEach { tier -> + TierOption( + tier = tier, + isSelected = tier.id == selectedTier.id, + onClick = { onTierSelected(tier) }, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun TierOption( + tier: PricingTier, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .clip(RoundedCornerShape(8.dp)) + .background( + if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ).clickable(onClick = onClick) + .padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = tier.getDisplayPrice(), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = tier.getDisplayDuration(), + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ErrorContent( + error: String, + onDismiss: () -> Unit, + onRetry: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "Oops!", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = error, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onRetry, + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + shape = RoundedCornerShape(8.dp), + ) { + Text("Retry") + } + + Text( + text = "Cancel", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .clickable { onDismiss() } + .padding(vertical = 8.dp), + ) + } +} diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/ShimmerEffect.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/ShimmerEffect.kt new file mode 100644 index 00000000..0c0fe4f0 --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/ShimmerEffect.kt @@ -0,0 +1,182 @@ +package bose.ankush.commonui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * KMP-compatible shimmer effect for loading states. + * Works seamlessly on Android and iOS. + */ +@Composable +fun ShimmerEffect( + modifier: Modifier = Modifier, + height: Dp = 12.dp, + cornerRadius: Dp = 4.dp, + baseColor: Color = Color.LightGray.copy(alpha = 0.3f), + highlightColor: Color = Color.White.copy(alpha = 0.8f), +) { + val infiniteTransition = rememberInfiniteTransition(label = "shimmer") + val shimmerX = + infiniteTransition.animateFloat( + initialValue = -1000f, + targetValue = 1000f, + animationSpec = + infiniteRepeatable( + animation = + androidx.compose.animation.core.tween( + durationMillis = 1200, + easing = LinearEasing, + ), + ), + label = "shimmer_x", + ) + + val shimmerBrush = + Brush.linearGradient( + colors = + listOf( + baseColor, + highlightColor, + baseColor, + ), + start = Offset(shimmerX.value - 200f, 0f), + end = Offset(shimmerX.value + 200f, 0f), + ) + + Box( + modifier = + modifier + .fillMaxWidth() + .height(height) + .background( + brush = shimmerBrush, + shape = RoundedCornerShape(cornerRadius), + ), + ) +} + +/** + * Shimmer loading skeleton for bottom sheet content + */ +@Composable +fun ShimmerBottomSheetSkeleton( + modifier: Modifier = Modifier, + baseColor: Color = Color.LightGray.copy(alpha = 0.3f), + highlightColor: Color = Color.White.copy(alpha = 0.8f), +) { + androidx.compose.foundation.layout.Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + ) { + // Header + ShimmerEffect( + height = 28.dp, + cornerRadius = 6.dp, + baseColor = baseColor, + highlightColor = highlightColor, + modifier = + Modifier + .fillMaxWidth(0.6f) + .padding(bottom = 16.dp), + ) + + // Description lines + repeat(2) { + ShimmerEffect( + height = 14.dp, + cornerRadius = 4.dp, + baseColor = baseColor, + highlightColor = highlightColor, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) + } + + ShimmerEffect( + height = 14.dp, + cornerRadius = 4.dp, + baseColor = baseColor, + highlightColor = highlightColor, + modifier = + Modifier + .fillMaxWidth(0.7f) + .padding(bottom = 16.dp), + ) + + // Features section + ShimmerEffect( + height = 18.dp, + cornerRadius = 4.dp, + baseColor = baseColor, + highlightColor = highlightColor, + modifier = + Modifier + .fillMaxWidth(0.3f) + .padding(bottom = 12.dp), + ) + + repeat(3) { + ShimmerEffect( + height = 14.dp, + cornerRadius = 4.dp, + baseColor = baseColor, + highlightColor = highlightColor, + modifier = + Modifier + .fillMaxWidth(0.8f) + .padding(bottom = 10.dp), + ) + } + + // Pricing section + ShimmerEffect( + height = 20.dp, + cornerRadius = 6.dp, + baseColor = baseColor, + highlightColor = highlightColor, + modifier = + Modifier + .fillMaxWidth(0.4f) + .padding(top = 16.dp, bottom = 12.dp), + ) + + ShimmerEffect( + height = 14.dp, + cornerRadius = 4.dp, + baseColor = baseColor, + highlightColor = highlightColor, + modifier = + Modifier + .fillMaxWidth(0.5f) + .padding(bottom = 20.dp), + ) + + // Button + ShimmerEffect( + height = 48.dp, + cornerRadius = 8.dp, + baseColor = baseColor, + highlightColor = highlightColor, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/components/SunriseSunsetAnimation.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/SunriseSunsetAnimation.kt similarity index 59% rename from sunriseui/src/main/java/bose/ankush/sunriseui/components/SunriseSunsetAnimation.kt rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/SunriseSunsetAnimation.kt index dfef20a0..3b560792 100644 --- a/sunriseui/src/main/java/bose/ankush/sunriseui/components/SunriseSunsetAnimation.kt +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/SunriseSunsetAnimation.kt @@ -1,4 +1,6 @@ -package bose.ankush.sunriseui.components +@file:Suppress("ktlint:standard:max-line-length") + +package bose.ankush.commonui.components /** * Dynamic sunrise/sunset landscape animation that responds to real-time data. @@ -31,10 +33,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope -import bose.ankush.sunriseui.constants.SunriseConstants +import bose.ankush.commonui.constants.SunriseConstants import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin @@ -48,25 +51,28 @@ fun SunriseSunsetCombinedAnimation( sunriseTimestamp: Long?, sunsetTimestamp: Long?, currentTimestamp: Long, - windDirection: Float = 225f + windDirection: Float = 225f, ) { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { if (sunriseTimestamp == null || sunsetTimestamp == null) { Box( - modifier = Modifier - .fillMaxSize() - .background( - brush = Brush.verticalGradient( - colors = SunriseConstants.Colors.DEFAULT_GRADIENT + modifier = + Modifier + .fillMaxSize() + .background( + brush = + Brush.verticalGradient( + colors = SunriseConstants.Colors.DEFAULT_GRADIENT, + ), + shape = + RoundedCornerShape( + topStart = SunriseConstants.Dimensions.CORNER_RADIUS, + topEnd = SunriseConstants.Dimensions.CORNER_RADIUS, + ), ), - shape = RoundedCornerShape( - topStart = SunriseConstants.Dimensions.CORNER_RADIUS, - topEnd = SunriseConstants.Dimensions.CORNER_RADIUS - ) - ) ) return@Box } @@ -74,11 +80,12 @@ fun SunriseSunsetCombinedAnimation( // Calculate the normalized position (0 to 1) based on current time val dayDuration = sunsetTimestamp - sunriseTimestamp val timeElapsed = currentTimestamp - sunriseTimestamp - val normalizedTimePosition = if (dayDuration == 0L) { - 0f // Safe default value if duration is zero - } else { - (timeElapsed.toFloat() / dayDuration).coerceIn(0f, 1f) - } + val normalizedTimePosition = + if (dayDuration == 0L) { + 0f // Safe default value if duration is zero + } else { + (timeElapsed.toFloat() / dayDuration).coerceIn(0f, 1f) + } val isBeforeSunrise = currentTimestamp < sunriseTimestamp val isAfterSunset = currentTimestamp > sunsetTimestamp @@ -91,18 +98,20 @@ fun SunriseSunsetCombinedAnimation( val cloudDrift = remember { Animatable(0f) } LaunchedEffect(Unit) { if (!initialAnimationPlayed) { - val targetProgress = when { - isBeforeSunrise -> 0f - isAfterSunset -> 1f - else -> normalizedTimePosition - } + val targetProgress = + when { + isBeforeSunrise -> 0f + isAfterSunset -> 1f + else -> normalizedTimePosition + } animatedProgress.animateTo( targetValue = targetProgress, - animationSpec = tween( - durationMillis = SunriseConstants.Durations.INITIAL_ANIMATION, - easing = FastOutSlowInEasing - ) + animationSpec = + tween( + durationMillis = SunriseConstants.Durations.INITIAL_ANIMATION, + easing = FastOutSlowInEasing, + ), ) initialAnimationPlayed = true } @@ -111,39 +120,45 @@ fun SunriseSunsetCombinedAnimation( LaunchedEffect(Unit) { starTwinkle.animateTo( targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = SunriseConstants.Durations.STAR_TWINKLE, - easing = EaseInOutCubic + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = SunriseConstants.Durations.STAR_TWINKLE, + easing = EaseInOutCubic, + ), + repeatMode = RepeatMode.Reverse, ), - repeatMode = RepeatMode.Reverse - ) ) } LaunchedEffect(Unit) { atmosphericGlow.animateTo( targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = SunriseConstants.Durations.ATMOSPHERIC_GLOW, - easing = EaseInOutCubic + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = SunriseConstants.Durations.ATMOSPHERIC_GLOW, + easing = EaseInOutCubic, + ), + repeatMode = RepeatMode.Reverse, ), - repeatMode = RepeatMode.Reverse - ) ) } LaunchedEffect(Unit) { cloudDrift.animateTo( targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = SunriseConstants.Durations.CLOUD_DRIFT, - easing = EaseInOutCubic + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = SunriseConstants.Durations.CLOUD_DRIFT, + easing = EaseInOutCubic, + ), + repeatMode = RepeatMode.Restart, ), - repeatMode = RepeatMode.Restart - ) ) } @@ -151,27 +166,37 @@ fun SunriseSunsetCombinedAnimation( val skyGradient = createSoothingSkyGradient(progress, isBeforeSunrise, isAfterSunset) Box( - modifier = Modifier - .fillMaxSize() - .background( - brush = if (isNight) Brush.verticalGradient(colors = SunriseConstants.Colors.NIGHT_GRADIENT) else skyGradient, - shape = RoundedCornerShape( - topStart = SunriseConstants.Dimensions.CORNER_RADIUS, - topEnd = SunriseConstants.Dimensions.CORNER_RADIUS - ) - ) + modifier = + Modifier + .fillMaxSize() + .background( + brush = + if (isNight) { + Brush.verticalGradient( + colors = SunriseConstants.Colors.NIGHT_GRADIENT, + ) + } else { + skyGradient + }, + shape = + RoundedCornerShape( + topStart = SunriseConstants.Dimensions.CORNER_RADIUS, + topEnd = SunriseConstants.Dimensions.CORNER_RADIUS, + ), + ), ) Canvas( - modifier = Modifier - .fillMaxSize() + modifier = + Modifier + .fillMaxSize(), ) { val isDaytime = !isBeforeSunrise && !isAfterSunset if (isNight) { drawStarField( twinkleIntensity = starTwinkle.value, - isBeforeSunrise = isBeforeSunrise + isBeforeSunrise = isBeforeSunrise, ) drawMoon( @@ -179,7 +204,7 @@ fun SunriseSunsetCombinedAnimation( atmosphericIntensity = atmosphericGlow.value, currentTimestamp = currentTimestamp, sunriseTimestamp = sunriseTimestamp, - sunsetTimestamp = sunsetTimestamp + sunsetTimestamp = sunsetTimestamp, ) } @@ -189,7 +214,7 @@ fun SunriseSunsetCombinedAnimation( atmosphericIntensity = atmosphericGlow.value, currentTimestamp = currentTimestamp, sunriseTimestamp = sunriseTimestamp, - sunsetTimestamp = sunsetTimestamp + sunsetTimestamp = sunsetTimestamp, ) } @@ -211,13 +236,17 @@ fun SunriseSunsetCombinedAnimation( * @param color2 Ending color (fraction = 1.0) * @param fraction Interpolation factor, clamped to [0.0, 1.0] */ -private fun lerpColor(color1: Color, color2: Color, fraction: Float): Color { +private fun lerpColor( + color1: Color, + color2: Color, + fraction: Float, +): Color { val clampedFraction = fraction.coerceIn(0f, 1f) return Color( red = color1.red + (color2.red - color1.red) * clampedFraction, green = color1.green + (color2.green - color1.green) * clampedFraction, blue = color1.blue + (color2.blue - color1.blue) * clampedFraction, - alpha = color1.alpha + (color2.alpha - color1.alpha) * clampedFraction + alpha = color1.alpha + (color2.alpha - color1.alpha) * clampedFraction, ) } @@ -230,7 +259,7 @@ private fun lerpColor(color1: Color, color2: Color, fraction: Float): Color { private fun createSoothingSkyGradient( progress: Float, isBeforeSunrise: Boolean, - isAfterSunset: Boolean + isAfterSunset: Boolean, ): Brush { val nightColors = SunriseConstants.Colors.NIGHT_GRADIENT @@ -244,39 +273,42 @@ private fun createSoothingSkyGradient( if (isAfterSunset) { return Brush.verticalGradient(colors = nightColors) } - val interpolatedColors = when { - progress <= SunriseConstants.TimeThresholds.DAWN_END -> { - val transitionFactor = - (progress / SunriseConstants.TimeThresholds.DAWN_END).coerceIn(0f, 1f) - listOf( - lerpColor(dawnColors[0], dayColors[0], transitionFactor), - lerpColor(dawnColors[1], dayColors[1], transitionFactor), - lerpColor(dawnColors[2], dayColors[2], transitionFactor), - lerpColor(dawnColors[3], dayColors[3], transitionFactor) - ) - } + val interpolatedColors = + when { + progress <= SunriseConstants.TimeThresholds.DAWN_END -> { + val transitionFactor = + (progress / SunriseConstants.TimeThresholds.DAWN_END).coerceIn(0f, 1f) + listOf( + lerpColor(dawnColors[0], dayColors[0], transitionFactor), + lerpColor(dawnColors[1], dayColors[1], transitionFactor), + lerpColor(dawnColors[2], dayColors[2], transitionFactor), + lerpColor(dawnColors[3], dayColors[3], transitionFactor), + ) + } - progress >= SunriseConstants.TimeThresholds.DUSK_START -> { - val transitionFactor = - ((progress - SunriseConstants.TimeThresholds.DUSK_START) / (1f - SunriseConstants.TimeThresholds.DUSK_START)).coerceIn( - 0f, - 1f + progress >= SunriseConstants.TimeThresholds.DUSK_START -> { + val transitionFactor = + ( + (progress - SunriseConstants.TimeThresholds.DUSK_START) / + (1f - SunriseConstants.TimeThresholds.DUSK_START) + ).coerceIn( + 0f, + 1f, + ) + listOf( + lerpColor(dayColors[0], duskColors[0], transitionFactor), + lerpColor(dayColors[1], duskColors[1], transitionFactor), + lerpColor(dayColors[2], duskColors[2], transitionFactor), + lerpColor(dayColors[3], duskColors[3], transitionFactor), ) - listOf( - lerpColor(dayColors[0], duskColors[0], transitionFactor), - lerpColor(dayColors[1], duskColors[1], transitionFactor), - lerpColor(dayColors[2], duskColors[2], transitionFactor), - lerpColor(dayColors[3], duskColors[3], transitionFactor) - ) - } + } - else -> dayColors - } + else -> dayColors + } return Brush.verticalGradient(colors = interpolatedColors) } - /** * Renders twinkling stars across the night sky with varying opacity and size. * @param twinkleIntensity Animation value (0.0-1.0) controlling twinkle effect @@ -284,7 +316,7 @@ private fun createSoothingSkyGradient( */ private fun DrawScope.drawStarField( twinkleIntensity: Float, - isBeforeSunrise: Boolean + isBeforeSunrise: Boolean, ) { val baseOpacity = if (isBeforeSunrise) SunriseConstants.Opacity.STAR_BASE_BEFORE_SUNRISE else SunriseConstants.Opacity.STAR_BASE_AFTER_SUNSET @@ -296,7 +328,8 @@ private fun DrawScope.drawStarField( // Create twinkling effect val twinkle = - sin((twinkleIntensity * 2 * PI + index * 0.5).toFloat()) * SunriseConstants.Opacity.TWINKLE_VARIATION + SunriseConstants.Opacity.TWINKLE_BASE + sin((twinkleIntensity * 2 * PI + index * 0.5).toFloat()) * SunriseConstants.Opacity.TWINKLE_VARIATION + + SunriseConstants.Opacity.TWINKLE_BASE val starOpacity = baseOpacity * twinkle val starSize = @@ -305,7 +338,7 @@ private fun DrawScope.drawStarField( drawCircle( color = SunriseConstants.Colors.STAR_COLOR.copy(alpha = starOpacity), radius = starSize, - center = androidx.compose.ui.geometry.Offset(x, y) + center = Offset(x, y), ) } } @@ -323,7 +356,7 @@ private fun DrawScope.drawMoon( atmosphericIntensity: Float, currentTimestamp: Long, sunriseTimestamp: Long?, - sunsetTimestamp: Long? + sunsetTimestamp: Long?, ) { val moonX: Float val moonY: Float @@ -334,50 +367,68 @@ private fun DrawScope.drawMoon( val timeElapsed = currentTimestamp - (sunsetTimestamp - 24 * 3600) val nightProgress = (timeElapsed.toFloat() / nightDuration).coerceIn(0f, 1f) moonX = - size.width * (SunriseConstants.Positioning.MOON_START_X - nightProgress * SunriseConstants.Positioning.MOON_TRAVEL_DISTANCE) + size.width * + ( + SunriseConstants.Positioning.MOON_START_X - + nightProgress * SunriseConstants.Positioning.MOON_TRAVEL_DISTANCE + ) moonY = - size.height * (SunriseConstants.Positioning.MOON_Y_VARIATION - (sin(nightProgress * PI).toFloat() * SunriseConstants.Positioning.MOON_Y_AMPLITUDE)) + size.height * + ( + SunriseConstants.Positioning.MOON_Y_VARIATION - + (sin(nightProgress * PI).toFloat() * SunriseConstants.Positioning.MOON_Y_AMPLITUDE) + ) } else { val nextSunrise = sunriseTimestamp + 24 * 3600 val nightDuration = nextSunrise - sunsetTimestamp val timeElapsed = currentTimestamp - sunsetTimestamp val nightProgress = (timeElapsed.toFloat() / nightDuration).coerceIn(0f, 1f) moonX = - size.width * (SunriseConstants.Positioning.MOON_END_X + nightProgress * SunriseConstants.Positioning.MOON_TRAVEL_DISTANCE) + size.width * + ( + SunriseConstants.Positioning.MOON_END_X + + nightProgress * SunriseConstants.Positioning.MOON_TRAVEL_DISTANCE + ) moonY = - size.height * (SunriseConstants.Positioning.MOON_Y_VARIATION - (sin(nightProgress * PI).toFloat() * SunriseConstants.Positioning.MOON_Y_AMPLITUDE)) + size.height * + ( + SunriseConstants.Positioning.MOON_Y_VARIATION - + (sin(nightProgress * PI).toFloat() * SunriseConstants.Positioning.MOON_Y_AMPLITUDE) + ) } } else { - moonX = if (isBeforeSunrise) { - size.width * SunriseConstants.Positioning.MOON_START_X - } else { - size.width * SunriseConstants.Positioning.MOON_END_X - } + moonX = + if (isBeforeSunrise) { + size.width * SunriseConstants.Positioning.MOON_START_X + } else { + size.width * SunriseConstants.Positioning.MOON_END_X + } moonY = size.height * SunriseConstants.Positioning.MOON_BASE_Y } val moonRadius = - SunriseConstants.Dimensions.MOON_BASE_RADIUS + (SunriseConstants.Dimensions.MOON_RADIUS_VARIATION * atmosphericIntensity) + SunriseConstants.Dimensions.MOON_BASE_RADIUS + + (SunriseConstants.Dimensions.MOON_RADIUS_VARIATION * atmosphericIntensity) val moonOpacity = SunriseConstants.Opacity.MOON_BASE + (SunriseConstants.Opacity.MOON_VARIATION * atmosphericIntensity) drawCircle( color = SunriseConstants.Colors.MOON_COLOR.copy(alpha = moonOpacity * 0.3f), radius = moonRadius * 1.5f, - center = androidx.compose.ui.geometry.Offset(moonX, moonY) + center = Offset(moonX, moonY), ) drawCircle( color = SunriseConstants.Colors.MOON_COLOR.copy(alpha = moonOpacity), radius = moonRadius, - center = androidx.compose.ui.geometry.Offset(moonX, moonY) + center = Offset(moonX, moonY), ) val phaseOffset = moonRadius * 0.3f drawCircle( color = SunriseConstants.Colors.MOON_PHASE_COLOR.copy(alpha = 0.2f), radius = moonRadius * 0.8f, - center = androidx.compose.ui.geometry.Offset(moonX + phaseOffset, moonY) + center = Offset(moonX + phaseOffset, moonY), ) } @@ -394,39 +445,46 @@ private fun DrawScope.drawSun( atmosphericIntensity: Float, currentTimestamp: Long, sunriseTimestamp: Long, - sunsetTimestamp: Long + sunsetTimestamp: Long, ) { val dayDuration = sunsetTimestamp - sunriseTimestamp val timeElapsed = currentTimestamp - sunriseTimestamp val timeProgress = (timeElapsed.toFloat() / dayDuration).coerceIn(0f, 1f) val sunX = - size.width * (SunriseConstants.Positioning.SUN_START_X + timeProgress * SunriseConstants.Positioning.SUN_TRAVEL_DISTANCE) + size.width * + (SunriseConstants.Positioning.SUN_START_X + timeProgress * SunriseConstants.Positioning.SUN_TRAVEL_DISTANCE) val sunY = - size.height * (SunriseConstants.Positioning.SUN_BASE_Y - (sin(timeProgress * PI).toFloat() * SunriseConstants.Positioning.SUN_Y_AMPLITUDE)) + size.height * + ( + SunriseConstants.Positioning.SUN_BASE_Y - + (sin(timeProgress * PI).toFloat() * SunriseConstants.Positioning.SUN_Y_AMPLITUDE) + ) val sunRadius = - SunriseConstants.Dimensions.SUN_BASE_RADIUS + (SunriseConstants.Dimensions.SUN_RADIUS_VARIATION * atmosphericIntensity) + SunriseConstants.Dimensions.SUN_BASE_RADIUS + + (SunriseConstants.Dimensions.SUN_RADIUS_VARIATION * atmosphericIntensity) val sunOpacity = SunriseConstants.Opacity.SUN_BASE + (SunriseConstants.Opacity.SUN_VARIATION * atmosphericIntensity) - val sunColor = when { - timeProgress < SunriseConstants.TimeThresholds.SUN_MORNING_END -> SunriseConstants.Colors.SUN_EARLY_MORNING - timeProgress < SunriseConstants.TimeThresholds.SUN_MIDMORNING_END -> SunriseConstants.Colors.SUN_MORNING - timeProgress < SunriseConstants.TimeThresholds.SUN_EVENING_START -> SunriseConstants.Colors.SUN_MIDDAY - timeProgress < SunriseConstants.TimeThresholds.SUN_LATE_EVENING_START -> SunriseConstants.Colors.SUN_EVENING - else -> SunriseConstants.Colors.SUN_LATE_EVENING - } + val sunColor = + when { + timeProgress < SunriseConstants.TimeThresholds.SUN_MORNING_END -> SunriseConstants.Colors.SUN_EARLY_MORNING + timeProgress < SunriseConstants.TimeThresholds.SUN_MIDMORNING_END -> SunriseConstants.Colors.SUN_MORNING + timeProgress < SunriseConstants.TimeThresholds.SUN_EVENING_START -> SunriseConstants.Colors.SUN_MIDDAY + timeProgress < SunriseConstants.TimeThresholds.SUN_LATE_EVENING_START -> SunriseConstants.Colors.SUN_EVENING + else -> SunriseConstants.Colors.SUN_LATE_EVENING + } drawCircle( color = sunColor.copy(alpha = sunOpacity * 0.3f), radius = sunRadius * 1.8f, - center = androidx.compose.ui.geometry.Offset(sunX, sunY) + center = Offset(sunX, sunY), ) drawCircle( color = sunColor.copy(alpha = sunOpacity), radius = sunRadius, - center = androidx.compose.ui.geometry.Offset(sunX, sunY) + center = Offset(sunX, sunY), ) val rayCount = SunriseConstants.Counts.SUN_RAY_COUNT @@ -445,9 +503,9 @@ private fun DrawScope.drawSun( drawLine( color = sunColor.copy(alpha = sunOpacity * 0.6f), - start = androidx.compose.ui.geometry.Offset(startX, startY), - end = androidx.compose.ui.geometry.Offset(endX, endY), - strokeWidth = rayWidth + start = Offset(startX, startY), + end = Offset(endX, endY), + strokeWidth = rayWidth, ) } } @@ -464,16 +522,20 @@ private fun DrawScope.drawClouds( windDirection: Float, ) { val cloudCount = SunriseConstants.Counts.CLOUD_COUNT - val cloudColor = when { - progress <= SunriseConstants.TimeThresholds.DAWN_END -> SunriseConstants.Colors.CLOUD_DAWN_COLOR - progress >= SunriseConstants.TimeThresholds.DUSK_START -> SunriseConstants.Colors.CLOUD_DUSK_COLOR - else -> SunriseConstants.Colors.CLOUD_DAY_COLOR - } + val cloudColor = + when { + progress <= SunriseConstants.TimeThresholds.DAWN_END -> SunriseConstants.Colors.CLOUD_DAWN_COLOR + progress >= SunriseConstants.TimeThresholds.DUSK_START -> SunriseConstants.Colors.CLOUD_DUSK_COLOR + else -> SunriseConstants.Colors.CLOUD_DAY_COLOR + } val baseOpacity = - SunriseConstants.Opacity.CLOUD_BASE + (SunriseConstants.Opacity.CLOUD_VARIATION * sin( - cloudDriftProgress * PI - ).toFloat()) + SunriseConstants.Opacity.CLOUD_BASE + ( + SunriseConstants.Opacity.CLOUD_VARIATION * + sin( + cloudDriftProgress * PI, + ).toFloat() + ) // Calculate wind influence on cloud movement val windInfluenceX = @@ -485,7 +547,8 @@ private fun DrawScope.drawClouds( val baseX = (i * SunriseConstants.Positioning.CLOUD_SPACING_X + cloudDriftProgress * windInfluenceX) % 1.2f - 0.1f val baseY = - SunriseConstants.Positioning.CLOUD_BASE_Y + (i % 2) * SunriseConstants.Positioning.CLOUD_Y_VARIATION + windInfluenceY + SunriseConstants.Positioning.CLOUD_BASE_Y + (i % 2) * SunriseConstants.Positioning.CLOUD_Y_VARIATION + + windInfluenceY val cloudX = size.width * baseX val cloudY = size.height * baseY @@ -503,7 +566,7 @@ private fun DrawScope.drawClouds( drawCircle( color = cloudColor.copy(alpha = baseOpacity * 0.8f), radius = puffSize, - center = androidx.compose.ui.geometry.Offset(puffX, puffY) + center = Offset(puffX, puffY), ) } } diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherAlertCard.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherAlertCard.kt new file mode 100644 index 00000000..24dd98f2 --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherAlertCard.kt @@ -0,0 +1,352 @@ +package bose.ankush.commonui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +/** + * A composable that displays weather alert information. + * Shows a summarized alert by default and can be expanded to show more details. + */ +@Composable +fun WeatherAlertCard( + title: String?, + description: String?, + startTime: Long?, + endTime: Long?, + source: String?, + onReadMoreClick: (() -> Unit)? = null, + initiallyExpanded: Boolean = false, +) { + // Skip rendering if essential data is missing + if (title.isNullOrEmpty() || description.isNullOrEmpty()) return + + // State and calculated values + var isExpanded by remember { mutableStateOf(initiallyExpanded) } + + // Define colors + val primaryColor = MaterialTheme.colorScheme.error + val textColor = MaterialTheme.colorScheme.onErrorContainer + val accentColor = primaryColor.copy(alpha = 0.8f) + val surfaceColor = textColor.copy(alpha = 0.07f) + val subtleTextColor = textColor.copy(alpha = 0.7f) + + // Create colors object + val colors = + AlertCardColors( + primaryColor = primaryColor, + textColor = textColor, + accentColor = accentColor, + surfaceColor = surfaceColor, + subtleTextColor = subtleTextColor, + ) + + // Format timestamps + val formattedStartTime = + remember(startTime) { + startTime?.let { formatTimestamp(it) } ?: "Unknown" + } + val formattedEndTime = + remember(endTime) { + endTime?.let { formatTimestamp(it) } ?: "Unknown" + } + + // Create short description + val shortDescription = + remember(description) { + if (description.length > 100) description.take(100) + "..." else description + } + + // Animation spec for content size changes + val contentSizeAnimSpec = + spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .animateContentSize(animationSpec = contentSizeAnimSpec), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.Start, + ) { + // Header section with icon, title and timestamp + AlertHeader( + title = title, + timestamp = formattedStartTime, + colors = colors, + ) + + // Report section with expandable description + AlertReportSection( + description = description, + shortDescription = shortDescription, + isExpanded = isExpanded, + colors = colors, + onToggleExpanded = { + isExpanded = !isExpanded + if (isExpanded && onReadMoreClick != null) { + onReadMoreClick() + } + }, + ) + + // Expanded content with source and validity + AnimatedVisibility( + visible = isExpanded, + enter = + fadeIn(tween(300, easing = FastOutSlowInEasing)) + + expandVertically(tween(350, easing = FastOutSlowInEasing)), + exit = + fadeOut(tween(200)) + + shrinkVertically(tween(250)), + ) { + Column(modifier = Modifier.padding(top = 16.dp)) { + // Source information + AlertInfoSection( + title = "Source", + content = source ?: "Unknown", + colors = colors, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Validity information + AlertInfoSection( + title = "Valid Until", + content = formattedEndTime, + colors = colors, + ) + } + } + } + } +} + +/** + * Header section of the alert card with icon, title and timestamp + */ +@Composable +private fun AlertHeader( + title: String?, + timestamp: String, + colors: AlertCardColors, +) { + // Alert Icon + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = "Weather Alert Icon", + tint = colors.primaryColor, + modifier = + Modifier + .size(32.dp) + .padding(bottom = 12.dp), + ) + + // Title + Text( + text = title ?: "Weather Alert", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = colors.textColor, + modifier = Modifier.padding(bottom = 4.dp), + ) + + // Timestamp + Text( + text = "Issued: $timestamp", + style = MaterialTheme.typography.bodySmall, + color = colors.subtleTextColor, + modifier = Modifier.padding(bottom = 16.dp), + ) +} + +/** + * Report section with expandable description and read more/less button + */ +@Composable +private fun AlertReportSection( + description: String, + shortDescription: String, + isExpanded: Boolean, + colors: AlertCardColors, + onToggleExpanded: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(12.dp), + color = colors.surfaceColor, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(16.dp)) { + // Report label + Text( + text = "Report", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = colors.accentColor, + modifier = Modifier.padding(bottom = 8.dp), + ) + + // Description text + Text( + text = if (isExpanded) description else shortDescription, + style = MaterialTheme.typography.bodyMedium, + color = colors.textColor, + lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2f, + overflow = if (isExpanded) TextOverflow.Visible else TextOverflow.Ellipsis, + maxLines = if (isExpanded) Int.MAX_VALUE else 3, + modifier = + Modifier.semantics { + contentDescription = "Alert description: $description" + }, + ) + + // Read more/less button + val readMoreText = if (isExpanded) "Read less" else "Read more" + val buttonAlpha by animateFloatAsState( + targetValue = 1f, + animationSpec = tween(300, easing = FastOutSlowInEasing), + label = "Button Alpha", + ) + + TextButton( + onClick = onToggleExpanded, + modifier = + Modifier + .align(Alignment.End) + .padding(top = 4.dp) + .alpha(buttonAlpha) + .semantics { + contentDescription = + if (isExpanded) { + "Read less about this alert" + } else { + "Read more about this alert" + } + }, + ) { + Text( + text = readMoreText, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = colors.primaryColor, + ) + } + } + } +} + +/** + * Reusable section for displaying information with a title and content + */ +@Composable +private fun AlertInfoSection( + title: String, + content: String, + colors: AlertCardColors, +) { + Surface( + shape = RoundedCornerShape(12.dp), + color = colors.surfaceColor, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = colors.accentColor, + modifier = Modifier.padding(bottom = 8.dp), + ) + + Text( + text = content, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colors.textColor, + ) + } + } +} + +/** + * Data class to hold color values for the alert card + */ +private data class AlertCardColors( + val primaryColor: Color, + val textColor: Color, + val accentColor: Color, + val surfaceColor: Color, + val subtleTextColor: Color, +) + +/** + * Formats a timestamp into a readable date and time string. + */ +private fun formatTimestamp(timestamp: Long): String { + val instant = Instant.fromEpochSeconds(timestamp) + val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + val month = localDateTime.month.name.take(3) + val day = localDateTime.dayOfMonth + val hour12 = + when { + localDateTime.hour == 0 -> 12 + localDateTime.hour > 12 -> localDateTime.hour - 12 + else -> localDateTime.hour + } + val minute = localDateTime.minute.toString().padStart(2, '0') + val amPm = if (localDateTime.hour < 12) "AM" else "PM" + return "$month $day, $hour12:$minute $amPm" +} diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherCondition.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherCondition.kt similarity index 93% rename from sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherCondition.kt rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherCondition.kt index 7fcb8507..2ce5968f 100644 --- a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherCondition.kt +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherCondition.kt @@ -1,6 +1,8 @@ -package bose.ankush.sunriseui.components +package bose.ankush.commonui.components -enum class WeatherCondition(val description: String) { +enum class WeatherCondition( + val description: String, +) { // Group 2xx: Thunderstorm THUNDERSTORM_WITH_LIGHT_RAIN("thunderstorm with light rain"), THUNDERSTORM_WITH_RAIN("thunderstorm with rain"), @@ -68,7 +70,8 @@ enum class WeatherCondition(val description: String) { FEW_CLOUDS("few clouds: 11-25%"), SCATTERED_CLOUDS("scattered clouds: 25-50%"), BROKEN_CLOUDS("broken clouds: 51-84%"), - OVERCAST_CLOUDS("overcast clouds: 85-100%"); + OVERCAST_CLOUDS("overcast clouds: 85-100%"), + ; override fun toString(): String = description -} \ No newline at end of file +} diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherDayCard.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherDayCard.kt similarity index 84% rename from sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherDayCard.kt rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherDayCard.kt index cf63bee6..bc4732f5 100644 --- a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherDayCard.kt +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherDayCard.kt @@ -1,4 +1,4 @@ -package bose.ankush.sunriseui.components +package bose.ankush.commonui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -38,26 +38,29 @@ fun WeatherDayCard( minTemperature: String, maxTemperature: String, weatherDescription: String? = null, - iconContent: @Composable () -> Unit + iconContent: @Composable () -> Unit, ) { Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp), + ), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), ) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { // Day name Text( @@ -66,16 +69,16 @@ fun WeatherDayCard( fontWeight = FontWeight.Medium, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) // Temperature range Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Row( - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { // Min temperature Text( @@ -103,7 +106,7 @@ fun WeatherDayCard( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } } @@ -112,11 +115,11 @@ fun WeatherDayCard( Surface( shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.size(48.dp) + modifier = Modifier.size(48.dp), ) { iconContent() } } } } -} \ No newline at end of file +} diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherHourCard.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherHourCard.kt similarity index 83% rename from sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherHourCard.kt rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherHourCard.kt index 85955b78..97f94455 100644 --- a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherHourCard.kt +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherHourCard.kt @@ -1,4 +1,4 @@ -package bose.ankush.sunriseui.components +package bose.ankush.commonui.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -39,24 +39,25 @@ fun WeatherHourCard( weatherDescription: String? = null, isSelected: Boolean = false, onClick: () -> Unit = {}, - iconContent: @Composable () -> Unit + iconContent: @Composable () -> Unit, ) { // Pre-calculate background colors val selectedBackground = MaterialTheme.colorScheme.primaryContainer val unselectedBackground = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) - + Box( - modifier = Modifier - .padding(horizontal = 8.dp) - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onClick) - .background(if (isSelected) selectedBackground else unselectedBackground) - .padding(horizontal = 10.dp, vertical = 20.dp) + modifier = + Modifier + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onClick) + .background(if (isSelected) selectedBackground else unselectedBackground) + .padding(horizontal = 10.dp, vertical = 20.dp), ) { Column( modifier = Modifier.width(IntrinsicSize.Max), verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { // Time Text( @@ -76,7 +77,7 @@ fun WeatherHourCard( style = MaterialTheme.typography.bodyMedium, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(top = 16.dp) + modifier = Modifier.padding(top = 16.dp), ) // Weather description (if available) @@ -87,9 +88,9 @@ fun WeatherHourCard( overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.alpha(0.6f), - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } } -} \ No newline at end of file +} diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherIcon.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherIcon.kt new file mode 100644 index 00000000..8888d3b9 --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherIcon.kt @@ -0,0 +1,449 @@ +package bose.ankush.commonui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseInOutCubic +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import bose.ankush.commonui.constants.WeatherIconConstants + +/** + * Holds color values for weather icons that adapt to light/dark theme. + */ +class WeatherIconColors( + val sunColor: Color, + val sunGlowColor: Color, + val cloudColor: Color, + val rainColor: Color, + val snowColor: Color, + val thunderColor: Color, + val fogColor: Color, +) { + companion object { + /** + * Creates theme-aware colors for weather icons. + */ + @Composable + fun default(isDarkTheme: Boolean = isSystemInDarkTheme()): WeatherIconColors { + // Use a try-catch to handle cases where MaterialTheme is not available + val sunColor = + try { + if (isDarkTheme) Color(0xFFFFD700) else Color(0xFFFF9800) + } catch (_: Exception) { + Color(0xFFFF9800) // Default fallback + } + + val sunGlowColor = + try { + if (isDarkTheme) { + Color(0xFFFFD700).copy(alpha = WeatherIconConstants.SUN_GLOW_ALPHA) + } else { + Color(0xFFFF9800).copy(alpha = WeatherIconConstants.SUN_GLOW_ALPHA) + } + } catch (_: Exception) { + Color(0xFFFF9800).copy(alpha = WeatherIconConstants.SUN_GLOW_ALPHA) // Default fallback + } + + return WeatherIconColors( + sunColor = sunColor, + sunGlowColor = sunGlowColor, + cloudColor = if (isDarkTheme) Color.White.copy(alpha = 0.9f) else Color.White, + rainColor = if (isDarkTheme) Color(0xFF64B5F6) else Color(0xFF2196F3), + snowColor = if (isDarkTheme) Color.White else Color.White.copy(alpha = 0.9f), + thunderColor = if (isDarkTheme) Color(0xFFFFEB3B) else Color(0xFFFFC107), + fogColor = + if (isDarkTheme) { + Color.LightGray.copy(alpha = 0.7f) + } else { + Color.Gray.copy( + alpha = 0.5f, + ) + }, + ) + } + } +} + +/** + * A composable that displays an animated weather icon based on the weather description. + * Maps the description to the appropriate WeatherCondition and renders the corresponding animation. + * Optimized for performance and supports dark mode. + * + * @param weatherDescription The description of the weather condition + * @param modifier Modifier to be applied to the icon + * @param colors Theme-aware colors for the weather icons + */ +@Composable +fun AnimatedWeatherIcon( + weatherDescription: String?, + modifier: Modifier = Modifier.size(48.dp), + colors: WeatherIconColors = WeatherIconColors.default(), +) { + // Map the weather description to a WeatherCondition + val weatherCondition = + remember(weatherDescription) { + mapToWeatherCondition(weatherDescription) + } + + // Create a content description for accessibility + val contentDesc = + remember(weatherCondition) { + "Weather icon: ${weatherCondition.description}" + } + + // Determine which animations are needed based on weather condition + val needsSunAnimation = + remember(weatherCondition) { + weatherCondition == WeatherCondition.CLEAR_SKY || + weatherCondition == WeatherCondition.FEW_CLOUDS + } + + val needsCloudAnimation = + remember(weatherCondition) { + weatherCondition in + listOf( + WeatherCondition.FEW_CLOUDS, + WeatherCondition.SCATTERED_CLOUDS, + WeatherCondition.BROKEN_CLOUDS, + WeatherCondition.OVERCAST_CLOUDS, + ) || weatherCondition.description.contains("rain") || + weatherCondition.description.contains("drizzle") || + weatherCondition.description.contains("snow") || + weatherCondition.description.contains("thunderstorm") + } + + val needsRainAnimation = + remember(weatherCondition) { + weatherCondition.description.contains("rain") || + weatherCondition.description.contains("drizzle") || + weatherCondition.description.contains("thunderstorm") + } + + val needsSnowAnimation = + remember(weatherCondition) { + weatherCondition.description.contains("snow") || + weatherCondition.description.contains("sleet") + } + + val needsThunderAnimation = + remember(weatherCondition) { + weatherCondition.description.contains("thunderstorm") + } + + val needsFogAnimation = + remember(weatherCondition) { + weatherCondition in + listOf( + WeatherCondition.MIST, + WeatherCondition.SMOKE, + WeatherCondition.HAZE, + WeatherCondition.SAND_DUST_WHIRLS, + WeatherCondition.FOG, + WeatherCondition.SAND, + WeatherCondition.DUST, + WeatherCondition.VOLCANIC_ASH, + WeatherCondition.SQUALLS, + WeatherCondition.TORNADO, + ) + } + + // Animation specs - define once to use as keys in LaunchedEffect + val sunAnimSpec = + remember { + infiniteRepeatable( + animation = + tween( + durationMillis = WeatherIconConstants.SUN_ANIMATION_DURATION, + easing = EaseInOutCubic, + ), + repeatMode = RepeatMode.Reverse, + ) + } + + val cloudAnimSpec = + remember { + infiniteRepeatable( + animation = + tween( + durationMillis = WeatherIconConstants.CLOUD_ANIMATION_DURATION, + easing = EaseInOutCubic, + ), + repeatMode = RepeatMode.Restart, + ) + } + + val rainAnimSpec = + remember { + infiniteRepeatable( + animation = + tween( + durationMillis = WeatherIconConstants.RAIN_ANIMATION_DURATION, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Restart, + ) + } + + val snowAnimSpec = + remember { + infiniteRepeatable( + animation = + tween( + durationMillis = WeatherIconConstants.SNOW_ANIMATION_DURATION, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Restart, + ) + } + + val thunderAnimSpec = + remember { + infiniteRepeatable( + animation = + tween( + durationMillis = WeatherIconConstants.THUNDER_ANIMATION_DURATION, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Restart, + ) + } + + // Animation states - only initialize what's needed + val sunGlow = remember { Animatable(0f) } + val cloudDrift = remember { Animatable(0f) } + val rainDrop = remember { Animatable(0f) } + val snowFall = remember { Animatable(0f) } + val thunderFlash = remember { Animatable(0f) } + + // Start animations only if needed, with proper keys to restart when specs change + if (needsSunAnimation) { + LaunchedEffect(weatherCondition, sunAnimSpec) { + sunGlow.animateTo( + targetValue = 1f, + animationSpec = sunAnimSpec, + ) + } + } + + if (needsCloudAnimation || needsFogAnimation) { + LaunchedEffect(weatherCondition, cloudAnimSpec) { + cloudDrift.animateTo( + targetValue = 1f, + animationSpec = cloudAnimSpec, + ) + } + } + + if (needsRainAnimation) { + LaunchedEffect(weatherCondition, rainAnimSpec) { + rainDrop.animateTo( + targetValue = 1f, + animationSpec = rainAnimSpec, + ) + } + } + + if (needsSnowAnimation) { + LaunchedEffect(weatherCondition, snowAnimSpec) { + snowFall.animateTo( + targetValue = 1f, + animationSpec = snowAnimSpec, + ) + } + } + + if (needsThunderAnimation) { + LaunchedEffect(weatherCondition, thunderAnimSpec) { + thunderFlash.animateTo( + targetValue = 1f, + animationSpec = thunderAnimSpec, + ) + } + } + + Box( + modifier = + modifier.semantics { + contentDescription = contentDesc + }, + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.matchParentSize()) { + when { + // Clear sky + weatherCondition == WeatherCondition.CLEAR_SKY -> { + drawSun( + animationProgress = sunGlow.value, + sunColor = colors.sunColor, + sunGlowColor = colors.sunGlowColor, + ) + } + + // Clouds + weatherCondition in + listOf( + WeatherCondition.FEW_CLOUDS, + WeatherCondition.SCATTERED_CLOUDS, + WeatherCondition.BROKEN_CLOUDS, + WeatherCondition.OVERCAST_CLOUDS, + ) + -> { + val cloudiness = + when (weatherCondition) { + WeatherCondition.FEW_CLOUDS -> 0.2f + WeatherCondition.SCATTERED_CLOUDS -> 0.4f + WeatherCondition.BROKEN_CLOUDS -> 0.7f + WeatherCondition.OVERCAST_CLOUDS -> 1.0f + else -> 0.5f + } + + if (weatherCondition == WeatherCondition.FEW_CLOUDS) { + drawSun( + animationProgress = sunGlow.value, + scale = 0.7f, + offsetX = -size.width * 0.15f, + sunColor = colors.sunColor, + sunGlowColor = colors.sunGlowColor, + ) + } + + drawClouds( + animationProgress = cloudDrift.value, + cloudiness = cloudiness, + cloudColor = colors.cloudColor, + ) + } + + // Rain + weatherCondition.description.contains("rain") && + !weatherCondition.description.contains( + "thunderstorm", + ) + -> { + val intensity = + when { + weatherCondition.description.contains("light") -> 0.3f + weatherCondition.description.contains("heavy") || + weatherCondition.description.contains("intense") || + weatherCondition.description.contains("extreme") -> 0.9f + + else -> 0.6f + } + + drawClouds( + animationProgress = cloudDrift.value, + cloudiness = 0.8f, + cloudColor = colors.cloudColor, + ) + drawRain( + animationProgress = rainDrop.value, + intensity = intensity, + rainColor = colors.rainColor, + ) + } + + // Snow + weatherCondition.description.contains("snow") || + weatherCondition.description.contains( + "sleet", + ) + -> { + val intensity = + when { + weatherCondition.description.contains("light") -> 0.3f + weatherCondition.description.contains("heavy") -> 0.9f + else -> 0.6f + } + + drawClouds( + animationProgress = cloudDrift.value, + cloudiness = 0.7f, + cloudColor = colors.cloudColor, + ) + drawSnow( + animationProgress = snowFall.value, + intensity = intensity, + snowColor = colors.snowColor, + ) + } + + // Thunderstorm + weatherCondition.description.contains("thunderstorm") -> { + drawClouds( + animationProgress = cloudDrift.value, + cloudiness = 0.9f, + cloudColor = colors.cloudColor, + ) + drawRain( + animationProgress = rainDrop.value, + intensity = 0.7f, + rainColor = colors.rainColor, + ) + drawThunder( + animationProgress = thunderFlash.value, + thunderColor = colors.thunderColor, + ) + } + + // Drizzle + weatherCondition.description.contains("drizzle") -> { + drawClouds( + animationProgress = cloudDrift.value, + cloudiness = 0.7f, + cloudColor = colors.cloudColor, + ) + drawRain( + animationProgress = rainDrop.value, + intensity = 0.3f, + rainColor = colors.rainColor, + ) + } + + // Atmosphere (mist, fog, etc.) + weatherCondition in + listOf( + WeatherCondition.MIST, + WeatherCondition.SMOKE, + WeatherCondition.HAZE, + WeatherCondition.SAND_DUST_WHIRLS, + WeatherCondition.FOG, + WeatherCondition.SAND, + WeatherCondition.DUST, + WeatherCondition.VOLCANIC_ASH, + WeatherCondition.SQUALLS, + WeatherCondition.TORNADO, + ) + -> { + drawFog( + animationProgress = cloudDrift.value, + fogColor = colors.fogColor, + ) + } + + // Default fallback + else -> { + drawSun( + animationProgress = sunGlow.value, + sunColor = colors.sunColor, + sunGlowColor = colors.sunGlowColor, + ) + } + } + } + } +} diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherIconDrawing.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherIconDrawing.kt similarity index 78% rename from sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherIconDrawing.kt rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherIconDrawing.kt index d84f8539..1d2e4f90 100644 --- a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherIconDrawing.kt +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherIconDrawing.kt @@ -1,8 +1,12 @@ -package bose.ankush.sunriseui.components +@file:Suppress("ktlint:standard:max-line-length") +package bose.ankush.commonui.components + +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope -import bose.ankush.sunriseui.constants.WeatherIconConstants +import bose.ankush.commonui.constants.WeatherIconConstants import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin @@ -12,29 +16,30 @@ import kotlin.math.sin * Uses theme-aware colors that adapt to light/dark mode. */ fun DrawScope.drawSun( - animationProgress: Float, - scale: Float = 1.0f, + animationProgress: Float, + scale: Float = 1.0f, offsetX: Float = 0f, sunColor: Color, - sunGlowColor: Color + sunGlowColor: Color, ) { val centerX = size.width / 2 + offsetX val centerY = size.height / 2 val radius = size.width.coerceAtMost(size.height) * 0.25f * scale // Enhanced glow effect with smoother pulsing - val glowRadius = radius * (1.0f + WeatherIconConstants.SUN_PULSE_SCALE * sin(animationProgress * PI).toFloat()) + val glowRadius = + radius * (1.0f + WeatherIconConstants.SUN_PULSE_SCALE * sin(animationProgress * PI).toFloat()) drawCircle( color = sunGlowColor, radius = glowRadius * WeatherIconConstants.SUN_GLOW_SCALE, - center = androidx.compose.ui.geometry.Offset(centerX, centerY) + center = Offset(centerX, centerY), ) // Sun body with slight variation for more natural appearance drawCircle( color = sunColor, radius = radius * (1.0f + WeatherIconConstants.SUN_BODY_VARIATION * sin(animationProgress * PI * 2).toFloat()), - center = androidx.compose.ui.geometry.Offset(centerX, centerY) + center = Offset(centerX, centerY), ) // More dynamic sun rays with varying lengths @@ -59,9 +64,9 @@ fun DrawScope.drawSun( drawLine( color = sunColor.copy(alpha = 0.7f), - start = androidx.compose.ui.geometry.Offset(startX, startY), - end = androidx.compose.ui.geometry.Offset(endX, endY), - strokeWidth = strokeWidth + start = Offset(startX, startY), + end = Offset(endX, endY), + strokeWidth = strokeWidth, ) } } @@ -71,17 +76,21 @@ fun DrawScope.drawSun( * Uses theme-aware colors that adapt to light/dark mode. */ fun DrawScope.drawClouds( - animationProgress: Float, + animationProgress: Float, cloudiness: Float, - cloudColor: Color + cloudColor: Color, ) { val cloudCount = (2 + (cloudiness * 2).toInt()).coerceAtMost(4) for (i in 0 until cloudCount) { // Smoother cloud movement with varying speeds val speedFactor = 0.8f + (i % 3) * 0.1f - val baseX = size.width * (0.3f + (i * 0.15f) + animationProgress * WeatherIconConstants.CLOUD_MOVEMENT_SCALE * speedFactor) % size.width - val baseY = size.height * (0.4f + (i % 2) * 0.1f + sin(animationProgress * PI * speedFactor) * 0.02f) + val baseX = + size.width * + (0.3f + (i * 0.15f) + animationProgress * WeatherIconConstants.CLOUD_MOVEMENT_SCALE * speedFactor) % + size.width + val baseY = + size.height * (0.4f + (i % 2) * 0.1f + sin(animationProgress * PI * speedFactor) * 0.02f) // Draw cloud as multiple overlapping circles with varying sizes val puffCount = 3 @@ -90,15 +99,17 @@ fun DrawScope.drawClouds( for (j in 0 until puffCount) { val puffX = baseX + (j - 1) * (puffRadius * 1.2f) val puffY = baseY + sin((j + animationProgress * 1.5f) * PI).toFloat() * 2f - val puffSize = puffRadius * (0.8f + (j % 2) * 0.4f + sin(animationProgress * PI + j) * 0.05f) + val puffSize = + puffRadius * (0.8f + (j % 2) * 0.4f + sin(animationProgress * PI + j) * 0.05f) // Vary opacity slightly for more natural appearance - val alpha = WeatherIconConstants.CLOUD_BASE_ALPHA + 0.2f * sin((animationProgress * PI + j * 0.5f).toFloat()) + val alpha = + WeatherIconConstants.CLOUD_BASE_ALPHA + 0.2f * sin((animationProgress * PI + j * 0.5f).toFloat()) drawCircle( color = cloudColor.copy(alpha = alpha), radius = puffSize.toFloat(), - center = androidx.compose.ui.geometry.Offset(puffX, puffY.toFloat()) + center = Offset(puffX, puffY.toFloat()), ) } } @@ -110,9 +121,9 @@ fun DrawScope.drawClouds( * Uses theme-aware colors that adapt to light/dark mode. */ fun DrawScope.drawRain( - animationProgress: Float, + animationProgress: Float, intensity: Float, - rainColor: Color + rainColor: Color, ) { // Increase drop count for heavier rain, with a higher maximum val baseDropCount = 8 + (intensity * 25).toInt() @@ -163,17 +174,18 @@ fun DrawScope.drawRain( // Vary opacity based on thickness and random factors // Thinner drops are more transparent - val baseAlpha = (WeatherIconConstants.RAIN_BASE_ALPHA - 0.2f + 0.4f * (dropThickness / 3.0f)) - .coerceIn(0.3f, 0.9f) + val baseAlpha = + (WeatherIconConstants.RAIN_BASE_ALPHA - 0.2f + 0.4f * (dropThickness / 3.0f)) + .coerceIn(0.3f, 0.9f) val alphaVariation = 0.15f * sin((animationProgress * PI * 0.7f + seed).toFloat()) val dropAlpha = (baseAlpha + alphaVariation).coerceIn(0.2f, 0.95f) // Draw the raindrop with slant from wind drawLine( color = rainColor.copy(alpha = dropAlpha), - start = androidx.compose.ui.geometry.Offset(dropX.toFloat(), dropY), - end = androidx.compose.ui.geometry.Offset(endX.toFloat(), endY), - strokeWidth = dropThickness + start = Offset(dropX.toFloat(), dropY), + end = Offset(endX.toFloat(), endY), + strokeWidth = dropThickness, ) // Add splash effect when drops hit the bottom @@ -190,7 +202,11 @@ fun DrawScope.drawRain( drawCircle( color = rainColor.copy(alpha = splashAlpha), radius = splashSize, - center = androidx.compose.ui.geometry.Offset(endX.toFloat(), size.height * 0.98f) + center = + Offset( + endX.toFloat(), + size.height * 0.98f, + ), ) // For heavier rain, add a second splash ripple @@ -203,7 +219,11 @@ fun DrawScope.drawRain( drawCircle( color = rainColor.copy(alpha = rippleAlpha), radius = rippleSize, - center = androidx.compose.ui.geometry.Offset(endX.toFloat(), size.height * 0.98f) + center = + Offset( + endX.toFloat(), + size.height * 0.98f, + ), ) } } @@ -217,16 +237,17 @@ fun DrawScope.drawRain( * Uses theme-aware colors that adapt to light/dark mode. */ fun DrawScope.drawSnow( - animationProgress: Float, + animationProgress: Float, intensity: Float, - snowColor: Color + snowColor: Color, ) { val flakeCount = (5 + (intensity * 15).toInt()).coerceAtMost(20) for (i in 0 until flakeCount) { // Vary flake speeds and paths for more realistic snow val speedFactor = 0.6f + (i % 5) * 0.1f - val horizontalMovement = sin((animationProgress + i * 0.1f) * PI * 2) * size.width * WeatherIconConstants.SNOW_HORIZONTAL_MOVEMENT + val horizontalMovement = + sin((animationProgress + i * 0.1f) * PI * 2) * size.width * WeatherIconConstants.SNOW_HORIZONTAL_MOVEMENT val flakeX = size.width * ((i * 0.1f) % 1.0f) + horizontalMovement val flakeProgress = (animationProgress * speedFactor + (i * 0.1f)) % 1.0f val flakeY = size.height * (0.5f + flakeProgress * 0.5f) @@ -236,9 +257,13 @@ fun DrawScope.drawSnow( // Draw snowflake (simple circle for now, could be enhanced to actual snowflake shape) drawCircle( - color = snowColor.copy(alpha = WeatherIconConstants.SNOW_BASE_ALPHA + 0.2f * sin((animationProgress * PI + i).toFloat())), + color = + snowColor.copy( + alpha = + WeatherIconConstants.SNOW_BASE_ALPHA + 0.2f * sin((animationProgress * PI + i).toFloat()), + ), radius = flakeSize, - center = androidx.compose.ui.geometry.Offset(flakeX.toFloat(), flakeY) + center = Offset(flakeX.toFloat(), flakeY), ) } } @@ -249,7 +274,7 @@ fun DrawScope.drawSnow( */ fun DrawScope.drawThunder( animationProgress: Float, - thunderColor: Color + thunderColor: Color, ) { // Make thunder appear more gradually instead of abruptly val flashIntensity = sin(animationProgress * PI * 2).toFloat().coerceIn(0f, 1f) @@ -259,26 +284,27 @@ fun DrawScope.drawThunder( val startY = size.height * 0.4f // Draw lightning bolt with varying intensity - val path = androidx.compose.ui.graphics.Path().apply { - moveTo(centerX, startY) - lineTo(centerX - size.width * 0.1f, startY + size.height * 0.15f) - lineTo(centerX, startY + size.height * 0.2f) - lineTo(centerX - size.width * 0.05f, startY + size.height * 0.4f) - lineTo(centerX + size.width * 0.1f, startY + size.height * 0.15f) - lineTo(centerX, startY + size.height * 0.1f) - close() - } + val path = + Path().apply { + moveTo(centerX, startY) + lineTo(centerX - size.width * 0.1f, startY + size.height * 0.15f) + lineTo(centerX, startY + size.height * 0.2f) + lineTo(centerX - size.width * 0.05f, startY + size.height * 0.4f) + lineTo(centerX + size.width * 0.1f, startY + size.height * 0.15f) + lineTo(centerX, startY + size.height * 0.1f) + close() + } drawPath( path = path, - color = thunderColor.copy(alpha = flashIntensity * WeatherIconConstants.THUNDER_FLASH_ALPHA) + color = thunderColor.copy(alpha = flashIntensity * WeatherIconConstants.THUNDER_FLASH_ALPHA), ) // Add a glow effect around the lightning drawCircle( color = thunderColor.copy(alpha = flashIntensity * 0.3f), radius = size.width * 0.2f, - center = androidx.compose.ui.geometry.Offset(centerX, startY + size.height * 0.2f) + center = Offset(centerX, startY + size.height * 0.2f), ) } } @@ -289,7 +315,7 @@ fun DrawScope.drawThunder( */ fun DrawScope.drawFog( animationProgress: Float, - fogColor: Color + fogColor: Color, ) { val layerCount = 6 @@ -298,17 +324,19 @@ fun DrawScope.drawFog( val layerY = size.height * (0.3f + i * 0.1f) val layerWidth = size.width * (0.6f + (i % 3) * 0.1f) val speedFactor = 0.8f + (i % 3) * 0.1f - val layerOffset = size.width * 0.15f + sin((animationProgress * speedFactor + i * 0.2f) * PI).toFloat() * size.width * 0.08f + val layerOffset = + size.width * 0.15f + sin((animationProgress * speedFactor + i * 0.2f) * PI).toFloat() * size.width * 0.08f // Vary opacity for more natural appearance - val alpha = WeatherIconConstants.FOG_BASE_ALPHA + 0.2f * sin((animationProgress * PI + i * 0.5f)).toFloat() + val alpha = + WeatherIconConstants.FOG_BASE_ALPHA + 0.2f * sin((animationProgress * PI + i * 0.5f)).toFloat() // Draw fog layer with rounded ends for more natural appearance drawLine( color = fogColor.copy(alpha = alpha), - start = androidx.compose.ui.geometry.Offset(layerOffset, layerY), - end = androidx.compose.ui.geometry.Offset(layerOffset + layerWidth, layerY), - strokeWidth = size.height * (0.02f + 0.01f * (i % 3) / 3f) + start = Offset(layerOffset, layerY), + end = Offset(layerOffset + layerWidth, layerY), + strokeWidth = size.height * (0.02f + 0.01f * (i % 3) / 3f), ) } } @@ -340,6 +368,7 @@ fun mapToWeatherCondition(description: String?): WeatherCondition { else -> WeatherCondition.THUNDERSTORM } } + "drizzle" in lowerDesc -> { when { "light" in lowerDesc || "slight" in lowerDesc -> WeatherCondition.LIGHT_INTENSITY_DRIZZLE @@ -347,6 +376,7 @@ fun mapToWeatherCondition(description: String?): WeatherCondition { else -> WeatherCondition.DRIZZLE } } + "rain" in lowerDesc -> { when { "light" in lowerDesc || "slight" in lowerDesc -> WeatherCondition.LIGHT_RAIN @@ -355,6 +385,7 @@ fun mapToWeatherCondition(description: String?): WeatherCondition { else -> WeatherCondition.MODERATE_RAIN } } + "snow" in lowerDesc -> { when { "light" in lowerDesc || "slight" in lowerDesc || "flurries" in lowerDesc -> WeatherCondition.LIGHT_SNOW @@ -362,6 +393,7 @@ fun mapToWeatherCondition(description: String?): WeatherCondition { else -> WeatherCondition.SNOW } } + "sleet" in lowerDesc -> WeatherCondition.SLEET "clear" in lowerDesc || "sunny" in lowerDesc || "fair" in lowerDesc -> WeatherCondition.CLEAR_SKY "cloud" in lowerDesc -> { @@ -373,6 +405,7 @@ fun mapToWeatherCondition(description: String?): WeatherCondition { else -> WeatherCondition.SCATTERED_CLOUDS } } + "mist" in lowerDesc -> WeatherCondition.MIST "fog" in lowerDesc -> WeatherCondition.FOG "haze" in lowerDesc -> WeatherCondition.HAZE diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/constants/SunriseConstants.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/SunriseConstants.kt similarity index 55% rename from sunriseui/src/main/java/bose/ankush/sunriseui/constants/SunriseConstants.kt rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/SunriseConstants.kt index e1f172b5..a770a6a4 100644 --- a/sunriseui/src/main/java/bose/ankush/sunriseui/constants/SunriseConstants.kt +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/SunriseConstants.kt @@ -1,4 +1,4 @@ -package bose.ankush.sunriseui.constants +package bose.ankush.commonui.constants import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -8,7 +8,6 @@ import androidx.compose.ui.unit.dp * Contains timing, visual, and behavioral parameters organized into logical groups. */ object SunriseConstants { - /** Animation timing constants in milliseconds. */ object Durations { const val INITIAL_ANIMATION = 3000 @@ -56,61 +55,66 @@ object SunriseConstants { /** Color schemes for different time periods and visual elements. */ object Colors { // Night colors - Deep blue to dark blue-black - val NIGHT_GRADIENT = listOf( - Color(0xFF000011).copy(alpha = 0.9f), // Almost black with slight blue tint - Color(0xFF0A1035).copy(alpha = 0.8f), // Very dark blue - Color(0xFF0F1A4A).copy(alpha = 0.7f), // Dark blue - Color(0xFF162554).copy(alpha = 0.6f) // Medium-dark blue - ) + val NIGHT_GRADIENT = + listOf( + Color(0xFF000011).copy(alpha = 0.9f), // Almost black with slight blue tint + Color(0xFF0A1035).copy(alpha = 0.8f), // Very dark blue + Color(0xFF0F1A4A).copy(alpha = 0.7f), // Dark blue + Color(0xFF162554).copy(alpha = 0.6f), // Medium-dark blue + ) // Dawn colors - Dark blue to purple, pink, orange, yellow - val DAWN_GRADIENT = listOf( - Color(0xFF0A1035).copy(alpha = 0.8f), // Very dark blue - Color(0xFF341C5D).copy(alpha = 0.7f), // Deep purple - Color(0xFF9A3A6A).copy(alpha = 0.6f), // Pink-purple - Color(0xFFE67E45).copy(alpha = 0.5f) // Orange - ) + val DAWN_GRADIENT = + listOf( + Color(0xFF0A1035).copy(alpha = 0.8f), // Very dark blue + Color(0xFF341C5D).copy(alpha = 0.7f), // Deep purple + Color(0xFF9A3A6A).copy(alpha = 0.6f), // Pink-purple + Color(0xFFE67E45).copy(alpha = 0.5f), // Orange + ) // Day colors - Deep blue to lighter blue - val DAY_GRADIENT = listOf( - Color(0xFF0E4C92).copy(alpha = 0.7f), // Deep blue - Color(0xFF1A75FF).copy(alpha = 0.6f), // Medium blue - Color(0xFF5D9EFF).copy(alpha = 0.5f), // Light blue - Color(0xFF87CEEB).copy(alpha = 0.4f) // Sky blue - ) + val DAY_GRADIENT = + listOf( + Color(0xFF0E4C92).copy(alpha = 0.7f), // Deep blue + Color(0xFF1A75FF).copy(alpha = 0.6f), // Medium blue + Color(0xFF5D9EFF).copy(alpha = 0.5f), // Light blue + Color(0xFF87CEEB).copy(alpha = 0.4f), // Sky blue + ) // Dusk colors - Dark blue to purple, pink, orange, red - val DUSK_GRADIENT = listOf( - Color(0xFF0A1035).copy(alpha = 0.8f), // Very dark blue - Color(0xFF341C5D).copy(alpha = 0.7f), // Deep purple - Color(0xFF9A3A6A).copy(alpha = 0.6f), // Pink-purple - Color(0xFFE05038).copy(alpha = 0.5f) // Orange-red - ) + val DUSK_GRADIENT = + listOf( + Color(0xFF0A1035).copy(alpha = 0.8f), // Very dark blue + Color(0xFF341C5D).copy(alpha = 0.7f), // Deep purple + Color(0xFF9A3A6A).copy(alpha = 0.6f), // Pink-purple + Color(0xFFE05038).copy(alpha = 0.5f), // Orange-red + ) // Default fallback colors - Realistic daytime sky - val DEFAULT_GRADIENT = listOf( - Color(0xFF0E4C92).copy(alpha = 0.7f), // Deep blue - Color(0xFF1A75FF).copy(alpha = 0.6f), // Medium blue - Color(0xFF5D9EFF).copy(alpha = 0.5f), // Light blue - Color(0xFF87CEEB).copy(alpha = 0.4f) // Sky blue - ) + val DEFAULT_GRADIENT = + listOf( + Color(0xFF0E4C92).copy(alpha = 0.7f), // Deep blue + Color(0xFF1A75FF).copy(alpha = 0.6f), // Medium blue + Color(0xFF5D9EFF).copy(alpha = 0.5f), // Light blue + Color(0xFF87CEEB).copy(alpha = 0.4f), // Sky blue + ) // Celestial body colors val MOON_COLOR = Color(0xFFF5F5DC) val MOON_PHASE_COLOR = Color(0xFF0F0F23) - val STAR_COLOR = Color.Companion.White + val STAR_COLOR = Color.White // Cloud colors - Adjusted to match realistic sky gradients - val CLOUD_DAY_COLOR = Color(0xFFFFFFFF) // Pure white for daytime - val CLOUD_DAWN_COLOR = Color(0xFFFAE3C6) // Warm cream/peach for sunrise - val CLOUD_DUSK_COLOR = Color(0xFFFFB8A0) // Soft orange-pink for sunset + val CLOUD_DAY_COLOR = Color(0xFFFFFFFF) // Pure white for daytime + val CLOUD_DAWN_COLOR = Color(0xFFFAE3C6) // Warm cream/peach for sunrise + val CLOUD_DUSK_COLOR = Color(0xFFFFB8A0) // Soft orange-pink for sunset // Sun colors by time - Enhanced for realistic appearance - val SUN_EARLY_MORNING = Color(0xFFFF7E45) // Warm orange-red for early morning - val SUN_MORNING = Color(0xFFFFAA33) // Golden orange for morning - val SUN_MIDDAY = Color(0xFFFFD700) // Bright gold for midday - val SUN_EVENING = Color(0xFFFFAA33) // Golden orange for evening - val SUN_LATE_EVENING = Color(0xFFFF7E45) // Warm orange-red for late evening + val SUN_EARLY_MORNING = Color(0xFFFF7E45) // Warm orange-red for early morning + val SUN_MORNING = Color(0xFFFFAA33) // Golden orange for morning + val SUN_MIDDAY = Color(0xFFFFD700) // Bright gold for midday + val SUN_EVENING = Color(0xFFFFAA33) // Golden orange for evening + val SUN_LATE_EVENING = Color(0xFFFF7E45) // Warm orange-red for late evening } /** Spatial positioning and movement parameters (normalized 0.0-1.0). */ @@ -144,10 +148,19 @@ object SunriseConstants { } /** Predefined star positions as normalized (x, y) coordinates. */ - val STAR_POSITIONS = listOf( - Pair(0.15f, 0.2f), Pair(0.3f, 0.15f), Pair(0.45f, 0.25f), - Pair(0.6f, 0.1f), Pair(0.75f, 0.3f), Pair(0.85f, 0.18f), - Pair(0.2f, 0.4f), Pair(0.4f, 0.45f), Pair(0.65f, 0.35f), - Pair(0.8f, 0.5f), Pair(0.1f, 0.6f), Pair(0.9f, 0.65f) - ) -} \ No newline at end of file + val STAR_POSITIONS = + listOf( + Pair(0.15f, 0.2f), + Pair(0.3f, 0.15f), + Pair(0.45f, 0.25f), + Pair(0.6f, 0.1f), + Pair(0.75f, 0.3f), + Pair(0.85f, 0.18f), + Pair(0.2f, 0.4f), + Pair(0.4f, 0.45f), + Pair(0.65f, 0.35f), + Pair(0.8f, 0.5f), + Pair(0.1f, 0.6f), + Pair(0.9f, 0.65f), + ) +} diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/constants/WeatherIconConstants.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/WeatherIconConstants.kt similarity index 85% rename from sunriseui/src/main/java/bose/ankush/sunriseui/constants/WeatherIconConstants.kt rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/WeatherIconConstants.kt index cd13614b..c11bd6fc 100644 --- a/sunriseui/src/main/java/bose/ankush/sunriseui/constants/WeatherIconConstants.kt +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/WeatherIconConstants.kt @@ -1,4 +1,4 @@ -package bose.ankush.sunriseui.constants +package bose.ankush.commonui.constants /** * Constants for weather icon drawing @@ -7,7 +7,7 @@ object WeatherIconConstants { // Animation constants const val SUN_ANIMATION_DURATION = 2500 const val CLOUD_ANIMATION_DURATION = 4000 - const val RAIN_ANIMATION_DURATION = 3500 // Increased for more natural rain movement + const val RAIN_ANIMATION_DURATION = 3500 // Increased for more natural rain movement const val SNOW_ANIMATION_DURATION = 3000 const val THUNDER_ANIMATION_DURATION = 3000 @@ -25,4 +25,4 @@ object WeatherIconConstants { const val SUN_BODY_VARIATION = 0.05f const val CLOUD_MOVEMENT_SCALE = 0.1f const val SNOW_HORIZONTAL_MOVEMENT = 0.05f -} \ No newline at end of file +} diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/locations/SavedLocationsScreen.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/locations/SavedLocationsScreen.kt new file mode 100644 index 00000000..6f3689a0 --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/locations/SavedLocationsScreen.kt @@ -0,0 +1,594 @@ +package bose.ankush.commonui.locations + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import bose.ankush.network.model.PlaceSuggestion +import bose.ankush.network.model.SavedLocation +import kotlin.math.round + +// ============ UI State Classes ============ + +@Immutable +data class SavedLocationsUiState( + val isPremium: Boolean = false, + val isLoading: Boolean = false, + val locations: List = emptyList(), + val error: String? = null, + val successMessage: String? = null, +) + +@Immutable +data class PlaceSearchUiState( + val searchQuery: String = "", + val results: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +) + +// ============ Strings ============ + +data class SavedLocationsStrings( + val title: String = "Saved Locations", + val premiumTitle: String = "Premium Feature", + val premiumDesc: String = + "Save your favorite locations to access them quickly. " + + "Upgrade to premium to unlock this feature.", + val emptyText: String = "No saved locations yet. Add one to get started!", + val searchHint: String = "Search for a place", + val searchDialogTitle: String = "Add Location", + val noResults: (String) -> String = { "No results found for \"$it\"" }, + val deleteContentDesc: String = "Delete location", + val addContentDesc: String = "Add location", + val cancelBtn: String = "Cancel", + val saveSuccessMsg: String = "Location saved successfully", + val deleteSuccessMsg: String = "Location deleted successfully", + val setAsDefaultDialogTitle: String = "Use as weather location?", + val setAsDefaultDialogBody: (String) -> String = { "Weather data will show for $it instead of your current GPS position." }, + val setAsDefaultDialogWarning: String = "Your live GPS location won't update while this is active.", + val setAsDefaultConfirmBtn: String = "Set as Default", +) + +// ============ Main Screen ============ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SavedLocationsScreen( + locationsState: SavedLocationsUiState, + searchState: PlaceSearchUiState, + onQueryChanged: (String) -> Unit, + onClearSearch: () -> Unit, + onSaveLocation: (name: String, lat: Double, lon: Double) -> Unit, + onDeleteLocation: (String) -> Unit, + onLocationSelected: (SavedLocation) -> Unit, + onMessageShown: () -> Unit, + strings: SavedLocationsStrings = SavedLocationsStrings(), + bottomBar: @Composable () -> Unit = {}, +) { + val snackbarHostState = remember { SnackbarHostState() } + val pendingLocation = remember { mutableStateOf(null) } + + pendingLocation.value?.let { location -> + SetAsDefaultLocationDialog( + locationName = location.name, + strings = strings, + onConfirm = { + onLocationSelected(location) + pendingLocation.value = null + }, + onDismiss = { pendingLocation.value = null }, + ) + } + + LaunchedEffect(locationsState.successMessage, locationsState.error) { + val message = locationsState.successMessage ?: locationsState.error + if (message != null) { + snackbarHostState.showSnackbar(message) + onMessageShown() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = strings.title, + style = MaterialTheme.typography.headlineSmall, + ) + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + if (locationsState.isPremium) { + AddLocationFab( + onPlaceSelected = { place -> + onSaveLocation( + place.name, + place.latitude.toDouble(), + place.longitude.toDouble(), + ) + }, + searchState = searchState, + onQueryChanged = onQueryChanged, + onClearSearch = onClearSearch, + strings = strings, + ) + } + }, + bottomBar = bottomBar, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + when { + !locationsState.isPremium -> PremiumGate(strings) + locationsState.isLoading && locationsState.locations.isEmpty() -> ShowLoading() + locationsState.locations.isEmpty() -> EmptyLocations(strings) + else -> LocationList( + locations = locationsState.locations, + onDelete = onDeleteLocation, + onLocationClick = { pendingLocation.value = it }, + strings = strings, + ) + } + } + } +} + +// ============ Composable Components ============ + +@Composable +private fun PremiumGate(strings: SavedLocationsStrings) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = strings.premiumTitle, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = strings.premiumDesc, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ShowLoading(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun EmptyLocations(strings: SavedLocationsStrings) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = strings.emptyText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(32.dp), + ) + } +} + +@Composable +private fun LocationList( + locations: List, + onDelete: (String) -> Unit, + onLocationClick: (SavedLocation) -> Unit, + strings: SavedLocationsStrings, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + locations.distinctBy { it.id.ifEmpty { "${it.lat}_${it.lon}_${it.name}" } }, + key = { it.id.ifEmpty { "${it.lat}_${it.lon}_${it.name}" } }, + ) { location -> + LocationCard( + location = location, + onClick = { onLocationClick(location) }, + onDelete = { onDelete(location.id) }, + strings = strings, + ) + } + } +} + +@Composable +private fun LocationCard( + location: SavedLocation, + onClick: () -> Unit, + onDelete: () -> Unit, + strings: SavedLocationsStrings, +) { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onClick, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = location.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatCoordinates(location.lat, location.lon), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = strings.deleteContentDesc, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +private fun formatCoordinates(lat: Double, lon: Double): String { + val latRounded = round(lat * 10000) / 10000.0 + val lonRounded = round(lon * 10000) / 10000.0 + return "$latRounded, $lonRounded" +} + +@Composable +private fun AddLocationFab( + onPlaceSelected: (PlaceSuggestion) -> Unit, + searchState: PlaceSearchUiState, + onQueryChanged: (String) -> Unit, + onClearSearch: () -> Unit, + strings: SavedLocationsStrings, +) { + val showDialog = remember { mutableStateOf(false) } + + FloatingActionButton(onClick = { showDialog.value = true }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = strings.addContentDesc, + ) + } + + if (showDialog.value) { + PlaceSearchDialog( + onDismiss = { showDialog.value = false }, + onPlaceSelected = { place -> + showDialog.value = false + onPlaceSelected(place) + }, + searchState = searchState, + onQueryChanged = onQueryChanged, + onClearSearch = onClearSearch, + strings = strings, + ) + } +} + +@Composable +private fun PlaceSearchDialog( + onDismiss: () -> Unit, + onPlaceSelected: (PlaceSuggestion) -> Unit, + searchState: PlaceSearchUiState, + onQueryChanged: (String) -> Unit, + onClearSearch: () -> Unit, + strings: SavedLocationsStrings, +) { + val focusRequester = remember { FocusRequester() } + + AlertDialog( + onDismissRequest = { + onClearSearch() + onDismiss() + }, + title = { Text(strings.searchDialogTitle) }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = searchState.searchQuery, + onValueChange = onQueryChanged, + placeholder = { Text(strings.searchHint) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) + + AnimatedVisibility( + visible = searchState.isLoading, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + + AnimatedVisibility( + visible = searchState.error != null, + enter = fadeIn(), + exit = fadeOut() + ) { + if (searchState.error != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.small, + ) + .padding(12.dp), + ) { + Text( + text = searchState.error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + + AnimatedVisibility( + visible = searchState.searchQuery.length >= 2 && + searchState.results.isEmpty() && + !searchState.isLoading && + searchState.error == null, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = strings.noResults(searchState.searchQuery), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + + AnimatedVisibility( + visible = searchState.results.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + ) { + LazyColumn( + modifier = Modifier.heightIn(max = 280.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items( + searchState.results, + key = { "${it.name}_${it.latitude}_${it.longitude}" }, + ) { place -> + PlaceSuggestionItem( + place = place, + onClick = { + onClearSearch() + onPlaceSelected(place) + }, + ) + } + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { + onClearSearch() + onDismiss() + }) { + Text(strings.cancelBtn) + } + }, + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Composable +private fun PlaceSuggestionItem( + place: PlaceSuggestion, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background( + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.small, + ) + .padding(vertical = 12.dp, horizontal = 12.dp), + ) { + Text( + text = place.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = listOfNotNull(place.city, place.state, place.country) + .filter { it.isNotEmpty() } + .joinToString(", "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun SetAsDefaultLocationDialog( + locationName: String, + strings: SavedLocationsStrings, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Outlined.LocationOn, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = strings.setAsDefaultDialogTitle, + style = MaterialTheme.typography.titleLarge, + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = strings.setAsDefaultDialogBody(locationName), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + HorizontalDivider() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.LocationOn, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.tertiary, + ) + Text( + text = strings.setAsDefaultDialogWarning, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + }, + confirmButton = { + Button(onClick = onConfirm) { + Text(strings.setAsDefaultConfirmBtn) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(strings.cancelBtn) + } + }, + ) +} diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/permissions/PermissionAlertDialog.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/permissions/PermissionAlertDialog.kt new file mode 100644 index 00000000..ca25ccb7 --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/permissions/PermissionAlertDialog.kt @@ -0,0 +1,51 @@ +package bose.ankush.commonui.permissions + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +/** + * A CMP-compatible permission alert dialog. + * + * The caller is responsible for: + * - Providing localised [descriptionText] based on permission state + * - Providing meaningful [positiveButtonLabel] / [negativeButtonLabel] + * - Handling back-press behaviour (e.g. via BackHandler in the host composable) + * + * @param descriptionText Body text shown inside the dialog. + * @param isPermanentlyDeclined When true the negative (Exit/Cancel) button is shown and + * tapping outside the dialog triggers [onNegativeAction]. + * @param onPositiveAction Called when the confirm button is tapped. + * @param onNegativeAction Called when the dismiss button is tapped or the dialog is + * dismissed by an outside tap (only when [isPermanentlyDeclined]). + * @param positiveButtonLabel Label for the confirm button. Defaults to "OK". + * @param negativeButtonLabel Label for the dismiss button. Defaults to "Cancel". + */ +@Composable +fun PermissionAlertDialog( + descriptionText: String, + isPermanentlyDeclined: Boolean, + onPositiveAction: () -> Unit, + onNegativeAction: () -> Unit, + positiveButtonLabel: String = "OK", + negativeButtonLabel: String = "Cancel", +) { + AlertDialog( + onDismissRequest = if (isPermanentlyDeclined) onNegativeAction else onPositiveAction, + title = { Text(text = "Permissions required") }, + text = { Text(text = descriptionText) }, + confirmButton = { + TextButton(onClick = onPositiveAction) { + Text(text = positiveButtonLabel) + } + }, + dismissButton = { + if (isPermanentlyDeclined) { + TextButton(onClick = onNegativeAction) { + Text(text = negativeButtonLabel) + } + } + }, + ) +} diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/settings/SettingsScreen.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/settings/SettingsScreen.kt new file mode 100644 index 00000000..efb3d44e --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/settings/SettingsScreen.kt @@ -0,0 +1,659 @@ +package bose.ankush.commonui.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Gavel +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.PrivacyTip +import androidx.compose.material.icons.outlined.WorkspacePremium +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import bose.ankush.commonui.components.NotificationToast +import bose.ankush.commonui.components.ServiceSubscriptionBottomSheet +import bose.ankush.commonui.components.ToastAnchorState +import bose.ankush.commonui.components.ToastType +import bose.ankush.commonui.util.formatDate +import bose.ankush.commonui.viewmodel.ServiceSubscriptionUiState +import bose.ankush.commonui.web.InAppWebView +import bose.ankush.network.model.PricingTier +import bose.ankush.network.model.Service +import bose.ankush.payment.presentation.PaymentStage +import bose.ankush.payment.presentation.PaymentUiState +import kotlinx.coroutines.delay + +/** + * Holds localized strings for SettingsScreen. + * Allows the KMP composable to accept platform-specific localized resources. + */ +data class SettingsScreenStrings( + val profileTitle: String, + val logout: String, + val logoutConfirmation: String, + val confirm: String, + val cancel: String, + val getPremium: String, + val processing: String, + val processingDescription: String, + val unlockDescription: String, + val upgradeNow: String, + val premiumActive: String, + val premiumExpires: String, + val premiumActiveStatus: String, + val notificationsTitle: String, + val languageTitle: String, + val privacyPolicy: String, + val termsOfUse: String, + val appVersion: String, + val backButtonDesc: String, + val arrowRightDesc: String, + val premiumActivatedTitle: String, + val premiumActivatedMessage: String, +) + +/** + * Holds UI state for SettingsScreen. + * Managed by parent ViewModel, not created within the screen. + */ +data class SettingsScreenState( + val showPremiumBottomSheet: Boolean = false, + val showLogoutDialog: Boolean = false, + val showPremiumActivationToast: Boolean = false, + val currentWebUrl: String? = null, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + paymentUiState: PaymentUiState, + isLoggingOut: Boolean, + isLoggedOut: Boolean, + versionName: String, + shouldShowNotificationItem: Boolean, + languageList: Array, + uiState: SettingsScreenState, + strings: SettingsScreenStrings, + serviceSubscriptionBottomSheetUiState: ServiceSubscriptionUiState, + onLogout: () -> Unit, + onLoggedOutHandled: () -> Unit, + onStartPayment: (amountPaise: Long) -> Unit, + onLoadServices: () -> Unit, + onServiceSelected: (Service) -> Unit, + onTierSelected: (PricingTier) -> Unit, + onBackNavAction: () -> Unit, + onLanguageNavAction: (Array) -> Unit, + onNotificationNavAction: () -> Unit, + onStateChange: (SettingsScreenState) -> Unit, + onBottomBarVisibilityChange: (Boolean) -> Unit = {}, + toastAnchorState: ToastAnchorState? = null, + bottomBar: @Composable () -> Unit = {}, +) { + val previousPaymentStage = remember { mutableStateOf(paymentUiState.stage) } + + LaunchedEffect(uiState.showPremiumBottomSheet) { + onBottomBarVisibilityChange(!uiState.showPremiumBottomSheet) + } + + LaunchedEffect(paymentUiState.stage) { + when { + paymentUiState.stage == PaymentStage.CreatingOrder || + paymentUiState.stage == PaymentStage.AwaitingPayment -> + onStateChange(uiState.copy(showPremiumBottomSheet = false)) + + paymentUiState.stage == PaymentStage.Success && + previousPaymentStage.value != PaymentStage.Success -> { + onStateChange( + uiState.copy( + showPremiumActivationToast = true, + showPremiumBottomSheet = false, + ), + ) + } + + paymentUiState.stage == PaymentStage.Failure -> + onStateChange(uiState.copy(showPremiumBottomSheet = false)) + } + previousPaymentStage.value = paymentUiState.stage + } + + LaunchedEffect(isLoggedOut) { + if (isLoggedOut) { + onStateChange(uiState.copy(showLogoutDialog = false)) + onLoggedOutHandled() + } + } + + val settingsSectionState = remember { MutableTransitionState(false) } + val legalSectionState = remember { MutableTransitionState(false) } + val logoutButtonState = remember { MutableTransitionState(false) } + + LaunchedEffect(Unit) { + settingsSectionState.targetState = false + legalSectionState.targetState = false + logoutButtonState.targetState = false + + delay(100) + settingsSectionState.targetState = true + delay(150) + legalSectionState.targetState = true + delay(150) + logoutButtonState.targetState = true + } + + if (uiState.currentWebUrl != null) { + InAppWebView( + url = uiState.currentWebUrl, + onClose = { onStateChange(uiState.copy(currentWebUrl = null)) }, + ) + } else { + Box(modifier = Modifier.fillMaxSize()) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + CenterAlignedTopAppBar( + title = { Text(strings.profileTitle, fontWeight = FontWeight.SemiBold) }, + navigationIcon = { + IconButton(onClick = onBackNavAction) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = strings.backButtonDesc, + ) + } + }, + colors = + TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) + }, + content = { innerPadding -> + LazyColumn( + modifier = + Modifier + .padding(innerPadding) + .padding(horizontal = 16.dp), + ) { + // Future enhancement: Add user profile section here + + item { Spacer(modifier = Modifier.height(24.dp)) } + + item { + PremiumCard( + paymentUiState = paymentUiState, + onClick = { onStateChange(uiState.copy(showPremiumBottomSheet = true)) }, + strings = strings, + ) + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + + item { + AnimatedVisibility( + visibleState = settingsSectionState, + enter = + fadeIn(animationSpec = tween(durationMillis = 500)) + + slideInVertically( + animationSpec = tween(durationMillis = 500), + initialOffsetY = { it / 3 }, + ), + exit = fadeOut(), + ) { + SettingsSection( + shouldShowNotificationItem = shouldShowNotificationItem, + onNotificationNavAction = onNotificationNavAction, + onLanguageNavAction = { onLanguageNavAction(languageList) }, + strings = strings, + ) + } + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + + item { + AnimatedVisibility( + visibleState = legalSectionState, + enter = + fadeIn(animationSpec = tween(durationMillis = 500)) + + slideInVertically( + animationSpec = tween(durationMillis = 500), + initialOffsetY = { it / 3 }, + ), + exit = fadeOut(), + ) { + LegalSection( + versionName = versionName, + onUrlClick = { url -> onStateChange(uiState.copy(currentWebUrl = url)) }, + strings = strings, + ) + } + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + + item { + AnimatedVisibility( + visibleState = logoutButtonState, + enter = + fadeIn(animationSpec = tween(durationMillis = 500)) + + slideInVertically( + animationSpec = tween(durationMillis = 500), + initialOffsetY = { it / 3 }, + ), + exit = fadeOut(), + ) { + TextButton( + onClick = { onStateChange(uiState.copy(showLogoutDialog = true)) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = strings.logout, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + } + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + } + + if (uiState.showLogoutDialog) { + AlertDialog( + onDismissRequest = { + if (!isLoggingOut) onStateChange(uiState.copy(showLogoutDialog = false)) + }, + title = { Text(text = strings.logout) }, + text = { Text(text = strings.logoutConfirmation) }, + confirmButton = { + TextButton( + onClick = onLogout, + enabled = !isLoggingOut, + ) { + Text(strings.confirm) + } + }, + dismissButton = { + TextButton( + onClick = { onStateChange(uiState.copy(showLogoutDialog = false)) }, + enabled = !isLoggingOut, + ) { + Text(strings.cancel) + } + }, + ) + } + + if (uiState.showPremiumBottomSheet) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + ) { + Spacer( + modifier = + Modifier + .fillMaxSize(0.2f) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { onStateChange(uiState.copy(showPremiumBottomSheet = false)) }, + ) + + ServiceSubscriptionBottomSheet( + uiState = serviceSubscriptionBottomSheetUiState, + loadService = onLoadServices, + onServiceSelected = onServiceSelected, + onTierSelected = onTierSelected, + onDismiss = { onStateChange(uiState.copy(showPremiumBottomSheet = false)) }, + onSubscribe = { _, tier -> + onStartPayment(tier.getAmountInPaise().toLong()) + onStateChange(uiState.copy(showPremiumBottomSheet = false)) + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } + }, + bottomBar = bottomBar, + ) + + NotificationToast( + modifier = Modifier.align(Alignment.BottomCenter), + message = strings.premiumActivatedMessage, + title = strings.premiumActivatedTitle, + type = ToastType.SUCCESS, + isVisible = uiState.showPremiumActivationToast, + onDismiss = { onStateChange(uiState.copy(showPremiumActivationToast = false)) }, + anchorState = toastAnchorState, + ) + } // end Box + } +} + +@Composable +fun PremiumCard( + paymentUiState: PaymentUiState, + onClick: () -> Unit, + strings: SettingsScreenStrings, +) { + val isPremiumActive = + paymentUiState.isPremiumActivated || paymentUiState.stage == PaymentStage.Success + val cardColors = + if (isPremiumActive) { + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) + } else { + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer) + } + + Card( + modifier = + Modifier + .fillMaxWidth() + .then(if (!isPremiumActive) Modifier.clickable(onClick = onClick) else Modifier), + shape = RoundedCornerShape(16.dp), + colors = cardColors, + ) { + if (isPremiumActive) { + SubscribedPremiumCard(paymentUiState, strings) + } else { + UnsubscribedPremiumCard(paymentUiState, onClick, strings) + } + } +} + +@Composable +fun UnsubscribedPremiumCard( + paymentUiState: PaymentUiState, + onClick: () -> Unit, + strings: SettingsScreenStrings, +) { + val loadingStages = + remember { + listOf( + PaymentStage.CreatingOrder, + PaymentStage.AwaitingPayment, + PaymentStage.Verifying, + ) + } + val isLoading = paymentUiState.loading || paymentUiState.stage in loadingStages + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (isLoading) { + LinearProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .height(2.dp), + color = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.height(12.dp)) + } + Icon( + imageVector = Icons.Outlined.WorkspacePremium, + contentDescription = "Premium subscription icon", + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = if (isLoading) strings.processing else strings.getPremium, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + if (isLoading) { + strings.processingDescription + } else { + strings.unlockDescription + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = onClick, + enabled = !isLoading, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary, + ), + ) { + Text(if (isLoading) strings.processing else strings.upgradeNow) + } + } +} + +@Composable +fun SubscribedPremiumCard( + paymentUiState: PaymentUiState, + strings: SettingsScreenStrings, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Outlined.WorkspacePremium, + contentDescription = "Premium subscription icon", + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = strings.premiumActive, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(modifier = Modifier.height(8.dp)) + val expiryTop = paymentUiState.expiryMillis + if (expiryTop != null) { + val dateStr = remember(expiryTop) { formatDate(expiryTop) } + Text( + text = strings.premiumExpires.replace("%s", dateStr), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f), + ) + } else { + Text( + text = strings.premiumActiveStatus, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +@Composable +fun SettingsSection( + shouldShowNotificationItem: Boolean, + onNotificationNavAction: () -> Unit, + onLanguageNavAction: () -> Unit, + strings: SettingsScreenStrings, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(vertical = 8.dp), + ) { + if (shouldShowNotificationItem) { + SettingsItem( + icon = Icons.Outlined.Notifications, + title = strings.notificationsTitle, + onClick = onNotificationNavAction, + arrowRightDesc = strings.arrowRightDesc, + ) + } + SettingsItem( + icon = Icons.Outlined.Language, + title = strings.languageTitle, + onClick = onLanguageNavAction, + arrowRightDesc = strings.arrowRightDesc, + ) + } +} + +@Composable +fun LegalSection( + versionName: String, + onUrlClick: (String) -> Unit, + strings: SettingsScreenStrings, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(vertical = 8.dp), + ) { + SettingsItem( + icon = Icons.Outlined.PrivacyTip, + title = strings.privacyPolicy, + onClick = { onUrlClick("https://data.androidplay.in/wfy/privacy-policy") }, + arrowRightDesc = strings.arrowRightDesc, + ) + SettingsItem( + icon = Icons.Outlined.Gavel, + title = strings.termsOfUse, + onClick = { + onUrlClick("https://data.androidplay.in/wfy/terms-and-conditions") + }, + arrowRightDesc = strings.arrowRightDesc, + ) + SettingsItem( + icon = Icons.Outlined.Info, + title = strings.appVersion, + trailingContent = { + Text( + text = versionName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + }, + arrowRightDesc = strings.arrowRightDesc, + ) + } +} + +@Composable +fun SettingsItem( + icon: ImageVector, + title: String, + onClick: (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + arrowRightDesc: String = "", +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = icon, + contentDescription = title, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (trailingContent != null) { + Spacer(modifier = Modifier.width(12.dp)) + trailingContent() + } else if (onClick != null) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = arrowRightDesc, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + } + } +} diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt new file mode 100644 index 00000000..a93b4265 --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt @@ -0,0 +1,6 @@ +package bose.ankush.commonui.util + +expect fun formatDate( + millis: Long, + pattern: String = "MMM d, yyyy", +): String diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/viewmodel/ServiceSubscriptionViewModel.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/viewmodel/ServiceSubscriptionViewModel.kt new file mode 100644 index 00000000..3abbdc33 --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/viewmodel/ServiceSubscriptionViewModel.kt @@ -0,0 +1,95 @@ +package bose.ankush.commonui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import bose.ankush.network.model.PricingTier +import bose.ankush.network.model.Service +import bose.ankush.network.repository.ServiceRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class ServiceSubscriptionUiState( + val isLoading: Boolean = true, + val services: List = emptyList(), + val selectedService: Service? = null, + val selectedTier: PricingTier? = null, + val error: String? = null, + val message: String? = null, +) + +class ServiceSubscriptionViewModel( + private val repository: ServiceRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(ServiceSubscriptionUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadServices() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + + repository.getServices().fold( + onSuccess = { services -> + _uiState.update { + it.copy( + isLoading = false, + services = services.filter { service -> service.isAvailable }, + selectedService = services.firstOrNull { service -> service.isAvailable }, + selectedTier = + services + .firstOrNull { service -> service.isAvailable } + ?.getRecommendedTier(), + ) + } + }, + onFailure = { error -> + val userMessage = getUserFriendlyErrorMessage(error) + _uiState.update { + it.copy( + isLoading = false, + error = userMessage, + ) + } + }, + ) + } + } + + private fun getUserFriendlyErrorMessage(error: Throwable): String = + when { + error.message?.contains("Illegal input", ignoreCase = true) == true -> + "Unable to load subscription plans. Please try again." + + error.message?.contains("Network", ignoreCase = true) == true -> + "Network connection issue. Please check your internet." + + error.message?.contains("404", ignoreCase = true) == true -> + "Service not found. Please try again later." + + error.message?.contains("500", ignoreCase = true) == true -> + "Server error. Please try again later." + + else -> "Unable to load subscription plans. Please try again." + } + + fun selectService(service: Service) { + _uiState.update { + it.copy( + selectedService = service, + selectedTier = service.getRecommendedTier(), + ) + } + } + + fun selectPricingTier(tier: PricingTier) { + _uiState.update { it.copy(selectedTier = tier) } + } + + fun resetState() { + _uiState.update { + ServiceSubscriptionUiState(isLoading = true) + } + } +} diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt new file mode 100644 index 00000000..0c745680 --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt @@ -0,0 +1,26 @@ +package bose.ankush.commonui.web + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Cross-platform in-app web view composable (CMP/KMP). + * + * Android: Uses Android android.webkit.WebView wrapped in androidx.compose.ui.viewinterop.AndroidView. + * - JavaScript disabled, cookies disabled, mixed-content blocked (security hardened). + * - URL whitelist enforced via android.webkit.WebViewClient. + * + * iOS: Uses platform.WebKit.WKWebView wrapped in androidx.compose.ui.viewinterop.UIKitView. + * - Non-persistent website data store (no cookie/cache persistence). + * - Navigation delegate tracks load state and errors. + * + * @param url The URL to load on first display. + * @param modifier Optional modifier for the root layout. + * @param onClose Called when the user navigates back past the first page or taps the back icon. + */ +@Composable +expect fun InAppWebView( + url: String, + modifier: Modifier = Modifier, + onClose: () -> Unit, +) diff --git a/common-ui/src/iosMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt b/common-ui/src/iosMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt new file mode 100644 index 00000000..13136b44 --- /dev/null +++ b/common-ui/src/iosMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt @@ -0,0 +1,18 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package bose.ankush.commonui.util + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSDate +import platform.Foundation.NSDateFormatter +import platform.Foundation.dateWithTimeIntervalSince1970 + +actual fun formatDate( + millis: Long, + pattern: String, +): String { + val date = NSDate.dateWithTimeIntervalSince1970(millis / 1000.0) + val formatter = NSDateFormatter() + formatter.dateFormat = pattern + return formatter.stringFromDate(date) +} diff --git a/common-ui/src/iosMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt b/common-ui/src/iosMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt new file mode 100644 index 00000000..edf51d3d --- /dev/null +++ b/common-ui/src/iosMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt @@ -0,0 +1,378 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package bose.ankush.commonui.web + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.OpenInBrowser +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.UIKitView +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCSignatureOverride +import kotlinx.cinterop.readValue +import platform.CoreGraphics.CGRectZero +import platform.Foundation.NSError +import platform.Foundation.NSURL +import platform.Foundation.NSURLRequest +import platform.UIKit.UIActivityViewController +import platform.UIKit.UIApplication +import platform.WebKit.WKNavigation +import platform.WebKit.WKNavigationAction +import platform.WebKit.WKNavigationActionPolicy +import platform.WebKit.WKNavigationDelegateProtocol +import platform.WebKit.WKWebView +import platform.WebKit.WKWebViewConfiguration +import platform.WebKit.WKWebsiteDataStore +import platform.darwin.NSObject + +/** + * iOS actual: WKWebView wrapped in UIKitView. + * Non-persistent website data store (no cookie/cache persistence). + * Back navigation via top-bar icon (iOS swipe-back gesture also supported natively). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun InAppWebView( + url: String, + modifier: Modifier, + onClose: () -> Unit, +) { + var pageTitle by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(true) } + var loadError by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + var canGoBack by remember { mutableStateOf(false) } + var currentUrl by remember { mutableStateOf(url) } + + // Hold a strong reference to the delegate โ€” WKWebView.navigationDelegate is weak in ObjC + val delegate = remember { InAppWebViewDelegate() } + + val webView = + remember { + val config = + WKWebViewConfiguration().apply { + // Non-persistent storage: equivalent to CookieManager.setAcceptCookie(false) on Android + websiteDataStore = WKWebsiteDataStore.nonPersistentDataStore() + } + WKWebView(frame = CGRectZero.readValue(), configuration = config).apply { + navigationDelegate = delegate + } + } + + // Wire delegate callbacks to the latest captured state setters on each recomposition + delegate.initialUrl = url + delegate.onLoadStart = { + isLoading = true + loadError = false + } + delegate.onLoadFinish = { wv -> + isLoading = false + canGoBack = wv.canGoBack + pageTitle = wv.title ?: "" + currentUrl = wv.URL?.absoluteString ?: url + } + delegate.onLoadError = { msg -> + isLoading = false + loadError = true + errorMessage = msg + } + delegate.onNavigationBlocked = { + onClose() + } + + // Load (or reload) the URL whenever it changes + LaunchedEffect(url) { + NSURL.URLWithString(url)?.let { nsUrl -> + webView.loadRequest(NSURLRequest.requestWithURL(nsUrl)) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = pageTitle.ifBlank { "Weatherify" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = { + if (canGoBack) webView.goBack() else onClose() + }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + ) + } + }, + actions = { + IconButton(onClick = { webView.reload() }) { + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = "Refresh page", + ) + } + IconButton(onClick = { + val activityVC = + UIActivityViewController( + activityItems = listOf(currentUrl), + applicationActivities = null, + ) + @Suppress("DEPRECATION") + UIApplication.sharedApplication.keyWindow + ?.rootViewController + ?.presentViewController(activityVC, animated = true, completion = null) + }) { + Icon( + imageVector = Icons.Outlined.Share, + contentDescription = "Share page", + ) + } + IconButton(onClick = { + NSURL.URLWithString(currentUrl)?.let { nsUrl -> + @Suppress("DEPRECATION") + UIApplication.sharedApplication.openURL(nsUrl) + } + }) { + Icon( + imageVector = Icons.Outlined.OpenInBrowser, + contentDescription = "Open in browser", + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) + }, + ) { paddingValues -> + Column( + modifier = + modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(paddingValues), + ) { + if (isLoading) { + LinearProgressIndicator( + modifier = + Modifier + .height(2.dp) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + ) + } + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + UIKitView( + factory = { webView }, + modifier = Modifier.fillMaxSize(), + update = {}, + ) + + // Loading overlay + if (isLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.8f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + // Error overlay + if (loadError) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.95f)), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Outlined.ErrorOutline, + contentDescription = "Error", + modifier = + Modifier + .size(64.dp) + .padding(bottom = 16.dp), + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = "Failed to load page", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(bottom = 8.dp), + ) + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + modifier = Modifier.padding(bottom = 24.dp), + ) + } + Button( + onClick = { + loadError = false + errorMessage = "" + isLoading = true + webView.reload() + }, + ) { + Text("Retry") + } + } + } + } + } + } + } + + // Release WKWebView resources when the composable leaves composition + DisposableEffect(Unit) { + onDispose { + webView.stopLoading() + webView.navigationDelegate = null + } + } +} + +/** + * WKNavigationDelegate implementation that forwards load events to Compose state setters. + * Kept as a named class so that a strong Kotlin reference can be held (WKWebView.navigationDelegate + * is a weak ObjC reference and would otherwise be immediately deallocated). + * + * URL whitelist enforcement: decidePolicyForNavigationAction validates navigation URLs + * against the same trusted domain list used on Android (e.g., data.androidplay.in). + */ +private class InAppWebViewDelegate : + NSObject(), + WKNavigationDelegateProtocol { + var onLoadStart: () -> Unit = {} + var onLoadFinish: (WKWebView) -> Unit = {} + var onLoadError: (String) -> Unit = {} + var onNavigationBlocked: () -> Unit = {} + var initialUrl: String = "" + + @ObjCSignatureOverride + override fun webView( + webView: WKWebView, + didStartProvisionalNavigation: WKNavigation?, + ) { + onLoadStart() + } + + @ObjCSignatureOverride + override fun webView( + webView: WKWebView, + didFinishNavigation: WKNavigation?, + ) { + onLoadFinish(webView) + } + + @ObjCSignatureOverride + override fun webView( + webView: WKWebView, + didFailProvisionalNavigation: WKNavigation?, + withError: NSError, + ) { + onLoadError(withError.localizedDescription) + } + + @ObjCSignatureOverride + override fun webView( + webView: WKWebView, + didFailNavigation: WKNavigation?, + withError: NSError, + ) { + onLoadError(withError.localizedDescription) + } + + @ObjCSignatureOverride + override fun webView( + webView: WKWebView, + decidePolicyForNavigationAction: WKNavigationAction, + decisionHandler: (WKNavigationActionPolicy) -> Unit, + ) { + val requestUrl = decidePolicyForNavigationAction.request.URL?.absoluteString ?: "" + + // Allow initial URL load + if (requestUrl == initialUrl) { + decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyAllow) + return + } + + // Validate subsequent navigation against whitelist + if (isWhitelistedUrl(requestUrl)) { + decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyAllow) + } else { + // Block navigation from untrusted domains + decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyCancel) + onNavigationBlocked() + } + } + + private fun isWhitelistedUrl(urlString: String): Boolean { + val url = NSURL.URLWithString(urlString) ?: return false + val host = url.host?.lowercase() ?: return false + val whitelistedDomains = + setOf( + "data.androidplay.in", // Terms, Privacy Policy + ) + return whitelistedDomains.any { trustedDomain -> + host == trustedDomain || host.endsWith(".$trustedDomain") + } + } +} diff --git a/feature-payment/build.gradle.kts b/feature-payment/build.gradle.kts new file mode 100644 index 00000000..157b6deb --- /dev/null +++ b/feature-payment/build.gradle.kts @@ -0,0 +1,65 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("multiplatform") + id("com.android.library") +} + +kotlin { + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { + it.binaries.framework { + baseName = "feature_payment" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + implementation(project(":network")) + implementation(KmmDeps.koinCore) + implementation(KmmDeps.kotlinxCoroutinesCore) + implementation(KmmDeps.kotlinxDateTime) + implementation(KmmDeps.kmpLifecycleViewModel) + } + + androidMain.dependencies { + implementation(KmmDeps.koinAndroid) + } + + val iosX64Main by getting + val iosArm64Main by getting + val iosSimulatorArm64Main by getting + + @Suppress("UNUSED_VARIABLE") + val iosMain by creating { + dependsOn(commonMain.get()) + iosX64Main.dependsOn(this) + iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) + } + } +} + +android { + namespace = "bose.ankush.payment" + compileSdk = ConfigData.compileSdkVersion + + defaultConfig { + minSdk = ConfigData.minSdkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/feature-payment/src/androidMain/kotlin/bose/ankush/payment/di/PaymentAndroidModule.kt b/feature-payment/src/androidMain/kotlin/bose/ankush/payment/di/PaymentAndroidModule.kt new file mode 100644 index 00000000..5b3a8322 --- /dev/null +++ b/feature-payment/src/androidMain/kotlin/bose/ankush/payment/di/PaymentAndroidModule.kt @@ -0,0 +1,20 @@ +package bose.ankush.payment.di + +import bose.ankush.payment.presentation.PaymentViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.module.Module +import org.koin.dsl.module + +val paymentViewModelModule: Module = + module { + viewModel { PaymentViewModel(get(), get(), get(), get()) } + } + +/** + * All Koin modules required by the feature-payment module on Android. + * Load these in the host application's [org.koin.core.context.startKoin] call, + * alongside the app-level module that provides the platform-specific bindings: + * [bose.ankush.network.api.PaymentApiService], [bose.ankush.network.common.NetworkConnectivity], + * [bose.ankush.payment.domain.store.PremiumStore], and [bose.ankush.payment.domain.config.PaymentConfig]. + */ +val featurePaymentModules: List = listOf(paymentDomainModule, paymentViewModelModule) diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/data/PaymentRepositoryImpl.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/data/PaymentRepositoryImpl.kt new file mode 100644 index 00000000..a6e42401 --- /dev/null +++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/data/PaymentRepositoryImpl.kt @@ -0,0 +1,28 @@ +package bose.ankush.payment.data + +import bose.ankush.network.api.PaymentApiService +import bose.ankush.network.common.NetworkConnectivity +import bose.ankush.network.model.CreateOrderRequest +import bose.ankush.network.model.CreateOrderResponse +import bose.ankush.network.model.VerifyPaymentRequest +import bose.ankush.network.model.VerifyPaymentResponse +import bose.ankush.payment.domain.repository.PaymentRepository + +internal class PaymentRepositoryImpl( + private val apiService: PaymentApiService, + private val networkConnectivity: NetworkConnectivity, +) : PaymentRepository { + override suspend fun createOrder(request: CreateOrderRequest): Result { + if (!networkConnectivity.isNetworkAvailable()) { + return Result.failure(IllegalStateException("No internet connection")) + } + return runCatching { apiService.createOrder(request) } + } + + override suspend fun verifyPayment(request: VerifyPaymentRequest): Result { + if (!networkConnectivity.isNetworkAvailable()) { + return Result.failure(IllegalStateException("No internet connection")) + } + return runCatching { apiService.verifyPayment(request) } + } +} diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/di/PaymentModule.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/di/PaymentModule.kt new file mode 100644 index 00000000..de9fc305 --- /dev/null +++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/di/PaymentModule.kt @@ -0,0 +1,22 @@ +package bose.ankush.payment.di + +import bose.ankush.payment.data.PaymentRepositoryImpl +import bose.ankush.payment.domain.repository.PaymentRepository +import bose.ankush.payment.domain.usecase.CreateOrderUseCase +import bose.ankush.payment.domain.usecase.VerifyPaymentUseCase +import org.koin.core.module.Module +import org.koin.dsl.module + +/** + * Koin module for the payment feature โ€” domain and data layer bindings. + * Platform-specific bindings (NetworkConnectivity, PaymentApiService, PremiumStore, + * PaymentConfig) and the ViewModel must be provided by the host application. + * + * @see bose.ankush.payment.di โ€” androidMain for Android ViewModel module. + */ +val paymentDomainModule: Module = + module { + single { PaymentRepositoryImpl(get(), get()) } + factory { CreateOrderUseCase(get()) } + factory { VerifyPaymentUseCase(get()) } + } diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/config/PaymentConfig.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/config/PaymentConfig.kt new file mode 100644 index 00000000..f358181f --- /dev/null +++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/config/PaymentConfig.kt @@ -0,0 +1,5 @@ +package bose.ankush.payment.domain.config + +interface PaymentConfig { + val razorpayKey: String +} diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/repository/PaymentRepository.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/repository/PaymentRepository.kt new file mode 100644 index 00000000..fd18b3e2 --- /dev/null +++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/repository/PaymentRepository.kt @@ -0,0 +1,12 @@ +package bose.ankush.payment.domain.repository + +import bose.ankush.network.model.CreateOrderRequest +import bose.ankush.network.model.CreateOrderResponse +import bose.ankush.network.model.VerifyPaymentRequest +import bose.ankush.network.model.VerifyPaymentResponse + +interface PaymentRepository { + suspend fun createOrder(request: CreateOrderRequest): Result + + suspend fun verifyPayment(request: VerifyPaymentRequest): Result +} diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/store/PremiumStore.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/store/PremiumStore.kt new file mode 100644 index 00000000..0b056693 --- /dev/null +++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/store/PremiumStore.kt @@ -0,0 +1,38 @@ +package bose.ankush.payment.domain.store + +import kotlinx.coroutines.flow.Flow + +/** + * Interface for managing and observing the premium subscription status of the user. + */ +interface PremiumStore { + /** + * Returns a [Flow] that emits the current [PremiumStatus]. + * + * @return A flow of premium status updates. + */ + fun observePremiumStatus(): Flow + + /** + * Persists the user's premium subscription status. + * + * @param isPremium True if the user has an active premium subscription, false otherwise. + * @param expiryMillis The expiration time of the premium subscription in milliseconds, or null if not applicable. + */ + suspend fun savePremiumStatus( + isPremium: Boolean, + expiryMillis: Long?, + ) +} + +/** + * Data class representing the premium subscription state. + * + * @property isPremium Indicates whether the user currently has a premium subscription. + * @property expiryMillis The timestamp in milliseconds when the premium subscription expires, + * or null if not applicable. + */ +data class PremiumStatus( + val isPremium: Boolean, + val expiryMillis: Long?, +) diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/usecase/CreateOrderUseCase.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/usecase/CreateOrderUseCase.kt new file mode 100644 index 00000000..31fff513 --- /dev/null +++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/usecase/CreateOrderUseCase.kt @@ -0,0 +1,12 @@ +package bose.ankush.payment.domain.usecase + +import bose.ankush.network.model.CreateOrderRequest +import bose.ankush.network.model.CreateOrderResponse +import bose.ankush.payment.domain.repository.PaymentRepository + +class CreateOrderUseCase( + private val repository: PaymentRepository, +) { + suspend operator fun invoke(request: CreateOrderRequest): Result = + repository.createOrder(request) +} diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/usecase/VerifyPaymentUseCase.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/usecase/VerifyPaymentUseCase.kt new file mode 100644 index 00000000..114927a2 --- /dev/null +++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/usecase/VerifyPaymentUseCase.kt @@ -0,0 +1,12 @@ +package bose.ankush.payment.domain.usecase + +import bose.ankush.network.model.VerifyPaymentRequest +import bose.ankush.network.model.VerifyPaymentResponse +import bose.ankush.payment.domain.repository.PaymentRepository + +class VerifyPaymentUseCase( + private val repository: PaymentRepository, +) { + suspend operator fun invoke(request: VerifyPaymentRequest): Result = + repository.verifyPayment(request) +} diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/presentation/PaymentUiState.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/presentation/PaymentUiState.kt new file mode 100644 index 00000000..ce0865c7 --- /dev/null +++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/presentation/PaymentUiState.kt @@ -0,0 +1,26 @@ +package bose.ankush.payment.presentation + +enum class PaymentStage { Idle, CreatingOrder, AwaitingPayment, Verifying, Success, Failure } + +data class PaymentUiState( + val loading: Boolean = false, + val message: String? = null, + val stage: PaymentStage = PaymentStage.Idle, + val isPremiumActivated: Boolean = false, + val expiryMillis: Long? = null, +) + +/** + * Parameters for launching the platform-specific payment checkout (e.g. Razorpay on Android). + * Emitted by [PaymentViewModel] after a successful order creation; consumed by the UI layer. + */ +data class CheckoutParams( + val keyId: String, + val orderId: String, + val amount: Long, + val currency: String, + val name: String, + val description: String, + val email: String? = null, + val contact: String? = null, +) diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/presentation/PaymentViewModel.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/presentation/PaymentViewModel.kt new file mode 100644 index 00000000..87cf0d81 --- /dev/null +++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/presentation/PaymentViewModel.kt @@ -0,0 +1,210 @@ +package bose.ankush.payment.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import bose.ankush.network.model.CreateOrderRequest +import bose.ankush.network.model.VerifyPaymentRequest +import bose.ankush.payment.domain.config.PaymentConfig +import bose.ankush.payment.domain.store.PremiumStore +import bose.ankush.payment.domain.usecase.CreateOrderUseCase +import bose.ankush.payment.domain.usecase.VerifyPaymentUseCase +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlin.time.Duration.Companion.days + +class PaymentViewModel( + private val createOrderUseCase: CreateOrderUseCase, + private val verifyPaymentUseCase: VerifyPaymentUseCase, + private val premiumStore: PremiumStore, + private val paymentConfig: PaymentConfig, +) : ViewModel() { + private val _uiState = MutableStateFlow(PaymentUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + /** + * One-shot events to trigger the platform-specific checkout UI (e.g. Razorpay on Android). + * Consumed by the Activity/UI layer; never observed from the ViewModel itself. + */ + private val _checkoutParams = Channel(Channel.BUFFERED) + val checkoutParams: Flow = _checkoutParams.receiveAsFlow() + + init { + observePremiumStatus() + } + + private fun observePremiumStatus() { + viewModelScope.launch { + premiumStore.observePremiumStatus().collect { status -> + val now = Clock.System.now().toEpochMilliseconds() + val isActive = status.expiryMillis != null && status.expiryMillis > now + _uiState.update { + it.copy( + isPremiumActivated = isActive, + expiryMillis = status.expiryMillis, + stage = if (isActive) PaymentStage.Success else it.stage, + ) + } + } + } + } + + fun startPayment( + amountPaise: Long = 10_000L, + currency: String = "INR", + ) { + viewModelScope.launch { + _uiState.update { + it.copy(loading = true, message = "Creating order...", stage = PaymentStage.CreatingOrder) + } + + val receipt = "receipt_${Clock.System.now().toEpochMilliseconds()}" + + createOrderUseCase( + CreateOrderRequest( + amount = amountPaise, + currency = currency, + receipt = receipt, + partialPayment = true, + firstPaymentMinAmount = 500L, + ), + ).fold( + onSuccess = { response -> + val data = response.extractData() + val key = paymentConfig.razorpayKey + when { + data == null -> + _uiState.update { + it.copy( + loading = false, + message = friendlyServerMessage(response.message), + stage = PaymentStage.Failure, + ) + } + + key.isBlank() -> + _uiState.update { + it.copy( + loading = false, + message = "Payment is temporarily unavailable. Please try again later.", + stage = PaymentStage.Failure, + ) + } + + data.orderId.isBlank() || data.amount <= 0L || data.currency.isBlank() -> + _uiState.update { + it.copy( + loading = false, + message = "We couldn't start the payment. Please try again.", + stage = PaymentStage.Failure, + ) + } + + else -> { + _checkoutParams.trySend( + CheckoutParams( + keyId = key, + orderId = data.orderId, + amount = data.amount, + currency = data.currency, + name = "Weatherify Subscription", + description = "Premium Plan", + ), + ) + _uiState.update { + it.copy( + loading = false, + message = response.message ?: "Order created", + stage = PaymentStage.AwaitingPayment, + ) + } + } + } + }, + onFailure = { e -> + _uiState.update { + it.copy(loading = false, message = friendlyErrorMessage(e), stage = PaymentStage.Failure) + } + }, + ) + } + } + + fun verifyPayment( + orderId: String, + paymentId: String, + signature: String, + ) { + viewModelScope.launch { + _uiState.update { + it.copy(loading = true, message = "Verifying payment...", stage = PaymentStage.Verifying) + } + + verifyPaymentUseCase( + VerifyPaymentRequest( + razorpayOrderId = orderId, + razorpayPaymentId = paymentId, + razorpaySignature = signature, + ), + ).fold( + onSuccess = { resp -> + if (!resp.success) { + _uiState.update { + it.copy( + loading = false, + message = friendlyServerMessage(resp.message), + stage = PaymentStage.Failure, + ) + } + return@fold + } + val expiryMillis = + Clock.System + .now() + .plus(30.days) + .toEpochMilliseconds() + premiumStore.savePremiumStatus(isPremium = true, expiryMillis = expiryMillis) + // _uiState auto-updates via observePremiumStatus() collecting the new value + _uiState.update { + it.copy(loading = false, message = "Payment verified", stage = PaymentStage.Success) + } + }, + onFailure = { e -> + _uiState.update { + it.copy(loading = false, message = friendlyErrorMessage(e), stage = PaymentStage.Failure) + } + }, + ) + } + } + + fun onPaymentFailed(message: String) { + _uiState.update { + it.copy(loading = false, message = friendlyServerMessage(message), stage = PaymentStage.Failure) + } + } + + private fun friendlyServerMessage(message: String?): String { + if (message.isNullOrBlank()) return "Something went wrong. Please try again." + val lower = message.lowercase() + return when { + "timeout" in lower -> "The server took too long to respond. Please try again." + "cancel" in lower -> "Payment was cancelled." + "network" in lower || "unable to resolve host" in lower -> + "Please check your internet connection and try again." + else -> "Something went wrong. Please try again." + } + } + + private fun friendlyErrorMessage(t: Throwable?): String { + if (t is CancellationException) throw t + return "Something went wrong. Please try again." + } +} diff --git a/gradle.properties b/gradle.properties index 0208a1ee..dd47eafc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,4 +17,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -org.gradle.unsafe.configuration-cache=false \ No newline at end of file +org.gradle.unsafe.configuration-cache=false +# Kotlin Multiplatform: Disable default hierarchy template since network/storage use explicit dependsOn() calls +kotlin.mpp.applyDefaultHierarchyTemplate=false \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..fd83dd97 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/0b98aec810298c2c1d7fdac5dac37910/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9c55677aff3966382f3d853c0959bfb2/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/23adb857f3cb3cbe28750bc7faa7abc0/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ac151d55def6b6a9a159dc4cb4642851/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c82..d4081da4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/language/build.gradle.kts b/language/build.gradle.kts index 315b8678..ee920589 100644 --- a/language/build.gradle.kts +++ b/language/build.gradle.kts @@ -20,7 +20,7 @@ android { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) } } @@ -39,14 +39,6 @@ android { jvmTarget = JavaVersion.VERSION_17.toString() } - kotlin { - sourceSets.all { - languageSettings { - languageVersion = Versions.kotlinCompiler - } - } - } - lint { abortOnError = false } @@ -75,4 +67,4 @@ dependencies { implementation(Deps.composeMaterial1) implementation(Deps.composeMaterial3) implementation(Deps.navigationCompose) -} \ No newline at end of file +} diff --git a/language/src/androidTest/java/bose/ankush/language/ExampleInstrumentedTest.kt b/language/src/androidTest/java/bose/ankush/language/ExampleInstrumentedTest.kt index dc686b74..f1b0bb15 100644 --- a/language/src/androidTest/java/bose/ankush/language/ExampleInstrumentedTest.kt +++ b/language/src/androidTest/java/bose/ankush/language/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package bose.ankush.language -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -21,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("bose.ankush.language.test", appContext.packageName) } -} \ No newline at end of file +} diff --git a/language/src/main/java/bose/ankush/language/presentation/LanguageScreen.kt b/language/src/main/java/bose/ankush/language/presentation/LanguageScreen.kt index c17c9a3c..455a2bab 100644 --- a/language/src/main/java/bose/ankush/language/presentation/LanguageScreen.kt +++ b/language/src/main/java/bose/ankush/language/presentation/LanguageScreen.kt @@ -82,19 +82,20 @@ fun LanguageScreen( } Box( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { Scaffold( topBar = { ScreenHeader(rememberedNavAction) }, content = { innerPadding -> AnimatedVisibility( visibleState = screenTransitionState, - enter = fadeIn(animationSpec = tween(durationMillis = 400)) + + enter = + fadeIn(animationSpec = tween(durationMillis = 400)) + slideInVertically( animationSpec = tween(durationMillis = 500), - initialOffsetY = { it / 3 } + initialOffsetY = { it / 3 }, ), - exit = fadeOut() + exit = fadeOut(), ) { Column(modifier = Modifier.padding(innerPadding)) { // Header text with animation @@ -104,11 +105,11 @@ fun LanguageScreen( ShowUI( languages = languages, - changedLanguage = changedLanguage + changedLanguage = changedLanguage, ) } } - } + }, ) } } @@ -116,15 +117,16 @@ fun LanguageScreen( @Composable private fun LanguageScreenHeader() { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), ) { Text( text = stringResource(R.string.language_screen_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Spacer(modifier = Modifier.height(4.dp)) @@ -132,7 +134,7 @@ private fun LanguageScreenHeader() { Text( text = stringResource(R.string.language_screen_subtitle), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), ) } } @@ -150,12 +152,13 @@ private fun ScreenHeader(navAction: () -> Unit) { AnimatedVisibility( visibleState = headerTransitionState, - enter = fadeIn(animationSpec = tween(durationMillis = 300)) + + enter = + fadeIn(animationSpec = tween(durationMillis = 300)) + slideInVertically( animationSpec = tween(durationMillis = 300), - initialOffsetY = { -it / 2 } + initialOffsetY = { -it / 2 }, ), - exit = fadeOut() + exit = fadeOut(), ) { TopAppBar( title = { /* Empty title, we'll use our custom title below */ }, @@ -163,55 +166,58 @@ private fun ScreenHeader(navAction: () -> Unit) { Surface( shape = CircleShape, color = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp), - modifier = Modifier - .padding(start = 8.dp) - .size(40.dp) - .clip(CircleShape) - .clickable { navAction.invoke() } + modifier = + Modifier + .padding(start = 8.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { navAction.invoke() }, ) { Icon( painter = painterResource(id = R.drawable.ic_back), tint = MaterialTheme.colorScheme.onSurface, contentDescription = stringResource(R.string.navigate_back), - modifier = Modifier.padding(8.dp) + modifier = Modifier.padding(8.dp), ) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background, - titleContentColor = MaterialTheme.colorScheme.onBackground - ) + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + ), ) } } - @Composable private fun ShowUI( languages: Array, - changedLanguage: androidx.compose.runtime.MutableState + changedLanguage: androidx.compose.runtime.MutableState, ) { val listState = rememberLazyListState() LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - state = listState + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + state = listState, ) { itemsIndexed( items = languages, - key = { _, item -> item } + key = { _, item -> item }, ) { index, language -> LanguageItem( language = language, index = index, isSelected = changedLanguage.value == language, - onLanguageSelected = remember(language) { - { - changedLanguage.value = changeLanguageTo(language) - } - } + onLanguageSelected = + remember(language) { + { + changedLanguage.value = changeLanguageTo(language) + } + }, ) } } @@ -222,17 +228,18 @@ private fun LanguageItem( language: String, index: Int, isSelected: Boolean, - onLanguageSelected: () -> Unit + onLanguageSelected: () -> Unit, ) { // Create animation for selection val scale by animateFloatAsState( targetValue = if (isSelected) 1.02f else 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessHigh, - visibilityThreshold = 0.005f - ), - label = "selection_scale" + animationSpec = + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessHigh, + visibilityThreshold = 0.005f, + ), + label = "selection_scale", ) // Create a staggered animation for items @@ -245,40 +252,47 @@ private fun LanguageItem( AnimatedVisibility( visibleState = itemTransitionState, - enter = fadeIn(animationSpec = tween(durationMillis = 300)) + + enter = + fadeIn(animationSpec = tween(durationMillis = 300)) + slideInVertically( animationSpec = tween(durationMillis = 400), - initialOffsetY = { it / 3 } + initialOffsetY = { it / 3 }, ), - exit = fadeOut() + exit = fadeOut(), ) { Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .clickable(onClick = onLanguageSelected) - .scale(scale), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clickable(onClick = onLanguageSelected) + .scale(scale), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = if (isSelected) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) - ), - elevation = CardDefaults.cardElevation( - defaultElevation = 0.dp - ) + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) + }, + ), + elevation = + CardDefaults.cardElevation( + defaultElevation = 0.dp, + ), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { LanguageFlag(language) @@ -289,10 +303,12 @@ private fun LanguageItem( text = language.getDisplayName(), style = MaterialTheme.typography.bodyLarge, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - color = if (isSelected) - MaterialTheme.colorScheme.onPrimaryContainer - else - MaterialTheme.colorScheme.onSurface + color = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, ) } @@ -310,18 +326,20 @@ private fun LanguageFlag(language: String) { Surface( shape = CircleShape, color = MaterialTheme.colorScheme.background, - modifier = Modifier.size(40.dp) + modifier = Modifier.size(40.dp), ) { Text( text = language.getCountryFlag(), fontFamily = FontFamily.Default, - style = TextStyle( - platformStyle = PlatformTextStyle( - emojiSupportMatch = EmojiSupportMatch.None - ) - ), + style = + TextStyle( + platformStyle = + PlatformTextStyle( + emojiSupportMatch = EmojiSupportMatch.None, + ), + ), textAlign = TextAlign.Center, - modifier = Modifier.padding(8.dp) + modifier = Modifier.padding(8.dp), ) } } @@ -331,13 +349,13 @@ private fun SelectionCheckmark(language: String) { Surface( shape = CircleShape, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), ) { Icon( imageVector = Icons.Filled.Check, tint = MaterialTheme.colorScheme.onPrimary, contentDescription = stringResource(R.string.language_selected, language), - modifier = Modifier.padding(6.dp) + modifier = Modifier.padding(6.dp), ) } } diff --git a/language/src/main/java/bose/ankush/language/util/LocaleHelper.kt b/language/src/main/java/bose/ankush/language/util/LocaleHelper.kt index 56bf04cc..689e9383 100644 --- a/language/src/main/java/bose/ankush/language/util/LocaleHelper.kt +++ b/language/src/main/java/bose/ankush/language/util/LocaleHelper.kt @@ -5,7 +5,6 @@ import androidx.core.os.LocaleListCompat import java.util.Locale internal object LocaleHelper { - fun String.getCountryFlag(): String { val countryCode = this.split("-").lastOrNull()?.uppercase(Locale.getDefault()) ?: return "" @@ -19,16 +18,22 @@ internal object LocaleHelper { } fun String.getDisplayName(): String { - val languageCode = this.split("-").firstOrNull() ?: this - val locale = Locale(languageCode) + val locale = + if (this.isBlank()) { + Locale.getDefault() + } else { + Locale.forLanguageTag(this) + } return locale.getDisplayName(locale) } fun changeLanguageTo(languageCode: String): String { AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(languageCode)) - return AppCompatDelegate.getApplicationLocales().toLanguageTags() + return AppCompatDelegate + .getApplicationLocales() + .toLanguageTags() .ifEmpty { getDefaultLanguage() } } fun getDefaultLanguage(): String = Locale.getDefault().toLanguageTag() -} \ No newline at end of file +} diff --git a/language/src/main/res/values-bn/strings.xml b/language/src/main/res/values-bn/strings.xml new file mode 100644 index 00000000..ff6a5d2d --- /dev/null +++ b/language/src/main/res/values-bn/strings.xml @@ -0,0 +1,9 @@ + + + เฆญเฆพเฆทเฆพ เฆฌเง‡เฆ›เง‡ เฆจเฆฟเฆจ + เฆซเฆฟเฆฐเง‡ เฆฏเฆพเฆจ + เฆ†เฆชเฆจเฆพเฆฐ เฆญเฆพเฆทเฆพ เฆฌเง‡เฆ›เง‡ เฆจเฆฟเฆจ + เฆฌเงเฆฏเฆ•เงเฆคเฆฟเฆ—เฆคเฆ•เงƒเฆค เฆ…เฆญเฆฟเฆœเงเฆžเฆคเฆพเฆฐ เฆœเฆจเงเฆฏ เฆ†เฆชเฆจเฆพเฆฐ เฆชเฆ›เฆจเงเฆฆเง‡เฆฐ เฆญเฆพเฆทเฆพ เฆฌเง‡เฆ›เง‡ เฆจเฆฟเฆจ + %s เฆจเฆฟเฆฐเงเฆฌเฆพเฆšเฆฟเฆค + เฆชเฆฟเฆ›เฆจเง‡ เฆจเง‡เฆญเฆฟเฆ—เง‡เฆŸ เฆ•เฆฐเงเฆจ + diff --git a/language/src/main/res/values-iw/strings.xml b/language/src/main/res/values-iw/strings.xml index 7c9783d9..246a67bc 100644 --- a/language/src/main/res/values-iw/strings.xml +++ b/language/src/main/res/values-iw/strings.xml @@ -2,4 +2,8 @@ ื‘ื—ืจ ืฉืคื” ืชื—ื–ื•ืจ + ื‘ื—ืจ ืืช ื”ืฉืคื” ืฉืœืš + ื‘ื—ืจ ืืช ื”ืฉืคื” ื”ืžื•ืขื“ืคืช ืขืœื™ืš ืœื—ื•ื•ื™ื” ืžื•ืชืืžืช ืื™ืฉื™ืช + %s ื ื‘ื—ืจ + ื ื•ื•ื˜ ืœืื—ื•ืจ \ No newline at end of file diff --git a/language/src/main/res/values-kn/strings.xml b/language/src/main/res/values-kn/strings.xml new file mode 100644 index 00000000..21579362 --- /dev/null +++ b/language/src/main/res/values-kn/strings.xml @@ -0,0 +1,9 @@ + + + เฒญเฒพเฒทเณ† เฒ†เฒฏเณเฒ•เณ†เฒฎเฒพเฒกเฒฟ + เฒนเฒฟเฒ‚เฒฆเณ† เฒนเณ‹เฒ—เฒฟ + เฒจเฒฟเฒฎเณเฒฎ เฒญเฒพเฒทเณ† เฒ†เฒฏเณเฒ•เณ†เฒฎเฒพเฒกเฒฟ + เฒตเณˆเฒฏเฒ•เณเฒคเฒฟเฒ• เฒ…เฒจเณเฒญเฒตเฒ•เณเฒ•เฒพเฒ—เฒฟ เฒจเฒฟเฒฎเณเฒฎ เฒ†เฒฆเณเฒฏเฒคเณ†เฒฏ เฒญเฒพเฒทเณ† เฒ†เฒฏเณเฒ•เณ†เฒฎเฒพเฒกเฒฟ + %s เฒ†เฒฏเณเฒ•เณ† เฒฎเฒพเฒกเฒฒเฒพเฒ—เฒฟเฒฆเณ† + เฒนเฒฟเฒ‚เฒฆเณ† เฒจเณเฒฏเฒพเฒตเฒฟเฒ—เณ‡เฒŸเณ เฒฎเฒพเฒกเฒฟ + diff --git a/language/src/main/res/values-ml/strings.xml b/language/src/main/res/values-ml/strings.xml new file mode 100644 index 00000000..a7243a32 --- /dev/null +++ b/language/src/main/res/values-ml/strings.xml @@ -0,0 +1,9 @@ + + + เดญเดพเดท เดคเดฟเดฐเดžเตเดžเต†เดŸเตเด•เตเด•เตเด• + เดคเดฟเดฐเดฟเด•เต† เดชเต‹เด•เตเด• + เดจเดฟเด™เตเด™เดณเตเดŸเต† เดญเดพเดท เดคเดฟเดฐเดžเตเดžเต†เดŸเตเด•เตเด•เตเด• + เดตเตเดฏเด•เตเดคเดฟเด—เดค เด…เดจเตเดญเดตเดคเตเดคเดฟเดจเดพเดฏเดฟ เดจเดฟเด™เตเด™เตพ เด‡เดทเตเดŸเดชเตเดชเต†เดŸเตเดจเตเดจ เดญเดพเดท เดคเดฟเดฐเดžเตเดžเต†เดŸเตเด•เตเด•เตเด• + %s เดคเดฟเดฐเดžเตเดžเต†เดŸเตเดคเตเดคเต + เดชเดฟเดฑเด•เต‹เดŸเตเดŸเต เดจเดพเดตเดฟเด—เต‡เดฑเตเดฑเต เดšเต†เดฏเตเดฏเตเด• + diff --git a/language/src/main/res/values-ta/strings.xml b/language/src/main/res/values-ta/strings.xml new file mode 100644 index 00000000..7d8a23b1 --- /dev/null +++ b/language/src/main/res/values-ta/strings.xml @@ -0,0 +1,9 @@ + + + เฎฎเฏŠเฎดเฎฟเฎฏเฏˆ เฎคเฏ‡เฎฐเฏเฎจเฏเฎคเฏ†เฎŸเฏเฎ•เฏเฎ•เฎตเฏเฎฎเฏ + เฎคเฎฟเฎฐเฏเฎฎเฏเฎชเฎฟ เฎšเฏ†เฎฒเฏเฎฒเฎตเฏเฎฎเฏ + เฎ‰เฎ™เฏเฎ•เฎณเฏ เฎฎเฏŠเฎดเฎฟเฎฏเฏˆ เฎคเฏ‡เฎฐเฏเฎจเฏเฎคเฏ†เฎŸเฏเฎ•เฏเฎ•เฎตเฏเฎฎเฏ + เฎคเฎฉเฎฟเฎชเฏเฎชเฎฏเฎฉเฎพเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฏเฎŸ เฎ…เฎฉเฏเฎชเฎตเฎคเฏเฎคเฎฟเฎฑเฏเฎ•เฏ เฎ‰เฎ™เฏเฎ•เฎณเฏเฎ•เฏเฎ•เฏ เฎตเฎฟเฎฐเฏเฎชเฏเฎชเฎฎเฎพเฎฉ เฎฎเฏŠเฎดเฎฟเฎฏเฏˆ เฎคเฏ‡เฎฐเฏเฎจเฏเฎคเฏ†เฎŸเฏเฎ•เฏเฎ•เฎตเฏเฎฎเฏ + %s เฎคเฏ‡เฎฐเฏเฎจเฏเฎคเฏ†เฎŸเฏเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฏเฎŸเฎคเฏ + เฎชเฎฟเฎฉเฏเฎฉเฎพเฎฒเฏ เฎšเฏ†เฎฒเฏเฎฒเฎตเฏเฎฎเฏ + diff --git a/language/src/main/res/values-te/strings.xml b/language/src/main/res/values-te/strings.xml new file mode 100644 index 00000000..7fd89b31 --- /dev/null +++ b/language/src/main/res/values-te/strings.xml @@ -0,0 +1,9 @@ + + + เฐญเฐพเฐท เฐŽเฐ‚เฐšเฑเฐ•เฑ‹เฐ‚เฐกเฐฟ + เฐตเฑ†เฐจเฑเฐ•เฐ•เฑ เฐตเฑ†เฐณเฑเฐณเฑ + เฐฎเฑ€ เฐญเฐพเฐท เฐŽเฐ‚เฐšเฑเฐ•เฑ‹เฐ‚เฐกเฐฟ + เฐตเฑเฐฏเฐ•เฑเฐคเฐฟเฐ—เฐค เฐ…เฐจเฑเฐญเฐตเฐ‚ เฐ•เฑ‹เฐธเฐ‚ เฐฎเฑ€เฐ•เฑ เฐ‡เฐทเฑเฐŸเฐฎเฑˆเฐจ เฐญเฐพเฐทเฐจเฑ เฐŽเฐ‚เฐšเฑเฐ•เฑ‹เฐ‚เฐกเฐฟ + %s เฐŽเฐ‚เฐšเฑเฐ•เฑ‹เฐฌเฐกเฐฟเฐ‚เฐฆเฐฟ + เฐตเฑ†เฐจเฑเฐ•เฐ•เฑ เฐจเฐพเฐตเฐฟเฐ—เฑ‡เฐŸเฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ + diff --git a/language/src/test/java/bose/ankush/language/ExampleUnitTest.kt b/language/src/test/java/bose/ankush/language/ExampleUnitTest.kt index 10f7f92f..365e820f 100644 --- a/language/src/test/java/bose/ankush/language/ExampleUnitTest.kt +++ b/language/src/test/java/bose/ankush/language/ExampleUnitTest.kt @@ -1,9 +1,8 @@ package bose.ankush.language +import org.junit.Assert.assertEquals import org.junit.Test -import org.junit.Assert.* - /** * Example local unit test, which will execute on the development machine (host). * @@ -14,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/local.properties b/local.properties index 8136714b..7da022eb 100644 --- a/local.properties +++ b/local.properties @@ -4,6 +4,6 @@ # Location of the SDK. This is only used by Gradle. # For customization when using a Version Control System, please read the # header note. -#Mon Aug 19 11:29:33 IST 2024 +#Thu Apr 16 01:21:59 IST 2026 sdk.dir=/Users/t0304iw/Library/Android/sdk -OPEN_WEATHER_API=eb1842dacd16299875b9b1eb9299108d \ No newline at end of file +RAZORPAY_KEY=rzp_test_SfEkbjOeXT0ilF \ No newline at end of file diff --git a/network/build.gradle.kts b/network/build.gradle.kts index 9b5a4943..c7b8db14 100644 --- a/network/build.gradle.kts +++ b/network/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { kotlin("multiplatform") id("com.android.library") @@ -6,17 +8,15 @@ plugins { kotlin { androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) } } listOf( iosX64(), iosArm64(), - iosSimulatorArm64() + iosSimulatorArm64(), ).forEach { it.binaries.framework { baseName = "network" @@ -26,6 +26,7 @@ kotlin { sourceSets { val commonMain by getting { dependencies { + implementation(project(":storage")) implementation(KmmDeps.ktorCore) implementation(KmmDeps.ktorSerialization) implementation(KmmDeps.ktorContentNegotiation) @@ -42,15 +43,21 @@ kotlin { implementation(kotlin("test")) } } + + @Suppress("UNUSED_VARIABLE") val androidMain by getting { dependencies { implementation(KmmDeps.ktorAndroid) } } + + @Suppress("UNUSED_VARIABLE") val androidUnitTest by getting val iosX64Main by getting val iosArm64Main by getting val iosSimulatorArm64Main by getting + + @Suppress("UNUSED_VARIABLE") val iosMain by creating { dependsOn(commonMain) iosX64Main.dependsOn(this) @@ -63,6 +70,8 @@ kotlin { val iosX64Test by getting val iosArm64Test by getting val iosSimulatorArm64Test by getting + + @Suppress("UNUSED_VARIABLE") val iosTest by creating { dependsOn(commonTest) iosX64Test.dependsOn(this) diff --git a/network/src/androidMain/kotlin/bose/ankush/network/common/AndroidNetworkConnectivity.kt b/network/src/androidMain/kotlin/bose/ankush/network/common/AndroidNetworkConnectivity.kt index 75a72f8c..525e26b9 100644 --- a/network/src/androidMain/kotlin/bose/ankush/network/common/AndroidNetworkConnectivity.kt +++ b/network/src/androidMain/kotlin/bose/ankush/network/common/AndroidNetworkConnectivity.kt @@ -9,14 +9,13 @@ import android.net.NetworkCapabilities * */ class AndroidNetworkConnectivity( - private val context: Context + private val context: Context, ) : NetworkConnectivity { - override fun isNetworkAvailable(): Boolean { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val network = connectivityManager.activeNetwork ?: return false val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false - + return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) || diff --git a/network/src/androidMain/kotlin/bose/ankush/network/di/AndroidHttpClient.kt b/network/src/androidMain/kotlin/bose/ankush/network/di/AndroidHttpClient.kt index 5b7a8187..74adaf46 100644 --- a/network/src/androidMain/kotlin/bose/ankush/network/di/AndroidHttpClient.kt +++ b/network/src/androidMain/kotlin/bose/ankush/network/di/AndroidHttpClient.kt @@ -1,36 +1,36 @@ package bose.ankush.network.di +import android.util.Log import io.ktor.client.HttpClient import io.ktor.client.engine.android.Android import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 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.ContentType import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json /** * Android implementation of createPlatformHttpClient */ -actual fun createPlatformHttpClient(json: Json): HttpClient { - return HttpClient(Android) { +actual fun createPlatformHttpClient(json: Json): HttpClient = + HttpClient(Android) { engine { connectTimeout = 60_000 socketTimeout = 60_000 } install(ContentNegotiation) { + // Register standard JSON handling once; other content types should be handled + // explicitly per request if needed. json(json) - // Register for mixed content type (application/json, text/html) - json(json, contentType = ContentType.parse("application/json, text/html; charset=UTF-8")) } install(Logging) { - logger = object : Logger { - override fun log(message: String) { - println("Ktor Android: $message") + logger = + object : Logger { + override fun log(message: String) { + Log.d("Ktor Android:", message) + } } - } level = LogLevel.INFO } } -} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/FeedbackApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/FeedbackApiService.kt new file mode 100644 index 00000000..241dc1b5 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/FeedbackApiService.kt @@ -0,0 +1,14 @@ +package bose.ankush.network.api + +import bose.ankush.network.model.FeedbackRequest +import bose.ankush.network.model.FeedbackResponse + +/** + * API service interface for feedback operations + */ +interface FeedbackApiService { + /** + * Submit user feedback + */ + suspend fun submitFeedback(request: FeedbackRequest): FeedbackResponse +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorFeedbackApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorFeedbackApiService.kt new file mode 100644 index 00000000..77248b9a --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorFeedbackApiService.kt @@ -0,0 +1,28 @@ +package bose.ankush.network.api + +import bose.ankush.network.model.FeedbackRequest +import bose.ankush.network.model.FeedbackResponse +import bose.ankush.network.utils.NetworkUtils +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType + +/** + * Ktor implementation of FeedbackApiService + */ +class KtorFeedbackApiService( + private val httpClient: HttpClient, + private val baseUrl: String, +) : FeedbackApiService { + override suspend fun submitFeedback(request: FeedbackRequest): FeedbackResponse = + NetworkUtils.retryWithExponentialBackoff { + httpClient + .post("$baseUrl/feedback") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() + } +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorLocationApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorLocationApiService.kt new file mode 100644 index 00000000..a2cb6cd5 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorLocationApiService.kt @@ -0,0 +1,42 @@ +package bose.ankush.network.api + +import bose.ankush.network.model.ApiResponse +import bose.ankush.network.model.PlaceSuggestion +import bose.ankush.network.model.SaveLocationRequest +import bose.ankush.network.model.SavedLocation +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType + +/** + * Ktor implementation of LocationApiService. + */ +class KtorLocationApiService( + private val httpClient: HttpClient, + private val baseUrl: String, +) : LocationApiService { + override suspend fun saveLocation(request: SaveLocationRequest): ApiResponse = + httpClient + .post("$baseUrl/save-location") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() + + override suspend fun getSavedLocations(): ApiResponse> = + httpClient.get("$baseUrl/saved-places").body() + + override suspend fun deleteLocation(id: String): ApiResponse = + httpClient.delete("$baseUrl/saved-places/$id").body() + + override suspend fun searchPlaces(query: String): ApiResponse> = + httpClient + .get("$baseUrl/search-place") { + parameter("q", query) + }.body() +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorPaymentApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorPaymentApiService.kt new file mode 100644 index 00000000..9c35690c --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorPaymentApiService.kt @@ -0,0 +1,39 @@ +package bose.ankush.network.api + +import bose.ankush.network.model.CreateOrderRequest +import bose.ankush.network.model.CreateOrderResponse +import bose.ankush.network.model.VerifyPaymentRequest +import bose.ankush.network.model.VerifyPaymentResponse +import bose.ankush.network.utils.NetworkUtils +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType + +/** + * Ktor implementation of PaymentApiService + */ +class KtorPaymentApiService( + private val httpClient: HttpClient, + private val baseUrl: String, +) : PaymentApiService { + override suspend fun createOrder(request: CreateOrderRequest): CreateOrderResponse = + NetworkUtils.retryWithExponentialBackoff { + httpClient + .post("$baseUrl/create-order") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() + } + + override suspend fun verifyPayment(request: VerifyPaymentRequest): VerifyPaymentResponse = + NetworkUtils.retryWithExponentialBackoff { + httpClient + .post("$baseUrl/store-payment") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() + } +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorServiceApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorServiceApiService.kt new file mode 100644 index 00000000..d30ac2a4 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorServiceApiService.kt @@ -0,0 +1,37 @@ +package bose.ankush.network.api + +import bose.ankush.network.model.ServiceListResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.parameter + +class KtorServiceApiService( + private val httpClient: HttpClient, + private val baseUrl: String, +) : ServiceApiService { + override suspend fun getServices( + page: Int, + pageSize: Int, + search: String?, + ): Result = + try { + val response = + httpClient + .get("$baseUrl/services/public") { + parameter("page", page) + parameter("pageSize", pageSize) + if (!search.isNullOrBlank()) { + parameter("search", search) + } + }.body() + + if (response.success) { + Result.success(response) + } else { + Result.failure(Exception(response.message)) + } + } catch (e: Exception) { + Result.failure(e) + } +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorWeatherApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorWeatherApiService.kt index 806a3289..4c8f5a51 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorWeatherApiService.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorWeatherApiService.kt @@ -1,6 +1,5 @@ package bose.ankush.network.api -import bose.ankush.network.model.AirQuality import bose.ankush.network.model.WeatherForecast import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -12,26 +11,15 @@ import io.ktor.client.request.parameter */ class KtorWeatherApiService( private val httpClient: HttpClient, - private val baseUrl: String + private val baseUrl: String, ) : WeatherApiService { - - override suspend fun getCurrentAirQuality( - latitude: String, - longitude: String - ): AirQuality { - return httpClient.get("$baseUrl/get-air-pollution") { - parameter("lat", latitude) - parameter("lon", longitude) - }.body() - } - override suspend fun getOneCallWeather( latitude: String, - longitude: String - ): WeatherForecast { - return httpClient.get("$baseUrl/get-weather") { - parameter("lat", latitude) - parameter("lon", longitude) - }.body() - } -} \ No newline at end of file + longitude: String, + ): WeatherForecast = + httpClient + .get("$baseUrl/weather") { + parameter("lat", latitude) + parameter("lon", longitude) + }.body() +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/LocationApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/LocationApiService.kt new file mode 100644 index 00000000..e35c4c81 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/LocationApiService.kt @@ -0,0 +1,23 @@ +package bose.ankush.network.api + +import bose.ankush.network.model.ApiResponse +import bose.ankush.network.model.PlaceSuggestion +import bose.ankush.network.model.SaveLocationRequest +import bose.ankush.network.model.SavedLocation + +/** + * API service for saved locations. All endpoints require a valid JWT (handled by auth interceptor). + */ +interface LocationApiService { + /** POST /save-location โ€” save a favourite location. */ + suspend fun saveLocation(request: SaveLocationRequest): ApiResponse + + /** GET /saved-places โ€” retrieve all saved locations for the current user. */ + suspend fun getSavedLocations(): ApiResponse> + + /** DELETE /saved-places/{id} โ€” remove a saved location by its id. */ + suspend fun deleteLocation(id: String): ApiResponse + + /** POST /api/v1/places/search โ€” search for place suggestions by query string. */ + suspend fun searchPlaces(query: String): ApiResponse> +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/PaymentApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/PaymentApiService.kt new file mode 100644 index 00000000..a4e30ba7 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/PaymentApiService.kt @@ -0,0 +1,21 @@ +package bose.ankush.network.api + +import bose.ankush.network.model.CreateOrderRequest +import bose.ankush.network.model.CreateOrderResponse +import bose.ankush.network.model.VerifyPaymentRequest +import bose.ankush.network.model.VerifyPaymentResponse + +/** + * API service interface for payment operations + */ +interface PaymentApiService { + /** + * Create an order on backend which in turn calls Razorpay Orders API + */ + suspend fun createOrder(request: CreateOrderRequest): CreateOrderResponse + + /** + * Verify payment signature on backend + */ + suspend fun verifyPayment(request: VerifyPaymentRequest): VerifyPaymentResponse +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/ServiceApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/ServiceApiService.kt new file mode 100644 index 00000000..50b73383 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/ServiceApiService.kt @@ -0,0 +1,11 @@ +package bose.ankush.network.api + +import bose.ankush.network.model.ServiceListResponse + +interface ServiceApiService { + suspend fun getServices( + page: Int = 1, + pageSize: Int = 20, + search: String? = null, + ): Result +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/WeatherApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/WeatherApiService.kt index 0df7a8af..0fa04887 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/api/WeatherApiService.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/WeatherApiService.kt @@ -1,6 +1,5 @@ package bose.ankush.network.api -import bose.ankush.network.model.AirQuality import bose.ankush.network.model.WeatherForecast /** @@ -8,24 +7,14 @@ import bose.ankush.network.model.WeatherForecast */ interface WeatherApiService { /** - * Get current air quality for a location + * Get unified weather data for a location (current, hourly, daily, alerts, air quality). + * Air quality and premium-only fields are null for free tier users. * @param latitude Latitude of the location * @param longitude Longitude of the location - * @return AirQuality - */ - suspend fun getCurrentAirQuality( - latitude: String, - longitude: String - ): AirQuality - - /** - * Get weather forecast for a location - * @param latitude Latitude of the location - * @param longitude Longitude of the location - * @return WeatherForecast + * @return WeatherForecast containing all available data for the user's subscription tier */ suspend fun getOneCallWeather( latitude: String, - longitude: String + longitude: String, ): WeatherForecast -} \ No newline at end of file +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/api/AuthApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/api/AuthApiService.kt new file mode 100644 index 00000000..7def4270 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/api/AuthApiService.kt @@ -0,0 +1,39 @@ +package bose.ankush.network.auth.api + +import bose.ankush.network.auth.model.AuthResponse +import bose.ankush.network.auth.model.LoginRequest +import bose.ankush.network.auth.model.LogoutResponse +import bose.ankush.network.auth.model.RefreshTokenRequest +import bose.ankush.network.auth.model.RegisterRequest + +/** + * API service interface for authentication operations + */ +interface AuthApiService { + /** + * Login with email and password + * @param request LoginRequest containing email and password + * @return AuthResponse with JWT token + */ + suspend fun login(request: LoginRequest): AuthResponse + + /** + * Register with email and password + * @param request RegisterRequest containing email and password + * @return AuthResponse with JWT token + */ + suspend fun register(request: RegisterRequest): AuthResponse + + /** + * Refresh JWT token + * @param request RefreshTokenRequest containing the expired token + * @return AuthResponse with new JWT token + */ + suspend fun refreshToken(request: RefreshTokenRequest): AuthResponse + + /** + * Logout the current user + * @return LogoutResponse indicating success or failure + */ + suspend fun logout(): LogoutResponse +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/api/KtorAuthApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/api/KtorAuthApiService.kt new file mode 100644 index 00000000..0925cd86 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/api/KtorAuthApiService.kt @@ -0,0 +1,54 @@ +package bose.ankush.network.auth.api + +import bose.ankush.network.auth.model.AuthResponse +import bose.ankush.network.auth.model.LoginRequest +import bose.ankush.network.auth.model.LogoutResponse +import bose.ankush.network.auth.model.RefreshTokenRequest +import bose.ankush.network.auth.model.RegisterRequest +import bose.ankush.network.utils.NetworkUtils +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType + +/** + * Ktor implementation of AuthApiService + */ +class KtorAuthApiService( + private val httpClient: HttpClient, + private val baseUrl: String, +) : AuthApiService { + override suspend fun login(request: LoginRequest): AuthResponse = + NetworkUtils.retryWithExponentialBackoff { + httpClient + .post("$baseUrl/login") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() + } + + override suspend fun register(request: RegisterRequest): AuthResponse = + NetworkUtils.retryWithExponentialBackoff { + httpClient + .post("$baseUrl/register") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() + } + + override suspend fun refreshToken(request: RefreshTokenRequest): AuthResponse = + NetworkUtils.retryWithExponentialBackoff { + httpClient + .post("$baseUrl/refresh-token") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() + } + + override suspend fun logout(): LogoutResponse = + NetworkUtils.retryWithExponentialBackoff { + httpClient.post("$baseUrl/logout").body() + } +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/events/AuthEvents.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/events/AuthEvents.kt new file mode 100644 index 00000000..7f348645 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/events/AuthEvents.kt @@ -0,0 +1,31 @@ +package bose.ankush.network.auth.events + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +/** + * Global authentication-related events emitted from the network layer. + * The app layer can observe these to react (e.g., navigate to Login on 401). + */ +sealed class AuthEvent { + data class Unauthorized( + val message: String, + ) : AuthEvent() +} + +object AuthEventBus { + private val _events = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val events: SharedFlow = _events + + /** Suspends only if buffer == capacity after dropping oldest. */ + suspend fun emit(event: AuthEvent) = _events.emit(event) + + /** Never suspends; drops oldest if full. */ + fun tryEmit(event: AuthEvent): Boolean = _events.tryEmit(event) +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/interceptor/AuthInterceptor.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/interceptor/AuthInterceptor.kt new file mode 100644 index 00000000..f1f0571a --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/interceptor/AuthInterceptor.kt @@ -0,0 +1,88 @@ +@file:Suppress("ktlint:standard:max-line-length") + +package bose.ankush.network.auth.interceptor + +import bose.ankush.network.auth.events.AuthEvent +import bose.ankush.network.auth.events.AuthEventBus +import bose.ankush.network.auth.token.TokenManager +import bose.ankush.network.auth.token.TokenResult +import bose.ankush.storage.api.TokenStorage +import io.ktor.client.HttpClientConfig +import io.ktor.client.plugins.api.Send +import io.ktor.client.plugins.api.createClientPlugin +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.runBlocking + +/** + * Helper function to configure a HttpClient with authentication + * + * @param tokenManager The manager for JWT tokens + * @return A configured HttpClient with authentication headers and token refresh + */ +fun HttpClientConfig<*>.configureAuth(tokenManager: TokenManager) { + install( + createClientPlugin("AuthTokenPlugin") { + on(Send) { request -> + // Attach stored token before sending โ€” no proactive refresh here + tokenManager.getStoredToken()?.takeIf { it.isNotBlank() }?.let { token -> + request.headers.append(HttpHeaders.Authorization, "Bearer $token") + } + + val originalCall = proceed(request) + + // On 401, refresh token and retry once + if (originalCall.response.status == HttpStatusCode.Unauthorized) { + when (val refreshResult = tokenManager.handleUnauthorized()) { + is TokenResult.Valid -> { + request.headers.remove(HttpHeaders.Authorization) + request.headers.append( + HttpHeaders.Authorization, + "Bearer ${refreshResult.token}", + ) + proceed(request) + } + + is TokenResult.Error -> { + val event = + AuthEvent.Unauthorized( + message = "Network error during re-authentication: ${refreshResult.exception.message}", + ) + AuthEventBus.tryEmit(event) + originalCall + } + + is TokenResult.InvalidToken, is TokenResult.NoToken -> { + tokenManager.forceLogout() + val event = + AuthEvent.Unauthorized( + message = "For security, please log in again to continue using the app.", + ) + AuthEventBus.tryEmit(event) + originalCall + } + } + } else { + originalCall + } + } + }, + ) +} + +/** + * Legacy helper function to maintain backward compatibility + * @param tokenStorage The storage for authentication tokens + */ +fun HttpClientConfig<*>.configureAuth(tokenStorage: TokenStorage) { + defaultRequest { + runBlocking { + val token = tokenStorage.getToken() + if (!token.isNullOrBlank()) { + header("Authorization", "Bearer $token") + } + } + } +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/model/AuthModels.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/model/AuthModels.kt new file mode 100644 index 00000000..42a6d8a1 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/model/AuthModels.kt @@ -0,0 +1,78 @@ +package bose.ankush.network.auth.model + +import kotlinx.serialization.Serializable + +/** + * Request model for login operation + */ +@Serializable +data class LoginRequest( + val email: String, + val password: String, +) + +/** + * Request model for register operation + */ +@Serializable +data class RegisterRequest( + val email: String, + val password: String, + val timestampOfRegistration: String? = null, + val deviceModel: String? = null, + val operatingSystem: String? = null, + val osVersion: String? = null, + val appVersion: String? = null, + val registrationSource: String? = null, + val firebaseToken: String? = null, +) + +/** + * Request model for token refresh operation + */ +@Serializable +data class RefreshTokenRequest( + val token: String, +) + +/** + * Data class for authentication response data. + * Defaults allow this to be used for both success and error shapes + * (e.g. TOKEN_NOT_EXPIRED only has errorCode, no token/email). + */ +@Serializable +data class AuthData( + val token: String = "", + val email: String = "", + val isActive: Boolean = false, + val isPremium: Boolean = false, + val premiumExpiresAt: String? = null, + val errorCode: String? = null, +) + +/** + * Response model for authentication operations + */ +@Serializable +data class AuthResponse( + val success: Boolean? = null, + val status: Boolean = false, + val message: String? = null, + val data: AuthData? = null, +) { + fun isSuccess(): Boolean = success ?: status +} + +@Serializable +data class LogoutErrorData( + val errorType: String? = null, + val errorMessage: String? = null, + val errorClass: String? = null, + val endpoint: String? = null, +) + +@Serializable +data class LogoutResponse( + val message: String? = null, + val data: LogoutErrorData? = null, +) diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepository.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepository.kt new file mode 100644 index 00000000..25a2ecd0 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepository.kt @@ -0,0 +1,67 @@ +package bose.ankush.network.auth.repository + +import bose.ankush.network.auth.model.AuthResponse +import kotlinx.coroutines.flow.Flow + +/** + * Repository interface for authentication operations + */ +interface AuthRepository { + /** + * Login with email and password + * @param email User's email + * @param password User's password + * @return Flow of AuthResponse + */ + suspend fun login( + email: String, + password: String, + ): AuthResponse + + /** + * Register with email and password and additional device information + * @param email User's email + * @param password User's password + * @param timestampOfRegistration UTC timestamp of registration + * @param deviceModel Device model (e.g., "Pixel 7 Pro") + * @param operatingSystem Operating system (e.g., "Android") + * @param osVersion Operating system version (e.g., "14") + * @param appVersion App version + * @param registrationSource Registration source (e.g., "Android App") + * @return AuthResponse + */ + suspend fun register( + email: String, + password: String, + timestampOfRegistration: String? = null, + deviceModel: String? = null, + operatingSystem: String? = null, + osVersion: String? = null, + appVersion: String? = null, + registrationSource: String? = null, + firebaseToken: String? = null, + ): AuthResponse + + /** + * Check if user is logged in + * @return Flow of Boolean indicating login status + */ + fun isLoggedIn(): Flow + + /** + * Get the current JWT token + * @return The JWT token or null if not logged in + */ + suspend fun getToken(): String? + + /** + * Refresh the JWT token + * @return AuthResponse with new token + */ + suspend fun refreshToken(): AuthResponse? + + /** + * Logout the user + */ + suspend fun logout(): Result +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepositoryImpl.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepositoryImpl.kt new file mode 100644 index 00000000..b74dd10c --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepositoryImpl.kt @@ -0,0 +1,107 @@ +package bose.ankush.network.auth.repository + +import bose.ankush.network.auth.api.AuthApiService +import bose.ankush.network.auth.model.AuthResponse +import bose.ankush.network.auth.model.LoginRequest +import bose.ankush.network.auth.model.RefreshTokenRequest +import bose.ankush.network.auth.model.RegisterRequest +import bose.ankush.storage.api.TokenStorage +import kotlinx.coroutines.flow.Flow + +/** + * Implementation of AuthRepository + */ +class AuthRepositoryImpl( + private val apiService: AuthApiService, + private val tokenStorage: TokenStorage, +) : AuthRepository { + override suspend fun login( + email: String, + password: String, + ): AuthResponse { + val request = LoginRequest(email = email, password = password) + val response = apiService.login(request) + + // Save token on successful login + val token = response.data?.token + if (response.isSuccess() && !token.isNullOrBlank()) { + tokenStorage.saveToken(token) + } + + return response + } + + override suspend fun register( + email: String, + password: String, + timestampOfRegistration: String?, + deviceModel: String?, + operatingSystem: String?, + osVersion: String?, + appVersion: String?, + registrationSource: String?, + firebaseToken: String?, + ): AuthResponse { + val request = + RegisterRequest( + email = email, + password = password, + timestampOfRegistration = timestampOfRegistration, + deviceModel = deviceModel, + operatingSystem = operatingSystem, + osVersion = osVersion, + appVersion = appVersion, + registrationSource = registrationSource, + firebaseToken = firebaseToken, + ) + val response = apiService.register(request) + + // Save token on successful registration + val token = response.data?.token + if (response.isSuccess() && !token.isNullOrBlank()) { + tokenStorage.saveToken(token) + } + + return response + } + + override fun isLoggedIn(): Flow = tokenStorage.hasToken() + + override suspend fun getToken(): String? = tokenStorage.getToken() + + override suspend fun refreshToken(): AuthResponse? { + val currentToken = tokenStorage.getToken() ?: return null + + val request = RefreshTokenRequest(token = currentToken) + val response = apiService.refreshToken(request) + + // Save new token on successful refresh + val token = response.data?.token + if (response.isSuccess() && !token.isNullOrBlank()) { + tokenStorage.saveToken(token) + } + + return response + } + + override suspend fun logout(): Result = + try { + val response = apiService.logout() + val isSuccess = response.data == null + if (isSuccess) { + tokenStorage.clearToken() + Result.success(Unit) + } else { + val errorMsg = response.data.errorMessage + val message = + if (!errorMsg.isNullOrBlank()) { + errorMsg + } else { + response.message ?: "Logout failed" + } + Result.failure(Exception(message)) + } + } catch (e: Exception) { + Result.failure(e) + } +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenManager.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenManager.kt new file mode 100644 index 00000000..2443da78 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenManager.kt @@ -0,0 +1,71 @@ +package bose.ankush.network.auth.token + +import bose.ankush.network.auth.repository.AuthRepository +import bose.ankush.storage.api.TokenStorage +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock + +/** + * Handles JWT token lifecycle including validation and refresh. + */ +class TokenManager( + private val tokenStorage: TokenStorage, + private val authRepository: AuthRepository, +) { + private val refreshMutex = Mutex() + private var lastRefreshTime: Long = 0 + + /** + * Refreshes the token if possible. + */ + suspend fun refreshToken(currentTime: Long = Clock.System.now().epochSeconds): TokenResult = + refreshMutex.withLock { + lastRefreshTime = currentTime + try { + val response = + authRepository.refreshToken() + ?: return TokenResult.NoToken + val newToken = response.data?.token + if (response.isSuccess() && !newToken.isNullOrBlank()) { + tokenStorage.saveToken(newToken) + return TokenResult.Valid(newToken) + } + // If token is missing in a successful response, treat as no token + if (response.isSuccess() && newToken.isNullOrBlank()) { + return TokenResult.NoToken + } + return TokenResult.InvalidToken(response.data?.errorCode) + } catch (e: Exception) { + if (e is CancellationException) throw e + return TokenResult.Error(e) + } + } + + /** + * Returns the currently stored token without attempting a refresh. + * Used by the auth interceptor to attach a token to every outgoing request. + */ + suspend fun getStoredToken(): String? = tokenStorage.getToken() + + /** + * Handles 401 Unauthorized by forcing a token refresh. + */ + suspend fun handleUnauthorized(): TokenResult { + lastRefreshTime = 0 + return refreshToken() + } + + /** + * Forces logout by clearing any stored token. + */ + suspend fun forceLogout(): TokenResult = + try { + tokenStorage.clearToken() + TokenResult.NoToken + } catch (e: Exception) { + if (e is CancellationException) throw e + TokenResult.Error(e) + } +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenResult.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenResult.kt new file mode 100644 index 00000000..55751cbe --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenResult.kt @@ -0,0 +1,17 @@ +package bose.ankush.network.auth.token + +sealed class TokenResult { + data class Valid( + val token: String, + ) : TokenResult() + + data object NoToken : TokenResult() + + data class InvalidToken( + val errorCode: String?, + ) : TokenResult() + + data class Error( + val exception: Exception, + ) : TokenResult() +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/utils/PremiumUtils.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/utils/PremiumUtils.kt new file mode 100644 index 00000000..317bb053 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/utils/PremiumUtils.kt @@ -0,0 +1,19 @@ +package bose.ankush.network.auth.utils + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * Returns true if the user's premium subscription is currently active. + * + * Derives premium status from [premiumExpiresAt] (ISO-8601 UTC string) at the point of + * use rather than relying on the stored boolean flag, which may be stale between requests. + */ +fun isPremiumActive(premiumExpiresAt: String?): Boolean { + if (premiumExpiresAt == null) return false + return try { + Clock.System.now() < Instant.parse(premiumExpiresAt) + } catch (_: Exception) { + false + } +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkConnectivity.kt b/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkConnectivity.kt index 7048413d..167441ec 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkConnectivity.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkConnectivity.kt @@ -10,4 +10,4 @@ interface NetworkConnectivity { * @return true if network is available, false otherwise */ fun isNetworkAvailable(): Boolean -} \ No newline at end of file +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkException.kt b/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkException.kt new file mode 100644 index 00000000..6bdc69ad --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkException.kt @@ -0,0 +1,44 @@ +package bose.ankush.network.common + +/** + * Exception class for network-related errors + * Encapsulates error codes and messages for better error handling + */ +class NetworkException( + val errorCode: Int, + override val message: String, + override val cause: Throwable? = null, +) : Exception(message, cause) { + companion object { + // Common HTTP error codes + const val BAD_REQUEST = 400 + const val UNAUTHORIZED = 401 + const val FORBIDDEN = 403 + const val NOT_FOUND = 404 + const val SERVER_ERROR = 500 + const val SERVICE_UNAVAILABLE = 503 + + // Network-specific error codes + const val NETWORK_UNAVAILABLE = 1000 + const val TIMEOUT = 1001 + const val UNKNOWN_HOST = 1002 + const val UNKNOWN_ERROR = 1999 + + /** + * Create a NetworkException from a generic exception + * Attempts to extract error code if possible, otherwise uses a default code + */ + fun fromException(e: Exception): NetworkException { + // Extract error code from exception message if possible + val errorCodeRegex = Regex("(\\d{3})") + val errorCodeMatch = errorCodeRegex.find(e.message ?: "") + val errorCode = errorCodeMatch?.value?.toIntOrNull() ?: UNKNOWN_ERROR + + return NetworkException( + errorCode = errorCode, + message = e.message ?: "Unknown error", + cause = e, + ) + } + } +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/di/NetworkModule.kt b/network/src/commonMain/kotlin/bose/ankush/network/di/NetworkModule.kt index ac8d8a2b..1b055427 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/di/NetworkModule.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/di/NetworkModule.kt @@ -1,11 +1,36 @@ package bose.ankush.network.di +import bose.ankush.network.api.FeedbackApiService +import bose.ankush.network.api.KtorFeedbackApiService +import bose.ankush.network.api.KtorLocationApiService +import bose.ankush.network.api.KtorPaymentApiService +import bose.ankush.network.api.KtorServiceApiService import bose.ankush.network.api.KtorWeatherApiService -import bose.ankush.network.utils.NetworkConstants +import bose.ankush.network.api.PaymentApiService +import bose.ankush.network.api.ServiceApiService +import bose.ankush.network.auth.api.KtorAuthApiService +import bose.ankush.network.auth.interceptor.configureAuth +import bose.ankush.network.auth.repository.AuthRepository +import bose.ankush.network.auth.repository.AuthRepositoryImpl +import bose.ankush.network.auth.token.TokenManager import bose.ankush.network.common.NetworkConnectivity +import bose.ankush.network.repository.FeedbackRepository +import bose.ankush.network.repository.FeedbackRepositoryImpl +import bose.ankush.network.repository.LocationRepository +import bose.ankush.network.repository.LocationRepositoryImpl +import bose.ankush.network.repository.ServiceRepository +import bose.ankush.network.repository.ServiceRepositoryImpl import bose.ankush.network.repository.WeatherRepository import bose.ankush.network.repository.WeatherRepositoryImpl +import bose.ankush.network.utils.NetworkConstants +import bose.ankush.storage.api.TokenStorage import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.logging.SIMPLE +import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json /** @@ -14,21 +39,199 @@ import kotlinx.serialization.json.Json */ expect fun createPlatformHttpClient(json: Json): HttpClient +/** + * Creates a basic HttpClient without auth. + */ +@Suppress("unused") +fun createBasicHttpClient(): HttpClient { + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = false + encodeDefaults = true + coerceInputValues = true + } + val client = createPlatformHttpClient(json) + return client.config { + install(ContentNegotiation) { + json(json) + } + } +} + +/** + * Creates a TokenManager instance + * @param tokenStorage The storage for authentication tokens + * @param authRepository The repository for authentication operations + * @return A TokenManager instance + */ +fun createTokenManager( + tokenStorage: TokenStorage, + authRepository: AuthRepository, +): TokenManager = TokenManager(tokenStorage, authRepository) + /** * Factory function to create a WeatherRepository instance * This is useful for non-Koin consumers of the network module + * + * @param networkConnectivity The network connectivity checker + * @param tokenStorage The storage for authentication tokens (required for JWT authentication) + * @param baseUrl The base URL for API requests + * @return A WeatherRepository instance with authentication */ fun createWeatherRepository( networkConnectivity: NetworkConnectivity, - baseUrl: String = NetworkConstants.WEATHER_BASE_URL + tokenStorage: TokenStorage, + baseUrl: String = NetworkConstants.WEATHER_BASE_URL, ): WeatherRepository { - val json = Json { - ignoreUnknownKeys = true - isLenient = true - prettyPrint = false - encodeDefaults = true - } - val httpClient = createPlatformHttpClient(json) + // Create AuthRepository first (needed for TokenManager) + val authRepository = createAuthRepository(tokenStorage, baseUrl) + + // Create TokenManager + val tokenManager = createTokenManager(tokenStorage, authRepository) + + // Create an authenticated HttpClient that will include the JWT token in requests + val httpClient = createAuthenticatedHttpClient(tokenManager) val apiService = KtorWeatherApiService(httpClient, baseUrl) return WeatherRepositoryImpl(apiService, networkConnectivity) } + +/** + * Factory function to create a [PaymentApiService] with an authenticated HTTP client. + * Consumed by the feature-payment module's Koin DI setup in the host application. + */ +fun createPaymentApiService( + tokenStorage: TokenStorage, + baseUrl: String = NetworkConstants.WEATHER_BASE_URL, +): PaymentApiService { + val authRepository = createAuthRepository(tokenStorage, baseUrl) + val tokenManager = createTokenManager(tokenStorage, authRepository) + val httpClient = createAuthenticatedHttpClient(tokenManager) + return KtorPaymentApiService(httpClient, baseUrl) +} + +/** + * Creates an HttpClient with authentication configuration using TokenManager + * @param tokenManager The manager for JWT tokens + * @return An HttpClient configured with authentication and token refresh + */ +fun createAuthenticatedHttpClient(tokenManager: TokenManager): HttpClient { + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = false + encodeDefaults = true + coerceInputValues = true + } + + // Create a platform-specific HttpClient with authentication configuration + val client = createPlatformHttpClient(json) + return client.config { + // Install ContentNegotiation plugin + install(ContentNegotiation) { + json(json) + } + + // Add authentication configuration with token refresh + configureAuth(tokenManager) + + // Install Logging plugin - SECURITY: Use LogLevel.NONE in production to prevent JWT token exposure in logs + // Debug mode can be enabled per-platform in androidMain/iosMain if needed + install(Logging) { + logger = Logger.SIMPLE + level = LogLevel.NONE + } + } +} + +/** + * Legacy function for backward compatibility + * Creates an HttpClient with basic authentication configuration + * @param tokenStorage The storage for authentication tokens + * @return An HttpClient configured with authentication (no token refresh) + */ +fun createAuthenticatedHttpClient(tokenStorage: TokenStorage): HttpClient { + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = false + encodeDefaults = true + coerceInputValues = true + } + + // Create a platform-specific HttpClient with authentication configuration + val client = createPlatformHttpClient(json) + return client.config { + // Install ContentNegotiation plugin + install(ContentNegotiation) { + json(json) + } + + // Add authentication configuration + configureAuth(tokenStorage) + + // Install Logging plugin - SECURITY: Use LogLevel.NONE in production to prevent JWT token exposure in logs + // Debug mode can be enabled per-platform in androidMain/iosMain if needed + install(Logging) { + logger = Logger.SIMPLE + level = LogLevel.NONE + } + } +} + +/** + * Factory function to create an AuthRepository instance + * This is useful for non-Koin consumers of the network module + */ +fun createAuthRepository( + tokenStorage: TokenStorage, + baseUrl: String = NetworkConstants.WEATHER_BASE_URL, +): AuthRepository { + // Use the legacy HttpClient for AuthRepository to avoid circular dependency + val httpClient = createAuthenticatedHttpClient(tokenStorage) + val apiService = KtorAuthApiService(httpClient, baseUrl) + return AuthRepositoryImpl(apiService, tokenStorage) +} + +/** + * Factory function to create a LocationRepository instance. + * Uses an authenticated HTTP client so all requests carry a valid JWT. + */ +fun createLocationRepository( + tokenStorage: TokenStorage, + baseUrl: String = NetworkConstants.WEATHER_BASE_URL, +): LocationRepository { + val authRepository = createAuthRepository(tokenStorage, baseUrl) + val tokenManager = createTokenManager(tokenStorage, authRepository) + val httpClient = createAuthenticatedHttpClient(tokenManager) + val apiService = KtorLocationApiService(httpClient, baseUrl) + return LocationRepositoryImpl(apiService) +} + +/** + * Factory function to create a FeedbackRepository instance + */ +fun createFeedbackRepository( + networkConnectivity: NetworkConnectivity, + tokenStorage: TokenStorage, + baseUrl: String = NetworkConstants.WEATHER_BASE_URL, +): FeedbackRepository { + val authRepository = createAuthRepository(tokenStorage, baseUrl) + val tokenManager = createTokenManager(tokenStorage, authRepository) + val httpClient = createAuthenticatedHttpClient(tokenManager) + val apiService: FeedbackApiService = KtorFeedbackApiService(httpClient, baseUrl) + return FeedbackRepositoryImpl(apiService, networkConnectivity) +} + +/** + * Factory function to create a ServiceRepository instance. + * Uses a basic HTTP client; the /services/public endpoint requires NO authentication. + */ +fun createServiceRepository(baseUrl: String = NetworkConstants.WEATHER_BASE_URL): ServiceRepository { + val httpClient = createBasicHttpClient() + val apiService: ServiceApiService = KtorServiceApiService(httpClient, baseUrl) + return ServiceRepositoryImpl(apiService) +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/domain/SavedLocationsUseCase.kt b/network/src/commonMain/kotlin/bose/ankush/network/domain/SavedLocationsUseCase.kt new file mode 100644 index 00000000..024e061e --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/domain/SavedLocationsUseCase.kt @@ -0,0 +1,22 @@ +package bose.ankush.network.domain + +import bose.ankush.network.model.SavedLocation +import bose.ankush.network.repository.LocationRepository + +/** + * Use case for managing saved locations. + * Wraps LocationRepository methods with KMP-compatible interface. + */ +class SavedLocationsUseCase( + private val repository: LocationRepository, +) { + suspend fun getSavedLocations(): Result> = repository.getSavedLocations() + + suspend fun saveLocation( + name: String, + lat: Double, + lon: Double, + ): Result = repository.saveLocation(name, lat, lon) + + suspend fun deleteLocation(id: String): Result = repository.deleteLocation(id) +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/domain/SearchPlacesUseCase.kt b/network/src/commonMain/kotlin/bose/ankush/network/domain/SearchPlacesUseCase.kt new file mode 100644 index 00000000..2718e961 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/domain/SearchPlacesUseCase.kt @@ -0,0 +1,15 @@ +package bose.ankush.network.domain + +import bose.ankush.network.model.PlaceSuggestion +import bose.ankush.network.repository.LocationRepository + +/** + * Use case for searching places by query string. + * Wraps LocationRepository.searchPlaces with KMP-compatible interface. + */ +class SearchPlacesUseCase( + private val repository: LocationRepository, +) { + suspend operator fun invoke(query: String): Result> = + repository.searchPlaces(query) +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/AirQuality.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/AirQuality.kt index 9bbb5d23..f01f98db 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/model/AirQuality.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/model/AirQuality.kt @@ -1,18 +1,57 @@ package bose.ankush.network.model +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -/** - * Domain model for air quality data - */ @Serializable data class AirQuality( - val id: Long? = null, - val aqi: Int = 0, - val co: Double = 0.0, - val no2: Double = 0.0, - val o3: Double = 0.0, - val so2: Double = 0.0, - val pm10: Double = 0.0, - val pm25: Double = 0.0, -) \ No newline at end of file + @SerialName("data") + val `data`: Data?, + @SerialName("message") + val message: String?, + @SerialName("status") + val status: Boolean?, +) { + @Serializable + data class Data( + @SerialName("list") + val list: List?, + ) { + @Serializable + data class Item9( + @SerialName("components") + val components: Components?, + @SerialName("dt") + val dt: Int?, + @SerialName("main") + val main: Main?, + ) { + @Serializable + data class Components( + @SerialName("co") + val co: Double?, + @SerialName("nh3") + val nh3: Double?, + @SerialName("no") + val no: Double?, + @SerialName("no2") + val no2: Double?, + @SerialName("o3") + val o3: Double?, + @SerialName("pm10") + val pm10: Double?, + @SerialName("pm2_5") + val pm25: Double?, + @SerialName("so2") + val so2: Double?, + ) + + @Serializable + data class Main( + @SerialName("aqi") + val aqi: Int?, + ) + } + } + +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/FeedbackModels.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/FeedbackModels.kt new file mode 100644 index 00000000..9a39c0d3 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/model/FeedbackModels.kt @@ -0,0 +1,19 @@ +package bose.ankush.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FeedbackRequest( + @SerialName("deviceId") val deviceId: String, + @SerialName("deviceOs") val deviceOs: String, + @SerialName("feedbackTitle") val feedbackTitle: String, + @SerialName("feedbackDescription") val feedbackDescription: String, +) + +@Serializable +data class FeedbackResponse( + val success: Boolean = false, + val data: String? = null, // feedback id + val message: String? = null, +) diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/LocationModels.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/LocationModels.kt new file mode 100644 index 00000000..f50ed907 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/model/LocationModels.kt @@ -0,0 +1,80 @@ +package bose.ankush.network.model + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive + +/** + * Handles MongoDB Extended JSON ObjectId format {"$oid": "..."} and plain strings. + */ +private object ObjectIdAsStringSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("ObjectId", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: String) = encoder.encodeString(value) + + override fun deserialize(decoder: Decoder): String { + val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString() + return when (val element = jsonDecoder.decodeJsonElement()) { + is JsonObject -> element["\$oid"]?.jsonPrimitive?.content ?: element.toString() + is JsonPrimitive -> element.content + else -> element.toString() + } + } +} + +/** + * A saved favourite location returned by GET /saved-places. + */ +@Serializable +data class SavedLocation( + @Serializable(with = ObjectIdAsStringSerializer::class) + val id: String = "", + val userEmail: String = "", + val name: String = "", + val lat: Double = 0.0, + val lon: Double = 0.0, + val createdAt: String = "", +) + +/** + * Request body for POST /save-location. + */ +@Serializable +data class SaveLocationRequest( + val name: String, + val lat: Double, + val lon: Double, +) + +/** + * Generic API envelope shared across location endpoints. + */ +@Serializable +data class ApiResponse( + val status: Boolean, + val message: String, + val data: T? = null, +) + +/** + * A place suggestion returned by GET /search-place. + */ +@Serializable +data class PlaceSuggestion( + val name: String, + val city: String, + val state: String, + val country: String, + @SerialName("lat") + val latitude: String, + @SerialName("lon") + val longitude: String, +) diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/PaymentModels.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/PaymentModels.kt new file mode 100644 index 00000000..93fb4b91 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/model/PaymentModels.kt @@ -0,0 +1,85 @@ +package bose.ankush.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +@Serializable +data class CreateOrderRequest( + val amount: Long, + val currency: String, + val receipt: String? = null, + @SerialName("partial_payment") val partialPayment: Boolean? = null, + @SerialName("first_payment_min_amount") val firstPaymentMinAmount: Long? = null, + val notes: Map? = null, +) + +@Serializable +data class CreateOrderData( + val orderId: String, + val amount: Long, + val currency: String, + val receipt: String? = null, + val status: String? = null, + val createdAt: Long? = null, +) + +@Serializable +data class CreateOrderResponse( + val message: String? = null, + val data: JsonElement? = null, + val status: JsonElement? = null, +) { + /** + * Safely extract CreateOrderData when the backend returns the expected object in `data`. + * Returns null if fields are missing or types are invalid. + */ + fun extractData(): CreateOrderData? { + val obj = data as? JsonObject ?: return null + val orderId = + obj["orderId"]?.jsonPrimitive?.contentOrNull + ?: obj["order_id"]?.jsonPrimitive?.contentOrNull ?: "" + val amountStr = obj["amount"]?.jsonPrimitive?.content + val amount = amountStr?.toLongOrNull() ?: 0L + val currency = obj["currency"]?.jsonPrimitive?.contentOrNull ?: "" + val receipt = obj["receipt"]?.jsonPrimitive?.contentOrNull + val statusStr = obj["status"]?.jsonPrimitive?.contentOrNull + val createdAt = + obj["createdAt"]?.jsonPrimitive?.content?.toLongOrNull() + ?: obj["created_at"]?.jsonPrimitive?.content?.toLongOrNull() + return if (orderId.isNotBlank() && amount > 0 && currency.isNotBlank()) { + CreateOrderData( + orderId = orderId, + amount = amount, + currency = currency, + receipt = receipt, + status = statusStr, + createdAt = createdAt, + ) + } else { + null + } + } +} + +@Serializable +data class VerifyPaymentRequest( + @SerialName("razorpay_order_id") val razorpayOrderId: String, + @SerialName("razorpay_payment_id") val razorpayPaymentId: String, + @SerialName("razorpay_signature") val razorpaySignature: String, +) + +@Serializable +data class VerifyPaymentData( + val verified: Boolean = false, +) + +@Serializable +data class VerifyPaymentResponse( + @SerialName("status") val success: Boolean = false, + val message: String? = null, + val data: VerifyPaymentData? = null, +) diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/ServiceModels.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/ServiceModels.kt new file mode 100644 index 00000000..1e82257f --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/model/ServiceModels.kt @@ -0,0 +1,234 @@ +package bose.ankush.network.model + +import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable + +// ============ DTO Models (Serializable) ============ + +@Serializable +data class ServiceListResponse( + val success: Boolean = true, + val message: String = "", + val data: ServiceListData, +) + +@Serializable +data class ServiceListData( + val services: List = emptyList(), + val totalCount: Long = 0, + val page: Int = 1, + val pageSize: Int = 20, +) + +@Serializable +data class ServiceDto( + val id: String, + val serviceCode: String, + val displayName: String, + val description: String, + val pricingTiers: List, + val features: List, + val status: String, + val limits: Map = emptyMap(), + val availabilityStart: String? = null, + val availabilityEnd: String? = null, + val totalPurchases: Long = 0, + val lowestPrice: Int = 0, + val currency: String = "INR", + val createdAt: String = "", + val updatedAt: String = "", +) + +@Serializable +data class PricingTierDto( + val id: String, + val amount: Int, + val currency: String, + val duration: Int, + val durationType: String, + val isDefault: Boolean = false, + val isFeatured: Boolean = false, + val displayOrder: Int = 0, +) + +@Serializable +data class FeatureDto( + val id: String, + val description: String, + val isHighlighted: Boolean = false, + val displayOrder: Int = 0, +) + +@Serializable +data class ServiceLimitDto( + val value: Long, + val type: String, + val unit: String, +) + +// ============ Domain Models ============ + +data class Service( + val id: String, + val serviceCode: String, + val displayName: String, + val description: String, + val pricingTiers: List, + val features: List, + val status: ServiceStatus, + val limits: Map, + val availabilityStart: String? = null, + val availabilityEnd: String? = null, + val totalPurchases: Long = 0, + val lowestPrice: Int = 0, + val currency: String = "INR", + val createdAt: String, + val updatedAt: String, +) { + val isAvailable: Boolean + get() = status == ServiceStatus.ACTIVE && isWithinAvailabilityWindow() + + private fun isWithinAvailabilityWindow(): Boolean { + if (availabilityStart == null && availabilityEnd == null) return true + + val now = Clock.System.now().toEpochMilliseconds() + val start = availabilityStart?.let { parseIsoDate(it) } + val end = availabilityEnd?.let { parseIsoDate(it) } + + if (start != null && now < start) return false + if (end != null && now > end) return false + return true + } + + fun getRecommendedTier(): PricingTier? = + pricingTiers.firstOrNull { it.isFeatured } + ?: pricingTiers.firstOrNull { it.isDefault } + ?: pricingTiers.firstOrNull() +} + +data class PricingTier( + val id: String, + val amount: Int, + val currency: String, + val duration: Int, + val durationType: DurationType, + val isDefault: Boolean = false, + val isFeatured: Boolean = false, + val displayOrder: Int = 0, +) { + fun getDisplayPrice(): String = "โ‚น$amount" + + fun getAmountInPaise(): Int = amount * 100 + + fun getDisplayDuration(): String = + when (durationType) { + DurationType.DAYS -> if (duration == 1) "1 day" else "$duration days" + DurationType.MONTHS -> if (duration == 1) "1 month" else "$duration months" + DurationType.YEARS -> if (duration == 1) "1 year" else "$duration years" + } +} + +data class Feature( + val id: String, + val description: String, + val isHighlighted: Boolean = false, + val displayOrder: Int = 0, +) + +data class ServiceLimit( + val value: Long, + val type: LimitType, + val unit: String, +) + +enum class ServiceStatus { + ACTIVE, + INACTIVE, + ARCHIVED, +} + +enum class DurationType { + DAYS, + MONTHS, + YEARS, +} + +enum class LimitType { + HARD, + SOFT, +} + +// ============ Extension Functions ============ + +fun ServiceDto.toDomain(): Service = + Service( + id = id, + serviceCode = serviceCode, + displayName = displayName, + description = description, + pricingTiers = pricingTiers.map { it.toDomain() }, + features = features.map { it.toDomain() }.sortedBy { it.displayOrder }, + status = + try { + ServiceStatus.valueOf(status.uppercase()) + } catch (_: Exception) { + ServiceStatus.ACTIVE + }, + limits = limits.mapValues { it.value.toDomain() }, + availabilityStart = availabilityStart, + availabilityEnd = availabilityEnd, + totalPurchases = totalPurchases, + lowestPrice = lowestPrice, + currency = currency, + createdAt = createdAt, + updatedAt = updatedAt, + ) + +fun PricingTierDto.toDomain(): PricingTier = + PricingTier( + id = id, + amount = amount, + currency = currency, + duration = duration, + durationType = + try { + DurationType.valueOf(durationType.uppercase()) + } catch (_: Exception) { + DurationType.MONTHS + }, + isDefault = isDefault, + isFeatured = isFeatured, + displayOrder = displayOrder, + ) + +fun FeatureDto.toDomain(): Feature = + Feature( + id = id, + description = description, + isHighlighted = isHighlighted, + displayOrder = displayOrder, + ) + +fun ServiceLimitDto.toDomain(): ServiceLimit = + ServiceLimit( + value = value, + type = + try { + LimitType.valueOf(type.uppercase()) + } catch (_: Exception) { + LimitType.HARD + }, + unit = unit, + ) + +fun parseIsoDate(dateString: String): Long? = + try { + // Simple ISO 8601 parser for basic format (YYYY-MM-DDTHH:mm:ssZ) + dateString.replace("Z", "+00:00").let { _ -> + // This is a simplified parser. For production, use proper date library + // For now, return current time as fallback + Clock.System.now().toEpochMilliseconds() + } + } catch (_: Exception) { + null + } diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherCondition.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherCondition.kt deleted file mode 100644 index 7516e32c..00000000 --- a/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherCondition.kt +++ /dev/null @@ -1,14 +0,0 @@ -package bose.ankush.network.model - -import kotlinx.serialization.Serializable - -/** - * Domain model for weather condition - */ -@Serializable -data class WeatherCondition( - val description: String, - val icon: String, - val id: Int, - val main: String -) \ No newline at end of file diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherForecast.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherForecast.kt index 1e967ee9..5a967ba9 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherForecast.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherForecast.kt @@ -1,79 +1,152 @@ package bose.ankush.network.model +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -/** - * Domain model for weather forecast data - */ @Serializable data class WeatherForecast( - val id: Long = 0, // Default value to handle missing id in API response - val alerts: List? = listOf(), - val current: Current? = null, - val daily: List? = listOf(), - val hourly: List? = listOf(), - val lastUpdated: Long = 0, + @SerialName("data") + val `data`: Data?, + @SerialName("message") + val message: String?, + @SerialName("status") + val status: Boolean?, ) { @Serializable - data class Alert( - val description: String? = null, - val end: Int? = null, - val event: String? = null, - val sender_name: String? = null, - val start: Int? = null, - ) + data class Data( + @SerialName("alerts") + val alerts: List? = null, + @SerialName("current") + val current: Current?, + @SerialName("daily") + val daily: List?, + @SerialName("hourly") + val hourly: List?, + @SerialName("airQuality") + val airQuality: AirQuality.Data? = null, + @SerialName("entitlements") + val entitlements: Entitlements? = null, + ) { + @Serializable + data class WeatherInfo( + @SerialName("description") + val description: String = "", + @SerialName("icon") + val icon: String = "", + @SerialName("id") + val id: Int = 0, + @SerialName("main") + val main: String = "", + ) - @Serializable - data class Current( - val clouds: Int? = null, - val dt: Long? = null, - val feels_like: Double? = null, - val humidity: Int? = null, - val pressure: Int? = null, - val sunrise: Int? = null, - val sunset: Int? = null, - val temp: Double? = null, - val uvi: Double? = null, - val weather: List? = listOf(), - val wind_gust: Double? = null, - val wind_speed: Double? = null - ) + @Serializable + data class Alert( + val description: String?, + val end: Long?, + val event: String?, + @SerialName("senderName") val senderName: String?, + val start: Long?, + ) - @Serializable - data class Daily( - val clouds: Int? = null, - val dew_point: Double? = null, - val dt: Long? = null, - val humidity: Int? = null, - val pressure: Int? = null, - val rain: Double? = null, - val summary: String? = null, - val sunrise: Int? = null, - val sunset: Int? = null, - val temp: Temp? = null, - val uvi: Double? = null, - val weather: List? = listOf(), - val wind_gust: Double? = null, - val wind_speed: Double? = null - ) { @Serializable - data class Temp( - val day: Double? = null, - val eve: Double? = null, - val max: Double? = null, - val min: Double? = null, - val morn: Double? = null, - val night: Double? = null + data class Current( + @SerialName("clouds") + val clouds: Int? = null, + @SerialName("dt") + val dt: Long? = null, + @SerialName("feelsLike") + val feelsLike: Double? = null, + @SerialName("humidity") + val humidity: Int? = null, + @SerialName("pressure") + val pressure: Int? = null, + @SerialName("sunrise") + val sunrise: Long? = null, + @SerialName("sunset") + val sunset: Long? = null, + @SerialName("temp") + val temp: Double? = null, + @SerialName("uvi") + val uvi: Double? = null, + @SerialName("weather") + val weather: List? = null, + @SerialName("windGust") + val windGust: Double? = null, + @SerialName("windSpeed") + val windSpeed: Double? = null, ) - } - @Serializable - data class Hourly( - val clouds: Int? = null, - val dt: Long? = null, - val feels_like: Double? = null, - val humidity: Int? = null, - val temp: Double? = null, - val weather: List? = listOf(), - ) + @Serializable + data class Daily( + @SerialName("clouds") + val clouds: Int? = null, + @SerialName("dewPoint") + val dewPoint: Double? = null, + @SerialName("dt") + val dt: Long? = null, + @SerialName("humidity") + val humidity: Int? = null, + @SerialName("pressure") + val pressure: Int? = null, + @SerialName("rain") + val rain: Double? = null, + @SerialName("summary") + val summary: String? = null, + @SerialName("sunrise") + val sunrise: Long? = null, + @SerialName("sunset") + val sunset: Long? = null, + @SerialName("temp") + val temp: Temp? = null, + @SerialName("uvi") + val uvi: Double? = null, + @SerialName("weather") + val weather: List? = null, + @SerialName("windGust") + val windGust: Double? = null, + @SerialName("windSpeed") + val windSpeed: Double? = null, + ) { + @Serializable + data class Temp( + @SerialName("day") + val day: Double?, + @SerialName("eve") + val eve: Double?, + @SerialName("max") + val max: Double?, + @SerialName("min") + val min: Double?, + @SerialName("morn") + val morn: Double?, + @SerialName("night") + val night: Double?, + ) + } + + @Serializable + data class Hourly( + @SerialName("clouds") + val clouds: Int? = null, + @SerialName("dt") + val dt: Long? = null, + @SerialName("feelsLike") + val feelsLike: Double? = null, + @SerialName("humidity") + val humidity: Int? = null, + @SerialName("temp") + val temp: Double? = null, + @SerialName("weather") + val weather: List? = null, + ) + + @Serializable + data class Entitlements( + val hourlyIncluded: Boolean = false, + val dailyIncluded: Boolean = false, + val alertsIncluded: Boolean = false, + val airQualityIncluded: Boolean = false, + val upgradeRequired: Boolean = true, + ) + } } diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepository.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepository.kt new file mode 100644 index 00000000..600098aa --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepository.kt @@ -0,0 +1,15 @@ +package bose.ankush.network.repository + +import bose.ankush.network.model.FeedbackRequest +import bose.ankush.network.model.FeedbackResponse + +/** + * Repository interface for feedback operations + */ +interface FeedbackRepository { + /** + * Submit user feedback + * Returns a Result wrapping either the response or an error + */ + suspend fun submitFeedback(request: FeedbackRequest): Result +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepositoryImpl.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepositoryImpl.kt new file mode 100644 index 00000000..7153b58f --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepositoryImpl.kt @@ -0,0 +1,21 @@ +package bose.ankush.network.repository + +import bose.ankush.network.api.FeedbackApiService +import bose.ankush.network.common.NetworkConnectivity +import bose.ankush.network.model.FeedbackRequest +import bose.ankush.network.model.FeedbackResponse + +/** + * Implementation of FeedbackRepository + */ +class FeedbackRepositoryImpl( + private val apiService: FeedbackApiService, + private val networkConnectivity: NetworkConnectivity, +) : FeedbackRepository { + override suspend fun submitFeedback(request: FeedbackRequest): Result { + if (!networkConnectivity.isNetworkAvailable()) { + return Result.failure(IllegalStateException("No internet connection")) + } + return runCatching { apiService.submitFeedback(request) } + } +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepository.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepository.kt new file mode 100644 index 00000000..2241a62c --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepository.kt @@ -0,0 +1,26 @@ +package bose.ankush.network.repository + +import bose.ankush.network.model.PlaceSuggestion +import bose.ankush.network.model.SavedLocation + +/** + * Repository interface for saved favourite locations. + * All operations require the user to be authenticated (JWT is attached by the interceptor). + */ +interface LocationRepository { + /** Save a new favourite location. */ + suspend fun saveLocation( + name: String, + lat: Double, + lon: Double, + ): Result + + /** Retrieve all saved locations for the current user. */ + suspend fun getSavedLocations(): Result> + + /** Delete a saved location by its server-assigned id. */ + suspend fun deleteLocation(id: String): Result + + /** Search for place suggestions by query string. */ + suspend fun searchPlaces(query: String): Result> +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepositoryImpl.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepositoryImpl.kt new file mode 100644 index 00000000..c8defbfc --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepositoryImpl.kt @@ -0,0 +1,66 @@ +package bose.ankush.network.repository + +import bose.ankush.network.api.LocationApiService +import bose.ankush.network.model.PlaceSuggestion +import bose.ankush.network.model.SaveLocationRequest +import bose.ankush.network.model.SavedLocation + +/** + * Implementation of LocationRepository. + */ +class LocationRepositoryImpl( + private val apiService: LocationApiService, +) : LocationRepository { + override suspend fun saveLocation( + name: String, + lat: Double, + lon: Double, + ): Result = + try { + val response = + apiService.saveLocation(SaveLocationRequest(name = name, lat = lat, lon = lon)) + if (response.status) { + Result.success(Unit) + } else { + Result.failure(Exception(response.message)) + } + } catch (e: Exception) { + Result.failure(e) + } + + override suspend fun getSavedLocations(): Result> = + try { + val response = apiService.getSavedLocations() + if (response.status) { + Result.success(response.data ?: emptyList()) + } else { + Result.failure(Exception(response.message)) + } + } catch (e: Exception) { + Result.failure(e) + } + + override suspend fun deleteLocation(id: String): Result = + try { + val response = apiService.deleteLocation(id) + if (response.status) { + Result.success(Unit) + } else { + Result.failure(Exception(response.message)) + } + } catch (e: Exception) { + Result.failure(e) + } + + override suspend fun searchPlaces(query: String): Result> = + try { + val response = apiService.searchPlaces(query) + if (response.status) { + Result.success(response.data ?: emptyList()) + } else { + Result.failure(Exception(response.message)) + } + } catch (e: Exception) { + Result.failure(e) + } +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/ServiceRepository.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/ServiceRepository.kt new file mode 100644 index 00000000..4429df2f --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/ServiceRepository.kt @@ -0,0 +1,47 @@ +package bose.ankush.network.repository + +import bose.ankush.network.model.Service +import bose.ankush.network.model.toDomain + +interface ServiceRepository { + suspend fun getServices( + page: Int = 1, + pageSize: Int = 20, + search: String? = null, + ): Result> +} + +class ServiceRepositoryImpl( + private val api: bose.ankush.network.api.ServiceApiService, +) : ServiceRepository { + override suspend fun getServices( + page: Int, + pageSize: Int, + search: String?, + ): Result> = + try { + val response = api.getServices(page, pageSize, search) + response.fold( + onSuccess = { data -> + val services = data.data.services.map { it.toDomain() } + Result.success(services) + }, + onFailure = { error -> + logError("Service API Error", error) + Result.failure(error) + }, + ) + } catch (e: Exception) { + logError("Service Repository Error", e) + Result.failure(e) + } + + private fun logError( + tag: String, + error: Throwable, + ) { + // Log to Firebase or your analytics service + // FirebaseCrashlytics.getInstance().recordException(error) + println("$tag: ${error.message}") + } +} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepository.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepository.kt index f0d5c383..3e4a7ff9 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepository.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepository.kt @@ -1,6 +1,5 @@ package bose.ankush.network.repository -import bose.ankush.network.model.AirQuality import bose.ankush.network.model.WeatherForecast import kotlinx.coroutines.flow.Flow @@ -9,21 +8,16 @@ import kotlinx.coroutines.flow.Flow */ interface WeatherRepository { /** - * Get air quality report for a location - * @param coordinates Pair of latitude and longitude - * @return Flow of AirQuality - */ - fun getAirQualityReport(coordinates: Pair): Flow - - /** - * Get weather report for a location + * Get unified weather report for a location. + * The response includes current, hourly, daily, alerts, and air quality. + * Premium-only fields are null for free tier users โ€” check entitlements.upgradeRequired. * @param coordinates Pair of latitude and longitude * @return Flow of WeatherForecast */ fun getWeatherReport(coordinates: Pair): Flow /** - * Refresh weather data for a location + * Force a fresh fetch from the network for a location * @param coordinates Pair of latitude and longitude */ suspend fun refreshWeatherData(coordinates: Pair) diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepositoryImpl.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepositoryImpl.kt index 668bbaff..40db05f6 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepositoryImpl.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepositoryImpl.kt @@ -1,138 +1,68 @@ package bose.ankush.network.repository import bose.ankush.network.api.WeatherApiService -import bose.ankush.network.utils.NetworkConstants import bose.ankush.network.common.NetworkConnectivity -import bose.ankush.network.utils.NetworkUtils -import bose.ankush.network.model.AirQuality import bose.ankush.network.model.WeatherForecast +import bose.ankush.network.utils.NetworkConstants +import bose.ankush.network.utils.NetworkUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.datetime.Clock /** - * Implementation of WeatherRepository + * Implementation of WeatherRepository. Provides an in-memory cache for the unified weather + * response, which now includes air quality and entitlements from a single /weather call. */ class WeatherRepositoryImpl( private val apiService: WeatherApiService, - private val networkConnectivity: NetworkConnectivity + private val networkConnectivity: NetworkConnectivity, ) : WeatherRepository { - - // In-memory cache for weather data - private val _weatherData = MutableStateFlow(null) - private val _airQualityData = MutableStateFlow(null) - - // Last update timestamps + private val weatherCache = MutableStateFlow(null) private var lastWeatherUpdateTime: Long = 0 - private var lastAirQualityUpdateTime: Long = 0 - - override fun getAirQualityReport(coordinates: Pair): Flow { - val currentTime = Clock.System.now().toEpochMilliseconds() - - // Check if we need to refresh the data - if (_airQualityData.value == null || - (currentTime - lastAirQualityUpdateTime > NetworkConstants.CACHE_EXPIRATION_TIME)) { - // Launch a coroutine to refresh the data - CoroutineScope(Dispatchers.Default).launch { - try { - // Only refresh if network is available - if (networkConnectivity.isNetworkAvailable()) { - val airQualityData = NetworkUtils.retryWithExponentialBackoff { - apiService.getCurrentAirQuality( - latitude = coordinates.first.toString(), - longitude = coordinates.second.toString() - ) - } - _airQualityData.value = airQualityData - lastAirQualityUpdateTime = currentTime - } - } catch (e: Exception) { - // Log the error but don't throw it to avoid crashing the UI - println("Failed to refresh air quality data: ${e.message}") - } - } - } - - // Initialize with a default AirQuality if null - if (_airQualityData.value == null) { - _airQualityData.value = AirQuality() - } - - // Map the nullable flow to a non-nullable flow - return _airQualityData.map { it ?: AirQuality() } - } override fun getWeatherReport(coordinates: Pair): Flow { val currentTime = Clock.System.now().toEpochMilliseconds() - // Check if we need to refresh the data - if (_weatherData.value == null || - (currentTime - lastWeatherUpdateTime > NetworkConstants.CACHE_EXPIRATION_TIME)) { - // Launch a coroutine to refresh the data + if (weatherCache.value == null || + (currentTime - lastWeatherUpdateTime > NetworkConstants.CACHE_EXPIRATION_TIME) + ) { CoroutineScope(Dispatchers.Default).launch { try { refreshWeatherData(coordinates) } catch (e: Exception) { - // Log the error but don't throw it to avoid crashing the UI println("Failed to refresh weather data: ${e.message}") } } } - return _weatherData.asStateFlow() + return weatherCache.asStateFlow() } override suspend fun refreshWeatherData(coordinates: Pair) { val currentTime = Clock.System.now().toEpochMilliseconds() val isNetworkAvailable = networkConnectivity.isNetworkAvailable() - // Only fetch new data if: - // 1. Network is available AND - // 2. Either data is null OR data is stale (older than cache expiration time) - if (isNetworkAvailable && - (_weatherData.value == null || (currentTime - lastWeatherUpdateTime > NetworkConstants.CACHE_EXPIRATION_TIME))) { + if (isNetworkAvailable && + ( + weatherCache.value == null || + (currentTime - lastWeatherUpdateTime > NetworkConstants.CACHE_EXPIRATION_TIME) + ) + ) { try { - coroutineScope { - // Use async to parallelize the API calls - val weatherDeferred = async { - NetworkUtils.retryWithExponentialBackoff { - apiService.getOneCallWeather( - coordinates.first.toString(), - coordinates.second.toString() - ) - } + val weatherData = + NetworkUtils.retryWithExponentialBackoff { + apiService.getOneCallWeather( + coordinates.first.toString(), + coordinates.second.toString(), + ) } - - val airQualityDeferred = async { - NetworkUtils.retryWithExponentialBackoff { - apiService.getCurrentAirQuality( - latitude = coordinates.first.toString(), - longitude = coordinates.second.toString() - ) - } - } - - // Wait for both API calls to complete - val weatherData = weatherDeferred.await() - val airQualityData = airQualityDeferred.await() - - // Update the weather data flow - _weatherData.value = weatherData.copy(lastUpdated = currentTime) - lastWeatherUpdateTime = currentTime - - // Update the air quality data flow - _airQualityData.value = airQualityData - lastAirQualityUpdateTime = currentTime - } + weatherCache.value = weatherData + lastWeatherUpdateTime = currentTime } catch (e: Exception) { - // If there's an error, throw a more descriptive exception throw Exception("Failed to refresh weather data: ${e.message}", e) } } diff --git a/network/src/commonMain/kotlin/bose/ankush/network/utils/Constants.kt b/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkConstants.kt similarity index 86% rename from network/src/commonMain/kotlin/bose/ankush/network/utils/Constants.kt rename to network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkConstants.kt index 096bca08..6f57b431 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/utils/Constants.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkConstants.kt @@ -7,7 +7,7 @@ object NetworkConstants { /** * Base URL for the weather API */ - const val WEATHER_BASE_URL = "https://data.androidplay.in/" + const val WEATHER_BASE_URL = "https://data.androidplay.in" /** * Cache expiration time in milliseconds (30 minutes) @@ -17,7 +17,7 @@ object NetworkConstants { /** * Maximum number of retries for network requests */ - const val MAX_RETRIES = 3 + const val MAX_RETRIES = 1 /** * Initial backoff delay in milliseconds for retry mechanism diff --git a/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkUtils.kt b/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkUtils.kt index 8a5bc08e..6dc9e5a5 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkUtils.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkUtils.kt @@ -1,5 +1,6 @@ package bose.ankush.network.utils +import bose.ankush.network.common.NetworkException import kotlinx.coroutines.delay /** @@ -13,21 +14,31 @@ object NetworkUtils { * @param maxDelayMillis Maximum delay in milliseconds * @param block The suspend function to retry * @return The result of the suspend function - * @throws Exception if all retries fail + * @throws NetworkException if all retries fail */ suspend fun retryWithExponentialBackoff( maxRetries: Int = NetworkConstants.MAX_RETRIES, initialDelayMillis: Long = NetworkConstants.INITIAL_BACKOFF_DELAY, maxDelayMillis: Long = NetworkConstants.MAX_BACKOFF_DELAY, - block: suspend () -> T + block: suspend () -> T, ): T { var currentDelay = initialDelayMillis + var lastException: Exception? = null + repeat(maxRetries) { attempt -> try { return block() } catch (e: Exception) { - // If this is the last attempt, throw the exception - if (attempt == maxRetries - 1) throw e + lastException = e + + // If this is the last attempt, convert to NetworkException and throw + if (attempt == maxRetries - 1) { + if (e is NetworkException) { + throw e + } else { + throw NetworkException.fromException(e) + } + } // Otherwise, delay and retry delay(currentDelay) @@ -36,6 +47,10 @@ object NetworkUtils { } } // This should never be reached, but is needed for compilation - throw IllegalStateException("Retry failed after $maxRetries attempts") + throw NetworkException( + NetworkException.UNKNOWN_ERROR, + "Retry failed after $maxRetries attempts", + lastException, + ) } -} \ No newline at end of file +} diff --git a/network/src/iosMain/kotlin/bose/ankush/network/common/IOSNetworkConnectivity.kt b/network/src/iosMain/kotlin/bose/ankush/network/common/IOSNetworkConnectivity.kt index 624abd74..bb02d486 100644 --- a/network/src/iosMain/kotlin/bose/ankush/network/common/IOSNetworkConnectivity.kt +++ b/network/src/iosMain/kotlin/bose/ankush/network/common/IOSNetworkConnectivity.kt @@ -1,28 +1,34 @@ package bose.ankush.network.common -import platform.Foundation.NSFileManager +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value import platform.SystemConfiguration.SCNetworkReachabilityCreateWithName -import platform.SystemConfiguration.SCNetworkReachabilityFlags +import platform.SystemConfiguration.SCNetworkReachabilityFlagsVar import platform.SystemConfiguration.SCNetworkReachabilityGetFlags +import platform.SystemConfiguration.kSCNetworkReachabilityFlagsConnectionRequired import platform.SystemConfiguration.kSCNetworkReachabilityFlagsReachable -import platform.darwin.NULL -/** - * iOS implementation of NetworkConnectivity - */ +@Suppress("unused") class IOSNetworkConnectivity : NetworkConnectivity { - + @OptIn(ExperimentalForeignApi::class) override fun isNetworkAvailable(): Boolean { - val reachability = SCNetworkReachabilityCreateWithName( - NULL, - "www.apple.com" - ) ?: return false - - val flags = ULongArray(1) - if (SCNetworkReachabilityGetFlags(reachability, flags)) { - return (flags[0].toInt() and kSCNetworkReachabilityFlagsReachable.toInt()) != 0 + val reachability = + SCNetworkReachabilityCreateWithName( + null, + "www.apple.com", + ) ?: return false + + return memScoped { + val flags = alloc() + if (SCNetworkReachabilityGetFlags(reachability, flags.ptr)) { + (flags.value and kSCNetworkReachabilityFlagsReachable) != 0u && + (flags.value and kSCNetworkReachabilityFlagsConnectionRequired) == 0u + } else { + false + } } - - return false } -} \ No newline at end of file +} diff --git a/network/src/iosMain/kotlin/bose/ankush/network/di/IOSHttpClient.kt b/network/src/iosMain/kotlin/bose/ankush/network/di/IOSHttpClient.kt index 40f030d9..b8debc25 100644 --- a/network/src/iosMain/kotlin/bose/ankush/network/di/IOSHttpClient.kt +++ b/network/src/iosMain/kotlin/bose/ankush/network/di/IOSHttpClient.kt @@ -6,15 +6,14 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 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.ContentType import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json /** * iOS implementation of createPlatformHttpClient */ -actual fun createPlatformHttpClient(json: Json): HttpClient { - return HttpClient(Darwin) { +actual fun createPlatformHttpClient(json: Json): HttpClient = + HttpClient(Darwin) { engine { configureRequest { setAllowsCellularAccess(true) @@ -22,16 +21,16 @@ actual fun createPlatformHttpClient(json: Json): HttpClient { } } install(ContentNegotiation) { - // Register for mixed content type (application/json, text/html) - json(json, contentType = ContentType.parse("application/json, text/html; charset=UTF-8")) + // Register standard JSON handling; handle other content types per request if required. + json(json) } install(Logging) { - logger = object : Logger { - override fun log(message: String) { - println("Ktor iOS: $message") + logger = + object : Logger { + override fun log(message: String) { + println("Ktor iOS: $message") + } } - } level = LogLevel.INFO } } -} diff --git a/network/src/iosMain/kotlin/bose/ankush/network/di/IOSNetworkModule.kt b/network/src/iosMain/kotlin/bose/ankush/network/di/IOSNetworkModule.kt deleted file mode 100644 index ea5bc697..00000000 --- a/network/src/iosMain/kotlin/bose/ankush/network/di/IOSNetworkModule.kt +++ /dev/null @@ -1,16 +0,0 @@ -package bose.ankush.network.di - -import bose.ankush.network.common.IOSNetworkConnectivity -import bose.ankush.network.common.NetworkConnectivity -import org.koin.core.module.Module -import org.koin.dsl.module - -/** - * iOS-specific network module - */ -fun iosNetworkModule(): Module = module { - // iOS-specific NetworkConnectivity implementation - single { - IOSNetworkConnectivity() - } -} \ No newline at end of file diff --git a/payment/build.gradle.kts b/payment/build.gradle.kts deleted file mode 100644 index b7537270..00000000 --- a/payment/build.gradle.kts +++ /dev/null @@ -1,82 +0,0 @@ -plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") - id("org.jetbrains.kotlin.plugin.compose") -} - -android { - namespace = "bose.ankush.payment" - compileSdk = ConfigData.compileSdkVersion - - defaultConfig { - minSdk = ConfigData.minSdkVersion - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - - buildFeatures { - compose = true - buildConfig = true - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - - kotlin { - sourceSets.all { - languageSettings { - languageVersion = Versions.kotlinCompiler - } - } - } - - lint { - abortOnError = false - } -} - -composeCompiler { - enableStrongSkippingMode = true -} - -dependencies { - - // Testing - testImplementation(Deps.junit) - - // UI Testing - androidTestImplementation(Deps.extJunit) - - // Core - implementation(Deps.androidCore) - implementation(Deps.appCompat) - - // Compose - implementation(platform(Deps.composeBom)) - implementation(Deps.composeUiTooling) - implementation(Deps.composeUiToolingPreview) - implementation(Deps.composeUi) - implementation(Deps.composeMaterial1) - implementation(Deps.composeMaterial3) - implementation(Deps.navigationCompose) - - // payment sdk - implementation(Deps.razorPay) -} \ No newline at end of file diff --git a/payment/consumer-rules.pro b/payment/consumer-rules.pro deleted file mode 100644 index e69de29b..00000000 diff --git a/payment/proguard-rules.pro b/payment/proguard-rules.pro deleted file mode 100644 index aec67fe4..00000000 --- a/payment/proguard-rules.pro +++ /dev/null @@ -1,37 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - --keepclassmembers class * { - @android.webkit.JavascriptInterface ; -} - --keepattributes JavascriptInterface --keepattributes *Annotation* - --dontwarn com.razorpay.** --keep class com.razorpay.** {*;} - --optimizations !method/inlining/* - --keepclasseswithmembers class * { - public void onPayment*(...); -} \ No newline at end of file diff --git a/payment/src/androidTest/java/bose/ankush/payment/ExampleInstrumentedTest.kt b/payment/src/androidTest/java/bose/ankush/payment/ExampleInstrumentedTest.kt deleted file mode 100644 index 84a387f9..00000000 --- a/payment/src/androidTest/java/bose/ankush/payment/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package bose.ankush.payment - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("bose.ankush.payment.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/payment/src/main/AndroidManifest.xml b/payment/src/main/AndroidManifest.xml deleted file mode 100644 index a921cff1..00000000 --- a/payment/src/main/AndroidManifest.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/payment/src/main/java/bose/ankush/payment/PaymentScreen.kt b/payment/src/main/java/bose/ankush/payment/PaymentScreen.kt deleted file mode 100644 index 62157761..00000000 --- a/payment/src/main/java/bose/ankush/payment/PaymentScreen.kt +++ /dev/null @@ -1,66 +0,0 @@ -package bose.ankush.payment - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.BottomSheetScaffold -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Text -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Preview(showBackground = true) -@Composable -fun PaymentScreen() { - val scope = rememberCoroutineScope() - val scaffoldState = rememberBottomSheetScaffoldState() - BottomSheetScaffold( - scaffoldState = scaffoldState, - sheetPeekHeight = 128.dp, - sheetContent = { - Box( - Modifier - .fillMaxWidth() - .height(128.dp), - contentAlignment = Alignment.Center - ) { - Text("Swipe up to expand sheet") - } - Column( - Modifier - .fillMaxWidth() - .padding(64.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Sheet content") - Spacer(Modifier.height(20.dp)) - Button( - onClick = { - scope.launch { scaffoldState.bottomSheetState.partialExpand() } - } - ) { - Text("Click to collapse sheet") - } - } - }) { innerPadding -> - Box(Modifier.padding(innerPadding)) { - Text("Scaffold Content") - } - } -} - -/*@Composable -private fun BottomSheetUI() { - -}*/ diff --git a/payment/src/main/res/values/strings.xml b/payment/src/main/res/values/strings.xml deleted file mode 100644 index 73862c41..00000000 --- a/payment/src/main/res/values/strings.xml +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/payment/src/test/java/bose/ankush/payment/ExampleUnitTest.kt b/payment/src/test/java/bose/ankush/payment/ExampleUnitTest.kt deleted file mode 100644 index d6e41b93..00000000 --- a/payment/src/test/java/bose/ankush/payment/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package bose.ankush.payment - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 2f833a76..2d7a356a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,6 +8,9 @@ pluginManagement { maven { url = uri("https://jitpack.io") } } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { @@ -21,9 +24,9 @@ rootProject.name = "Weatherify" include( ":app", + ":common-ui", + ":feature-payment", ":language", ":network", - ":storage", - ":sunriseui", - ":payment", + ":storage" ) diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts index 21f40702..ce4d5ec6 100644 --- a/storage/build.gradle.kts +++ b/storage/build.gradle.kts @@ -1,23 +1,27 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { kotlin("multiplatform") id("com.android.library") kotlin("plugin.serialization") - id("kotlin-kapt") + id("com.google.devtools.ksp") } kotlin { androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) } } + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + listOf( iosX64(), iosArm64(), - iosSimulatorArm64() + iosSimulatorArm64(), ).forEach { it.binaries.framework { baseName = "storage" @@ -38,24 +42,29 @@ kotlin { implementation(kotlin("test")) } } + + @Suppress("UNUSED_VARIABLE") val androidMain by getting { dependencies { // Room dependencies implementation(Deps.room) implementation(Deps.roomKtx) + // Security: Encrypted token storage + implementation(Deps.securityCrypto) // Gson for JSON serialization - implementation("com.google.code.gson:gson:2.10.1") - // Network module dependency - implementation(project(":network")) - // Dagger/Hilt dependencies - implementation(Deps.hilt) - // We can't use kapt here directly, it will be applied in the android block + implementation(Deps.gson) + // Note: Network dependency removed to avoid circular dependency + // WeatherDataFetcher is injected via DI from app module } } + + @Suppress("UNUSED_VARIABLE") val androidUnitTest by getting val iosX64Main by getting val iosArm64Main by getting val iosSimulatorArm64Main by getting + + @Suppress("UNUSED_VARIABLE") val iosMain by creating { dependsOn(commonMain) iosX64Main.dependsOn(this) @@ -65,6 +74,8 @@ kotlin { val iosX64Test by getting val iosArm64Test by getting val iosSimulatorArm64Test by getting + + @Suppress("UNUSED_VARIABLE") val iosTest by creating { dependsOn(commonTest) iosX64Test.dependsOn(this) @@ -87,19 +98,14 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } +} - // Room schema location - kapt { - arguments { - arg("room.schemaLocation", "$projectDir/schemas") - } - } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") } -// Apply kapt plugin for Room and Hilt annotation processing +// KSP configuration for Room annotation processing (Hilt moved to app module) dependencies { // Room annotation processor - "kapt"(Deps.roomCompiler) - // Hilt annotation processor - "kapt"(Deps.hiltDaggerAndroidCompiler) + add("kspAndroid", Deps.roomCompiler) } diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt new file mode 100644 index 00000000..eeccf72c --- /dev/null +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt @@ -0,0 +1,91 @@ +package bose.ankush.storage.impl + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import bose.ankush.storage.api.TokenStorage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext + +/** + * SECURITY: Encrypted token storage using EncryptedSharedPreferences with Android Keystore + * + * This implementation uses androidx.security:security-crypto to encrypt tokens at rest + * using the Android Keystore system. Tokens are encrypted/decrypted transparently. + * + * Benefits: + * - Tokens encrypted with AES-256-GCM + * - Keys managed by Android Keystore (hardware-backed on supported devices) + * - Protection against database extraction attacks + * - Complies with OWASP guidelines for credential storage + */ +actual class EncryptedTokenStorageImpl : TokenStorage { + private val context: Context by lazy { + getApplicationContext() + } + + private val masterKey: MasterKey by lazy { + MasterKey + .Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + } + + private val encryptedSharedPreferences by lazy { + EncryptedSharedPreferences.create( + context, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + private val _hasToken by lazy { + MutableStateFlow(encryptedSharedPreferences.contains(TOKEN_KEY)) + } + + actual override suspend fun saveToken(token: String) { + withContext(Dispatchers.IO) { + val success = encryptedSharedPreferences.edit().putString(TOKEN_KEY, token).commit() + if (success) { + _hasToken.value = true + } + } + } + + actual override suspend fun getToken(): String? = + withContext(Dispatchers.IO) { + encryptedSharedPreferences.getString(TOKEN_KEY, null) + } + + actual override fun hasToken(): Flow = _hasToken.asStateFlow() + + actual override suspend fun clearToken() { + withContext(Dispatchers.IO) { + val success = encryptedSharedPreferences.edit().remove(TOKEN_KEY).commit() + if (success) { + _hasToken.value = false + } + } + } + + companion object { + private const val PREFS_NAME = "encrypted_auth_prefs" + private const val TOKEN_KEY = "auth_token" + } +} + +private var appContext: Context? = null + +fun setApplicationContext(context: Context) { + appContext = context +} + +private fun getApplicationContext(): Context = + appContext ?: throw IllegalStateException( + "Application context not initialized. Call setApplicationContext() in your Application.onCreate()", + ) diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/impl/WeatherStorageImpl.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/impl/WeatherStorageImpl.kt index 878d0424..6e3c99a4 100644 --- a/storage/src/androidMain/kotlin/bose/ankush/storage/impl/WeatherStorageImpl.kt +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/impl/WeatherStorageImpl.kt @@ -1,212 +1,65 @@ package bose.ankush.storage.impl -import bose.ankush.network.model.AirQuality as NetworkAirQuality -import bose.ankush.network.model.WeatherForecast as NetworkWeatherForecast -import bose.ankush.network.repository.WeatherRepository as NetworkWeatherRepository import bose.ankush.storage.api.WeatherStorage import bose.ankush.storage.room.AirQualityEntity -import bose.ankush.storage.room.Weather import bose.ankush.storage.room.WeatherDatabase import bose.ankush.storage.room.WeatherEntity +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.firstOrNull -import java.io.IOException -import javax.inject.Inject -import javax.inject.Singleton +import kotlinx.coroutines.withContext /** - * Implementation of WeatherStorage that uses Room database for storage - * and the network module for fetching data. - * - * This class is responsible for: - * - Retrieving weather and air quality data from the local database - * - Refreshing data from the network when needed - * - Mapping between network models and database entities - * - Tracking the last update time for weather data + * Implementation of WeatherStorage that uses Room database for persistence. + * + * This class is responsible ONLY for: + * - Reading weather data from the local database + * - Reading air quality data from the local database + * - Saving weather data to the database (called by orchestration layer) + * + * Data synchronization (fetch from network, map, save to DB) is handled + * by the orchestration layer (WeatherRepository in app module). */ -@Singleton -class WeatherStorageImpl @Inject constructor( - private val networkRepository: NetworkWeatherRepository, - private val weatherDatabase: WeatherDatabase +class WeatherStorageImpl( + private val weatherDatabase: WeatherDatabase, ) : WeatherStorage { + // In-memory per-location timestamp map. Keyed by "lat_lon" string. + // Reset on process restart intentionally โ€” fresh data should be fetched after a cold start. + private val locationTimestamps = mutableMapOf() - /** - * Gets the latest weather report from the local database - * @param coordinates Pair of latitude and longitude (not used in current implementation) - * @return Flow of WeatherEntity as Any? - */ - override fun getWeatherReport(coordinates: Pair): Flow { - return weatherDatabase.weatherDao().getWeather() - } + private fun locationKey(coordinates: Pair) = + "${coordinates.first}_${coordinates.second}" - /** - * Gets the latest air quality report from the local database - * @param coordinates Pair of latitude and longitude (not used in current implementation) - * @return Flow of AirQualityEntity as Any? - */ - override fun getAirQualityReport(coordinates: Pair): Flow { - return weatherDatabase.weatherDao().getAirQuality() - } + override fun getWeatherReport(coordinates: Pair): Flow = + weatherDatabase.weatherDao().getWeather() - /** - * Refreshes weather and air quality data from the network and stores it in the local database - * @param coordinates Pair of latitude and longitude - * @throws IOException if there's an error refreshing the data - */ - override suspend fun refreshWeatherData(coordinates: Pair) { - try { - // Delegate to the network module's repository to refresh data - networkRepository.refreshWeatherData(coordinates) + override fun getAirQualityReport(coordinates: Pair): Flow = + weatherDatabase.weatherDao().getAirQuality() - // Get the latest data from the network repository - val weatherData = networkRepository.getWeatherReport(coordinates).firstOrNull() - val airQualityData = networkRepository.getAirQualityReport(coordinates).firstOrNull() + override suspend fun getLastWeatherUpdateTime(coordinates: Pair): Long = + locationTimestamps[locationKey(coordinates)] ?: 0L - if (weatherData != null && airQualityData != null) { - // Convert network models to storage entities - val weatherEntity = mapNetworkWeatherToEntity(weatherData) - val airQualityEntity = mapNetworkAirQualityToEntity(airQualityData) + override suspend fun saveLastWeatherUpdateTime( + coordinates: Pair, + time: Long, + ) { + locationTimestamps[locationKey(coordinates)] = time + } - // Store the data in room db + override suspend fun saveWeatherData( + weatherEntity: Any, + airQualityEntity: Any, + ) { + withContext(Dispatchers.IO) { + if (weatherEntity is WeatherEntity && airQualityEntity is AirQualityEntity) { weatherDatabase.weatherDao().refreshWeather(weatherEntity, airQualityEntity) } - } catch (e: Exception) { - // If there's an error, throw a more specific IOException with detailed information - throw IOException("Failed to refresh weather data for coordinates (${coordinates.first}, ${coordinates.second}): ${e.message}", e) } } - /** - * Gets the timestamp of the last weather data update - * @return Timestamp in milliseconds, or 0 if no data is available - */ - override suspend fun getLastWeatherUpdateTime(): Long { - val weatherEntity = weatherDatabase.weatherDao().getWeather().firstOrNull() - return weatherEntity?.lastUpdated ?: 0L - } - - /** - * Maps a NetworkWeatherForecast to a WeatherEntity for storage in the database - * @param weatherData The network model to map - * @return A WeatherEntity with all fields mapped from the network model - */ - private fun mapNetworkWeatherToEntity(weatherData: NetworkWeatherForecast): WeatherEntity { - return WeatherEntity( - id = 0, // Room will auto-generate this - lastUpdated = System.currentTimeMillis(), - alerts = weatherData.alerts?.map { alert -> - alert?.let { - WeatherEntity.Alert( - description = it.description, - end = it.end, - event = it.event, - sender_name = it.sender_name, - start = it.start - ) - } - }, - current = weatherData.current?.let { current -> - WeatherEntity.Current( - clouds = current.clouds, - dt = current.dt, - feels_like = current.feels_like, - humidity = current.humidity, - pressure = current.pressure, - sunrise = current.sunrise, - sunset = current.sunset, - temp = current.temp, - uvi = current.uvi, - weather = current.weather?.map { weatherCondition -> - weatherCondition?.let { - Weather( - description = it.description, - icon = it.icon, - id = it.id, - main = it.main - ) - } - }, - wind_gust = current.wind_gust, - wind_speed = current.wind_speed - ) - }, - daily = weatherData.daily?.map { daily -> - daily?.let { - WeatherEntity.Daily( - clouds = it.clouds, - dew_point = it.dew_point, - dt = it.dt, - humidity = it.humidity, - pressure = it.pressure, - rain = it.rain, - summary = it.summary, - sunrise = it.sunrise, - sunset = it.sunset, - temp = it.temp?.let { temp -> - WeatherEntity.Daily.Temp( - day = temp.day, - eve = temp.eve, - max = temp.max, - min = temp.min, - morn = temp.morn, - night = temp.night - ) - }, - uvi = it.uvi, - weather = it.weather?.map { weatherCondition -> - weatherCondition?.let { - Weather( - description = it.description, - icon = it.icon, - id = it.id, - main = it.main - ) - } - }, - wind_gust = it.wind_gust, - wind_speed = it.wind_speed - ) - } - }, - hourly = weatherData.hourly?.map { hourly -> - hourly?.let { - WeatherEntity.Hourly( - clouds = it.clouds, - dt = it.dt, - feels_like = it.feels_like, - humidity = it.humidity, - temp = it.temp, - weather = it.weather?.map { weatherCondition -> - weatherCondition?.let { - Weather( - description = it.description, - icon = it.icon, - id = it.id, - main = it.main - ) - } - } - ) - } - } - ) - } - - /** - * Maps a NetworkAirQuality to an AirQualityEntity for storage in the database - * @param airQualityData The network model to map - * @return An AirQualityEntity with all fields mapped from the network model - */ - private fun mapNetworkAirQualityToEntity(airQualityData: NetworkAirQuality): AirQualityEntity { - return AirQualityEntity( - id = null, // Room will auto-generate this - aqi = airQualityData.aqi, - co = airQualityData.co, - no2 = airQualityData.no2, - o3 = airQualityData.o3, - so2 = airQualityData.so2, - pm10 = airQualityData.pm10, - pm25 = airQualityData.pm25 - ) + override suspend fun clearAllData() { + withContext(Dispatchers.IO) { + weatherDatabase.weatherDao().clearAll() + } + locationTimestamps.clear() } } diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/AirQualityEntity.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AirQualityEntity.kt index a73a29a7..ed3f2ab6 100644 --- a/storage/src/androidMain/kotlin/bose/ankush/storage/room/AirQualityEntity.kt +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AirQualityEntity.kt @@ -14,4 +14,4 @@ data class AirQualityEntity( var so2: Double? = 0.0, var pm10: Double? = 0.0, var pm25: Double? = 0.0, -) \ No newline at end of file +) diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthToken.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthToken.kt new file mode 100644 index 00000000..d5230dde --- /dev/null +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthToken.kt @@ -0,0 +1,15 @@ +package bose.ankush.storage.room + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Room entity for storing authentication token + */ +@Entity(tableName = "auth_tokens") +data class AuthToken( + @PrimaryKey + val id: Int = 1, // We only need one token, so use a fixed ID + val token: String, + val createdAt: Long = System.currentTimeMillis(), +) diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthTokenDao.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthTokenDao.kt new file mode 100644 index 00000000..731cf07c --- /dev/null +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthTokenDao.kt @@ -0,0 +1,42 @@ +package bose.ankush.storage.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +/** + * Data Access Object for AuthToken entity + */ +@Dao +interface AuthTokenDao { + /** + * Insert or replace a token + * @param token The token to save + * @return The row ID of the inserted token + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun saveToken(token: AuthToken): Long + + /** + * Get the stored token + * @return The token or null if not found + */ + @Query("SELECT * FROM auth_tokens WHERE id = 1 LIMIT 1") + fun getToken(): AuthToken? + + /** + * Observe if a token exists + * @return Flow of Boolean indicating if a token exists + */ + @Query("SELECT EXISTS(SELECT 1 FROM auth_tokens WHERE id = 1 LIMIT 1)") + fun hasToken(): Flow + + /** + * Delete all tokens + * @return The number of tokens deleted + */ + @Query("DELETE FROM auth_tokens") + fun clearTokens(): Int +} diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/JsonParser.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/JsonParser.kt index 8d0d4109..57249414 100644 --- a/storage/src/androidMain/kotlin/bose/ankush/storage/room/JsonParser.kt +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/JsonParser.kt @@ -3,17 +3,28 @@ package bose.ankush.storage.room import com.google.gson.Gson import java.lang.reflect.Type -class JsonParser(private val gson: Gson) : Parser { - override fun fromJson(json: String, type: Type): T? { - return gson.fromJson(json, type) - } +class JsonParser( + private val gson: Gson, +) : Parser { + override fun fromJson( + json: String, + type: Type, + ): T? = gson.fromJson(json, type) - override fun toJson(obj: T, type: Type): String? { - return gson.toJson(obj, type) - } + override fun toJson( + obj: T, + type: Type, + ): String? = gson.toJson(obj, type) } interface Parser { - fun fromJson(json: String, type: Type): T? - fun toJson(obj: T, type: Type): String? -} \ No newline at end of file + fun fromJson( + json: String, + type: Type, + ): T? + + fun toJson( + obj: T, + type: Type, + ): String? +} diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDao.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDao.kt index 5148cd76..43848e3d 100644 --- a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDao.kt +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDao.kt @@ -11,9 +11,11 @@ import kotlinx.coroutines.flow.Flow @Dao interface WeatherDao { - @Transaction - fun refreshWeather(weather: WeatherEntity, airQuality: AirQualityEntity) { + fun refreshWeather( + weather: WeatherEntity, + airQuality: AirQualityEntity, + ) { deleteAllWeatherDetails() deleteAllAirQualityDetails() insertWeather(weather) @@ -27,14 +29,20 @@ interface WeatherDao { fun insertAirQuality(airQuality: AirQualityEntity) @Query("SELECT * from $WEATHER_DATABASE_NAME") - fun getWeather(): Flow + fun getWeather(): Flow @Query("SELECT * from $AQ_DATABASE_NAME") - fun getAirQuality(): Flow + fun getAirQuality(): Flow @Query("DELETE from $WEATHER_DATABASE_NAME") fun deleteAllWeatherDetails() @Query("DELETE from $AQ_DATABASE_NAME") fun deleteAllAirQualityDetails() -} \ No newline at end of file + + @Transaction + fun clearAll() { + deleteAllWeatherDetails() + deleteAllAirQualityDetails() + } +} diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDataModelConverters.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDataModelConverters.kt index 7f3d7768..fb7f3e3c 100644 --- a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDataModelConverters.kt +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDataModelConverters.kt @@ -5,58 +5,62 @@ import androidx.room.TypeConverter import com.google.gson.reflect.TypeToken @ProvidedTypeConverter -class WeatherDataModelConverters(private val parser: Parser) { - +class WeatherDataModelConverters( + private val parser: Parser, +) { @TypeConverter - fun toAlertJson(alerts: List?): String = parser.toJson( - alerts, - object : TypeToken?>() {}.type - ) ?: "[]" + fun toAlertJson(alerts: List?): String? = + parser.toJson( + alerts, + object : TypeToken?>() {}.type, + ) @TypeConverter fun fromAlertJson(alertString: String): List = parser.fromJson( alertString, - object : TypeToken?>() {}.type + object : TypeToken?>() {}.type, ) ?: emptyList() @TypeConverter - fun toDailyWeatherJson(dailyWeatherReports: List?): String = parser.toJson( - dailyWeatherReports, - object : TypeToken?>() {}.type - ) ?: "[]" + fun toDailyWeatherJson(dailyWeatherReports: List?): String? = + parser.toJson( + dailyWeatherReports, + object : TypeToken?>() {}.type, + ) @TypeConverter fun fromDailyWeather(dailyWeatherString: String): List = parser.fromJson( dailyWeatherString, - object : TypeToken?>() {}.type + object : TypeToken?>() {}.type, ) ?: emptyList() @TypeConverter - fun toHourlyWeatherJson(hourlyWeatherReports: List?): String = + fun toHourlyWeatherJson(hourlyWeatherReports: List?): String? = parser.toJson( hourlyWeatherReports, - object : TypeToken?>() {}.type - ) ?: "[]" + object : TypeToken?>() {}.type, + ) @TypeConverter fun fromHourlyWeather(hourlyWeatherString: String): List = parser.fromJson( hourlyWeatherString, - object : TypeToken?>() {}.type + object : TypeToken?>() {}.type, ) ?: emptyList() @TypeConverter - fun toWeatherJson(weatherReports: List?): String = parser.toJson( - weatherReports, - object : TypeToken?>() {}.type - ) ?: "[]" + fun toWeatherJson(weatherReports: List?): String? = + parser.toJson( + weatherReports, + object : TypeToken?>() {}.type, + ) @TypeConverter fun fromWeatherJson(weatherString: String): List? = parser.fromJson( weatherString, - object : TypeToken?>() {}.type + object : TypeToken?>() {}.type, ) -} \ No newline at end of file +} diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDatabase.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDatabase.kt index fe2b4805..751b17e3 100644 --- a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDatabase.kt +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDatabase.kt @@ -4,11 +4,12 @@ import androidx.room.Database import androidx.room.RoomDatabase @Database( - entities = [WeatherEntity::class, AirQualityEntity::class], - version = 2, - exportSchema = false + entities = [WeatherEntity::class, AirQualityEntity::class, AuthToken::class], + version = 3, + exportSchema = false, ) abstract class WeatherDatabase : RoomDatabase() { - abstract fun weatherDao(): WeatherDao -} \ No newline at end of file + + abstract fun authTokenDao(): AuthTokenDao +} diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherEntity.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherEntity.kt index 3589b5ca..de478c41 100644 --- a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherEntity.kt +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherEntity.kt @@ -14,14 +14,14 @@ data class WeatherEntity( @Embedded val current: Current? = null, @field:TypeConverters(WeatherDataModelConverters::class) val daily: List? = listOf(), @field:TypeConverters(WeatherDataModelConverters::class) val hourly: List? = listOf(), - @ColumnInfo(defaultValue = "0") val lastUpdated: Long = System.currentTimeMillis() + @ColumnInfo(defaultValue = "0") val lastUpdated: Long = System.currentTimeMillis(), ) { data class Alert( val description: String?, - val end: Int?, + val end: Long?, val event: String?, val sender_name: String?, - val start: Int? + val start: Long?, ) data class Current( @@ -30,13 +30,13 @@ data class WeatherEntity( val feels_like: Double?, val humidity: Int?, val pressure: Int?, - val sunrise: Int?, - val sunset: Int?, + val sunrise: Long?, + val sunset: Long?, val temp: Double?, val uvi: Double?, @field:TypeConverters(WeatherDataModelConverters::class) val weather: List? = listOf(), val wind_gust: Double?, - val wind_speed: Double? + val wind_speed: Double?, ) data class Daily( @@ -47,13 +47,13 @@ data class WeatherEntity( val pressure: Int?, val rain: Double?, val summary: String?, - val sunrise: Int?, - val sunset: Int?, + val sunrise: Long?, + val sunset: Long?, @Embedded val temp: Temp?, val uvi: Double?, @field:TypeConverters(WeatherDataModelConverters::class) val weather: List? = listOf(), val wind_gust: Double?, - val wind_speed: Double? + val wind_speed: Double?, ) { data class Temp( val day: Double?, @@ -61,7 +61,7 @@ data class WeatherEntity( val max: Double?, val min: Double?, val morn: Double?, - val night: Double? + val night: Double?, ) } @@ -71,13 +71,13 @@ data class WeatherEntity( val feels_like: Double?, val humidity: Int?, val temp: Double?, - @field:TypeConverters(WeatherDataModelConverters::class) val weather: List? = listOf() + @field:TypeConverters(WeatherDataModelConverters::class) val weather: List? = listOf(), ) } data class Weather( - val description: String, - val icon: String, + val description: String? = null, + val icon: String? = null, val id: Int, - val main: String + val main: String? = null, ) diff --git a/storage/src/commonMain/kotlin/bose/ankush/storage/api/TokenStorage.kt b/storage/src/commonMain/kotlin/bose/ankush/storage/api/TokenStorage.kt new file mode 100644 index 00000000..dca29a1d --- /dev/null +++ b/storage/src/commonMain/kotlin/bose/ankush/storage/api/TokenStorage.kt @@ -0,0 +1,32 @@ +package bose.ankush.storage.api + +import kotlinx.coroutines.flow.Flow + +/** + * Interface for secure token storage + * This will be implemented differently on each platform + */ +interface TokenStorage { + /** + * Save a token + * @param token The JWT token to save + */ + suspend fun saveToken(token: String) + + /** + * Get the stored token + * @return The JWT token or null if not available + */ + suspend fun getToken(): String? + + /** + * Check if a token exists + * @return Flow of Boolean indicating if a token exists + */ + fun hasToken(): Flow + + /** + * Clear the stored token + */ + suspend fun clearToken() +} diff --git a/storage/src/commonMain/kotlin/bose/ankush/storage/api/WeatherStorage.kt b/storage/src/commonMain/kotlin/bose/ankush/storage/api/WeatherStorage.kt index 6d251383..8c54141a 100644 --- a/storage/src/commonMain/kotlin/bose/ankush/storage/api/WeatherStorage.kt +++ b/storage/src/commonMain/kotlin/bose/ankush/storage/api/WeatherStorage.kt @@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.Flow /** * Interface for weather data storage operations. - * + * * This interface defines the contract for storing and retrieving weather and air quality data. * It abstracts the underlying storage mechanism (e.g., Room database) from the rest of the application. * Implementations of this interface are responsible for: @@ -28,15 +28,38 @@ interface WeatherStorage { fun getAirQualityReport(coordinates: Pair): Flow /** - * Refresh weather data from the network and store it + * Get the timestamp of the last weather data update for a specific location. * @param coordinates Pair of latitude and longitude - * @throws Exception if there's an error refreshing the data + * @return Timestamp in milliseconds, or 0 if no update has been recorded for this location */ - suspend fun refreshWeatherData(coordinates: Pair) + suspend fun getLastWeatherUpdateTime(coordinates: Pair): Long /** - * Get the timestamp of the last weather data update - * @return Timestamp in milliseconds + * Record the timestamp of the last weather data update for a specific location. + * @param coordinates Pair of latitude and longitude + * @param time Timestamp in milliseconds + */ + suspend fun saveLastWeatherUpdateTime( + coordinates: Pair, + time: Long, + ) + + /** + * Save weather and air quality data to storage. + * + * This method is called by the orchestration layer after fetching and mapping data from network. + * + * @param weatherEntity The weather data to save + * @param airQualityEntity The air quality data to save + */ + suspend fun saveWeatherData( + weatherEntity: Any, + airQualityEntity: Any, + ) + + /** + * Delete all weather and air quality records and clear any cached metadata (e.g. timestamps). + * Must be called on logout so no stale data survives into the next session. */ - suspend fun getLastWeatherUpdateTime(): Long + suspend fun clearAllData() } diff --git a/storage/src/commonMain/kotlin/bose/ankush/storage/common/Constants.kt b/storage/src/commonMain/kotlin/bose/ankush/storage/common/Constants.kt index be1f164a..146cd6d4 100644 --- a/storage/src/commonMain/kotlin/bose/ankush/storage/common/Constants.kt +++ b/storage/src/commonMain/kotlin/bose/ankush/storage/common/Constants.kt @@ -1,9 +1,5 @@ package bose.ankush.storage.common -/** - * Constants used in the storage module - */ - -/*Room central db name*/ +/** Constants used in the storage module (Room DB names). */ const val WEATHER_DATABASE_NAME = "central_weather_table" -const val AQ_DATABASE_NAME = "central_aq_table" \ No newline at end of file +const val AQ_DATABASE_NAME = "central_aq_table" diff --git a/storage/src/commonMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt b/storage/src/commonMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt new file mode 100644 index 00000000..39c68445 --- /dev/null +++ b/storage/src/commonMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt @@ -0,0 +1,21 @@ +package bose.ankush.storage.impl + +import bose.ankush.storage.api.TokenStorage +import kotlinx.coroutines.flow.Flow + +/** + * Platform-specific encrypted token storage factory + * + * Each platform provides its own implementation: + * - Android: Uses EncryptedSharedPreferences with Android Keystore + * - iOS: Uses Keychain for secure token storage + */ +expect class EncryptedTokenStorageImpl : TokenStorage { + override suspend fun saveToken(token: String) + + override suspend fun getToken(): String? + + override fun hasToken(): Flow + + override suspend fun clearToken() +} diff --git a/storage/src/iosMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt b/storage/src/iosMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt new file mode 100644 index 00000000..2b7def5b --- /dev/null +++ b/storage/src/iosMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt @@ -0,0 +1,115 @@ +@file:Suppress("UNCHECKED_CAST") + +package bose.ankush.storage.impl + +import bose.ankush.storage.api.TokenStorage +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCObjectVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import platform.Foundation.NSData +import platform.Foundation.NSMutableDictionary +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create +import platform.Foundation.dataUsingEncoding +import platform.Security.SecItemAdd +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.kSecAttrAccessible +import platform.Security.kSecAttrAccessibleWhenUnlockedThisDeviceOnly +import platform.Security.kSecAttrAccount +import platform.Security.kSecAttrService +import platform.Security.kSecClass +import platform.Security.kSecClassGenericPassword +import platform.Security.kSecMatchLimit +import platform.Security.kSecMatchLimitOne +import platform.Security.kSecReturnData +import platform.Security.kSecValueData + +/** + * SECURITY: Encrypted token storage using iOS Keychain with Secure Enclave support. + * + * Tokens are encrypted by the OS, hardware-backed via Secure Enclave (A7+), protected + * by device lock, and stored with kSecAttrAccessibleWhenUnlockedThisDeviceOnly so they + * are never synced to iCloud. + */ +@OptIn(ExperimentalForeignApi::class) +actual class EncryptedTokenStorageImpl : TokenStorage { + private val hasTokenState = MutableStateFlow(false) + + init { + hasTokenState.value = retrieveTokenFromKeychain() != null + } + + actual override suspend fun saveToken(token: String) { + val tokenData = NSString.create(string = token).dataUsingEncoding(NSUTF8StringEncoding) + ?: throw Exception("Failed to encode token to NSData") + + deleteTokenFromKeychain() + + val query = buildBaseQuery() + query.setObject(tokenData, forKey = kSecValueData as Any) + + val status = SecItemAdd(query, null) + if (status == 0) { + hasTokenState.value = true + } else { + throw Exception("Failed to save token to Keychain: error code $status") + } + } + + actual override suspend fun getToken(): String? = retrieveTokenFromKeychain() + + actual override fun hasToken(): Flow = hasTokenState.asStateFlow() + + actual override suspend fun clearToken() { + deleteTokenFromKeychain() + hasTokenState.value = false + } + + private fun retrieveTokenFromKeychain(): String? { + val query = buildBaseQuery() + query.setObject(true, forKey = kSecReturnData as Any) + query.setObject(kSecMatchLimitOne, forKey = kSecMatchLimit as Any) + + return memScoped { + val resultRef = alloc>() + val status = SecItemCopyMatching(query, resultRef.ptr) + if (status == 0) { + val nsData = resultRef.value as? NSData + nsData?.let { + NSString.create(data = it, encoding = NSUTF8StringEncoding)?.toString() + } + } else { + null + } + } + } + + private fun deleteTokenFromKeychain() { + val query = buildBaseQuery() + val status = SecItemDelete(query) + // errSecItemNotFound (-25300) is acceptable โ€” nothing to delete + if (status != 0 && status != -25300) { + throw Exception("Failed to delete token from Keychain: error code $status") + } + } + + private fun buildBaseQuery(): NSMutableDictionary = NSMutableDictionary().apply { + setObject(kSecClassGenericPassword, forKey = kSecClass as Any) + setObject(SERVICE_ID, forKey = kSecAttrService as Any) + setObject(ACCOUNT_ID, forKey = kSecAttrAccount as Any) + setObject(kSecAttrAccessibleWhenUnlockedThisDeviceOnly, forKey = kSecAttrAccessible as Any) + } + + companion object { + private const val SERVICE_ID = "com.weatherify.auth" + private const val ACCOUNT_ID = "auth_token" + } +} diff --git a/sunriseui/build.gradle.kts b/sunriseui/build.gradle.kts deleted file mode 100644 index 5fc8a3ba..00000000 --- a/sunriseui/build.gradle.kts +++ /dev/null @@ -1,40 +0,0 @@ -plugins { - id("com.android.library") - id("kotlin-android") - id("org.jetbrains.kotlin.plugin.compose") -} - -dependencies { - implementation(platform(Deps.composeBom)) - implementation(Deps.composeUi) - implementation(Deps.composeMaterial3) - implementation(Deps.composeUiToolingPreview) - debugImplementation(Deps.composeUiTooling) - implementation(Deps.coroutinesCore) - - testImplementation(Deps.junit) - androidTestImplementation(Deps.extJunit) - androidTestImplementation(Deps.espressoCore) -} - -android { - namespace = "bose.ankush.sunriseui" - compileSdk = ConfigData.compileSdkVersion - - defaultConfig { - minSdk = ConfigData.minSdkVersion - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - buildFeatures { - compose = true - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } -} diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherIcon.kt b/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherIcon.kt deleted file mode 100644 index 8d0c34a8..00000000 --- a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherIcon.kt +++ /dev/null @@ -1,407 +0,0 @@ -package bose.ankush.sunriseui.components - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.EaseInOutCubic -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import bose.ankush.sunriseui.constants.WeatherIconConstants - -/** - * Holds color values for weather icons that adapt to light/dark theme. - */ -class WeatherIconColors( - val sunColor: Color, - val sunGlowColor: Color, - val cloudColor: Color, - val rainColor: Color, - val snowColor: Color, - val thunderColor: Color, - val fogColor: Color -) { - companion object { - /** - * Creates theme-aware colors for weather icons. - */ - @Composable - fun default(isDarkTheme: Boolean = isSystemInDarkTheme()): WeatherIconColors { - // Use a try-catch to handle cases where MaterialTheme is not available - val sunColor = try { - if (isDarkTheme) Color(0xFFFFD700) else Color(0xFFFF9800) - } catch (_: Exception) { - Color(0xFFFF9800) // Default fallback - } - - val sunGlowColor = try { - if (isDarkTheme) Color(0xFFFFD700).copy(alpha = WeatherIconConstants.SUN_GLOW_ALPHA) - else Color(0xFFFF9800).copy(alpha = WeatherIconConstants.SUN_GLOW_ALPHA) - } catch (_: Exception) { - Color(0xFFFF9800).copy(alpha = WeatherIconConstants.SUN_GLOW_ALPHA) // Default fallback - } - - return WeatherIconColors( - sunColor = sunColor, - sunGlowColor = sunGlowColor, - cloudColor = if (isDarkTheme) Color.White.copy(alpha = 0.9f) else Color.White, - rainColor = if (isDarkTheme) Color(0xFF64B5F6) else Color(0xFF2196F3), - snowColor = if (isDarkTheme) Color.White else Color.White.copy(alpha = 0.9f), - thunderColor = if (isDarkTheme) Color(0xFFFFEB3B) else Color(0xFFFFC107), - fogColor = if (isDarkTheme) Color.LightGray.copy(alpha = 0.7f) else Color.Gray.copy( - alpha = 0.5f - ) - ) - } - } -} - -/** - * A composable that displays an animated weather icon based on the weather description. - * Maps the description to the appropriate WeatherCondition and renders the corresponding animation. - * Optimized for performance and supports dark mode. - * - * @param weatherDescription The description of the weather condition - * @param modifier Modifier to be applied to the icon - * @param colors Theme-aware colors for the weather icons - */ -@Composable -fun AnimatedWeatherIcon( - weatherDescription: String?, - modifier: Modifier = Modifier.size(48.dp), - colors: WeatherIconColors = WeatherIconColors.default() -) { - // Map the weather description to a WeatherCondition - val weatherCondition = remember(weatherDescription) { - mapToWeatherCondition(weatherDescription) - } - - // Create a content description for accessibility - val contentDesc = remember(weatherCondition) { - "Weather icon: ${weatherCondition.description}" - } - - // Determine which animations are needed based on weather condition - val needsSunAnimation = remember(weatherCondition) { - weatherCondition == WeatherCondition.CLEAR_SKY || - weatherCondition == WeatherCondition.FEW_CLOUDS - } - - val needsCloudAnimation = remember(weatherCondition) { - weatherCondition in listOf( - WeatherCondition.FEW_CLOUDS, - WeatherCondition.SCATTERED_CLOUDS, - WeatherCondition.BROKEN_CLOUDS, - WeatherCondition.OVERCAST_CLOUDS - ) || weatherCondition.description.contains("rain") || - weatherCondition.description.contains("drizzle") || - weatherCondition.description.contains("snow") || - weatherCondition.description.contains("thunderstorm") - } - - val needsRainAnimation = remember(weatherCondition) { - weatherCondition.description.contains("rain") || - weatherCondition.description.contains("drizzle") || - weatherCondition.description.contains("thunderstorm") - } - - val needsSnowAnimation = remember(weatherCondition) { - weatherCondition.description.contains("snow") || - weatherCondition.description.contains("sleet") - } - - val needsThunderAnimation = remember(weatherCondition) { - weatherCondition.description.contains("thunderstorm") - } - - val needsFogAnimation = remember(weatherCondition) { - weatherCondition in listOf( - WeatherCondition.MIST, - WeatherCondition.SMOKE, - WeatherCondition.HAZE, - WeatherCondition.SAND_DUST_WHIRLS, - WeatherCondition.FOG, - WeatherCondition.SAND, - WeatherCondition.DUST, - WeatherCondition.VOLCANIC_ASH, - WeatherCondition.SQUALLS, - WeatherCondition.TORNADO - ) - } - - // Animation specs - define once to use as keys in LaunchedEffect - val sunAnimSpec = remember { - infiniteRepeatable( - animation = tween( - durationMillis = WeatherIconConstants.SUN_ANIMATION_DURATION, - easing = EaseInOutCubic - ), - repeatMode = RepeatMode.Reverse - ) - } - - val cloudAnimSpec = remember { - infiniteRepeatable( - animation = tween( - durationMillis = WeatherIconConstants.CLOUD_ANIMATION_DURATION, - easing = EaseInOutCubic - ), - repeatMode = RepeatMode.Restart - ) - } - - val rainAnimSpec = remember { - infiniteRepeatable( - animation = tween( - durationMillis = WeatherIconConstants.RAIN_ANIMATION_DURATION, - easing = LinearEasing - ), - repeatMode = RepeatMode.Restart - ) - } - - val snowAnimSpec = remember { - infiniteRepeatable( - animation = tween( - durationMillis = WeatherIconConstants.SNOW_ANIMATION_DURATION, - easing = LinearEasing - ), - repeatMode = RepeatMode.Restart - ) - } - - val thunderAnimSpec = remember { - infiniteRepeatable( - animation = tween( - durationMillis = WeatherIconConstants.THUNDER_ANIMATION_DURATION, - easing = FastOutSlowInEasing - ), - repeatMode = RepeatMode.Restart - ) - } - - // Animation states - only initialize what's needed - val sunGlow = remember { Animatable(0f) } - val cloudDrift = remember { Animatable(0f) } - val rainDrop = remember { Animatable(0f) } - val snowFall = remember { Animatable(0f) } - val thunderFlash = remember { Animatable(0f) } - - // Start animations only if needed, with proper keys to restart when specs change - if (needsSunAnimation) { - LaunchedEffect(weatherCondition, sunAnimSpec) { - sunGlow.animateTo( - targetValue = 1f, - animationSpec = sunAnimSpec - ) - } - } - - if (needsCloudAnimation || needsFogAnimation) { - LaunchedEffect(weatherCondition, cloudAnimSpec) { - cloudDrift.animateTo( - targetValue = 1f, - animationSpec = cloudAnimSpec - ) - } - } - - if (needsRainAnimation) { - LaunchedEffect(weatherCondition, rainAnimSpec) { - rainDrop.animateTo( - targetValue = 1f, - animationSpec = rainAnimSpec - ) - } - } - - if (needsSnowAnimation) { - LaunchedEffect(weatherCondition, snowAnimSpec) { - snowFall.animateTo( - targetValue = 1f, - animationSpec = snowAnimSpec - ) - } - } - - if (needsThunderAnimation) { - LaunchedEffect(weatherCondition, thunderAnimSpec) { - thunderFlash.animateTo( - targetValue = 1f, - animationSpec = thunderAnimSpec - ) - } - } - - Box( - modifier = modifier.semantics { - contentDescription = contentDesc - }, - contentAlignment = Alignment.Center - ) { - Canvas(modifier = Modifier.matchParentSize()) { - when { - // Clear sky - weatherCondition == WeatherCondition.CLEAR_SKY -> { - drawSun( - animationProgress = sunGlow.value, - sunColor = colors.sunColor, - sunGlowColor = colors.sunGlowColor - ) - } - - // Clouds - weatherCondition in listOf( - WeatherCondition.FEW_CLOUDS, - WeatherCondition.SCATTERED_CLOUDS, - WeatherCondition.BROKEN_CLOUDS, - WeatherCondition.OVERCAST_CLOUDS - ) -> { - val cloudiness = when (weatherCondition) { - WeatherCondition.FEW_CLOUDS -> 0.2f - WeatherCondition.SCATTERED_CLOUDS -> 0.4f - WeatherCondition.BROKEN_CLOUDS -> 0.7f - WeatherCondition.OVERCAST_CLOUDS -> 1.0f - else -> 0.5f - } - - if (weatherCondition == WeatherCondition.FEW_CLOUDS) { - drawSun( - animationProgress = sunGlow.value, - scale = 0.7f, - offsetX = -size.width * 0.15f, - sunColor = colors.sunColor, - sunGlowColor = colors.sunGlowColor - ) - } - - drawClouds( - animationProgress = cloudDrift.value, - cloudiness = cloudiness, - cloudColor = colors.cloudColor - ) - } - - // Rain - weatherCondition.description.contains("rain") && !weatherCondition.description.contains( - "thunderstorm" - ) -> { - val intensity = when { - weatherCondition.description.contains("light") -> 0.3f - weatherCondition.description.contains("heavy") || - weatherCondition.description.contains("intense") || - weatherCondition.description.contains("extreme") -> 0.9f - - else -> 0.6f - } - - drawClouds( - animationProgress = cloudDrift.value, - cloudiness = 0.8f, - cloudColor = colors.cloudColor - ) - drawRain( - animationProgress = rainDrop.value, - intensity = intensity, - rainColor = colors.rainColor - ) - } - - // Snow - weatherCondition.description.contains("snow") || weatherCondition.description.contains( - "sleet" - ) -> { - val intensity = when { - weatherCondition.description.contains("light") -> 0.3f - weatherCondition.description.contains("heavy") -> 0.9f - else -> 0.6f - } - - drawClouds( - animationProgress = cloudDrift.value, - cloudiness = 0.7f, - cloudColor = colors.cloudColor - ) - drawSnow( - animationProgress = snowFall.value, - intensity = intensity, - snowColor = colors.snowColor - ) - } - - // Thunderstorm - weatherCondition.description.contains("thunderstorm") -> { - drawClouds( - animationProgress = cloudDrift.value, - cloudiness = 0.9f, - cloudColor = colors.cloudColor - ) - drawRain( - animationProgress = rainDrop.value, - intensity = 0.7f, - rainColor = colors.rainColor - ) - drawThunder( - animationProgress = thunderFlash.value, - thunderColor = colors.thunderColor - ) - } - - // Drizzle - weatherCondition.description.contains("drizzle") -> { - drawClouds( - animationProgress = cloudDrift.value, - cloudiness = 0.7f, - cloudColor = colors.cloudColor - ) - drawRain( - animationProgress = rainDrop.value, - intensity = 0.3f, - rainColor = colors.rainColor - ) - } - - // Atmosphere (mist, fog, etc.) - weatherCondition in listOf( - WeatherCondition.MIST, - WeatherCondition.SMOKE, - WeatherCondition.HAZE, - WeatherCondition.SAND_DUST_WHIRLS, - WeatherCondition.FOG, - WeatherCondition.SAND, - WeatherCondition.DUST, - WeatherCondition.VOLCANIC_ASH, - WeatherCondition.SQUALLS, - WeatherCondition.TORNADO - ) -> { - drawFog( - animationProgress = cloudDrift.value, - fogColor = colors.fogColor - ) - } - - // Default fallback - else -> { - drawSun( - animationProgress = sunGlow.value, - sunColor = colors.sunColor, - sunGlowColor = colors.sunGlowColor - ) - } - } - } - } -}