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..b0f852d5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,35 +1,33 @@ -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.gms.google-services") - id("dagger.hilt.android.plugin") - id("kotlin-parcelize") - id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") - id("com.github.ben-manes.versions") - id("org.jetbrains.kotlin.plugin.compose") + alias(libs.plugins.android.application) + alias(libs.plugins.ksp) + alias(libs.plugins.google.services) + alias(libs.plugins.hilt.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.secrets.gradle.plugin) + alias(libs.plugins.ben.manes.versions) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) } android { - compileSdk = ConfigData.compileSdkVersion + compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { applicationId = "bose.ankush.weatherify" - minSdk = ConfigData.minSdkVersion - targetSdk = ConfigData.targetSdkVersion + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() versionCode = ConfigData.versionCode 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,101 +59,134 @@ 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) - implementation(Deps.appCompat) - implementation(Deps.androidMaterial) - implementation(Deps.viewModelCompose) - implementation(Deps.navigationCompose) - implementation(Deps.inAppUpdate) - implementation(Deps.inAppUpdateKtx) - implementation(Deps.googlePlayLocation) - implementation(Deps.systemUIController) - implementation(Deps.composePermission) - implementation(Deps.dataStore) - implementation(Deps.splashScreen) - + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.google.material) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Navigation 3 + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(libs.google.play.app.update) + implementation(libs.google.play.app.update.ktx) + implementation(libs.google.play.services.location) + implementation(libs.accompanist.systemuicontroller) + implementation(libs.accompanist.permissions) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.core.splashscreen) // Compose - implementation(platform(Deps.composeBom)) - implementation(Deps.composeUi) - debugImplementation(Deps.composeUiTooling) - implementation(Deps.composeUiToolingPreview) - implementation(Deps.composeMaterial3) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) // Unit Testing - testImplementation(Deps.junit) - testImplementation(Deps.truth) - testImplementation(Deps.turbine) - testImplementation(Deps.coroutineTest) - testImplementation(Deps.coreTesting) - testImplementation(Deps.mockitoInline) - testImplementation(Deps.mockitoNhaarman) - testImplementation(Deps.mockWebServer) - testImplementation(Deps.mockk) + testImplementation(libs.junit) + testImplementation(libs.truth) + testImplementation(libs.turbine) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.arch.core.testing) + testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.mockk) // UI Testing - androidTestImplementation(Deps.extJunit) - androidTestImplementation(Deps.espressoCore) - androidTestImplementation(Deps.espressoContrib) - androidTestImplementation(Deps.hiltTesting) - kaptAndroidTest(Deps.hiltDaggerAndroidCompiler) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.espresso.contrib) + androidTestImplementation(libs.google.dagger.hilt.android.testing) + kspAndroidTest(libs.google.dagger.hilt.android.compiler) // Networking - implementation(Deps.gson) + implementation(libs.gson) - // Firebase - implementation(platform(Deps.firebaseBom)) - implementation(Deps.firebaseConfig) - implementation(Deps.firebaseAnalytics) - implementation(Deps.firebasePerformanceMonitoring) + // Room runtime for providing WeatherDatabase from app DI + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + // Firebase - BOM + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.config) + implementation(libs.firebase.analytics) + implementation(libs.firebase.perf) + implementation(libs.firebase.messaging) // Coroutines - implementation(Deps.coroutinesCore) - implementation(Deps.coroutinesAndroid) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + + // Date/Time (KMP-compatible, replaces java.time) + implementation(libs.kotlinx.datetime) // Dependency Injection - implementation(Deps.hilt) - implementation(Deps.hiltNavigationCompose) - kapt(Deps.hiltDaggerAndroidCompiler) + implementation(libs.google.dagger.hilt.android) + implementation(libs.androidx.hilt.navigation.compose) + ksp(libs.google.dagger.hilt.android.compiler) + ksp(libs.androidx.hilt.compiler) // Miscellaneous - implementation(Deps.timber) - implementation(Deps.lottieCompose) - implementation(Deps.coilCompose) + implementation(libs.timber) + implementation(libs.coil.compose) // Memory leak - debugImplementation(Deps.leakCanary) + debugImplementation(libs.leakcanary.android) + + // Payment SDK (Android-only โ€” Razorpay checkout is launched from the app layer) + implementation(libs.razorpay.checkout) + + // Koin โ€” bridges the feature-payment Koin module with Hilt-managed singletons + implementation(libs.koin.android) + implementation(libs.koin.androidx.compose) +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.addAll( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=androidx.compose.animation.ExperimentalAnimationApi", + ) + } +} + +// com.razorpay:checkout:1.6.41 pulls in standard-core via a dynamic "latest.integration" version, +// which resolves to releases that split out com.razorpay:core as a separate artifact while keeping +// the same com.razorpay namespace on both โ€” AGP rejects that as a duplicate namespace. Pin to the +// last version before the split. +configurations.all { + resolutionStrategy { + force("com.razorpay:standard-core:1.6.56") + } } 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 56% 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..96415ed6 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,36 @@ 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( - networkRepository: NetworkWeatherRepository, - weatherDatabase: WeatherDatabase - ): WeatherStorage { - return WeatherStorageImpl(networkRepository, weatherDatabase) + fun provideWeatherStorage(weatherDatabase: WeatherDatabase): WeatherStorage = + WeatherStorageImpl(weatherDatabase) + + @Provides + @Singleton + fun provideTokenStorage( + @ApplicationContext context: Context, + ): TokenStorage { + 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..917a5458 100644 --- a/app/src/main/java/bose/ankush/weatherify/WeatherifyApplication.kt +++ b/app/src/main/java/bose/ankush/weatherify/WeatherifyApplication.kt @@ -2,30 +2,56 @@ 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 -/**Created by -Author: Ankush Bose -Date: 05,May,2021 - **/ - @HiltAndroidApp class WeatherifyApplication : WeatherifyApplicationCore() { - @Inject lateinit var remoteConfigService: RemoteConfigService override fun onCreate() { super.onCreate() + 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 +59,43 @@ 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?, + ) { + 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..bbf548ab 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/DateTimeUtils.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/DateTimeUtils.kt @@ -4,37 +4,21 @@ 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 } - /** - * Returns name of the day from given epoch. Epoch to be provided in Integer format - * via argument - */ fun Long.dayName(): String { val calendar = Calendar.getInstance() calendar.time = Date(this * 1000) @@ -50,9 +34,6 @@ object DateTimeUtils { } } - /** - * Returns current date in Calender type - */ fun getTodayDateInCalenderFormat(): Calendar { val todayDate = Date(System.currentTimeMillis()) val calendarForToday = Calendar.getInstance() @@ -60,41 +41,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..3bff2f9a 100644 --- a/app/src/main/java/bose/ankush/weatherify/base/LocaleConfigMapper.kt +++ b/app/src/main/java/bose/ankush/weatherify/base/LocaleConfigMapper.kt @@ -1,23 +1,23 @@ package bose.ankush.weatherify.base import android.content.Context -import com.google.gson.Gson import com.google.gson.GsonBuilder object LocaleConfigMapper { + fun getAvailableLanguagesFromJson( + jsonFile: String, + context: Context, + ): Array { + val jsonString = + context.assets + .open(jsonFile) + .bufferedReader() + .use { it.readText() } - fun getAvailableLanguagesFromJson(jsonFile: String, context: Context): Array { - // read JSON file - val jsonString: String = context.assets.open(jsonFile) - .bufferedReader() - .use { it.readText() } - - // convert JSON to Map - val gson: Gson = GsonBuilder().setPrettyPrinting().create() + val 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..7604f8ec 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,8 @@ 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 +10,11 @@ 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..AQI_SINGLE_DIGIT_MAX) { + "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..79ca12d0 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 @@ -2,39 +2,30 @@ package bose.ankush.weatherify.base.common import android.annotation.SuppressLint -/**Created by -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*/ const val APP_PREFERENCE_KEY = "app_preferences" -/*Fallback user location coordinates*/ const val DEFAULT_CITY_NAME = "New Delhi" -/* 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*/ const val ENABLE_NOTIFICATION = "enable_notification" + +const val KELVIN_OFFSET = 273 + +const val LUMINANCE_THRESHOLD = 0.5f + +const val AQI_SINGLE_DIGIT_MAX = 9 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..77a3b52d 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,24 +1,26 @@ 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 -Author: Ankush Bose -Date: 06,May,2021 - **/ - object Extension { - - fun Double.toCelsius() = (this - 273).roundToInt().toString() + fun Double.toCelsius() = (this - KELVIN_OFFSET).roundToInt().toString() fun String.getIconUrl(size: String = "@2x.png") = "$WEATHER_IMG_URL$this$size" @@ -26,42 +28,73 @@ 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) + }, + ) - @RequiresApi(Build.VERSION_CODES.TIRAMISU) - fun Context.openAppLocaleSettings() = startActivity( - Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { - data = Uri.fromParts("package", packageName, null) - } - ) + fun Context.openLocationSettings() = + startActivity( + Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS), + ) - 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 + @SuppressLint("QueryPermissionsNeeded") + fun Context.openAppLocaleSettings() { + val resolved = resolveLocaleIntent() ?: return + startActivity(resolved) } - private fun Context.hasPhoneCallPermission(): Boolean { - return ContextCompat.checkSelfPermission( - this, - ACCESS_PHONE_CALL - ) == PackageManager.PERMISSION_GRANTED + @SuppressLint("QueryPermissionsNeeded") + private fun Context.resolveLocaleIntent(): Intent? { + val pm = packageManager + val candidates = buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add( + Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } + add( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + add( + Intent(Settings.ACTION_LOCALE_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } + return candidates.firstOrNull { intent -> + try { + intent.resolveActivity(pm) != null + } catch (_: Exception) { + false + } + } } - fun Context.hasNotificationPermission(): Boolean { - return ContextCompat.checkSelfPermission( + 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 = + ContextCompat.checkSelfPermission( this, - ACCESS_NOTIFICATION + ACCESS_NOTIFICATION, ) == PackageManager.PERMISSION_GRANTED - } fun String.wrapText(): String { - val words: List = this.split(" ") + val words = this.split(" ") return if (words.size == 2) { "${words[0]}\n${words[1]}" } else { @@ -69,11 +102,47 @@ object Extension { } } - fun Context.callNumber(): Boolean = hasPhoneCallPermission().also { hasPermission -> - if (hasPermission) startActivity( - Intent(Intent.ACTION_CALL).apply { - data = PHONE_NUMBER.toUri() - } - ) + fun getDeviceModel(): String = Build.MODEL + + const val OPERATING_SYSTEM: String = "Android" + fun getOperatingSystem(): String = OPERATING_SYSTEM + + fun getOsVersion(): String = Build.VERSION.RELEASE + + fun getAppVersion(): String = BuildConfig.VERSION_NAME + + 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()) } + + const val REGISTRATION_SOURCE: String = "Android App" + fun getRegistrationSource(): String = REGISTRATION_SOURCE + + fun getIpAddress(): String? = runCatching { + NetworkInterface.getNetworkInterfaces() + .asSequence() + .flatMap { it.inetAddresses.asSequence() } + .firstOrNull { !it.isLoopbackAddress && !it.isLinkLocalAddress } + ?.hostAddress + }.getOrNull() + + 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..6237cae9 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 @@ -6,29 +6,29 @@ import com.google.android.play.core.appupdate.AppUpdateManagerFactory import com.google.android.play.core.install.model.AppUpdateType import com.google.android.play.core.install.model.UpdateAvailability import kotlinx.coroutines.ExperimentalCoroutinesApi +import timber.log.Timber @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) { + Timber.w(exception, "Failed to launch in-app update flow") } } } - -} \ 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..b686c060 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,42 @@ 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) + is StringResource -> @Suppress("SpreadOperator") 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) +fun errorResponse(errorCode: Int): UiText.StringResource = + when (errorCode) { + 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) + 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) + else -> UiText.StringResource(resId = R.string.general_error_txt) + } + +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..38c7175e 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() } + .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..576aba1d 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,30 +11,31 @@ 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()) { throw LocationClient.LocationException("Location permission is not given.") } } private fun checkGpsEnabled(): Pair { - // if device's GPS or network is disabled val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager val isGPSEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) @@ -48,64 +48,70 @@ 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"))) + 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 { 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..a53175a7 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/base/notification/WeatherifyMessagingService.kt @@ -0,0 +1,85 @@ +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") + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + Timber.d("Message data payload: ${remoteMessage.data}") + + 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() + ?: "" + + val customData = remoteMessage.data.filterKeys { it != "title" && it != "message" } + if (customData.isNotEmpty()) { + Timber.d("Custom data payload: $customData") + } + + 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) + } + + 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) + + val notificationId = System.currentTimeMillis().toInt() + notificationManager.notify(notificationId, notificationBuilder.build()) + } +} 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..6b3d780e 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,11 @@ 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.model.AirQualityData as StorageAirQualityData -/** - * 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: StorageAirQualityData): AirQuality = + AirQuality( id = entity.id, aqi = entity.aqi ?: 0, co = entity.co ?: 0.0, @@ -20,7 +13,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..02fe989b --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/data/mapper/NetworkToStorageMapper.kt @@ -0,0 +1,129 @@ +package bose.ankush.weatherify.data.mapper + +import bose.ankush.storage.model.AirQualityData +import bose.ankush.storage.model.WeatherCondition +import bose.ankush.storage.model.WeatherData +import bose.ankush.network.model.AirQuality as NetworkAirQuality +import bose.ankush.network.model.WeatherForecast as NetworkWeatherForecast + +object NetworkToStorageMapper { + + private fun mapWeatherInfo(list: List?) = + list?.mapNotNull { info -> + info?.let { + WeatherCondition( + description = it.description, + icon = it.icon, + id = it.id, + main = it.main, + ) + } + } + + private fun mapCurrentToData( + current: NetworkWeatherForecast.Data.Current?, + ) = current?.let { + WeatherData.Current( + clouds = it.clouds, + dt = it.dt, + feels_like = it.feelsLike, + humidity = it.humidity, + pressure = it.pressure, + sunrise = it.sunrise, + sunset = it.sunset, + temp = it.temp, + uvi = it.uvi, + weather = mapWeatherInfo(it.weather), + wind_gust = it.windGust, + wind_speed = it.windSpeed, + ) + } + + private fun mapDailyToData( + daily: List?, + ) = daily?.map { item -> + item?.let { + WeatherData.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 { t -> + WeatherData.Daily.Temp( + day = t.day, eve = t.eve, max = t.max, + min = t.min, morn = t.morn, night = t.night, + ) + }, + uvi = it.uvi, + weather = mapWeatherInfo(it.weather), + wind_gust = it.windGust, + wind_speed = it.windSpeed, + ) + } + } + + private fun mapHourlyToData( + hourly: List?, + ) = hourly?.map { item -> + item?.let { + WeatherData.Hourly( + clouds = it.clouds, + dt = it.dt, + feels_like = it.feelsLike, + humidity = it.humidity, + temp = it.temp, + weather = mapWeatherInfo(it.weather), + ) + } + } + + private fun mapAlertsToData( + alerts: List?, + ) = alerts?.mapNotNull { alert -> + alert?.let { + WeatherData.Alert( + description = it.description, + end = it.end, + event = it.event, + sender_name = it.senderName, + start = it.start, + ) + } + } ?: emptyList() + + fun mapWeatherToStorageEntity(weatherData: NetworkWeatherForecast): WeatherData { + val data = weatherData.data + return WeatherData( + id = 0, + lastUpdated = System.currentTimeMillis(), + current = mapCurrentToData(data?.current), + daily = mapDailyToData(data?.daily), + hourly = mapHourlyToData(data?.hourly), + alerts = mapAlertsToData(data?.alerts), + ) + } + + /** + * Maps the air quality data embedded in the unified weather response to AirQualityData. + * 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?): AirQualityData { + val entry = airQualityData?.list?.firstOrNull() + return AirQualityData( + 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..4d44716a 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,103 @@ 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.model.WeatherData import bose.ankush.weatherify.domain.model.WeatherCondition import bose.ankush.weatherify.domain.model.WeatherForecast +import bose.ankush.storage.model.WeatherCondition 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? { - if (entity == null) return null + private fun mapWeather(list: List?) = + list?.map { it?.let { w -> mapStorageWeatherToDomain(w) } } - 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) - } + private fun mapAlerts(alerts: List?) = + alerts?.map { alert -> + alert?.let { + WeatherForecast.Alert( + description = it.description, + end = it.end, + event = it.event, + sender_name = it.sender_name, + start = it.start, + ) + } + } + + private fun mapCurrent(current: WeatherData.Current?) = + current?.let { + WeatherForecast.Current( + clouds = it.clouds, + dt = it.dt, + feels_like = it.feels_like, + humidity = it.humidity, + pressure = it.pressure, + sunrise = it.sunrise, + sunset = it.sunset, + temp = it.temp, + uvi = it.uvi, + weather = mapWeather(it.weather), + wind_gust = it.wind_gust, + wind_speed = it.wind_speed, + ) + } + + private fun mapDaily(daily: List?) = + daily?.map { item -> + item?.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 { t -> + WeatherForecast.Daily.Temp( + day = t.day, eve = t.eve, max = t.max, + min = t.min, morn = t.morn, night = t.night, + ) }, - wind_gust = current.wind_gust, - wind_speed = current.wind_speed + uvi = it.uvi, + weather = mapWeather(it.weather), + wind_gust = it.wind_gust, + wind_speed = it.wind_speed, + ) + } + } + + private fun mapHourly(hourly: List?) = + hourly?.map { item -> + item?.let { + WeatherForecast.Hourly( + clouds = it.clouds, + dt = it.dt, + feels_like = it.feels_like, + humidity = it.humidity, + temp = it.temp, + weather = mapWeather(it.weather), ) - }, - 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 + } + } + + fun mapToDomain(data: WeatherData?): WeatherForecast? { + if (data == null) return null + return WeatherForecast( + id = data.id, + alerts = mapAlerts(data.alerts), + current = mapCurrent(data.current), + daily = mapDaily(data.daily), + hourly = mapHourly(data.hourly), + lastUpdated = data.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..68931583 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManagerImpl.kt @@ -0,0 +1,82 @@ +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 + +@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..50fff284 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,32 +2,26 @@ 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 -/** - * Firebase implementation of the RemoteConfigService interface. - * 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} ->" - /** - * Initializes the Firebase Remote Config with default settings. - * 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) @@ -37,22 +31,19 @@ class FirebaseRemoteConfigService @Inject constructor() : RemoteConfigService { Timber.tag(tag).d("Firebase Remote Config initialized") } - /** - * Gets a boolean value from Firebase Remote Config. - * @param key The key for the configuration value - * @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 { + @Suppress("TooGenericExceptionCaught") + 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 + private const val DEFAULT_MINIMUM_FETCH_INTERVAL_SECONDS = 3600L } } 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..d1490ef6 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 @@ -1,10 +1,9 @@ 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.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 @@ -13,52 +12,62 @@ import kotlinx.coroutines.flow.Flow 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 - */ -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) + weatherStorage.getAirQualityReport(coordinates).map { data -> + data?.let { AirQualityMapper.mapToDomain(it) } ?: AirQuality() } override fun getWeatherReport(location: Pair): Flow = - weatherStorage.getWeatherReport(location).map { entity -> - WeatherMapper.mapToDomain(entity as StorageWeatherEntity) - } + weatherStorage.getWeatherReport(location).map { data -> WeatherMapper.mapToDomain(data) } /** - * 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 - // Refresh data if it's stale or if this is a forced refresh - if (isDataStale) { - weatherStorage.refreshWeatherData(coordinates) + if (isDataStale) { + val weatherData = networkRepository.refreshWeatherData(coordinates) + + if (weatherData != null) { + val weatherStorageData = + NetworkToStorageMapper.mapWeatherToStorageEntity(weatherData) + val airQualityStorageData = + NetworkToStorageMapper.mapAirQualityToStorageEntity( + weatherData.data?.airQuality, + ) + weatherStorage.saveWeatherData(weatherStorageData, airQualityStorageData) + 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..e0697f23 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 @@ -12,32 +25,57 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -/** - * Module for providing network-related dependencies - */ @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 - */ @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 + @Singleton + fun provideAuthRepository(tokenStorage: TokenStorage): AuthRepository = + createAuthRepository(tokenStorage) + + @Provides + @Singleton + fun provideTokenManager( + tokenStorage: TokenStorage, + authRepository: AuthRepository, + ): TokenManager = createTokenManager(tokenStorage, authRepository) + + @Provides + @Singleton + fun provideFeedbackRepository( + networkConnectivity: NetworkConnectivity, + tokenStorage: TokenStorage, + ): FeedbackRepository = createFeedbackRepository(networkConnectivity, tokenStorage) + + @Provides + @Singleton + fun provideLocationRepository(tokenStorage: TokenStorage): LocationRepository = + createLocationRepository(tokenStorage) + + @Provides + @Singleton + fun provideServiceRepository(): ServiceRepository = createServiceRepository() + + @Provides + @Singleton + fun provideSearchPlacesUseCase(repository: LocationRepository): SearchPlacesUseCase = + SearchPlacesUseCase(repository) + + @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..0ac45bf9 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/di/PaymentKoinModule.kt @@ -0,0 +1,40 @@ +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 { + single { AndroidNetworkConnectivity(androidContext()) } + single { createPaymentApiService(tokenStorage) } + single { PreferenceManagerPremiumStore(preferenceManager) } + 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..b01dc2a6 100644 --- a/app/src/main/java/bose/ankush/weatherify/di/PreferenceModule.kt +++ b/app/src/main/java/bose/ankush/weatherify/di/PreferenceModule.kt @@ -8,19 +8,11 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -/** - * Module for providing preference-related dependencies - */ @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..9900f0c6 100644 --- a/app/src/main/java/bose/ankush/weatherify/di/RemoteConfigModule.kt +++ b/app/src/main/java/bose/ankush/weatherify/di/RemoteConfigModule.kt @@ -8,19 +8,11 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -/** - * Module for providing remote configuration related dependencies - */ @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..d8bdec4e 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,25 @@ 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 { - @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/AirQuality.kt b/app/src/main/java/bose/ankush/weatherify/domain/model/AirQuality.kt index 4e66892d..5047687a 100644 --- a/app/src/main/java/bose/ankush/weatherify/domain/model/AirQuality.kt +++ b/app/src/main/java/bose/ankush/weatherify/domain/model/AirQuality.kt @@ -1,8 +1,5 @@ package bose.ankush.weatherify.domain.model -/** - * Domain model for air quality data - */ data class AirQuality( val id: Long? = null, val aqi: Int = 0, diff --git a/app/src/main/java/bose/ankush/weatherify/domain/model/AvgForecast.kt b/app/src/main/java/bose/ankush/weatherify/domain/model/AvgForecast.kt index fe16180a..8ffafd13 100644 --- a/app/src/main/java/bose/ankush/weatherify/domain/model/AvgForecast.kt +++ b/app/src/main/java/bose/ankush/weatherify/domain/model/AvgForecast.kt @@ -1,9 +1,5 @@ package bose.ankush.weatherify.domain.model -/**Created by -Author: Ankush Bose -Date: 07,May,2021 - **/ data class AvgForecast( val id: Int?, val date: Int?, 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..a326839a 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 @@ -1,8 +1,7 @@ +@file:Suppress("ConstructorParameterNaming") + package bose.ankush.weatherify.domain.model -/** - * Domain model for weather forecast data - */ data class WeatherForecast( val id: Long, val alerts: List? = listOf(), @@ -13,10 +12,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 +24,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 +41,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 +55,7 @@ data class WeatherForecast( val max: Double?, val min: Double?, val morn: Double?, - val night: Double? + val night: Double?, ) } @@ -70,12 +69,9 @@ 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..ae40f377 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,28 +1,49 @@ 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 -/** - * Interface for managing user preferences - */ interface PreferenceManager { - /** - * Get the flow of location preferences - */ - fun getLocationPreferenceFlow(): Flow + fun getUserPreferencesFlow(): Flow - /** - * Save location coordinates to preferences - * @param coordinates Pair of latitude and longitude - */ suspend fun saveLocationPreferences(coordinates: Pair) + suspend fun savePremiumStatus( + isPremium: Boolean, + expiryMillis: Long?, + ) + + suspend fun saveLocationOverride(lat: Double, lon: Double, name: String) + + suspend fun clearLocationOverride() + /** - * Preference keys + * Must be called on logout so no stale state survives into the next session. */ + suspend fun clearAll() + 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 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..3a41499b 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 @@ -1,23 +1,10 @@ package bose.ankush.weatherify.domain.remote_config -/** - * Service interface for handling remote configuration functionality. - * This interface abstracts the underlying implementation details of remote configuration, - * allowing for easier testing and flexibility in implementation. - */ interface RemoteConfigService { - - /** - * Initializes the remote configuration service. - * This should be called early in the application lifecycle. - */ fun initialize() - /** - * Gets a boolean value from remote configuration. - * @param key The key for the configuration value - * @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..5698f38b 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 @@ -4,16 +4,15 @@ import bose.ankush.weatherify.domain.model.AirQuality import bose.ankush.weatherify.domain.model.WeatherForecast import kotlinx.coroutines.flow.Flow -/**Created by -Author: Ankush Bose -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..296f7e38 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/feedback/SubmitFeedback.kt @@ -0,0 +1,14 @@ +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..387f0ed7 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/MainActivity.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/MainActivity.kt @@ -2,174 +2,393 @@ 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.ToastAnchorState +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.LUMINANCE_THRESHOLD import bose.ankush.weatherify.base.common.PERMISSIONS_TO_REQUEST 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) - // Enable edge-to-edge display WindowCompat.setDecorFitsSystemWindows(window, false) startInAppUpdate(this) - 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) + AppContent() + } + } + } + + @Composable + private fun AppContent() { + val context = LocalContext.current + val isLoggedIn by viewModel.isLoggedIn.collectAsState() + val authState by viewModel.authState.collectAsState() + val isAuthInitialized by viewModel.isAuthInitialized.collectAsState() + val toastAnchorState = rememberToastAnchorState() + 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 + } + + SetupSystemUi() + ObserveAuthState(authState = authState, context = context, onShowToast = ::showToast) + ObserveAuthEventBus(onShowToast = ::showToast) + ObservePaymentCheckout(context = context) + + AppScreen( + isAuthInitialized = isAuthInitialized, + isLoggedIn = isLoggedIn, + authState = authState, + toastAnchorState = toastAnchorState, + toastState = ToastDisplayState( + visible = toastVisible, + message = toastMessage, + title = toastTitle, + type = toastType, + onDismiss = { toastVisible = false }, + ), + ) + } + + @Suppress("DEPRECATION") + @Composable + private fun SetupSystemUi() { + val systemUiController = rememberSystemUiController() + val bgColor = MaterialTheme.colorScheme.background + val useDarkIcons = bgColor.luminance() > LUMINANCE_THRESHOLD + SideEffect { + systemUiController.setStatusBarColor( + color = Color.Transparent, + darkIcons = useDarkIcons, + ) + } + } + + @Composable + private fun ObserveAuthState( + authState: AuthState, + context: Context, + onShowToast: (String, String, ToastType) -> Unit, + ) { + LaunchedEffect(authState) { + when (authState) { + is AuthState.Error -> { + onShowToast(authState.message.asString(context), "Error", ToastType.ERROR) + viewModel.resetAuthState() } - if (launchPhoneCallPermissionState.value) { - // request phone call permission - RequestPhoneCallPermission(context) + + is AuthState.Success -> { + onShowToast("Authentication successful", "Success", ToastType.SUCCESS) + viewModel.resetAuthState() } - if (launchNotificationPermissionState.value) { - // request notification permission - RequestNotificationPermission(context) + + else -> Unit + } + } + } + + @Composable + private fun ObserveAuthEventBus(onShowToast: (String, String, ToastType) -> Unit) { + LaunchedEffect(Unit) { + bose.ankush.network.auth.events.AuthEventBus.events.collect { event -> + if (event is bose.ankush.network.auth.events.AuthEvent.Unauthorized) { + onShowToast( + event.message.ifBlank { + "You need to log in again to continue using the app for security purposes." + }, + "Session Expired", + ToastType.WARNING, + ) } + } + } + } - /** - * 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) - } + @Composable + private fun ObservePaymentCheckout(context: Context) { + 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 (@Suppress("TooGenericExceptionCaught") e: Exception) { + paymentViewModel.onPaymentFailed( + e.message ?: "Unable to open payment checkout", + ) + Checkout.clearUserData(context) + razorpayCheckout = null } + } + } + } - // main container holding all app composable screens - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - ) { - AppNavigation(viewModel) + @Composable + private fun AppScreen( + isAuthInitialized: Boolean, + isLoggedIn: Boolean, + authState: AuthState, + toastAnchorState: ToastAnchorState, + toastState: ToastDisplayState, + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), + ), + ) { + when { + !isAuthInitialized -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } } + + isLoggedIn -> AuthorizedContent(toastAnchorState = toastAnchorState) + else -> UnauthorizedContent(authState = authState) + } + NotificationToast( + modifier = Modifier.align(Alignment.BottomCenter), + message = toastState.message, + title = toastState.title, + type = toastState.type, + isVisible = toastState.visible, + onDismiss = toastState.onDismiss, + anchorState = toastAnchorState, + ) + } + } + + @Composable + private fun AuthorizedContent(toastAnchorState: ToastAnchorState) { + val context = LocalContext.current + val launchNotificationPermissionState = + viewModel.launchNotificationPermission.collectAsState() + LaunchedEffect(true) { + if (locationClient.hasLocationPermission()) { + viewModel.fetchAndSaveLocationCoordinates() } } + if (!locationClient.hasLocationPermission()) { + RequestLocationPermission(context) + } + if (launchNotificationPermissionState.value) { + RequestNotificationPermission(context) + } + LaunchedEffect(launchNotificationPermissionState.value) { + viewModel.updateShowNotificationBannerState(!context.hasNotificationPermission()) + } + AppNavigation(viewModel, paymentViewModel, toastAnchorState) + } + + @Composable + private fun UnauthorizedContent(authState: AuthState) { + var currentWebUrl by remember { mutableStateOf(null) } + if (currentWebUrl != null) { + InAppWebView( + url = currentWebUrl!!, + onClose = { currentWebUrl = null }, + ) + } else { + LoginScreen( + onLoginClick = { email, password -> viewModel.login(email, password) }, + onRegisterClick = { email, password -> viewModel.register(email, password) }, + onWebUrlClick = { url -> currentWebUrl = url }, + isLoading = authState is AuthState.Loading, + ) + } } @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", ) + } + + // 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) + } + } - LaunchedEffect(key1 = Unit) { - phoneCallPermissionResultLauncher.launch(ACCESS_PHONE_CALL) + // 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) + } } } @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 +396,68 @@ 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 } } + +private class ToastDisplayState( + val visible: Boolean, + val message: String, + val title: String, + val type: ToastType, + val onDismiss: () -> Unit, +) 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..ddad9731 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,774 @@ 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.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 kotlin.time.Clock +import kotlin.time.Instant import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds /** - * 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) +@Suppress("TooGenericExceptionCaught") @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. - */ var permissionDialogQueue = mutableStateListOf() private set 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() + + private val _authState = MutableStateFlow(AuthState.Initial) + val authState: StateFlow = _authState.asStateFlow() + + private val _isLoggedIn = MutableStateFlow(false) + val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + + private val _isAuthInitialized = MutableStateFlow(false) + val isAuthInitialized: StateFlow = _isAuthInitialized.asStateFlow() + + private val _savedLocationsState = MutableStateFlow(SavedLocationsUiState()) + val savedLocationsState: StateFlow = _savedLocationsState.asStateFlow() + + private val _placeSearchState = MutableStateFlow(PlaceSearchUiState()) + val placeSearchState: StateFlow = _placeSearchState.asStateFlow() - private val tag = "${MainViewModel::class.simpleName} ->" + private val _queryFlow = MutableStateFlow("") - // Track active jobs for proper cancellation 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. - */ + 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(dispatchers.io) { + var initialized = false + authRepository.isLoggedIn().collectLatest { loggedIn -> + _isLoggedIn.value = loggedIn + logger.d("Auth state changed - isLoggedIn: $loggedIn") + if (!initialized) { + _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(dispatchers.io) { + preferenceManager + .getUserPreferencesFlow() + .map { it.isPremium } + .distinctUntilChanged() + .drop(1) + .collect { isPremium -> + logger.d("Premium status changed to $isPremium โ€” forcing weather data refresh") + performInitialDataLoading(forceRefresh = true) + } + } + + viewModelScope.launch(dispatchers.io) { + _queryFlow + .debounce(500.milliseconds) + .filter { it.length >= MIN_QUERY_LENGTH } + .distinctUntilChanged() + .collect { query -> fetchPlaceSuggestions(query) } + } + + 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() + } + } + } + fun dismissDialog() { - permissionDialogQueue.removeAt(0) + if (permissionDialogQueue.isNotEmpty()) { + val dismissed = permissionDialogQueue.removeAt(0) + logger.d("Dismissed permission dialog: $dismissed") + } + } + + /** 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() + } } - /** - * 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 - */ 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 - */ 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 - */ 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. - */ + 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())) } + 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)) + } + } + } + } + + 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) + ) } + } + } + } + + 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 } + } + + fun login( + email: String, + password: String, + ) = launchAuth("Login", email) { + authRepository.login(email, 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 (_: CancellationException) { + throw CancellationException() + } catch (e: Exception) { + logger.e("$actionName failed for email: $email", e) + _authState.value = + AuthState.Error(UiText.DynamicText(e.message ?: "$actionName failed")) + } + } + + 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")) + }, + ) + } + + /** 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() + ONE_YEAR_MILLIS) + 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() + 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( + 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()) - ) - } + } + }, + ) + } + } + + 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 + }, + ) + } + } + + 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.", + ) + } + } + }, + ) + } + } + + 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) } + } + } + + fun clearPlaceSearch() { + _placeSearchState.value = PlaceSearchUiState() + _queryFlow.value = "" + } + + 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() + } + } + } + + 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() } } + +private const val ONE_YEAR_MILLIS = 365L * 24 * 60 * 60 * 1000 +private const val MIN_QUERY_LENGTH = 2 + +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..0a89d405 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/UIState.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/UIState.kt @@ -1,23 +1,17 @@ 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. - * This class encapsulates all the information needed to render the UI. - * - * @property isLoading Indicates whether data is currently being loaded. - * @property userLocation The geographical coordinates (latitude, longitude) of the user's location. - * @property weatherData The weather forecast data for the user's location. - * @property airQualityData The air quality information for the user's location. - * @property error Any error message that should be displayed to the user. - */ 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..4bdec544 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 @@ -13,40 +23,37 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController import bose.ankush.weatherify.R import bose.ankush.weatherify.base.common.component.ScreenTopAppBar import bose.ankush.weatherify.presentation.cities.component.CityListItem import bose.ankush.weatherify.presentation.home.state.ShowLoading -import bose.ankush.weatherify.presentation.navigation.Screen +import bose.ankush.weatherify.presentation.navigation.AppNavigator @Composable -fun CitiesListScreen( - navController: NavController, -) { +fun CitiesListScreen(navigator: AppNavigator) { Box( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { Scaffold( topBar = { ScreenTopAppBar( headlineId = R.string.select_city, - navIconAction = { navController.popBackStack() }, + navIconAction = { navigator.goBack() }, ) }, content = { innerPadding -> Column( - modifier = Modifier.padding(innerPadding) + modifier = Modifier.padding(innerPadding), ) { - CityNameSearchBarWithList(navController) + CityNameSearchBarWithList(navigator) } - } + }, ) } } @Composable -private fun CityNameSearchBarWithList(navController: NavController) { +private fun CityNameSearchBarWithList(navigator: AppNavigator) { val viewModels: CitiesViewModel = hiltViewModel() val searchText by viewModels.searchText.collectAsState() val isSearching by viewModels.isSearching.collectAsState() @@ -55,7 +62,7 @@ private fun CityNameSearchBarWithList(navController: NavController) { Column( modifier = Modifier .fillMaxWidth() - .padding(10.dp) + .padding(10.dp), ) { TextField( modifier = Modifier @@ -70,8 +77,8 @@ private fun CityNameSearchBarWithList(navController: NavController) { unfocusedIndicatorColor = Color.Transparent, cursorColor = MaterialTheme.colorScheme.onSecondaryContainer, focusedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, - focusedPlaceholderColor = MaterialTheme.colorScheme.onSecondaryContainer - ) + focusedPlaceholderColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), ) Spacer(modifier = Modifier.height(10.dp)) if (isSearching) { @@ -80,11 +87,11 @@ private fun CityNameSearchBarWithList(navController: NavController) { LazyColumn( modifier = Modifier .fillMaxWidth() - .weight(1f) + .weight(1f), ) { items(cityName.size) { - CityListItem(cityNameList = cityName, position = it) { _, name -> - navController.navigate(Screen.HomeScreen.withArgs(name)) + CityListItem(cityNameList = cityName, position = it) { _, _ -> + navigator.goBack() } } } 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..65fe8e59 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,24 @@ 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 +private const val SEARCH_DEBOUNCE_MS = 500L + @HiltViewModel -class CitiesViewModel @Inject constructor( - getCityNames: GetCityNames +class CitiesViewModel +@Inject +constructor( + getCityNames: GetCityNames, ) : ViewModel() { - var searchText = MutableStateFlow("") private set @@ -23,21 +33,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(SEARCH_DEBOUNCE_MS) + .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(5_000), + 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..35de5d80 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,39 @@ 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,42 +52,71 @@ 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 +import bose.ankush.weatherify.presentation.navigation.AppNavigator import kotlinx.coroutines.delay +private const val ANIMATION_INITIAL_DELAY_MS = 100L +private const val ANIMATION_STAGGER_DELAY_MS = 150L + +data class NotificationCardState( + val isVisible: Boolean = false, + val isPermanentlyDeclined: Boolean = false, + val onEnableClick: () -> Unit = {}, + val onDismissClick: () -> Unit = {}, +) + @Composable fun HomeScreen( viewModel: MainViewModel, - navController: NavController + navigator: AppNavigator, + toastAnchorState: ToastAnchorState? = null, ) { - val context: Context = LocalContext.current - val uiState: UIState = viewModel.uiState.collectAsState().value + val context = LocalContext.current + val 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, + navigator = navigator, + toastAnchorState = toastAnchorState, + notificationCardState = NotificationCardState( + isVisible = showNotificationCard, + isPermanentlyDeclined = isNotificationPermissionPermanentlyDeclined, + onEnableClick = { viewModel.updateNotificationPermission(true) }, + onDismissClick = { viewModel.updateShowNotificationBannerState(false) }, + ), + onRefresh = { viewModel.refreshWeatherData() }, + onResetLocationOverride = { viewModel.clearLocationOverride() }, + ) } else -> { - // Show loading HandleScreenLoading() } } - // Handle back button press to exit app BackHandler { (context as? Activity)?.finish() } @@ -87,151 +131,253 @@ 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 + navigator: AppNavigator, + toastAnchorState: ToastAnchorState? = null, + notificationCardState: NotificationCardState = NotificationCardState(), + onRefresh: () -> Unit = {}, + onResetLocationOverride: () -> Unit = {}, ) { val weatherReports = uiState.weatherData val airQualityReports = uiState.airQualityData - // Create transition states for animations + val pullToRefreshState = rememberPullToRefreshState() + 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) } // Start animations with staggered delays when data is loaded LaunchedEffect(weatherReports, airQualityReports) { - // Reset states first currentWeatherTransitionState.targetState = false + alertsTransitionState.targetState = false airQualityTransitionState.targetState = false hourlyForecastTransitionState.targetState = false dailyForecastTransitionState.targetState = false - // Start animations with staggered delays - delay(100) // Small initial delay + delay(ANIMATION_INITIAL_DELAY_MS) currentWeatherTransitionState.targetState = true - delay(200) // Delay for air quality + delay(ANIMATION_STAGGER_DELAY_MS) + alertsTransitionState.targetState = true + + delay(ANIMATION_STAGGER_DELAY_MS) airQualityTransitionState.targetState = true - delay(300) // Delay for hourly forecast + delay(ANIMATION_STAGGER_DELAY_MS) hourlyForecastTransitionState.targetState = true - delay(400) // Delay for daily forecast + delay(ANIMATION_STAGGER_DELAY_MS) dailyForecastTransitionState.targetState = true } Box { - // 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 (notificationCardState.isVisible) { + PermissionAlertDialog( + descriptionText = stringResource(R.string.notification_permission_message), + isPermanentlyDeclined = notificationCardState.isPermanentlyDeclined, + onPositiveAction = notificationCardState.onEnableClick, + onNegativeAction = notificationCardState.onDismissClick, + positiveButtonLabel = stringResource(R.string.enable_notification_btn), + negativeButtonLabel = stringResource(R.string.cancel_btn_txt), ) } Scaffold( - containerColor = Color.Transparent, // Make the scaffold background transparent + containerColor = Color.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), + state = rememberLazyListState(), + ) { + 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) + 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) + 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) + 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) + } + } + } + + 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) + } + } + } + + 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 = { + }, + bottomBar = { AppBottomBar( isVisible = rememberSaveable { mutableStateOf(true) }, - navController = navController + navigator = navigator, + 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..a62ab8a2 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,212 +1,309 @@ 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 -/** - * This composable is response to show air quality card on HomeScreen. - * Shows what is the current air quality based return value of [getAQIAnalysedText] - */ -@SuppressLint("MissingPermission") -@Composable -internal fun BriefAirQualityReportCardLayout( - airQuality: AirQuality, - navController: NavController -) { - ShowUI( - aq = airQuality, - onItemClick = { navController.navigate(Screen.AirQualityDetailsScreen.route) } - ) -} +// OWM AQI scale (1โ€“6) to EPA AQI scale (0โ€“500) mapping +private const val OWM_AQI_GOOD = 1 +private const val OWM_AQI_FAIR = 2 +private const val OWM_AQI_MODERATE = 3 +private const val OWM_AQI_POOR = 4 +private const val OWM_AQI_VERY_POOR = 5 +private const val OWM_AQI_EXTREME = 6 +private const val EPA_AQI_GOOD_MID = 25 +private const val EPA_AQI_MODERATE_MID = 75 +private const val EPA_AQI_UNHEALTHY_SENSITIVE_MID = 125 +private const val EPA_AQI_UNHEALTHY_MID = 175 +private const val EPA_AQI_VERY_UNHEALTHY_MID = 250 +private const val EPA_AQI_HAZARDOUS_MID = 425 +private const val EPA_AQI_MIN = 0 +private const val EPA_AQI_MAX = 500 -/** - * 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) } +// EPA AQI color range thresholds +private const val EPA_GOOD_MAX = 50 +private const val EPA_MODERATE_MAX = 100 +private const val EPA_UNHEALTHY_SENSITIVE_MAX = 150 +private const val EPA_UNHEALTHY_MAX = 200 +private const val EPA_VERY_UNHEALTHY_MAX = 300 - // 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ยณ" } +// AQI colors (ARGB hex) +private const val COLOR_AQI_GOOD = 0xFF4CAF50L +private const val COLOR_AQI_MODERATE = 0xFFFFEB3BL +private const val COLOR_AQI_UNHEALTHY_SENSITIVE = 0xFFFF9800L +private const val COLOR_AQI_UNHEALTHY = 0xFFE53935L +private const val COLOR_AQI_VERY_UNHEALTHY = 0xFF9C27B0L +private const val COLOR_AQI_HAZARDOUS = 0xFF7E0023L - // Pre-calculate formatted AQI - val formattedAQI = remember(aq.aqi) { aq.aqi.getFormattedAQI() } +private data class AqiUiState( + val statusText: String, + val qualityColor: Color, + val formattedAqi: String, +) - 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) +@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(), ) + } + +@SuppressLint("MissingPermission") +@Composable +internal fun BriefAirQualityReportCardLayout(airQuality: AirQuality) { + val aqiUiState = rememberAqiUiState(airQuality.aqi) + var isExpanded by remember { mutableStateOf(false) } + + Card( + 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)) - - // 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) - ) - } - - 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) - ) - } - } - + HorizontalDivider( + Modifier, + DividerDefaults.Thickness, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + ) Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - PollutantItem(name = "PM2.5", value = pm25Value) - PollutantItem(name = "CO", value = coValue) - PollutantItem(name = "Oโ‚ƒ", value = o3Value) + if (isExpanded) { + ExpandedPollutantsDetails(airQuality = airQuality) + } else { + KeyPollutants(airQuality = airQuality) } } } } -/** - * 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 - } -} - -/** - * 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) { - Column( - horizontalAlignment = Alignment.CenterHorizontally +private fun AqiSummary( + aqiUiState: AqiUiState, + isExpanded: Boolean, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.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) + modifier = + Modifier + .size(72.dp) + .background(aqiUiState.qualityColor, shape = CircleShape), + contentAlignment = Alignment.Center, ) { Text( - text = name, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = aqiUiState.formattedAqi, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = contentColorFor(backgroundColor = aqiUiState.qualityColor), ) } + 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), + ) + } +} + +@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()) + } + } +} + +@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) { + OWM_AQI_GOOD -> EPA_AQI_GOOD_MID + OWM_AQI_FAIR -> EPA_AQI_MODERATE_MID + OWM_AQI_MODERATE -> EPA_AQI_UNHEALTHY_SENSITIVE_MID + OWM_AQI_POOR -> EPA_AQI_UNHEALTHY_MID + OWM_AQI_VERY_POOR -> EPA_AQI_VERY_UNHEALTHY_MID + OWM_AQI_EXTREME -> EPA_AQI_HAZARDOUS_MID + else -> owmAqi.coerceIn(EPA_AQI_MIN, EPA_AQI_MAX) + } +private fun getAirQualityColor(aqi: Int): Color = + when (aqi) { + in EPA_AQI_MIN..EPA_GOOD_MAX -> Color(COLOR_AQI_GOOD) + in (EPA_GOOD_MAX + 1)..EPA_MODERATE_MAX -> Color(COLOR_AQI_MODERATE) + in (EPA_MODERATE_MAX + 1)..EPA_UNHEALTHY_SENSITIVE_MAX -> Color( + COLOR_AQI_UNHEALTHY_SENSITIVE + ) + + in (EPA_UNHEALTHY_SENSITIVE_MAX + 1)..EPA_UNHEALTHY_MAX -> Color(COLOR_AQI_UNHEALTHY) + in (EPA_UNHEALTHY_MAX + 1)..EPA_VERY_UNHEALTHY_MAX -> Color(COLOR_AQI_VERY_UNHEALTHY) + else -> Color(COLOR_AQI_HAZARDOUS) + } + +@Composable +private fun PollutantItem( + name: String, + value: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(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, + 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..fa0b3ee1 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,62 +55,62 @@ 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 LocationAndDateHeader(currentWeather, userLocation) Spacer(modifier = Modifier.height(24.dp)) - // Current weather visualization CurrentWeatherVisualization(currentWeather) Spacer(modifier = Modifier.height(32.dp)) - // Weather metrics WeatherMetricsGrid(currentWeather) Spacer(modifier = Modifier.height(16.dp)) - // Sunrise and sunset info SunriseSunsetInfo(currentWeather) - // Weather summary summary?.let { summaryText -> if (summaryText.isNotEmpty()) { 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 +120,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,63 +133,59 @@ 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 var locationName by remember(userLocation) { mutableStateOf("Current Location") } - // Move Geocoder operation to LaunchedEffect but with IO dispatcher to avoid blocking UI LaunchedEffect(userLocation) { 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() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + 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)) @@ -203,61 +201,62 @@ private fun LocationAndDateHeader( @Composable 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), ) } } - // 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 +267,7 @@ private fun CurrentWeatherVisualization(currentWeather: WeatherForecast.Current) style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } @@ -276,51 +275,49 @@ 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) { secondRowMetrics.add( WeatherMetric( icon = R.drawable.ic_wind, value = "${weatherData.wind_gust} m/s", - label = "Wind Gust" - ) + label = "Wind Gust", + ), ) } - // Display the metrics rows MetricsRow(metrics = firstRowMetrics) Spacer(modifier = Modifier.height(16.dp)) @@ -333,23 +330,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 +356,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,81 +371,83 @@ 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 if (isSunrise) { "$timeWithoutAmPm AM" } else { @@ -457,32 +456,30 @@ private fun formatTimeWithAmPm(timestamp: Int?, isSunrise: Boolean): String { } } -// Data class to hold weather metric information 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), ) } - // Add empty space if needed if (fillEmptySpace && metrics.size < 3) { repeat(3 - metrics.size) { Spacer(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..e641ce82 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,36 +11,33 @@ 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 import bose.ankush.weatherify.domain.model.WeatherForecast -/** - * This composable is responsible for showing daily weather forecast section on HomeScreen. - * It displays a heading and a list of daily forecasts. - */ @Composable 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) @@ -50,13 +47,11 @@ internal fun DailyWeatherForecastReportLayout(list: List } } -/** - * This composable is responsible for showing a single daily weather forecast item. - * Shows the forecast for a specific day including day name, temperature range, and weather icon. - * 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 +67,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..f3de5de6 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,82 +36,81 @@ 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) {} } } } - } else { - // Return empty content when no data is available } } - @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) } 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(), ) { items( items = limitedForecast, - key = { item -> item?.dt ?: 0 } // Use unique key for each item + key = { item -> item?.dt ?: 0 }, ) { 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 +129,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..e03bf742 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/WeatherAlertLayout.kt @@ -0,0 +1,24 @@ +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 + +@Composable +fun WeatherAlertLayout( + alerts: List?, + onReadMoreClick: (() -> Unit)? = null, +) { + if (alerts.isNullOrEmpty()) return + + val firstAlert = alerts.firstOrNull() ?: return + + 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..79c5dabc 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,78 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import bose.ankush.weatherify.R +@Composable +fun ErrorBackgroundAnimation() { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) +} + @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), ) { 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)) + 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)) + 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..1139fec3 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 @@ -16,9 +18,6 @@ import androidx.compose.material3.NavigationBarItemDefaults 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 @@ -26,31 +25,33 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation3.runtime.NavKey +import bose.ankush.commonui.components.ToastAnchorState +import bose.ankush.commonui.components.toastAnchor import bose.ankush.weatherify.R +private data class TabItem(val route: NavKey, val labelResId: Int) + +private val TAB_ITEMS = listOf( + TabItem(HomeRoute, R.string.home_nested_nav), + TabItem(SavedLocationsRoute, R.string.saved_locations_nested_nav), + TabItem(SettingsRoute, R.string.profile_nested_nav), +) + @Composable fun AppBottomBar( isVisible: MutableState, - navController: NavController + navigator: AppNavigator, + 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 currentRoute = navigator.navigationState.topLevelRoute 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() @@ -59,49 +60,45 @@ fun AppBottomBar( .shadow( elevation = 6.dp, shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), - spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), ) .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp).copy(alpha = 0.8f) + 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) + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), ), - containerColor = Color.Transparent // Make the container transparent to show our custom background + containerColor = Color.Transparent, ) { - screenItems.forEachIndexed { index, screen -> + TAB_ITEMS.forEach { tab -> NavigationBarItem( icon = { - when (screen.resourceId) { - R.string.home_nested_nav -> Icon( + when (tab.route) { + HomeRoute -> Icon( painter = painterResource(id = R.drawable.ic_home), - contentDescription = stringResource(id = screen.resourceId) + contentDescription = stringResource(id = tab.labelResId), ) - R.string.profile_nested_nav -> Icon( + SavedLocationsRoute -> Icon( + imageVector = Icons.Outlined.BookmarkBorder, + contentDescription = stringResource(id = R.string.saved_locations_icon_content), + ) + + SettingsRoute -> Icon( painter = painterResource(id = R.drawable.ic_profile), - contentDescription = stringResource(id = screen.resourceId) + contentDescription = stringResource(id = tab.labelResId), ) } }, - selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, - onClick = { - selectedItem.intValue = index - navController.navigate(screen.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - }, + selected = tab.route == currentRoute, + onClick = { navigator.navigate(tab.route) }, colors = NavigationBarItemDefaults.colors( indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f), selectedIconColor = MaterialTheme.colorScheme.primary, - unselectedIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) + 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..14d56a28 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,185 +1,263 @@ package bose.ankush.weatherify.presentation.navigation import android.annotation.SuppressLint -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.tween +import android.widget.Toast import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.platform.LocalContext -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument -import androidx.navigation.navigation +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +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.SettingsScreenState +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.PaymentStage +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" +import bose.ankush.weatherify.presentation.strings.rememberLanguageScreenStrings @SuppressLint("NewApi") -@ExperimentalAnimationApi @Composable -fun AppNavigation(viewModel: MainViewModel) { - val navController = rememberNavController() - val context = LocalContext.current - NavHost( - navController = navController, - startDestination = Screen.HomeNestedNav.route - ) { - /*Home Screens*/ - navigation( - startDestination = Screen.HomeScreen.route, - route = Screen.HomeNestedNav.route - ) { - composable( - route = Screen.HomeScreen.route, - ) { - HomeScreen( - viewModel = viewModel, - navController = navController - ) - } - composable( - route = Screen.CitiesListScreen.route, - enterTransition = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Down, - animationSpec = tween(500) - ) - }, - popEnterTransition = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Down, - animationSpec = tween(500) - ) - }, - exitTransition = { - slideOutOfContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Up, - animationSpec = tween(500) - ) - }, - popExitTransition = { - slideOutOfContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Up, - 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 - ) - } - } +fun AppNavigation( + viewModel: MainViewModel, + paymentViewModel: PaymentViewModel, + toastAnchorState: ToastAnchorState? = null, +) { + val navigationState = rememberAppNavigationState() + val navigator = remember { AppNavigator(navigationState) } - /*Account/Profile Screens*/ - navigation( - startDestination = Screen.SettingsScreen.route, - route = Screen.ProfileNestedNav.route - ) { - composable( - route = Screen.SettingsScreen.route, - ) { - SettingsScreen( - viewModel = viewModel, - navController = navController, - onLanguageNavAction = { - if (isDeviceSDKAndroid13OrAbove()) { - navController.navigate(Screen.LanguageScreen.withArgs(it)) - } else { - context.openAppLocaleSettings() - } - }, - onNotificationNavAction = { - if (!context.hasNotificationPermission()) { - viewModel.updateNotificationPermission(launchState = true) - } - }, - onAvatarNavAction = { - if (!context.callNumber()) { - viewModel.updatePhoneCallPermission(launchState = true) - } - } - ) - } - composable( - route = Screen.LanguageScreen.route + "/{$LANGUAGE_ARGUMENT_KEY}", - arguments = listOf(navArgument(LANGUAGE_ARGUMENT_KEY) { - type = StringListType() - nullable = false - }), - enterTransition = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(500) + NavDisplay( + entries = navigationState.toEntries( + entryProvider { + entry { HomeScreen(viewModel, navigator, toastAnchorState) } + entry { CitiesListScreen(navigator) } + entry { + SavedLocationsEntry( + viewModel, + navigator, + toastAnchorState ) - }, - 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) + } + entry { + SettingsEntry( + viewModel, + paymentViewModel, + navigator, + toastAnchorState ) } - ) { entry -> - entry.arguments?.let { - it.getStringArray(LANGUAGE_ARGUMENT_KEY)?.let { listOfString -> - LanguageScreen( - languages = listOfString, - navAction = { navController.popBackStack() } - ) - } + entry { route -> + LanguageScreen( + languages = route.languages.toTypedArray(), + strings = rememberLanguageScreenStrings(), + ) { navigator.goBack() } } } + ), + onBack = navigator::goBack, + ) +} + +@Composable +private fun SavedLocationsEntry( + viewModel: MainViewModel, + navigator: AppNavigator, + toastAnchorState: ToastAnchorState?, +) { + val locationsState by viewModel.savedLocationsState.collectAsState() + val searchState by viewModel.placeSearchState.collectAsState() + + SavedLocationsScreen( + locationsState = locationsState, + searchState = searchState, + onQueryChanged = viewModel::onPlaceSearchQueryChanged, + onClearSearch = viewModel::clearPlaceSearch, + onSaveLocation = { name, lat, lon -> viewModel.saveLocation(name, lat, lon) }, + onDeleteLocation = viewModel::deleteLocation, + onLocationSelected = { viewModel.setDefaultLocation(it.lat, it.lon, it.name) }, + onMessageShown = viewModel::clearLocationMessage, + strings = rememberSavedLocationsStrings(), + bottomBar = { + AppBottomBar(rememberSaveable { mutableStateOf(true) }, navigator, toastAnchorState) + }, + ) +} + +@SuppressLint("NewApi") +@Composable +private fun SettingsEntry( + viewModel: MainViewModel, + paymentViewModel: PaymentViewModel, + navigator: AppNavigator, + toastAnchorState: ToastAnchorState?, +) { + val context = LocalContext.current + val authState by viewModel.authState.collectAsState() + val paymentUiState by paymentViewModel.uiState.collectAsState() + val settingsViewModel = hiltViewModel() + val settingsUiState by settingsViewModel.uiState.collectAsState() + val serviceSubscriptionUiState by settingsViewModel.serviceSubscriptionViewModel.uiState.collectAsState() + val isBottomBarVisible = rememberSaveable { mutableStateOf(true) } + val previousPaymentStage = remember { mutableStateOf(paymentUiState.stage) } + val languageList = rememberLanguageList() + + LaunchedEffect(paymentUiState.stage) { + if (paymentUiState.stage == PaymentStage.Success && previousPaymentStage.value != PaymentStage.Success) { + settingsViewModel.showPremiumActivationToast() + } + previousPaymentStage.value = paymentUiState.stage + } + + SettingsScreen( + 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 = rememberSettingsStrings(), + serviceSubscriptionBottomSheetUiState = serviceSubscriptionUiState, + onLogout = viewModel::logout, + onLoggedOutHandled = viewModel::resetAuthState, + onStartPayment = paymentViewModel::startPayment, + onLoadServices = { settingsViewModel.serviceSubscriptionViewModel.loadServices() }, + onServiceSelected = { settingsViewModel.serviceSubscriptionViewModel.selectService(it) }, + onTierSelected = { settingsViewModel.serviceSubscriptionViewModel.selectPricingTier(it) }, + onBackNavAction = navigator::goBack, + onLanguageNavAction = { list -> + if (isDeviceSDKAndroid13OrAbove()) navigator.navigate(LanguageRoute(list.toList())) + else context.openAppLocaleSettings() + }, + onNotificationNavAction = { + if (!context.hasNotificationPermission()) viewModel.updateNotificationPermission(true) + }, + onStateChange = { settingsViewModel.handleScreenStateChange(it, settingsUiState) }, + onBottomBarVisibilityChange = { isBottomBarVisible.value = it }, + toastAnchorState = toastAnchorState, + bottomBar = { AppBottomBar(isBottomBarVisible, navigator, toastAnchorState) }, + ) +} + +@Composable +private fun rememberLanguageList(): Array { + val context = LocalContext.current + val showError = remember { mutableStateOf(false) } + val errorMessage = stringResource(R.string.locale_config_error_txt) + val list = remember(context) { + runCatching { + LocaleConfigMapper.getAvailableLanguagesFromJson("countryConfig.json", context) + }.getOrElse { + showError.value = true + emptyArray() + } + } + LaunchedEffect(showError.value) { + if (showError.value) { + Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show() + showError.value = false } } + return list +} + +@Composable +private fun rememberSavedLocationsStrings(): SavedLocationsStrings { + val noResultsTemplate = stringResource(R.string.place_search_no_results) + val setAsDefaultBodyTemplate = stringResource(R.string.set_as_default_dialog_body) + return 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), + ) +} + +@Composable +private fun rememberSettingsStrings() = 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), +) + +private fun SettingsViewModel.handleScreenStateChange( + newState: SettingsScreenState, + current: SettingsScreenState, +) { + when { + newState.showPremiumBottomSheet != current.showPremiumBottomSheet -> { + if (!newState.showPremiumBottomSheet) serviceSubscriptionViewModel.resetState() + handleEvent( + if (newState.showPremiumBottomSheet) SettingsEvent.OpenPremiumSheet + else SettingsEvent.ClosePremiumSheet, + ) + } + + newState.showLogoutDialog != current.showLogoutDialog -> + handleEvent( + if (newState.showLogoutDialog) SettingsEvent.OpenLogoutDialog + else SettingsEvent.CloseLogoutDialog, + ) + + newState.showPremiumActivationToast != current.showPremiumActivationToast -> + if (!newState.showPremiumActivationToast) handleEvent(SettingsEvent.DismissPremiumToast) + + newState.currentWebUrl != current.currentWebUrl -> + handleEvent( + newState.currentWebUrl?.let(SettingsEvent::OpenWebUrl) + ?: SettingsEvent.CloseWebView, + ) + } } diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppNavigator.kt b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppNavigator.kt new file mode 100644 index 00000000..ef78474c --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppNavigator.kt @@ -0,0 +1,110 @@ +package bose.ankush.weatherify.presentation.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator + +private val TAB_ROUTES: List = listOf(HomeRoute, SavedLocationsRoute, SettingsRoute) + +private val TabRouteSaver = Saver, Int>( + save = { state -> TAB_ROUTES.indexOf(state.value).coerceAtLeast(0) }, + restore = { index -> mutableStateOf(TAB_ROUTES.getOrElse(index) { HomeRoute }) }, +) + +class AppNavigationState( + topLevelRoute: MutableState, + val backStacks: Map>, +) { + val startRoute: NavKey = HomeRoute + var topLevelRoute: NavKey by topLevelRoute + + val currentStack: NavBackStack + get() = backStacks[topLevelRoute] ?: error("No back stack for $topLevelRoute") + + fun isTabRoute(route: NavKey): Boolean = route in backStacks.keys +} + +@Composable +fun rememberAppNavigationState(): AppNavigationState { + val topLevelRoute = rememberSaveable(saver = TabRouteSaver) { + mutableStateOf(HomeRoute) + } + val homeStack = rememberNavBackStack(HomeRoute) + val savedLocationsStack = rememberNavBackStack(SavedLocationsRoute) + val settingsStack = rememberNavBackStack(SettingsRoute) + + return remember { + AppNavigationState( + topLevelRoute = topLevelRoute, + backStacks = mapOf( + HomeRoute to homeStack, + SavedLocationsRoute to savedLocationsStack, + SettingsRoute to settingsStack, + ), + ) + } +} + +@Composable +fun AppNavigationState.toEntries( + entryProvider: (NavKey) -> NavEntry, +): List> { + val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator() + val vmDecorator = rememberViewModelStoreNavEntryDecorator() + + val homeEntries = rememberDecoratedNavEntries( + backStack = backStacks[HomeRoute]!!, + entryDecorators = listOf(saveableDecorator, vmDecorator), + entryProvider = entryProvider, + ) + val savedLocationsEntries = rememberDecoratedNavEntries( + backStack = backStacks[SavedLocationsRoute]!!, + entryDecorators = listOf(saveableDecorator, vmDecorator), + entryProvider = entryProvider, + ) + val settingsEntries = rememberDecoratedNavEntries( + backStack = backStacks[SettingsRoute]!!, + entryDecorators = listOf(saveableDecorator, vmDecorator), + entryProvider = entryProvider, + ) + + return when (topLevelRoute) { + SavedLocationsRoute -> savedLocationsEntries + SettingsRoute -> settingsEntries + else -> homeEntries + } +} + +class AppNavigator(private val state: AppNavigationState) { + val navigationState: AppNavigationState get() = state + + fun navigate(route: NavKey) { + if (state.isTabRoute(route)) { + state.topLevelRoute = route + } else { + state.currentStack.add(route) + } + } + + fun goBack() { + val stack = state.currentStack + if (stack.size > 1) { + stack.removeLastOrNull() + } else if (state.topLevelRoute != state.startRoute) { + state.topLevelRoute = state.startRoute + } + // When on startRoute with a single entry, HomeScreen's BackHandler exits the app + } +} 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 deleted file mode 100644 index 8b1a224c..00000000 --- a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/CustomNavType.kt +++ /dev/null @@ -1,29 +0,0 @@ -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 { - val stringArray = bundle.getStringArray(key) - return stringArray?.toList() ?: emptyList() - } - - override fun parseValue(value: String): List { - return value.split(",").map { it.trim() } - } - - 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/Routes.kt b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/Routes.kt new file mode 100644 index 00000000..3a50a686 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/Routes.kt @@ -0,0 +1,19 @@ +package bose.ankush.weatherify.presentation.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object HomeRoute : NavKey + +@Serializable +data object CitiesListRoute : NavKey + +@Serializable +data object SavedLocationsRoute : NavKey + +@Serializable +data object SettingsRoute : NavKey + +@Serializable +data class LanguageRoute(val languages: List) : NavKey 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 deleted file mode 100644 index 0c6f9b73..00000000 --- a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/Screen.kt +++ /dev/null @@ -1,36 +0,0 @@ -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*/ - 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*/ - 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 { - append(route) - args.forEach { arg -> - append("/$arg") - } - } - } - - fun withArgs(vararg args: Array): String { - return 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/strings/LanguageScreenStrings.kt b/app/src/main/java/bose/ankush/weatherify/presentation/strings/LanguageScreenStrings.kt index ebf08bbf..8ae68c6d 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/strings/LanguageScreenStrings.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/strings/LanguageScreenStrings.kt @@ -12,6 +12,6 @@ fun rememberLanguageScreenStrings(): LanguageScreenStrings { screenTitle = stringResource(R.string.language_screen_title), screenSubtitle = stringResource(R.string.language_screen_subtitle), navigateBack = stringResource(R.string.language_navigate_back), - languageSelected = { language -> languageSelectedTemplate.replace("%s", language) }, + languageSelected = { language -> languageSelectedTemplate.format(language) }, ) } diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/strings/SavedLocationsScreenStrings.kt b/app/src/main/java/bose/ankush/weatherify/presentation/strings/SavedLocationsScreenStrings.kt new file mode 100644 index 00000000..b11e692b --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/presentation/strings/SavedLocationsScreenStrings.kt @@ -0,0 +1,30 @@ +package bose.ankush.weatherify.presentation.strings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import bose.ankush.commonui.locations.SavedLocationsStrings +import bose.ankush.weatherify.R + +@Composable +fun rememberSavedLocationsStrings(): SavedLocationsStrings { + val noResultsTemplate = stringResource(R.string.place_search_no_results) + val setAsDefaultBodyTemplate = stringResource(R.string.set_as_default_dialog_body) + return 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), + ) +} diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/strings/SettingsScreenStrings.kt b/app/src/main/java/bose/ankush/weatherify/presentation/strings/SettingsScreenStrings.kt new file mode 100644 index 00000000..46740b60 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/presentation/strings/SettingsScreenStrings.kt @@ -0,0 +1,32 @@ +package bose.ankush.weatherify.presentation.strings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import bose.ankush.commonui.settings.SettingsScreenStrings +import bose.ankush.weatherify.R + +@Composable +fun rememberSettingsScreenStrings() = 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), +) 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..b58c712a 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,57 +11,53 @@ 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 val context = LocalContext.current - // 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..21f5de5b --- /dev/null +++ b/app/src/main/res/values-bn/strings.xml @@ -0,0 +1,125 @@ + + + + เฆธเงเฆชเงเฆฒเงเฆฏเฆพเฆถ + เฆนเง‹เฆฎ + เฆฌเฆพเฆฏเฆผเง เฆฎเฆพเฆจ + เฆถเฆนเฆฐเฆ—เงเฆฒเง‹ + เฆชเงเฆฐเง‹เฆซเฆพเฆ‡เฆฒ + เฆธเง‡เฆŸเฆฟเฆ‚เฆธ + + + 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-เฆ เฆฐเฆฟเฆธเง‡เฆŸ เฆ•เฆฐเฆคเง‡ เฆŸเงเฆฏเฆพเฆช เฆ•เฆฐเงเฆจเฅค + เฆญเฆพเฆทเฆพ เฆฌเง‡เฆ›เง‡ เฆจเฆฟเฆจ + เฆซเฆฟเฆฐเง‡ เฆฏเฆพเฆจ + เฆ†เฆชเฆจเฆพเฆฐ เฆญเฆพเฆทเฆพ เฆฌเง‡เฆ›เง‡ เฆจเฆฟเฆจ + เฆฌเงเฆฏเฆ•เงเฆคเฆฟเฆ—เฆคเฆ•เงƒเฆค เฆ…เฆญเฆฟเฆœเงเฆžเฆคเฆพเฆฐ เฆœเฆจเงเฆฏ เฆ†เฆชเฆจเฆพเฆฐ เฆชเฆ›เฆจเงเฆฆเง‡เฆฐ เฆญเฆพเฆทเฆพ เฆฌเง‡เฆ›เง‡ เฆจเฆฟเฆจ + %s เฆจเฆฟเฆฐเงเฆฌเฆพเฆšเฆฟเฆค + เฆชเฆฟเฆ›เฆจเง‡ เฆจเง‡เฆญเฆฟเฆ—เง‡เฆŸ เฆ•เฆฐเงเฆจ + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 76e03445..8febdaef 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,56 @@ เคคเฅเคฐเฅเคŸเคฟ เคšเคฟเคนเฅ เคฌเค‚เคฆ เค•เคฐเฅ‡เค‚ เคšเคฟเคนเฅ เคฆเฅˆเคจเคฟเค• เคชเฅ‚เคฐเฅเคตเคพเคจเฅเคฎเคพเคจ + เคตเคพเคชเคธ เคฌเคŸเคจ + เคธเฅ‚เคšเคจเคพ เคธเฅ‡เคŸเคฟเค‚เค—เฅเคธ เค†เค‡เค•เคจ + เค—เฅ‹เคชเคจเฅ€เคฏเคคเคพ เคจเฅ€เคคเคฟ เค†เค‡เค•เคจ + เค‰เคชเคฏเฅ‹เค— เค•เฅ€ เคถเคฐเฅเคคเฅ‡เค‚ เค†เค‡เค•เคจ + เคœเคพเคจเค•เคพเคฐเฅ€ เค†เค‡เค•เคจ + เค…เค—เคฒเฅ€ เคธเฅเค•เฅเคฐเฅ€เคจ เคชเคฐ เคจเฅ‡เคตเคฟเค—เฅ‡เคŸ เค•เคฐเฅ‡เค‚ + เคญเคพเคทเคพ เค•เฅ‰เคจเฅเคซเคผเคฟเค—เคฐเฅ‡เคถเคจ เคฒเฅ‹เคก เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคตเคฟเคซเคฒเฅค เคกเคฟเคซเคผเฅ‰เคฒเฅเคŸ เคญเคพเคทเคพ เค•เคพ เค‰เคชเคฏเฅ‹เค— เค•เคฐ เคฐเคนเฅ‡ เคนเฅˆเค‚เฅค + + + เค…เคชเคกเฅ‡เคŸ เคฐเคนเฅ‡เค‚ + เคฎเฅŒเคธเคฎ เคธเคคเคฐเฅเค•เคคเคพ เค•เฅ‡ เคฒเคฟเค เคธเฅ‚เคšเคจเคพเคเค‚ เคธเค•เฅเคทเคฎ เค•เคฐเฅ‡เค‚ + เคธเค•เฅเคทเคฎ เค•เคฐเฅ‡เค‚ + + + เคชเฅเคฐเฅ€เคฎเคฟเคฏเคฎ เคธเค•เฅเคฐเคฟเคฏ + เค†เคชเค•เฅ€ เคชเฅเคฐเฅ€เคฎเคฟเคฏเคฎ เคธเคฆเคธเฅเคฏเคคเคพ เค…เคฌ เคธเค•เฅเคฐเคฟเคฏ เคนเฅˆ! + + + เคธเคนเฅ‡เคœเฅ‡ เค—เค เคธเฅเคฅเคพเคจ + saved_locations_nested_nav + เคธเคนเฅ‡เคœเฅ‡ เค—เค เคธเฅเคฅเคพเคจ + เค•เฅ‹เคˆ เคธเคนเฅ‡เคœเคพ เค—เคฏเคพ เคธเฅเคฅเคพเคจ เคจเคนเฅ€เค‚ เคนเฅˆเฅค เคœเฅ‹เคกเคผเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค + เคŸเฅˆเคช เค•เคฐเฅ‡เค‚เฅค + เคธเฅเคฅเคพเคจ เคœเฅ‹เคกเคผเฅ‡เค‚ + เคนเคŸเคพเคเค‚ + เคธเคนเฅ‡เคœเฅ‡เค‚ + เคธเฅเคฅเคพเคจ เค•เคพ เคจเคพเคฎ (เคœเฅˆเคธเฅ‡ เค˜เคฐ) + เคชเฅเคฐเฅ€เคฎเคฟเคฏเคฎ เคธเฅเคตเคฟเคงเคพ + เค…เคชเคจเฅ‡ เคชเคธเค‚เคฆเฅ€เคฆเคพ เคธเฅเคฅเคพเคจเฅ‹เค‚ เค•เฅ‹ เคธเคนเฅ‡เคœเฅ‡เค‚ เค”เคฐ เคคเฅเคฐเค‚เคค เคเค•เฅเคธเฅ‡เคธ เค•เคฐเฅ‡เค‚เฅค เค‡เคธ เคธเฅเคตเคฟเคงเคพ เค•เฅ‹ เค…เคจเคฒเฅ‰เค• เค•เคฐเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค เคชเฅเคฐเฅ€เคฎเคฟเคฏเคฎ เคฎเฅ‡เค‚ เค…เคชเค—เฅเคฐเฅ‡เคก เค•เคฐเฅ‡เค‚เฅค + เคธเคนเฅ‡เคœเฅ‡ เค—เค เคธเฅเคฅเคพเคจ เคฒเฅ‹เคก เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคตเคฟเคซเคฒเฅค + เคธเฅเคฅเคพเคจ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคธเคนเฅ‡เคœเคพ เค—เคฏเคพเฅค + เคธเฅเคฅเคพเคจ เคนเคŸเคพ เคฆเคฟเคฏเคพ เค—เคฏเคพเฅค + เคธเคนเฅ‡เคœเฅ‡ เค—เค เคธเฅเคฅเคพเคจ + เคจเคฏเคพ เคธเฅเคฅเคพเคจ เคœเฅ‹เคกเคผเฅ‡เค‚ + เคธเฅเคฅเคพเคจ เคนเคŸเคพเคเค‚ + + + เค•เคฟเคธเฅ€ เคธเฅเคฅเคพเคจ เค•เฅ‹ เค–เฅ‹เคœเฅ‡เค‚ + เคถเคนเคฐ เคฏเคพ เคชเคคเคพ เคŸเคพเค‡เคช เค•เคฐเฅ‡เค‚โ€ฆ + \'%1$s\' เค•เฅ‡ เคฒเคฟเค เค•เฅ‹เคˆ เคธเฅเคฅเคพเคจ เคจเคนเฅ€เค‚ เคฎเคฟเคฒเคพ + เคฎเฅŒเคธเคฎ เคธเฅเคฅเคพเคจ เค•เฅ‡ เคฐเฅ‚เคช เคฎเฅ‡เค‚ เค‰เคชเคฏเฅ‹เค— เค•เคฐเฅ‡เค‚? + เค†เคชเค•เฅ€ เคตเคฐเฅเคคเคฎเคพเคจ GPS เคธเฅเคฅเคฟเคคเคฟ เค•เฅ‡ เคฌเคœเคพเคฏ %1$s เค•เคพ เคฎเฅŒเคธเคฎ เคกเฅ‡เคŸเคพ เคฆเคฟเค–เคพเคฏเคพ เคœเคพเคเค—เคพเฅค + เค‡เคธ เคฆเฅŒเคฐเคพเคจ เค†เคชเค•เฅ€ เคฒเคพเค‡เคต GPS เคธเฅเคฅเคพเคจ เค…เคชเคกเฅ‡เคŸ เคจเคนเฅ€เค‚ เคนเฅ‹เค—เฅ€เฅค + เคกเคฟเคซเคผเฅ‰เคฒเฅเคŸ เค•เฅ‡ เคฐเฅ‚เคช เคฎเฅ‡เค‚ เคธเฅ‡เคŸ เค•เคฐเฅ‡เค‚ + %1$s เค•เคพ เค‰เคชเคฏเฅ‹เค— เคนเฅ‹ เคฐเคนเคพ เคนเฅˆ + GPS เคชเคฐ เคฐเฅ€เคธเฅ‡เคŸ เค•เคฐเฅ‡เค‚ + เคตเคฐเฅเคคเคฎเคพเคจ เคฎเฅ‡เค‚ %1$s เค•เคพ เคฎเฅŒเคธเคฎ เคฆเคฟเค–เคพเคฏเคพ เคœเคพ เคฐเคนเคพ เคนเฅˆเฅค GPS เคชเคฐ เคฐเฅ€เคธเฅ‡เคŸ เค•เคฐเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค เคŸเฅˆเคช เค•เคฐเฅ‡เค‚เฅค + เคญเคพเคทเคพ เคšเฅเคจเฅ‡เค‚ + เคตเคพเคชเคธ เคœเคพเค“ + เค…เคชเคจเฅ€ เคญเคพเคทเคพ เคšเฅเคจเฅ‡เค‚ + เคตเฅเคฏเค•เฅเคคเคฟเค—เคค เค…เคจเฅเคญเคต เค•เฅ‡ เคฒเคฟเค เค…เคชเคจเฅ€ เคชเคธเค‚เคฆเฅ€เคฆเคพ เคญเคพเคทเคพ เคšเฅเคจเฅ‡เค‚ + %s เคšเคฏเคจเคฟเคค + เคตเคพเคชเคธ เคœเคพเคเค‚ diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 978b0392..e17d6a32 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,64 @@ ืกืžืœ ืฉื’ื™ืื” ืกืžืœ ืกื’ื™ืจื” ืชื—ื–ื™ืช ื™ื•ืžื™ืช + ื›ืคืชื•ืจ ื—ื–ืจื” + ืกืžืœ ื”ื’ื“ืจื•ืช ื”ืชืจืื” + ืกืžืœ ืžื“ื™ื ื™ื•ืช ืคืจื˜ื™ื•ืช + ืกืžืœ ืชื ืื™ ื”ืฉื™ืžื•ืฉ + ืกืžืœ ืžื™ื“ืข + ื ื•ื•ื˜ ืœืžืกืš ื”ื‘ื + ื›ื™ืฉืœื•ืŸ ื‘ื˜ืขื™ื ืช ืชืฆื•ืจืช ืฉืคื”. ืฉื™ืžื•ืฉ ื‘ืฉืคื” ื‘ืจื™ืจืช ืžื—ื“ืœ. + + + ืœื”ื™ืฉืืจ ืžืขื•ื“ื›ืŸ + ื”ืคื•ืš ื”ืชืจืื•ืช ื—ื“ืฉื•ืช ืžื–ื’ ืื•ื•ื™ืจ + ื”ืคื•ืš + + + ืงื‘ืœ ืคืจื™ืžื™ื•ื + ื‘ืขื™ื‘ื•ื“โ€ฆ + ืื ื ื”ืžืชืŸ ื‘ื–ืžืŸ ืฉืื ื• ืžืคืขื™ืœื™ื ืืช ืžื ื•ื™ ื”ืคืจื™ืžื™ื•ื ืฉืœืš. + ื‘ื˜ืœ ืืช ื ืขื™ืœืช ื›ืœ ื”ืชื›ื•ื ื•ืช ื•ื”ื ื”ื ื” ืžื—ื•ื•ื™ื” ืœืœื ืžื•ื“ืขื•ืช. + ืฉื“ืจื’ ื›ืขืช + ืืชื” ืžืฉืชืžืฉ ืคืจื™ืžื™ื•ื + ืคื’ ื‘ืชื•ืงืฃ %1$s + ืคืขื™ืœ + ืคืจื™ืžื™ื•ื ื”ื•ืคืขืœ + ืžื ื•ื™ ื”ืคืจื™ืžื™ื•ื ืฉืœืš ืคืขื™ืœ ื›ืขืช! + + + ืžื™ืงื•ืžื™ื ืฉืžื•ืจื™ื + saved_locations_nested_nav + ืžื™ืงื•ืžื™ื ืฉืžื•ืจื™ื + ืื™ืŸ ืžื™ืงื•ืžื™ื ืฉืžื•ืจื™ื ืขื“ื™ื™ืŸ. ื”ืงืฉ + ื›ื“ื™ ืœื”ื•ืกื™ืฃ ืื—ื“. + ื”ื•ืกืฃ ืžื™ืงื•ื + ืžื—ืง + ืฉืžื•ืจ + ืฉื ืžื™ืงื•ื (ืœืžืฉืœ ื‘ื™ืช) + ืชื›ื•ื ืช ืคืจื™ืžื™ื•ื + ืฉืžื•ืจ ื•ื’ืฉ ืืœ ืžื™ืงื•ืžื™ืš ื”ืžื•ืขื“ืคื™ื ื‘ืื•ืคืŸ ืžื™ื™ื“ื™. ืฉื“ืจื’ ืœืคืจื™ืžื™ื•ื ื›ื“ื™ ืœื”ืฉื™ื’ ื ืขื™ืœืช ืชื›ื•ื ื” ื–ื•. + ื›ื™ืฉืœื•ืŸ ื‘ื˜ืขื™ื ืช ืžื™ืงื•ืžื™ื ืฉืžื•ืจื™ื. + ืžื™ืงื•ื ื ืฉืžืจ ื‘ื”ืฆืœื—ื”. + ืžื™ืงื•ื ื”ื•ืกืจ. + ืžื™ืงื•ืžื™ื ืฉืžื•ืจื™ื + ื”ื•ืกืฃ ืžื™ืงื•ื ื—ื“ืฉ + ืžื—ืง ืžื™ืงื•ื + + + ื—ืคืฉ ืžื™ืงื•ื + ื”ืงืœื“ ืขื™ืจ ืื• ื›ืชื•ื‘ืชโ€ฆ + ืœื ื ืžืฆืื• ืžื™ืงื•ืžื™ื ืขื‘ื•ืจ \'%1$s\' + ืœื”ืฉืชืžืฉ ื›ืžื™ืงื•ื ืžื–ื’ ื”ืื•ื•ื™ืจ? + ื ืชื•ื ื™ ืžื–ื’ ื”ืื•ื•ื™ืจ ื™ื•ืฆื’ื• ืขื‘ื•ืจ %1$s ื‘ืžืงื•ื ืžื™ืงื•ื ื”-GPS ื”ื ื•ื›ื—ื™ ืฉืœืš. + ืžื™ืงื•ื ื”-GPS ื”ื—ื™ ืฉืœืš ืœื ื™ืชืขื“ื›ืŸ ื›ืœ ืขื•ื“ ื–ื” ืคืขื™ืœ. + ื”ื’ื“ืจ ื›ื‘ืจื™ืจืช ืžื—ื“ืœ + ืžืฉืชืžืฉ ื‘-%1$s + ืืคืก ืœ-GPS + ื›ืจื’ืข ืžื•ืฆื’ ืžื–ื’ ื”ืื•ื•ื™ืจ ืขื‘ื•ืจ %1$s. ื”ืงืฉ ื›ื“ื™ ืœืืคืก ืœ-GPS. + ื‘ื—ืจ ืฉืคื” + ืชื—ื–ื•ืจ + ื‘ื—ืจ ืืช ื”ืฉืคื” ืฉืœืš + ื‘ื—ืจ ืืช ื”ืฉืคื” ื”ืžื•ืขื“ืคืช ืขืœื™ืš ืœื—ื•ื•ื™ื” ืžื•ืชืืžืช ืื™ืฉื™ืช + %s ื ื‘ื—ืจ + ื ื•ื•ื˜ ืœืื—ื•ืจ 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..ed54ae56 --- /dev/null +++ b/app/src/main/res/values-kn/strings.xml @@ -0,0 +1,124 @@ + + + + เฒธเณเฒชเณเฒฒเณเฒฏเฒพเฒทเณ + เฒฎเณเฒ–เฒชเณเฒŸ + เฒตเฒพเฒฏเณ เฒ—เณเฒฃเฒฎเฒŸเณเฒŸ + เฒจเฒ—เฒฐเฒ—เฒณเณ + เฒชเณเฒฐเณŠเฒซเณˆเฒฒเณ + เฒธเณ†เฒŸเณเฒŸเฒฟเฒ‚เฒ—เณโ€Œเฒ—เฒณเณ + + + 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 เฒ—เณ† เฒฐเณ€เฒธเณ†เฒŸเณ เฒฎเฒพเฒกเฒฒเณ เฒŸเณเฒฏเฒพเฒชเณ เฒฎเฒพเฒกเฒฟ. + เฒญเฒพเฒทเณ† เฒ†เฒฏเณเฒ•เณ†เฒฎเฒพเฒกเฒฟ + เฒนเฒฟเฒ‚เฒฆเณ† เฒนเณ‹เฒ—เฒฟ + เฒจเฒฟเฒฎเณเฒฎ เฒญเฒพเฒทเณ† เฒ†เฒฏเณเฒ•เณ†เฒฎเฒพเฒกเฒฟ + เฒตเณˆเฒฏเฒ•เณเฒคเฒฟเฒ• เฒ…เฒจเณเฒญเฒตเฒ•เณเฒ•เฒพเฒ—เฒฟ เฒจเฒฟเฒฎเณเฒฎ เฒ†เฒฆเณเฒฏเฒคเณ†เฒฏ เฒญเฒพเฒทเณ† เฒ†เฒฏเณเฒ•เณ†เฒฎเฒพเฒกเฒฟ + %s เฒ†เฒฏเณเฒ•เณ† เฒฎเฒพเฒกเฒฒเฒพเฒ—เฒฟเฒฆเณ† + เฒนเฒฟเฒ‚เฒฆเณ† เฒจเณเฒฏเฒพเฒตเฒฟเฒ—เณ‡เฒŸเณ เฒฎเฒพเฒกเฒฟ + 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..0b66cf76 --- /dev/null +++ b/app/src/main/res/values-ml/strings.xml @@ -0,0 +1,124 @@ + + + + เดธเตโ€Œเดชเตเดฒเดพเดทเต + เดนเต‹เด‚ + เดตเดพเดฏเต เด—เตเดฃเดจเดฟเดฒเดตเดพเดฐเด‚ + เดจเด—เดฐเด™เตเด™เตพ + เดชเตเดฐเตŠเดซเตˆเตฝ + เด•เตเดฐเดฎเต€เด•เดฐเดฃเด™เตเด™เตพ + + + 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-เดฒเต‡เด•เตเด•เต เดฑเต€เดธเต†เดฑเตเดฑเต เดšเต†เดฏเตเดฏเดพเตป เดŸเดพเดชเตเดชเต เดšเต†เดฏเตเดฏเตเด•. + เดญเดพเดท เดคเดฟเดฐเดžเตเดžเต†เดŸเตเด•เตเด•เตเด• + เดคเดฟเดฐเดฟเด•เต† เดชเต‹เด•เตเด• + เดจเดฟเด™เตเด™เดณเตเดŸเต† เดญเดพเดท เดคเดฟเดฐเดžเตเดžเต†เดŸเตเด•เตเด•เตเด• + เดตเตเดฏเด•เตเดคเดฟเด—เดค เด…เดจเตเดญเดตเดคเตเดคเดฟเดจเดพเดฏเดฟ เดจเดฟเด™เตเด™เตพ เด‡เดทเตเดŸเดชเตเดชเต†เดŸเตเดจเตเดจ เดญเดพเดท เดคเดฟเดฐเดžเตเดžเต†เดŸเตเด•เตเด•เตเด• + %s เดคเดฟเดฐเดžเตเดžเต†เดŸเตเดคเตเดคเต + เดชเดฟเดฑเด•เต‹เดŸเตเดŸเต เดจเดพเดตเดฟเด—เต‡เดฑเตเดฑเต เดšเต†เดฏเตเดฏเตเด• + 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..6da38d62 --- /dev/null +++ b/app/src/main/res/values-ta/strings.xml @@ -0,0 +1,124 @@ + + + + เฎธเฏเฎชเฎฟเฎณเฎพเฎทเฏ + เฎฎเฏเฎ•เฎชเฏเฎชเฏ + เฎ•เฎพเฎฑเฏเฎฑเฎฟเฎฉเฏ เฎคเฎฐเฎฎเฏ + เฎจเฎ•เฎฐเฎ™เฏเฎ•เฎณเฏ + เฎšเฏเฎฏเฎตเฎฟเฎตเฎฐเฎฎเฏ + เฎ…เฎฎเฏˆเฎชเฏเฎชเฏเฎ•เฎณเฏ + + + 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-เฎ•เฏเฎ•เฏ เฎฎเฏ€เฎŸเฏเฎŸเฎฎเฏˆเฎ•เฏเฎ• เฎคเฎŸเฏเฎŸเฎตเฏเฎฎเฏ. + เฎฎเฏŠเฎดเฎฟเฎฏเฏˆ เฎคเฏ‡เฎฐเฏเฎจเฏเฎคเฏ†เฎŸเฏเฎ•เฏเฎ•เฎตเฏเฎฎเฏ + เฎคเฎฟเฎฐเฏเฎฎเฏเฎชเฎฟ เฎšเฏ†เฎฒเฏเฎฒเฎตเฏเฎฎเฏ + เฎ‰เฎ™เฏเฎ•เฎณเฏ เฎฎเฏŠเฎดเฎฟเฎฏเฏˆ เฎคเฏ‡เฎฐเฏเฎจเฏเฎคเฏ†เฎŸเฏเฎ•เฏเฎ•เฎตเฏเฎฎเฏ + เฎคเฎฉเฎฟเฎชเฏเฎชเฎฏเฎฉเฎพเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฏเฎŸ เฎ…เฎฉเฏเฎชเฎตเฎคเฏเฎคเฎฟเฎฑเฏเฎ•เฏ เฎ‰เฎ™เฏเฎ•เฎณเฏเฎ•เฏเฎ•เฏ เฎตเฎฟเฎฐเฏเฎชเฏเฎชเฎฎเฎพเฎฉ เฎฎเฏŠเฎดเฎฟเฎฏเฏˆ เฎคเฏ‡เฎฐเฏเฎจเฏเฎคเฏ†เฎŸเฏเฎ•เฏเฎ•เฎตเฏเฎฎเฏ + %s เฎคเฏ‡เฎฐเฏเฎจเฏเฎคเฏ†เฎŸเฏเฎ•เฏเฎ•เฎชเฏเฎชเฎŸเฏเฎŸเฎคเฏ + เฎชเฎฟเฎฉเฏเฎฉเฎพเฎฒเฏ เฎšเฏ†เฎฒเฏเฎฒเฎตเฏเฎฎเฏ + 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..834652a4 --- /dev/null +++ b/app/src/main/res/values-te/strings.xml @@ -0,0 +1,124 @@ + + + + เฐธเฑเฐชเฑเฐฒเฐพเฐทเฑ + เฐนเฑ‹เฐฎเฑ + เฐตเฐพเฐฏเฑ เฐจเฐพเฐฃเฑเฐฏเฐค + เฐจเฐ—เฐฐเฐพเฐฒเฑ + เฐชเฑเฐฐเฑŠเฐซเฑˆเฐฒเฑ + เฐธเฑ†เฐŸเฑเฐŸเฐฟเฐ‚เฐ—เฑเฐฒเฑ + + + 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 เฐ•เฐฟ เฐฐเฑ€เฐธเฑ†เฐŸเฑ เฐšเฑ‡เฐฏเฐกเฐพเฐจเฐฟเฐ•เฐฟ เฐจเฑŠเฐ•เฑเฐ•เฐ‚เฐกเฐฟ. + เฐญเฐพเฐท เฐŽเฐ‚เฐšเฑเฐ•เฑ‹เฐ‚เฐกเฐฟ + เฐตเฑ†เฐจเฑเฐ•เฐ•เฑ เฐตเฑ†เฐณเฑเฐณเฑ + เฐฎเฑ€ เฐญเฐพเฐท เฐŽเฐ‚เฐšเฑเฐ•เฑ‹เฐ‚เฐกเฐฟ + เฐตเฑเฐฏเฐ•เฑเฐคเฐฟเฐ—เฐค เฐ…เฐจเฑเฐญเฐตเฐ‚ เฐ•เฑ‹เฐธเฐ‚ เฐฎเฑ€เฐ•เฑ เฐ‡เฐทเฑเฐŸเฐฎเฑˆเฐจ เฐญเฐพเฐทเฐจเฑ เฐŽเฐ‚เฐšเฑเฐ•เฑ‹เฐ‚เฐกเฐฟ + %s เฐŽเฐ‚เฐšเฑเฐ•เฑ‹เฐฌเฐกเฐฟเฐ‚เฐฆเฐฟ + เฐตเฑ†เฐจเฑเฐ•เฐ•เฑ เฐจเฐพเฐตเฐฟเฐ—เฑ‡เฐŸเฑ เฐšเฑ‡เฐฏเฐ‚เฐกเฐฟ + 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 1a07cbba..9d51bbf0 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 @@ -29,18 +37,46 @@ Kolkata Go back Got it - This is a new change to be connect with {brand} - This is a second new fine ness to be connected with {brand} - x + Unauthorised access, Please log in! City not found! - Ummโ€ฆ seems like our server did not respond! - Ummโ€ฆ Please check internet connectivity! - Oops!..Something really went wrong. Please retry! - Location coordinates yet to updated. - No cities found. - No details 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 @@ -50,4 +86,54 @@ 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 + + + Choose language + Go back + Select Your Language + Choose your preferred language for a personalized experience + %s selected + Navigate back + + + 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/app/src/test/java/bose/ankush/weatherify/common/TestDispatcher.kt b/app/src/test/java/bose/ankush/weatherify/common/TestDispatcher.kt deleted file mode 100644 index 2cf15678..00000000 --- a/app/src/test/java/bose/ankush/weatherify/common/TestDispatcher.kt +++ /dev/null @@ -1,21 +0,0 @@ -package bose.ankush.weatherify.common - -import bose.ankush.weatherify.base.dispatcher.DispatcherProvider -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher - -@ExperimentalCoroutinesApi -class TestDispatcher : DispatcherProvider { - - private val testDispatcher = StandardTestDispatcher() - - override val main: CoroutineDispatcher - get() = testDispatcher - override val io: CoroutineDispatcher - get() = testDispatcher - override val default: CoroutineDispatcher - get() = testDispatcher - override val unconfined: CoroutineDispatcher - get() = testDispatcher -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index d73f6982..ffc07fca 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,25 +1,23 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. -buildscript { - dependencies { - classpath(BuildPlugins.buildGradle) - classpath(BuildPlugins.kotlinGradlePlugin) - classpath(BuildPlugins.googleServicePlugin) - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } -}// Top-level build file where you can add configuration options common to all sub-projects/modules. +// Plugin versions all come from gradle/libs.versions.toml โ€” the single source of truth for every +// dependency/plugin version across modules. No buildscript{} classpath block is needed: applying +// plugins below via the version catalog is enough to put them on every subproject's classpath. plugins { - id("com.android.application") version Versions.buildGradle apply false - id("com.android.library") version Versions.buildGradle apply false - id("org.jetbrains.kotlin.android") version Versions.kotlin apply false - 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.android.libraries.mapsplatform.secrets-gradle-plugin") version Versions.secretPlugin apply false - id("org.jlleitschuh.gradle.ktlint") version Versions.ktLintVersion apply false - id("com.diffplug.spotless") version Versions.spotlessVersion apply false - id("com.github.ben-manes.versions") version Versions.benManes - id("org.jetbrains.kotlin.plugin.compose") version Versions.kotlin apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.kotlin.multiplatform.library) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.hilt.android) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.secrets.gradle.plugin) apply false + alias(libs.plugins.ktlint) apply false + alias(libs.plugins.spotless) apply false + alias(libs.plugins.detekt) apply false + alias(libs.plugins.ben.manes.versions) + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.compose.multiplatform) apply false + alias(libs.plugins.google.services) apply false } tasks.named("dependencyUpdates").configure { @@ -30,3 +28,136 @@ 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(libs.versions.ktlintCli.get()).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(libs.versions.ktlintCli.get()) + } + } +} + +// 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 + config.setFrom(files("$rootDir/config/detekt.yml")) + } + + // 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 + config.setFrom(rootProject.files("config/detekt.yml")) + 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..e188ddae 100644 --- a/buildSrc/src/main/java/ConfigData.kt +++ b/buildSrc/src/main/java/ConfigData.kt @@ -1,10 +1,8 @@ +// SDK levels (compileSdk/minSdk/targetSdk) live in gradle/libs.versions.toml โ€” read them via +// libs.versions.compileSdk/minSdk/targetSdk in each module's build.gradle.kts instead. object ConfigData { - const val compileSdkVersion = 34 - const val buildToolsVersion = "30.0.3" - const val minSdkVersion = 26 - const val targetSdkVersion = 34 const val versionCode = 101 const val versionName = "1.1" const val multiDexEnabled = true -} \ No newline at end of file +} diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt deleted file mode 100644 index de2204c0..00000000 --- a/buildSrc/src/main/java/Dependencies.kt +++ /dev/null @@ -1,82 +0,0 @@ -// Plugins -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}" } -} - -// Dependencies -object Deps { - // Core - val androidCore by lazy { "androidx.core:core-ktx:${Versions.androidCore}" } - val appCompat by lazy { "androidx.appcompat:appcompat:${Versions.appCompat}" } - val androidMaterial by lazy { "com.google.android.material:material:${Versions.androidMaterial}" } - val viewModelCompose by lazy { "androidx.lifecycle:lifecycle-viewmodel-compose:${Versions.lifecycle}" } - val navigationCompose by lazy { "androidx.navigation:navigation-compose:${Versions.navigation}" } - val inAppUpdate by lazy { "com.google.android.play:app-update:${Versions.googlePlayCore}" } - val inAppUpdateKtx by lazy { "com.google.android.play:app-update-ktx:${Versions.googlePlayCore}" } - val googlePlayLocation by lazy { "com.google.android.gms:play-services-location:${Versions.googlePlayLocation}" } - val composePermission by lazy { "com.google.accompanist:accompanist-permissions:${Versions.accompanist}" } - 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}" } - - // Compose - val composeBom by lazy { "androidx.compose:compose-bom:${Versions.composeBom}" } - val composeMaterial1 by lazy { "androidx.compose.material:material" } - val composeMaterial3 by lazy { "androidx.compose.material3:material3" } - 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" } - - // Unit Testing - val junit by lazy { "junit:junit:${Versions.junit}" } - val truth by lazy { "com.google.truth:truth:${Versions.truth}" } - val turbine by lazy { "app.cash.turbine:turbine:${Versions.turbine}" } - val coroutineTest by lazy { "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.coroutineTest}" } - val coreTesting by lazy { "androidx.arch.core:core-testing:${Versions.coreTesting}" } - val mockitoInline by lazy { "org.mockito:mockito-inline:${Versions.mockitoInline}" } - val mockitoNhaarman by lazy { "com.nhaarman.mockitokotlin2:mockito-kotlin:${Versions.mockitoNhaarman}" } - val mockWebServer by lazy { "com.squareup.okhttp3:mockwebserver:${Versions.mockWebServer}" } - val mockk by lazy { "io.mockk:mockk:${Versions.mockk}" } - - // Room - val room by lazy { "androidx.room:room-runtime:${Versions.room}" } - val roomKtx by lazy { "androidx.room:room-ktx:${Versions.room}" } - val roomCompiler by lazy { "androidx.room:room-compiler:${Versions.room}" } - - // UI Testing - val extJunit by lazy { "androidx.test.ext:junit:${Versions.extJunit}" } - val espressoCore by lazy { "androidx.test.espresso:espresso-core:${Versions.espresso}" } - val espressoContrib by lazy { "androidx.test.espresso:espresso-contrib:${Versions.espresso}" } - - // Networking - val gson by lazy { "com.google.code.gson:gson:${Versions.gson}" } - - // 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" } - val firebasePerformanceMonitoring by lazy { "com.google.firebase:firebase-perf" } - - // 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}" } - - // 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}" } - - // 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*/ - 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 deleted file mode 100644 index d91a74ea..00000000 --- a/buildSrc/src/main/java/Versions.kt +++ /dev/null @@ -1,70 +0,0 @@ - -object Versions { - - // Kotlin - const val kotlin = "2.0.20" - const val kotlinCompiler = "1.9" - - // Compose - const val composeBom = "2023.08.00" - - // Plugins - const val buildGradle = "8.11.1" - const val navigation = "2.7.0" - 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" - - // 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 coreTesting = "2.2.0" - const val espresso = "3.5.1" - const val mockitoInline = "5.2.0" - const val mockitoNhaarman = "2.2.0" - const val mockWebServer = "4.9.3" - const val mockk = "1.13.5" - - // 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 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" - - // Room - const val room = "2.5.2" - - // Networking - const val gson = "2.13.1" - - // Firebase - const val firebaseBom = "32.2.0" - - // Coroutines - const val coroutines = "1.6.4" - - // Dependency Injection - const val hilt = "2.52" - const val hiltCompose = "1.0.0" - - // Miscellaneous - const val timber = "5.0.1" - const val lottie = "6.0.0" - const val coilCompose = "2.4.0" - - // Memory leak - const val leakCanary = "2.12" - - /*For Payment module*/ - const val razorPay = "1.6.30" -} diff --git a/buildSrc/src/main/kotlin/KmmDeps.kt b/buildSrc/src/main/kotlin/KmmDeps.kt deleted file mode 100644 index 08751443..00000000 --- a/buildSrc/src/main/kotlin/KmmDeps.kt +++ /dev/null @@ -1,58 +0,0 @@ -import org.gradle.api.artifacts.dsl.DependencyHandler - -object KmmVersions { - const val ktor = "2.3.13" - const val kotlinxSerialization = "1.6.0" - const val kotlinxCoroutines = "1.7.3" - const val koin = "3.5.6" - const val kotlinxDateTime = "0.4.1" -} - -object KmmDeps { - // Ktor - const val ktorCore = "io.ktor:ktor-client-core:${KmmVersions.ktor}" - const val ktorSerialization = "io.ktor:ktor-client-serialization:${KmmVersions.ktor}" - 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}" - - // DateTime - const val kotlinxDateTime = "org.jetbrains.kotlinx:kotlinx-datetime:${KmmVersions.kotlinxDateTime}" -} - -fun DependencyHandler.addKmmCommonDependencies() { - implementation(KmmDeps.ktorCore) - implementation(KmmDeps.ktorSerialization) - implementation(KmmDeps.ktorContentNegotiation) - implementation(KmmDeps.ktorJson) - implementation(KmmDeps.ktorLogging) - implementation(KmmDeps.kotlinxSerialization) - implementation(KmmDeps.kotlinxCoroutinesCore) - implementation(KmmDeps.koinCore) - implementation(KmmDeps.kotlinxDateTime) -} - -fun DependencyHandler.addKmmAndroidDependencies() { - implementation(KmmDeps.ktorAndroid) -} - -fun DependencyHandler.addKmmIOSDependencies() { - implementation(KmmDeps.ktorIOS) -} - -private fun DependencyHandler.implementation(depName: String) { - add("implementation", depName) -} \ No newline at end of file diff --git a/common-ui/build.gradle.kts b/common-ui/build.gradle.kts new file mode 100644 index 00000000..37145ff0 --- /dev/null +++ b/common-ui/build.gradle.kts @@ -0,0 +1,73 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.compose.multiplatform) +} + +kotlin { + android { + namespace = "bose.ankush.commonui" + compileSdk = libs.versions.compileSdk.get().toInt() + minSdk = libs.versions.minSdk.get().toInt() + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + // iosX64 (Intel simulator) dropped: Compose Multiplatform stopped publishing artifacts for it + // starting at 1.11.0, following Apple's deprecation of the x86_64 iOS Simulator. + iosArm64() + iosSimulatorArm64() + + targets.withType { + 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(libs.kotlinx.datetime) + } + } + + val androidMain by getting { + dependencies { + implementation(libs.kotlinx.coroutines.core) + // BackHandler support for InAppWebView + implementation(libs.androidx.activity.compose) + } + } + + val iosMain by creating { + dependsOn(commonMain) + } + + @Suppress("UNUSED_VARIABLE") + val iosArm64Main by getting { + dependsOn(iosMain) + } + @Suppress("UNUSED_VARIABLE") + val iosSimulatorArm64Main by getting { + dependsOn(iosMain) + } + } +} 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..7474802c --- /dev/null +++ b/common-ui/src/androidMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt @@ -0,0 +1,448 @@ +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 + +@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) } + + 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) + } + }, + ) + + if (isInitialLoad && progress < 100) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.8f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + 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") + } + } + } + } + } + } + } + + 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) + 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) + 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 { + 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)) + } + } + return true +} + +private fun isWhitelistedUrl(uri: Uri): Boolean { + val host = uri.host?.lowercase() ?: return false + val whitelistedDomains = + setOf( + "data.androidplay.in", + ) + 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..4426cd7c --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/auth/LoginScreen.kt @@ -0,0 +1,391 @@ +package bose.ankush.commonui.auth + +import androidx.compose.animation.core.FastOutSlowInEasing +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,}$") + +@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()), + ) { + 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 = 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)) + + 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, + ), + ) + } + } + } + + 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..07fc1d55 --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/ShimmerEffect.kt @@ -0,0 +1,171 @@ +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.Column +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 + +@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), + ), + ) +} + +@Composable +fun ShimmerBottomSheetSkeleton( + modifier: Modifier = Modifier, + baseColor: Color = Color.LightGray.copy(alpha = 0.3f), + highlightColor: Color = Color.White.copy(alpha = 0.8f), +) { + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + ) { + ShimmerEffect( + height = 28.dp, + cornerRadius = 6.dp, + baseColor = baseColor, + highlightColor = highlightColor, + modifier = + Modifier + .fillMaxWidth(0.6f) + .padding(bottom = 16.dp), + ) + + 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), + ) + + 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), + ) + } + + 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), + ) + + 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 51% 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..2db7c3b8 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,16 +1,6 @@ -package bose.ankush.sunriseui.components - -/** - * Dynamic sunrise/sunset landscape animation that responds to real-time data. - * - * Features: - * - Sky gradients that transition between night, dawn, day, and dusk - * - Animated sun and moon with realistic arc movement - * - Twinkling stars during night hours - * - Wind-driven cloud animation during daytime - * - Atmospheric glow effects around celestial bodies - * - */ +@file:Suppress("ktlint:standard:max-line-length") + +package bose.ankush.commonui.components import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.EaseInOutCubic @@ -31,54 +21,54 @@ 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 -/** - * Main composable function that creates the animated sunrise/sunset landscape. - * See class-level documentation above for detailed feature descriptions and usage examples. - */ @Composable 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 } - // 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 + } else { + (timeElapsed.toFloat() / dayDuration).coerceIn(0f, 1f) + } val isBeforeSunrise = currentTimestamp < sunriseTimestamp val isAfterSunset = currentTimestamp > sunsetTimestamp @@ -91,18 +81,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 +103,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 +149,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 +187,7 @@ fun SunriseSunsetCombinedAnimation( atmosphericIntensity = atmosphericGlow.value, currentTimestamp = currentTimestamp, sunriseTimestamp = sunriseTimestamp, - sunsetTimestamp = sunsetTimestamp + sunsetTimestamp = sunsetTimestamp, ) } @@ -189,11 +197,10 @@ fun SunriseSunsetCombinedAnimation( atmosphericIntensity = atmosphericGlow.value, currentTimestamp = currentTimestamp, sunriseTimestamp = sunriseTimestamp, - sunsetTimestamp = sunsetTimestamp + sunsetTimestamp = sunsetTimestamp, ) } - // Draw clouds during daytime with wind-based movement if (isDaytime) { drawClouds( progress = progress, @@ -205,32 +212,24 @@ fun SunriseSunsetCombinedAnimation( } } -/** - * Interpolates between two colors based on the given fraction. - * @param color1 Starting color (fraction = 0.0) - * @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, ) } -/** - * Creates sky gradient that transitions between night, dawn, day, and dusk colors. - * @param progress Normalized time progress (0.0 = sunrise, 1.0 = sunset) - * @param isBeforeSunrise True if before sunrise - * @param isAfterSunset True if after sunset - */ private fun createSoothingSkyGradient( progress: Float, isBeforeSunrise: Boolean, - isAfterSunset: Boolean + isAfterSunset: Boolean, ): Brush { val nightColors = SunriseConstants.Colors.NIGHT_GRADIENT @@ -244,47 +243,45 @@ 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 - * @param isBeforeSunrise True if before sunrise, affects star opacity - */ 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 @@ -294,9 +291,9 @@ private fun DrawScope.drawStarField( val x = size.width * xRatio val y = size.height * yRatio - // 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,25 +302,17 @@ 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), ) } } -/** - * Renders animated moon that travels across the night sky in an arc pattern. - * @param isBeforeSunrise True if before sunrise, affects moon trajectory - * @param atmosphericIntensity Animation value (0.0-1.0) for glow effect - * @param currentTimestamp Current Unix timestamp in seconds - * @param sunriseTimestamp Sunrise timestamp, null for fallback positioning - * @param sunsetTimestamp Sunset timestamp, null for fallback positioning - */ private fun DrawScope.drawMoon( isBeforeSunrise: Boolean, atmosphericIntensity: Float, currentTimestamp: Long, sunriseTimestamp: Long?, - sunsetTimestamp: Long? + sunsetTimestamp: Long?, ) { val moonX: Float val moonY: Float @@ -334,99 +323,125 @@ 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), ) } -/** - * Renders animated sun that travels across the sky with dynamic colors and rays. - * @param progress Normalized time progress (unused) - * @param atmosphericIntensity Animation value (0.0-1.0) for glow effect - * @param currentTimestamp Current Unix timestamp in seconds - * @param sunriseTimestamp Sunrise timestamp (as Long) - * @param sunsetTimestamp Sunset timestamp (as Long) - */ private fun DrawScope.drawSun( progress: Float, 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,37 +460,34 @@ 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, ) } } -/** - * Renders animated clouds that drift across the sky based on wind direction. - * @param progress Normalized time progress for color determination - * @param cloudDriftProgress Animation value (0.0-1.0) controlling cloud movement - * @param windDirection Wind direction in degrees (0-360ยฐ) affecting movement - */ private fun DrawScope.drawClouds( progress: Float, cloudDriftProgress: Float, 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 = cos(windDirection * PI / 180f).toFloat() * SunriseConstants.Positioning.CLOUD_DRIFT_SPEED val windInfluenceY = @@ -485,12 +497,12 @@ 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 - // Draw cloud as multiple overlapping circles (puffs) val puffCount = SunriseConstants.Counts.CLOUD_PUFFS_PER_CLOUD val puffRadius = SunriseConstants.Dimensions.CLOUD_PUFF_RADIUS val cloudWidth = SunriseConstants.Dimensions.CLOUD_WIDTH @@ -503,7 +515,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..35499284 --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherAlertCard.kt @@ -0,0 +1,316 @@ +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 + +@Composable +fun WeatherAlertCard( + title: String?, + description: String?, + startTime: Long?, + endTime: Long?, + source: String?, + onReadMoreClick: (() -> Unit)? = null, + initiallyExpanded: Boolean = false, +) { + if (title.isNullOrEmpty() || description.isNullOrEmpty()) return + + var isExpanded by remember { mutableStateOf(initiallyExpanded) } + + 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) + + val colors = + AlertCardColors( + primaryColor = primaryColor, + textColor = textColor, + accentColor = accentColor, + surfaceColor = surfaceColor, + subtleTextColor = subtleTextColor, + ) + + val formattedStartTime = + remember(startTime) { + startTime?.let { formatTimestamp(it) } ?: "Unknown" + } + val formattedEndTime = + remember(endTime) { + endTime?.let { formatTimestamp(it) } ?: "Unknown" + } + + val shortDescription = + remember(description) { + if (description.length > 100) description.take(100) + "..." else description + } + + 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, + ) { + AlertHeader( + title = title, + timestamp = formattedStartTime, + colors = colors, + ) + + 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)) { + AlertInfoSection( + title = "Source", + content = source ?: "Unknown", + colors = colors, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + AlertInfoSection( + title = "Valid Until", + content = formattedEndTime, + colors = colors, + ) + } + } + } + } +} + +@Composable +private fun AlertHeader( + title: String?, + timestamp: String, + colors: AlertCardColors, +) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = "Weather Alert Icon", + tint = colors.primaryColor, + modifier = + Modifier + .size(32.dp) + .padding(bottom = 12.dp), + ) + + Text( + text = title ?: "Weather Alert", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = colors.textColor, + modifier = Modifier.padding(bottom = 4.dp), + ) + + Text( + text = "Issued: $timestamp", + style = MaterialTheme.typography.bodySmall, + color = colors.subtleTextColor, + modifier = Modifier.padding(bottom = 16.dp), + ) +} + +@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)) { + Text( + text = "Report", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = colors.accentColor, + modifier = Modifier.padding(bottom = 8.dp), + ) + + 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" + }, + ) + + 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, + ) + } + } + } +} + +@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, + ) + } + } +} + +private data class AlertCardColors( + val primaryColor: Color, + val textColor: Color, + val accentColor: Color, + val surfaceColor: Color, + val subtleTextColor: Color, +) + +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 70% 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..3372719c 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 @@ -22,62 +22,52 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -/** - * A composable that displays weather information for a specific day. - * This component is designed to be flexible and reusable across different platforms. - * - * @param dayName The name of the day (e.g., "Monday", "Tuesday") - * @param minTemperature The minimum temperature for the day - * @param maxTemperature The maximum temperature for the day - * @param weatherDescription Optional description of the weather conditions - * @param iconContent Composable content for the weather icon - */ @Composable fun WeatherDayCard( dayName: String, 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( text = dayName, style = MaterialTheme.typography.titleMedium, 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( text = minTemperature, style = MaterialTheme.typography.bodyLarge, @@ -87,7 +77,6 @@ fun WeatherDayCard( Spacer(modifier = Modifier.width(8.dp)) - // Max temperature Text( text = maxTemperature, style = MaterialTheme.typography.bodyLarge, @@ -96,27 +85,25 @@ fun WeatherDayCard( ) } - // Optional: Add weather description if available weatherDescription?.let { description -> Text( text = description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } } - // Weather icon 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 65% 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..4fb3f2f2 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 @@ -21,17 +21,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -/** - * A composable that displays weather information for a specific hour. - * This component is designed to be flexible and reusable across different platforms. - * - * @param time The formatted time (e.g., "12:00 PM") - * @param temperature The temperature value with unit - * @param weatherDescription Optional description of the weather conditions - * @param isSelected Whether this hour card is currently selected - * @param onClick Callback for when the card is clicked - * @param iconContent Composable content for the weather icon - */ @Composable fun WeatherHourCard( time: String, @@ -39,26 +28,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( text = time, style = MaterialTheme.typography.bodySmall, @@ -67,19 +55,16 @@ fun WeatherHourCard( modifier = Modifier.alpha(0.6f), ) - // Weather icon iconContent() - // Temperature Text( text = temperature, 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) weatherDescription?.let { description -> Text( text = description, @@ -87,9 +72,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..d890f2d6 --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherIcon.kt @@ -0,0 +1,419 @@ +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 + +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 { + @Composable + fun default(isDarkTheme: Boolean = isSystemInDarkTheme()): WeatherIconColors { + val sunColor = + try { + if (isDarkTheme) Color(0xFFFFD700) else Color(0xFFFF9800) + } catch (_: Exception) { + Color(0xFFFF9800) + } + + 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) + } + + 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, + ) + }, + ) + } + } +} + +@Composable +fun AnimatedWeatherIcon( + weatherDescription: String?, + modifier: Modifier = Modifier.size(48.dp), + colors: WeatherIconColors = WeatherIconColors.default(), +) { + val weatherCondition = + remember(weatherDescription) { + mapToWeatherCondition(weatherDescription) + } + + val contentDesc = + remember(weatherCondition) { + "Weather icon: ${weatherCondition.description}" + } + + 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, + ) + } + + 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, + ) + } + + 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) } + + 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 { + weatherCondition == WeatherCondition.CLEAR_SKY -> { + drawSun( + animationProgress = sunGlow.value, + sunColor = colors.sunColor, + sunGlowColor = colors.sunGlowColor, + ) + } + + 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, + ) + } + + 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, + ) + } + + 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, + ) + } + + 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, + ) + } + + weatherCondition.description.contains("drizzle") -> { + drawClouds( + animationProgress = cloudDrift.value, + cloudiness = 0.7f, + cloudColor = colors.cloudColor, + ) + drawRain( + animationProgress = rainDrop.value, + intensity = 0.3f, + rainColor = colors.rainColor, + ) + } + + 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, + ) + } + + 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 56% 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..55d964ba 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,47 +1,44 @@ -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 -/** - * Draws a sun with animated glow and rays for the weather icon. - * 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 val rayCount = 8 for (i in 0 until rayCount) { - // Vary ray length based on position and animation val rayFactor = 0.8f + 0.2f * sin((animationProgress * PI * 2 + i).toFloat()) val rayLength = radius * 0.6f * rayFactor @@ -54,146 +51,110 @@ fun DrawScope.drawSun( val endX = centerX + cos(angle).toFloat() * endRadius val endY = centerY + sin(angle).toFloat() * endRadius - // Vary ray thickness for more natural appearance val strokeWidth = 2f + 1f * sin((animationProgress * PI * 2 + i * 0.5f).toFloat()) 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, ) } } -/** - * Draws clouds with smoother animation. - * 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 val puffRadius = size.width * 0.1f 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()), ) } } } -/** - * Draws rain with a natural, continuous animation. - * Features dynamic intensity, varied raindrop appearance, wind effects, and splash effects. - * 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() val dropCount = baseDropCount.coerceAtMost(30) - // Wind effect - varies over time for natural feel val windStrength = sin(animationProgress * PI * 0.3f) * 0.2f + 0.1f - // Create a pseudo-random distribution of raindrops for (i in 0 until dropCount) { - // Create unique seed for each raindrop to avoid visible patterns val seed = (i * 13 + 7) % dropCount - - // Vary drop speeds significantly for more realistic rain - // Heavier rain falls faster on average val speedFactor = 0.6f + (seed % 7) * 0.08f + intensity * 0.3f - - // Create a unique phase for each raindrop to avoid synchronized movement val phase = (seed * 0.1f) % 1.0f - - // Non-repeating progress calculation with unique offsets - // This creates the illusion of continuous rainfall without visible loops val uniqueProgress = (animationProgress * speedFactor + phase) % 1.0f - - // Horizontal position with wind effect and slight randomization - // Wind effect is stronger for lighter drops (smaller thickness) val horizontalSeed = (seed * 17 + 3) % dropCount val dropThickness = 1.0f + intensity * 1.2f * (0.7f + (seed % 5) * 0.1f) val windEffect = windStrength * (1.5f - dropThickness * 0.3f) - - // Initial horizontal position is distributed across the width val initialX = size.width * ((horizontalSeed * 0.1f) % 1.0f) - - // Apply wind and slight randomization to horizontal position val dropX = initialX + sin(animationProgress * PI * 0.2f + seed) * size.width * 0.05f - - // Vertical position with continuous movement val dropY = size.height * (0.3f + uniqueProgress * 0.7f) - - // Vary drop length based on intensity, speed, and randomization - // Faster drops appear longer (motion blur effect) val lengthVariation = 0.7f + (seed % 5) * 0.1f + speedFactor * 0.3f val dropLength = size.height * (0.05f + 0.08f * intensity) * lengthVariation - - // Calculate end position with wind slant val endX = dropX + windEffect * dropLength val endY = dropY + dropLength - // 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 - // Only some drops create visible splashes if (endY >= size.height * 0.95f && seed % 3 == 0) { val splashProgress = (uniqueProgress * 3f) % 1.0f - // Only show splash at the beginning of its animation cycle if (splashProgress < 0.3f) { val splashSize = size.width * 0.02f * (1f - splashProgress / 0.3f) * intensity val splashAlpha = (0.7f - splashProgress / 0.3f * 0.7f) * intensity * 0.8f - // Draw splash as a small circle 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 if (intensity > 0.6f && seed % 6 == 0) { val rippleProgress = splashProgress * 1.5f if (rippleProgress < 0.3f) { @@ -203,7 +164,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, + ), ) } } @@ -212,126 +177,105 @@ fun DrawScope.drawRain( } } -/** - * Draws snow with more realistic animation. - * 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) - // Vary flake size for more natural appearance val flakeSize = size.width * (0.015f + 0.01f * (i % 3) / 3f) - // 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), ) } } -/** - * Draws thunder with more realistic animation. - * Uses theme-aware colors that adapt to light/dark mode. - */ 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) if (flashIntensity > 0.2f) { val centerX = size.width * 0.5f 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), ) } } -/** - * Draws fog with more realistic animation. - * Uses theme-aware colors that adapt to light/dark mode. - */ fun DrawScope.drawFog( animationProgress: Float, - fogColor: Color + fogColor: Color, ) { val layerCount = 6 for (i in 0 until layerCount) { - // Vary layer positions and speeds for more natural fog 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), ) } } -/** - * Maps a weather description string to a WeatherCondition enum value. - * Uses fuzzy matching to handle variations in description text. - * Improved to handle more real-world API responses. - */ fun mapToWeatherCondition(description: String?): WeatherCondition { if (description.isNullOrBlank()) return WeatherCondition.CLEAR_SKY - // Convert to lowercase for case-insensitive matching val lowerDesc = description.lowercase() - // Try to find an exact match first WeatherCondition.entries.forEach { condition -> if (lowerDesc == condition.description.lowercase()) { return condition } } - // If no exact match, try fuzzy matching based on keywords return when { "thunderstorm" in lowerDesc -> { when { @@ -340,6 +284,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 +292,7 @@ fun mapToWeatherCondition(description: String?): WeatherCondition { else -> WeatherCondition.DRIZZLE } } + "rain" in lowerDesc -> { when { "light" in lowerDesc || "slight" in lowerDesc -> WeatherCondition.LIGHT_RAIN @@ -355,6 +301,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 +309,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,12 +321,13 @@ fun mapToWeatherCondition(description: String?): WeatherCondition { else -> WeatherCondition.SCATTERED_CLOUDS } } + "mist" in lowerDesc -> WeatherCondition.MIST "fog" in lowerDesc -> WeatherCondition.FOG "haze" in lowerDesc -> WeatherCondition.HAZE "smoke" in lowerDesc -> WeatherCondition.SMOKE "dust" in lowerDesc || "sand" in lowerDesc -> WeatherCondition.SAND_DUST_WHIRLS "tornado" in lowerDesc || "cyclone" in lowerDesc || "hurricane" in lowerDesc -> WeatherCondition.TORNADO - else -> WeatherCondition.CLEAR_SKY // Default fallback + else -> WeatherCondition.CLEAR_SKY } } diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/SunriseConstants.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/SunriseConstants.kt new file mode 100644 index 00000000..2527834f --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/SunriseConstants.kt @@ -0,0 +1,146 @@ +package bose.ankush.commonui.constants + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +object SunriseConstants { + object Durations { + const val INITIAL_ANIMATION = 3000 + const val STAR_TWINKLE = 2000 + const val ATMOSPHERIC_GLOW = 4000 + const val CLOUD_DRIFT = 3000 + } + + object Dimensions { + val CORNER_RADIUS = 12.dp + const val MOON_BASE_RADIUS = 12f + const val MOON_RADIUS_VARIATION = 3f + const val SUN_BASE_RADIUS = 15f + const val SUN_RADIUS_VARIATION = 5f + const val STAR_BASE_SIZE = 2f + const val STAR_SIZE_VARIATION = 1f + const val SUN_RAY_LENGTH = 25f + const val SUN_RAY_WIDTH = 2f + const val CLOUD_WIDTH = 40f + const val CLOUD_PUFF_RADIUS = 8f + } + + object Opacity { + const val MOON_BASE = 0.8f + const val MOON_VARIATION = 0.2f + const val SUN_BASE = 0.9f + const val SUN_VARIATION = 0.1f + const val STAR_BASE_BEFORE_SUNRISE = 0.8f + const val STAR_BASE_AFTER_SUNSET = 0.6f + const val TWINKLE_VARIATION = 0.3f + const val TWINKLE_BASE = 0.7f + const val CLOUD_BASE = 0.6f + const val CLOUD_VARIATION = 0.2f + } + + object Counts { + const val SUN_RAY_COUNT = 8 + const val CLOUD_COUNT = 6 + const val CLOUD_PUFFS_PER_CLOUD = 3 + } + + object Colors { + val NIGHT_GRADIENT = + listOf( + Color(0xFF000011).copy(alpha = 0.9f), + Color(0xFF0A1035).copy(alpha = 0.8f), + Color(0xFF0F1A4A).copy(alpha = 0.7f), + Color(0xFF162554).copy(alpha = 0.6f), + ) + + val DAWN_GRADIENT = + listOf( + Color(0xFF0A1035).copy(alpha = 0.8f), + Color(0xFF341C5D).copy(alpha = 0.7f), + Color(0xFF9A3A6A).copy(alpha = 0.6f), + Color(0xFFE67E45).copy(alpha = 0.5f), + ) + + val DAY_GRADIENT = + listOf( + Color(0xFF0E4C92).copy(alpha = 0.7f), + Color(0xFF1A75FF).copy(alpha = 0.6f), + Color(0xFF5D9EFF).copy(alpha = 0.5f), + Color(0xFF87CEEB).copy(alpha = 0.4f), + ) + + val DUSK_GRADIENT = + listOf( + Color(0xFF0A1035).copy(alpha = 0.8f), + Color(0xFF341C5D).copy(alpha = 0.7f), + Color(0xFF9A3A6A).copy(alpha = 0.6f), + Color(0xFFE05038).copy(alpha = 0.5f), + ) + + val DEFAULT_GRADIENT = + listOf( + Color(0xFF0E4C92).copy(alpha = 0.7f), + Color(0xFF1A75FF).copy(alpha = 0.6f), + Color(0xFF5D9EFF).copy(alpha = 0.5f), + Color(0xFF87CEEB).copy(alpha = 0.4f), + ) + + val MOON_COLOR = Color(0xFFF5F5DC) + val MOON_PHASE_COLOR = Color(0xFF0F0F23) + val STAR_COLOR = Color.White + + val CLOUD_DAY_COLOR = Color(0xFFFFFFFF) + val CLOUD_DAWN_COLOR = Color(0xFFFAE3C6) + val CLOUD_DUSK_COLOR = Color(0xFFFFB8A0) + + val SUN_EARLY_MORNING = Color(0xFFFF7E45) + val SUN_MORNING = Color(0xFFFFAA33) + val SUN_MIDDAY = Color(0xFFFFD700) + val SUN_EVENING = Color(0xFFFFAA33) + val SUN_LATE_EVENING = Color(0xFFFF7E45) + } + + object Positioning { + const val MOON_BASE_Y = 0.25f + const val MOON_Y_VARIATION = 0.3f + const val MOON_Y_AMPLITUDE = 0.1f + const val MOON_START_X = 0.9f + const val MOON_END_X = 0.1f + const val MOON_TRAVEL_DISTANCE = 0.8f + + const val SUN_START_X = 0.1f + const val SUN_TRAVEL_DISTANCE = 0.8f + const val SUN_BASE_Y = 0.6f + const val SUN_Y_AMPLITUDE = 0.4f + + const val CLOUD_BASE_Y = 0.2f + const val CLOUD_Y_VARIATION = 0.15f + const val CLOUD_SPACING_X = 0.25f + const val CLOUD_DRIFT_SPEED = 0.08f + } + + object TimeThresholds { + const val DAWN_END = 0.2f + const val DUSK_START = 0.8f + const val SUN_MORNING_END = 0.1f + const val SUN_MIDMORNING_END = 0.2f + const val SUN_EVENING_START = 0.8f + const val SUN_LATE_EVENING_START = 0.9f + } + + 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 72% 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..ce2a97e6 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,17 +1,12 @@ -package bose.ankush.sunriseui.constants +package bose.ankush.commonui.constants -/** - * Constants for weather icon drawing - */ 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 const val SNOW_ANIMATION_DURATION = 3000 const val THUNDER_ANIMATION_DURATION = 3000 - // Alpha values const val SUN_GLOW_ALPHA = 0.3f const val CLOUD_BASE_ALPHA = 0.7f const val RAIN_BASE_ALPHA = 0.7f @@ -19,10 +14,9 @@ object WeatherIconConstants { const val THUNDER_FLASH_ALPHA = 0.8f const val FOG_BASE_ALPHA = 0.4f - // Scaling factors const val SUN_GLOW_SCALE = 1.8f const val SUN_PULSE_SCALE = 0.25f 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..6297cb9b --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/locations/SavedLocationsScreen.kt @@ -0,0 +1,602 @@ +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 + +@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, +) + +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", +) + +@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 +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..d148d315 --- /dev/null +++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/settings/SettingsScreen.kt @@ -0,0 +1,649 @@ +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 + +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, +) + +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), + ) { + 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, + ) + } + } +} + +@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("%1\$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..7b0c8069 --- /dev/null +++ b/common-ui/src/iosMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt @@ -0,0 +1,365 @@ +@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 + +@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 + } + } + + 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() + } + + 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 = {}, + ) + + if (isLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.8f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + 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") + } + } + } + } + } + } + } + + 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 ?: "" + + if (requestUrl == initialUrl) { + decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyAllow) + return + } + + if (isWhitelistedUrl(requestUrl)) { + decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyAllow) + } else { + 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", + ) + return whitelistedDomains.any { trustedDomain -> + host == trustedDomain || host.endsWith(".$trustedDomain") + } + } +} diff --git a/config/detekt.yml b/config/detekt.yml new file mode 100644 index 00000000..a5baa545 --- /dev/null +++ b/config/detekt.yml @@ -0,0 +1,56 @@ +naming: + FunctionNaming: + # Compose functions are conventionally PascalCase + ignoreAnnotated: + - 'Composable' + - 'Preview' + PackageNaming: + # Allow snake_case segments (e.g. use_case, remote_config, get_weather_reports) + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9_]*)*' + ConstructorParameterNaming: + # Broaden to cover snake_case API/JSON model parameters + parameterPattern: '[a-z][A-Za-z0-9_]*' + +complexity: + LongMethod: + # Raise ceiling for long Composable layout functions + threshold: 80 + ignoreAnnotated: + - 'Composable' + LongParameterList: + # Hilt-injected constructors are intentionally large + constructorThreshold: 15 + ignoreAnnotated: + - 'HiltViewModel' + - 'Inject' + LargeClass: + # MainViewModel is an architectural seam; split is tracked separately + threshold: 900 + TooManyFunctions: + thresholdInClasses: 30 + thresholdInObjects: 20 + thresholdInFiles: 20 + +style: + MagicNumber: + # UI layout constants in Composables are self-documenting in context + ignoreAnnotated: + - 'Composable' + - 'Preview' + # Standard calendar/time integers are universally understood + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + - '3' + - '4' + - '5' + - '6' + - '7' + - '100' + - '400' + - '1000' + - '5000' + ForbiddenComment: + active: false diff --git a/feature-payment/build.gradle.kts b/feature-payment/build.gradle.kts new file mode 100644 index 00000000..a62c933b --- /dev/null +++ b/feature-payment/build.gradle.kts @@ -0,0 +1,55 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) +} + +kotlin { + android { + namespace = "bose.ankush.payment" + compileSdk = libs.versions.compileSdk.get().toInt() + minSdk = libs.versions.minSdk.get().toInt() + + 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(libs.koin.core) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) + implementation(libs.androidx.lifecycle.viewmodel.kmp) + } + + androidMain.dependencies { + implementation(libs.koin.android) + } + + 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) + } + } +} 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..4b6aa32a --- /dev/null +++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/store/PremiumStore.kt @@ -0,0 +1,17 @@ +package bose.ankush.payment.domain.store + +import kotlinx.coroutines.flow.Flow + +interface PremiumStore { + fun observePremiumStatus(): Flow + + suspend fun savePremiumStatus( + isPremium: Boolean, + expiryMillis: Long?, + ) +} + +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..d13b2e3b --- /dev/null +++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/presentation/PaymentViewModel.kt @@ -0,0 +1,208 @@ +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 kotlin.time.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/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..e02c18c8 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,210 @@ +[versions] +# SDK +compileSdk = "37" +minSdk = "28" +targetSdk = "37" + +# Kotlin / Compose +kotlin = "2.3.21" +composeBom = "2026.06.00" +composeMultiplatform = "1.11.1" + +# Gradle plugins +agp = "9.2.1" +secretsPlugin = "2.0.1" +benManesVersions = "0.54.0" +spotless = "8.7.0" +ksp = "2.3.9" +ktlintGradle = "14.2.0" +ktlintCli = "1.7.1" +detekt = "1.23.8" +googleServices = "4.5.0" + +# Testing +junit = "4.13.2" +extJunit = "1.3.0" +truth = "1.4.5" +turbine = "1.2.1" +coreTesting = "2.2.0" +espresso = "3.7.0" +mockitoInline = "5.2.0" +mockitoNhaarman = "2.2.0" +mockWebServer = "5.4.0" +mockk = "1.14.11" + +# AndroidX core +androidxCore = "1.19.0" +appCompat = "1.7.1" +androidMaterial = "1.14.0" +lifecycle = "2.11.0" +googlePlayCore = "2.1.0" +googlePlayLocation = "21.3.0" +accompanistPermissions = "0.37.3" +# accompanist-systemuicontroller was discontinued after 0.36.0 (superseded by androidx edge-to-edge +# APIs) โ€” do not bump this in lockstep with accompanistPermissions, newer versions don't exist. +accompanistSystemUiController = "0.36.0" +dataStore = "1.2.1" +splashScreen = "1.2.0" +securityCrypto = "1.1.0" +activityCompose = "1.13.0" + +# Room +room = "2.8.4" + +# Networking +gson = "2.14.0" + +# Firebase +firebaseBom = "34.15.0" + +# Coroutines (also covers kotlinx-coroutines-test) +coroutines = "1.11.0" + +# Kotlinx Date/Time (KMP-compatible, replaces java.time) โ€” single version shared by all modules +kotlinxDatetime = "0.8.0" + +# Dependency Injection +hilt = "2.59.2" +hiltCompose = "1.3.0" + +# Navigation 3 +nav3 = "1.1.3" +lifecycleViewmodelNav3 = "2.11.0" + +# Miscellaneous +timber = "5.0.1" +coilCompose = "2.7.0" + +# Memory leak +leakCanary = "2.14" + +# Payment module +razorpayCheckout = "1.6.41" + +# KMP โ€” Ktor +ktor = "3.5.0" + +# KMP โ€” Serialization +kotlinxSerialization = "1.11.0" + +# KMP โ€” Koin +koin = "4.2.1" + +# KMP โ€” ViewModel (JetBrains port of AndroidX lifecycle-viewmodel) +kmpLifecycleViewModel = "2.10.0" + +[libraries] +# AndroidX core +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appCompat" } +google-material = { module = "com.google.android.material:material", version.ref = "androidMaterial" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +google-play-app-update = { module = "com.google.android.play:app-update", version.ref = "googlePlayCore" } +google-play-app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "googlePlayCore" } +google-play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "googlePlayLocation" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } +accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemUiController" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "dataStore" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashScreen" } +androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } + +# Compose (versions resolved via androidx-compose-bom) +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-compose-material = { module = "androidx.compose.material:material" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } + +# Testing +junit = { module = "junit:junit", version.ref = "junit" } +truth = { module = "com.google.truth:truth", version.ref = "truth" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +androidx-arch-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } +mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInline" } +mockito-kotlin = { module = "com.nhaarman.mockitokotlin2:mockito-kotlin", version.ref = "mockitoNhaarman" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockWebServer" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "extJunit" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } +androidx-test-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espresso" } + +# Room +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } + +# Networking +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } + +# Firebase (versions resolved via firebase-bom) +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } +firebase-config = { module = "com.google.firebase:firebase-config" } +firebase-analytics = { module = "com.google.firebase:firebase-analytics" } +firebase-perf = { module = "com.google.firebase:firebase-perf" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging" } + +# Coroutines / Date-time +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +# Dependency Injection +google-dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +google-dagger-hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +google-dagger-hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltCompose" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltCompose" } + +# Navigation 3 +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } + +# Miscellaneous +timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } +leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakCanary" } + +# Payment (Android-only โ€” Razorpay checkout is launched from the app layer) +razorpay-checkout = { module = "com.razorpay:checkout", version.ref = "razorpayCheckout" } + +# KMP โ€” Ktor +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } + +# KMP โ€” Koin +koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } +koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } + +# KMP โ€” ViewModel +androidx-lifecycle-viewmodel-kmp = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "kmpLifecycleViewModel" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +secrets-gradle-plugin = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsPlugin" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +ben-manes-versions = { id = "com.github.ben-manes.versions", version.ref = "benManesVersions" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c82..ea2f5611 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ +#Sun Jun 21 13:42:41 IST 2026 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-9.5.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/language/build.gradle.kts b/language/build.gradle.kts index 315b8678..e64dc327 100644 --- a/language/build.gradle.kts +++ b/language/build.gradle.kts @@ -1,15 +1,14 @@ plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") - id("org.jetbrains.kotlin.plugin.compose") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) } android { namespace = "bose.ankush.language" - compileSdk = ConfigData.compileSdkVersion + compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { - minSdk = ConfigData.minSdkVersion + minSdk = libs.versions.minSdk.get().toInt() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") @@ -20,7 +19,7 @@ android { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) } } @@ -35,15 +34,9 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - kotlin { - sourceSets.all { - languageSettings { - languageVersion = Versions.kotlinCompiler - } + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } @@ -52,27 +45,23 @@ android { } } -composeCompiler { - enableStrongSkippingMode = true -} - dependencies { // Testing - testImplementation(Deps.junit) + testImplementation(libs.junit) // UI Testing - androidTestImplementation(Deps.extJunit) + androidTestImplementation(libs.androidx.test.ext.junit) // Core - implementation(Deps.androidCore) - implementation(Deps.appCompat) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) // Compose - implementation(platform(Deps.composeBom)) - implementation(Deps.composeUiTooling) - implementation(Deps.composeUiToolingPreview) - implementation(Deps.composeUi) - implementation(Deps.composeMaterial1) - implementation(Deps.composeMaterial3) - implementation(Deps.navigationCompose) -} \ No newline at end of file + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.core) +} 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..14d85c16 100644 --- a/language/src/main/java/bose/ankush/language/presentation/LanguageScreen.kt +++ b/language/src/main/java/bose/ankush/language/presentation/LanguageScreen.kt @@ -1,3 +1,5 @@ +@file:Suppress("MatchingDeclarationName") + package bose.ankush.language.presentation import androidx.compose.animation.AnimatedVisibility @@ -49,7 +51,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.EmojiSupportMatch import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle @@ -64,154 +65,173 @@ import bose.ankush.language.util.LocaleHelper.getDefaultLanguage import bose.ankush.language.util.LocaleHelper.getDisplayName import kotlinx.coroutines.delay +private const val ITEM_STAGGER_DELAY_MS = 100L + +data class LanguageScreenStrings( + val screenTitle: String, + val screenSubtitle: String, + val navigateBack: String, + val languageSelected: (String) -> String, +) + @Composable fun LanguageScreen( languages: Array, + strings: LanguageScreenStrings, navAction: () -> Unit, ) { - // Create a transition state for the screen animation val screenTransitionState = remember { MutableTransitionState(false) } - // Remember the navigation action to prevent recompositions val rememberedNavAction = remember { navAction } // Hoist the changedLanguage state to prevent recreation in ShowUI val changedLanguage = remember { mutableStateOf(getDefaultLanguage()) } - // Start the animation when the screen is first displayed LaunchedEffect(Unit) { screenTransitionState.targetState = true } Box( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { Scaffold( - topBar = { ScreenHeader(rememberedNavAction) }, + topBar = { ScreenHeader(strings.navigateBack, 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 - LanguageScreenHeader() + LanguageScreenHeader(strings.screenTitle, strings.screenSubtitle) Spacer(modifier = Modifier.height(16.dp)) ShowUI( languages = languages, - changedLanguage = changedLanguage + changedLanguage = changedLanguage, + languageSelectedLabel = strings.languageSelected, ) } } - } + }, ) } } @Composable -private fun LanguageScreenHeader() { +private fun LanguageScreenHeader( + title: String, + subtitle: String, +) { 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), + text = title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = stringResource(R.string.language_screen_subtitle), + text = subtitle, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ScreenHeader(navAction: () -> Unit) { - // Create a transition state for the header animation +private fun ScreenHeader( + navigateBackDesc: String, + navAction: () -> Unit, +) { 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 = 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 */ }, + title = { }, navigationIcon = { 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) + contentDescription = navigateBackDesc, + 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, + languageSelectedLabel: (String) -> String, ) { 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) - } - } + languageSelectedLabel = languageSelectedLabel, + onLanguageSelected = + remember(language) { + { + changedLanguage.value = changeLanguageTo(language) + } + }, ) } } @@ -222,82 +242,90 @@ private fun LanguageItem( language: String, index: Int, isSelected: Boolean, - onLanguageSelected: () -> Unit + languageSelectedLabel: (String) -> String, + 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 val itemTransitionState = remember { MutableTransitionState(false) } LaunchedEffect(Unit) { - delay(100L * index) // Staggered delay based on item position + delay(ITEM_STAGGER_DELAY_MS * index) itemTransitionState.targetState = true } 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) Spacer(modifier = Modifier.width(16.dp)) - // Language name Text( 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 + }, ) } if (isSelected) { - SelectionCheckmark(language) + SelectionCheckmark(language, languageSelectedLabel) } } } @@ -306,38 +334,42 @@ private fun LanguageItem( @Composable private fun LanguageFlag(language: String) { - // Flag in a circle 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), ) } } @Composable -private fun SelectionCheckmark(language: String) { +private fun SelectionCheckmark( + language: String, + languageSelectedLabel: (String) -> 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) + contentDescription = languageSelectedLabel(language), + 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..f0982fbd 100644 --- a/language/src/main/java/bose/ankush/language/util/LocaleHelper.kt +++ b/language/src/main/java/bose/ankush/language/util/LocaleHelper.kt @@ -4,31 +4,42 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import java.util.Locale -internal object LocaleHelper { +private const val REGIONAL_INDICATOR_SYMBOL_LETTER_A = 0x1F1E6 +private const val ASCII_UPPERCASE_A = 0x41 +internal object LocaleHelper { fun String.getCountryFlag(): String { - val countryCode = this.split("-").lastOrNull()?.uppercase(Locale.getDefault()) ?: return "" - - if (countryCode.length != 2) return "\uD83C\uDF3F" - - val flagOffset = 0x1F1E6 - val asciiOffset = 0x41 - val firstChar = countryCode[0].code - asciiOffset + flagOffset - val secondChar = countryCode[1].code - asciiOffset + flagOffset - return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar)) + val countryCode = + split("-").lastOrNull()?.uppercase(Locale.getDefault()) + ?: return "" + return if (countryCode.length == 2) { + val firstChar = + countryCode[0].code - ASCII_UPPERCASE_A + REGIONAL_INDICATOR_SYMBOL_LETTER_A + val secondChar = + countryCode[1].code - ASCII_UPPERCASE_A + REGIONAL_INDICATOR_SYMBOL_LETTER_A + String(Character.toChars(firstChar)) + String(Character.toChars(secondChar)) + } else { + "๐ŸŒฟ" + } } 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-hi/strings.xml b/language/src/main/res/values-hi/strings.xml deleted file mode 100644 index 79b0f97f..00000000 --- a/language/src/main/res/values-hi/strings.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - เคญเคพเคทเคพ เคšเฅเคจเฅ‡เค‚ - เคตเคพเคชเคธ เคœเคพเค“ - เค…เคชเคจเฅ€ เคญเคพเคทเคพ เคšเฅเคจเฅ‡เค‚ - เคตเฅเคฏเค•เฅเคคเคฟเค—เคค เค…เคจเฅเคญเคต เค•เฅ‡ เคฒเคฟเค เค…เคชเคจเฅ€ เคชเคธเค‚เคฆเฅ€เคฆเคพ เคญเคพเคทเคพ เคšเฅเคจเฅ‡เค‚ - %s เคšเคฏเคจเคฟเคค - เคตเคพเคชเคธ เคœเคพเคเค‚ - diff --git a/language/src/main/res/values-iw/strings.xml b/language/src/main/res/values-iw/strings.xml deleted file mode 100644 index 7c9783d9..00000000 --- a/language/src/main/res/values-iw/strings.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - ื‘ื—ืจ ืฉืคื” - ืชื—ื–ื•ืจ - \ No newline at end of file diff --git a/language/src/main/res/values/strings.xml b/language/src/main/res/values/strings.xml deleted file mode 100644 index a3a7ddce..00000000 --- a/language/src/main/res/values/strings.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - Choose language - Go back - Select Your Language - Choose your preferred language for a personalized experience - %s selected - Navigate back - 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..ee0ff129 100644 --- a/network/build.gradle.kts +++ b/network/build.gradle.kts @@ -1,22 +1,26 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { - kotlin("multiplatform") - id("com.android.library") - kotlin("plugin.serialization") + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.kotlin.serialization) } kotlin { - androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } + android { + namespace = "bose.ankush.network" + compileSdk = libs.versions.compileSdk.get().toInt() + minSdk = libs.versions.minSdk.get().toInt() + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) } } listOf( iosX64(), iosArm64(), - iosSimulatorArm64() + iosSimulatorArm64(), ).forEach { it.binaries.framework { baseName = "network" @@ -26,15 +30,16 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - implementation(KmmDeps.ktorCore) - implementation(KmmDeps.ktorSerialization) - implementation(KmmDeps.ktorContentNegotiation) - implementation(KmmDeps.ktorJson) - implementation(KmmDeps.ktorLogging) - implementation(KmmDeps.kotlinxSerialization) - implementation(KmmDeps.kotlinxCoroutinesCore) - implementation(KmmDeps.koinCore) - implementation(KmmDeps.kotlinxDateTime) + implementation(project(":storage")) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.serialization) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.logging) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.koin.core) + implementation(libs.kotlinx.datetime) } } val commonTest by getting { @@ -42,27 +47,33 @@ kotlin { implementation(kotlin("test")) } } + + @Suppress("UNUSED_VARIABLE") val androidMain by getting { dependencies { - implementation(KmmDeps.ktorAndroid) + implementation(libs.ktor.client.android) } } - 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) iosArm64Main.dependsOn(this) iosSimulatorArm64Main.dependsOn(this) dependencies { - implementation(KmmDeps.ktorIOS) + implementation(libs.ktor.client.darwin) } } val iosX64Test by getting val iosArm64Test by getting val iosSimulatorArm64Test by getting + + @Suppress("UNUSED_VARIABLE") val iosTest by creating { dependsOn(commonTest) iosX64Test.dependsOn(this) @@ -71,18 +82,3 @@ kotlin { } } } - -android { - namespace = "bose.ankush.network" - compileSdk = ConfigData.compileSdkVersion - - defaultConfig { - minSdk = ConfigData.minSdkVersion - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } -} 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..01136c55 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,31 @@ 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) { 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..c405b7cd --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/FeedbackApiService.kt @@ -0,0 +1,8 @@ +package bose.ankush.network.api + +import bose.ankush.network.model.FeedbackRequest +import bose.ankush.network.model.FeedbackResponse + +interface FeedbackApiService { + 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..7885a26e --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorFeedbackApiService.kt @@ -0,0 +1,25 @@ +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 + +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..1a6d73db --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorLocationApiService.kt @@ -0,0 +1,39 @@ +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 + +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..28f884d6 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorPaymentApiService.kt @@ -0,0 +1,36 @@ +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 + +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..0f872373 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorWeatherApiService.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorWeatherApiService.kt @@ -1,37 +1,22 @@ 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 import io.ktor.client.request.get import io.ktor.client.request.parameter -/** - * Ktor implementation of WeatherApiService - */ 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..d34d8a67 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/PaymentApiService.kt @@ -0,0 +1,12 @@ +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 + +interface PaymentApiService { + suspend fun createOrder(request: CreateOrderRequest): CreateOrderResponse + + 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..5c0061e2 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/api/WeatherApiService.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/WeatherApiService.kt @@ -1,31 +1,10 @@ package bose.ankush.network.api -import bose.ankush.network.model.AirQuality import bose.ankush.network.model.WeatherForecast -/** - * API service interface for weather data - */ interface WeatherApiService { - /** - * Get current air quality for a location - * @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 - */ 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..e7b03449 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/api/AuthApiService.kt @@ -0,0 +1,17 @@ +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 + +interface AuthApiService { + suspend fun login(request: LoginRequest): AuthResponse + + suspend fun register(request: RegisterRequest): AuthResponse + + suspend fun refreshToken(request: RefreshTokenRequest): AuthResponse + + 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..d09dd04e --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/api/KtorAuthApiService.kt @@ -0,0 +1,51 @@ +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 + +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..fa17f294 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/interceptor/AuthInterceptor.kt @@ -0,0 +1,82 @@ +@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 + +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..92448ab2 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/model/AuthModels.kt @@ -0,0 +1,66 @@ +package bose.ankush.network.auth.model + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + val email: String, + val password: String, +) + +@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, +) + +@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, +) + +@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..a827bf3c --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepository.kt @@ -0,0 +1,31 @@ +package bose.ankush.network.auth.repository + +import bose.ankush.network.auth.model.AuthResponse +import kotlinx.coroutines.flow.Flow + +interface AuthRepository { + suspend fun login( + email: String, + password: String, + ): 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 + + fun isLoggedIn(): Flow + + suspend fun getToken(): String? + + suspend fun refreshToken(): AuthResponse? + + 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..8ea632db --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepositoryImpl.kt @@ -0,0 +1,98 @@ +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 + +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) + 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) + 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) + 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..dc44af6a --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenManager.kt @@ -0,0 +1,59 @@ +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 kotlin.time.Clock + +class TokenManager( + private val tokenStorage: TokenStorage, + private val authRepository: AuthRepository, +) { + private val refreshMutex = Mutex() + private var lastRefreshTime: Long = 0 + + 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() + + suspend fun handleUnauthorized(): TokenResult { + lastRefreshTime = 0 + return refreshToken() + } + + 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..a2ce74e5 --- /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 kotlin.time.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..29c3b673 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkConnectivity.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkConnectivity.kt @@ -1,13 +1,5 @@ package bose.ankush.network.common -/** - * Interface for checking network connectivity - * This will be implemented differently on each platform (Android, iOS) - */ interface NetworkConnectivity { - /** - * Check if network is available - * @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..b2fcded6 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkException.kt @@ -0,0 +1,33 @@ +package bose.ankush.network.common + +class NetworkException( + val errorCode: Int, + override val message: String, + override val cause: Throwable? = null, +) : Exception(message, cause) { + companion object { + 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 + + const val NETWORK_UNAVAILABLE = 1000 + const val TIMEOUT = 1001 + const val UNKNOWN_HOST = 1002 + const val UNKNOWN_ERROR = 1999 + + fun fromException(e: Exception): NetworkException { + 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..aec9f597 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/di/NetworkModule.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/di/NetworkModule.kt @@ -1,34 +1,170 @@ package bose.ankush.network.di +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.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 -/** - * Create platform-specific HttpClient - * This is an expect function that will be implemented differently on each platform - */ expect fun createPlatformHttpClient(json: Json): HttpClient -/** - * Factory function to create a WeatherRepository instance - * This is useful for non-Koin consumers of the network module - */ +@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) + } + } +} + +fun createTokenManager( + tokenStorage: TokenStorage, + authRepository: AuthRepository, +): TokenManager = TokenManager(tokenStorage, authRepository) + 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) + val authRepository = createAuthRepository(tokenStorage, baseUrl) + val tokenManager = createTokenManager(tokenStorage, authRepository) + val httpClient = createAuthenticatedHttpClient(tokenManager) val apiService = KtorWeatherApiService(httpClient, baseUrl) return WeatherRepositoryImpl(apiService, networkConnectivity) } + +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) +} + +fun createAuthenticatedHttpClient(tokenManager: TokenManager): 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) + } + configureAuth(tokenManager) + // SECURITY: Use LogLevel.NONE in production to prevent JWT token exposure in logs + install(Logging) { + logger = Logger.SIMPLE + level = LogLevel.NONE + } + } +} + +/** + * Legacy function for backward compatibility + * Creates an HttpClient with basic authentication configuration (no token refresh) + */ +fun createAuthenticatedHttpClient(tokenStorage: TokenStorage): 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) + } + configureAuth(tokenStorage) + // SECURITY: Use LogLevel.NONE in production to prevent JWT token exposure in logs + install(Logging) { + logger = Logger.SIMPLE + level = LogLevel.NONE + } + } +} + +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) +} + +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) +} + +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 = KtorFeedbackApiService(httpClient, baseUrl) + return FeedbackRepositoryImpl(apiService, networkConnectivity) +} + +fun createServiceRepository(baseUrl: String = NetworkConstants.WEATHER_BASE_URL): ServiceRepository { + val httpClient = createBasicHttpClient() + val apiService = 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..cdb599b5 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/domain/SavedLocationsUseCase.kt @@ -0,0 +1,18 @@ +package bose.ankush.network.domain + +import bose.ankush.network.model.SavedLocation +import bose.ankush.network.repository.LocationRepository + +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..e6e2f46d --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/domain/SearchPlacesUseCase.kt @@ -0,0 +1,11 @@ +package bose.ankush.network.domain + +import bose.ankush.network.model.PlaceSuggestion +import bose.ankush.network.repository.LocationRepository + +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..f6056eb4 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,56 @@ 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..44632d1a --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/model/LocationModels.kt @@ -0,0 +1,83 @@ +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..7fbeacb5 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/model/ServiceModels.kt @@ -0,0 +1,225 @@ +package bose.ankush.network.model + +import kotlin.time.Clock +import kotlinx.serialization.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, +) + +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, +} + +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 { + dateString.replace("Z", "+00:00").let { _ -> + 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..34033e6c --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepository.kt @@ -0,0 +1,8 @@ +package bose.ankush.network.repository + +import bose.ankush.network.model.FeedbackRequest +import bose.ankush.network.model.FeedbackResponse + +interface FeedbackRepository { + 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..d5674968 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepositoryImpl.kt @@ -0,0 +1,18 @@ +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 + +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..4cdcd367 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepository.kt @@ -0,0 +1,18 @@ +package bose.ankush.network.repository + +import bose.ankush.network.model.PlaceSuggestion +import bose.ankush.network.model.SavedLocation + +interface LocationRepository { + suspend fun saveLocation( + name: String, + lat: Double, + lon: Double, + ): Result + + suspend fun getSavedLocations(): Result> + + suspend fun deleteLocation(id: String): Result + + 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..854b2e0f --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepositoryImpl.kt @@ -0,0 +1,63 @@ +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 + +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..24ad4566 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/ServiceRepository.kt @@ -0,0 +1,46 @@ +package bose.ankush.network.repository + +import bose.ankush.network.api.ServiceApiService +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: 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, + ) { + 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..b2784a25 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepository.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepository.kt @@ -1,30 +1,8 @@ package bose.ankush.network.repository -import bose.ankush.network.model.AirQuality import bose.ankush.network.model.WeatherForecast -import kotlinx.coroutines.flow.Flow -/** - * Repository interface for weather data - */ 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 - * @param coordinates Pair of latitude and longitude - * @return Flow of WeatherForecast - */ - fun getWeatherReport(coordinates: Pair): Flow - - /** - * Refresh weather data for a location - * @param coordinates Pair of latitude and longitude - */ - suspend fun refreshWeatherData(coordinates: Pair) + /** Fetches fresh weather data from the network. Returns null if offline. */ + suspend fun refreshWeatherData(coordinates: Pair): WeatherForecast? } 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..659a08aa 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepositoryImpl.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepositoryImpl.kt @@ -1,140 +1,26 @@ 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 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 +import bose.ankush.network.utils.NetworkUtils -/** - * Implementation of WeatherRepository - */ 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 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 - 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() - } - - 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))) { - try { - coroutineScope { - // Use async to parallelize the API calls - val weatherDeferred = async { - 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 - } - } 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 refreshWeatherData(coordinates: Pair): WeatherForecast? { + if (!networkConnectivity.isNetworkAvailable()) return null + + return try { + NetworkUtils.retryWithExponentialBackoff { + apiService.getOneCallWeather( + coordinates.first.toString(), + coordinates.second.toString(), + ) } + } catch (e: Exception) { + throw Exception("Network | 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/Constants.kt deleted file mode 100644 index 096bca08..00000000 --- a/network/src/commonMain/kotlin/bose/ankush/network/utils/Constants.kt +++ /dev/null @@ -1,31 +0,0 @@ -package bose.ankush.network.utils - -/** - * Network-specific constants for the network module - */ -object NetworkConstants { - /** - * Base URL for the weather API - */ - const val WEATHER_BASE_URL = "https://data.androidplay.in/" - - /** - * Cache expiration time in milliseconds (30 minutes) - */ - const val CACHE_EXPIRATION_TIME = 30 * 60 * 1000L - - /** - * Maximum number of retries for network requests - */ - const val MAX_RETRIES = 3 - - /** - * Initial backoff delay in milliseconds for retry mechanism - */ - const val INITIAL_BACKOFF_DELAY = 1000L - - /** - * Maximum backoff delay in milliseconds for retry mechanism - */ - const val MAX_BACKOFF_DELAY = 30000L -} diff --git a/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkConstants.kt b/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkConstants.kt new file mode 100644 index 00000000..3f37cacf --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkConstants.kt @@ -0,0 +1,8 @@ +package bose.ankush.network.utils + +object NetworkConstants { + const val WEATHER_BASE_URL = "https://data.androidplay.in" + const val MAX_RETRIES = 1 + const val INITIAL_BACKOFF_DELAY = 1000L + const val MAX_BACKOFF_DELAY = 30000L +} 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..c2f5029a 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkUtils.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkUtils.kt @@ -1,41 +1,35 @@ package bose.ankush.network.utils +import bose.ankush.network.common.NetworkException import kotlinx.coroutines.delay -/** - * Utility functions for network operations - */ object NetworkUtils { - /** - * Retry a network request with exponential backoff - * @param maxRetries Maximum number of retries - * @param initialDelayMillis Initial delay in milliseconds - * @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 - */ 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 - - // Otherwise, delay and retry + lastException = e + if (attempt == maxRetries - 1) { + if (e is NetworkException) throw e else throw NetworkException.fromException(e) + } delay(currentDelay) - // Simply double the delay for each retry, but cap it at maxDelayMillis currentDelay = (currentDelay * 2).coerceAtMost(maxDelayMillis) } } // 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..e390326a 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,11 @@ 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 +18,15 @@ 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")) + 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/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..6bbb371b 100644 --- a/storage/build.gradle.kts +++ b/storage/build.gradle.kts @@ -1,23 +1,31 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { - kotlin("multiplatform") - id("com.android.library") - kotlin("plugin.serialization") - id("kotlin-kapt") + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) } kotlin { - androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } + android { + namespace = "bose.ankush.storage" + compileSdk = libs.versions.compileSdk.get().toInt() + minSdk = libs.versions.minSdk.get().toInt() + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) } } + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + listOf( iosX64(), iosArm64(), - iosSimulatorArm64() + iosSimulatorArm64(), ).forEach { it.binaries.framework { baseName = "storage" @@ -27,10 +35,10 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - implementation(KmmDeps.kotlinxCoroutinesCore) - implementation(KmmDeps.koinCore) - implementation(KmmDeps.kotlinxDateTime) - implementation(KmmDeps.kotlinxSerialization) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.koin.core) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) } } val commonTest by getting { @@ -38,24 +46,27 @@ kotlin { implementation(kotlin("test")) } } + + @Suppress("UNUSED_VARIABLE") val androidMain by getting { dependencies { // Room dependencies - implementation(Deps.room) - implementation(Deps.roomKtx) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + // Security: Encrypted token storage + implementation(libs.androidx.security.crypto) // 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(libs.gson) + // Note: Network dependency removed to avoid circular dependency + // WeatherDataFetcher is injected via DI from app module } } - 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 +76,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) @@ -74,32 +87,12 @@ kotlin { } } -android { - namespace = "bose.ankush.storage" - compileSdk = ConfigData.compileSdkVersion - - defaultConfig { - minSdk = ConfigData.minSdkVersion - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - compileOptions { - 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", libs.androidx.room.compiler) } diff --git a/payment/consumer-rules.pro b/storage/src/androidInstrumentedTest/kotlin/bose/ankush/storage/.gitkeep similarity index 100% rename from payment/consumer-rules.pro rename to storage/src/androidInstrumentedTest/kotlin/bose/ankush/storage/.gitkeep 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..422fd41c --- /dev/null +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt @@ -0,0 +1,83 @@ +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: Tokens encrypted at rest using AES-256-GCM via Android Keystore + * (hardware-backed on supported devices). Complies with OWASP credential storage guidelines. + */ +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..576ceffc 100644 --- a/storage/src/androidMain/kotlin/bose/ankush/storage/impl/WeatherStorageImpl.kt +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/impl/WeatherStorageImpl.kt @@ -1,137 +1,156 @@ 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.model.AirQualityData +import bose.ankush.storage.model.WeatherCondition +import bose.ankush.storage.model.WeatherData 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.flow.map +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 - */ -@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().map { it?.toWeatherData() } - /** - * 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().map { it?.toAirQualityData() } - // 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 - 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) + override suspend fun saveWeatherData( + weatherData: WeatherData, + airQualityData: AirQualityData, + ) { + withContext(Dispatchers.IO) { + weatherDatabase.weatherDao().refreshWeather( + weatherData.toWeatherEntity(), + airQualityData.toAirQualityEntity(), + ) } } - /** - * 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 + override suspend fun clearAllData() { + withContext(Dispatchers.IO) { + weatherDatabase.weatherDao().clearAll() + } + locationTimestamps.clear() } - /** - * 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 + private fun List?.toWeatherConditions() = + this?.map { it?.let { w -> WeatherCondition(w.description, w.icon, w.id, w.main) } } + + private fun List?.toStorageWeather() = + this?.map { it?.let { w -> Weather(w.description, w.icon, w.id, w.main) } } + + private fun WeatherEntity.toWeatherData() = + WeatherData( + id = id, + alerts = alerts?.map { + it?.let { a -> + WeatherData.Alert(a.description, a.end, a.event, a.sender_name, a.start) + } + }, + current = current?.let { + WeatherData.Current( + clouds = it.clouds, + dt = it.dt, + feels_like = it.feels_like, + humidity = it.humidity, + pressure = it.pressure, + sunrise = it.sunrise, + sunset = it.sunset, + temp = it.temp, + uvi = it.uvi, + weather = it.weather.toWeatherConditions(), + wind_gust = it.wind_gust, + wind_speed = it.wind_speed, + ) + }, + daily = daily?.map { item -> + item?.let { + WeatherData.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 { t -> + WeatherData.Daily.Temp(t.day, t.eve, t.max, t.min, t.morn, t.night) + }, + uvi = it.uvi, + weather = it.weather.toWeatherConditions(), + wind_gust = it.wind_gust, + wind_speed = it.wind_speed, + ) + } + }, + hourly = hourly?.map { item -> + item?.let { + WeatherData.Hourly( + clouds = it.clouds, + dt = it.dt, + feels_like = it.feels_like, + humidity = it.humidity, + temp = it.temp, + weather = it.weather.toWeatherConditions(), ) } }, - current = weatherData.current?.let { current -> + lastUpdated = lastUpdated, + ) + + private fun WeatherData.toWeatherEntity() = + WeatherEntity( + id = id, + alerts = alerts?.map { + it?.let { a -> + WeatherEntity.Alert(a.description, a.end, a.event, a.sender_name, a.start) + } + }, + current = current?.let { 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 + clouds = it.clouds, + dt = it.dt, + feels_like = it.feels_like, + humidity = it.humidity, + pressure = it.pressure, + sunrise = it.sunrise, + sunset = it.sunset, + temp = it.temp, + uvi = it.uvi, + weather = it.weather.toStorageWeather(), + wind_gust = it.wind_gust, + wind_speed = it.wind_speed, ) }, - daily = weatherData.daily?.map { daily -> - daily?.let { + daily = daily?.map { item -> + item?.let { WeatherEntity.Daily( clouds = it.clouds, dew_point = it.dew_point, @@ -142,71 +161,34 @@ class WeatherStorageImpl @Inject constructor( 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 - ) + temp = it.temp?.let { t -> + WeatherEntity.Daily.Temp(t.day, t.eve, t.max, t.min, t.morn, t.night) }, uvi = it.uvi, - weather = it.weather?.map { weatherCondition -> - weatherCondition?.let { - Weather( - description = it.description, - icon = it.icon, - id = it.id, - main = it.main - ) - } - }, + weather = it.weather.toStorageWeather(), wind_gust = it.wind_gust, - wind_speed = it.wind_speed + wind_speed = it.wind_speed, ) } }, - hourly = weatherData.hourly?.map { hourly -> - hourly?.let { + hourly = hourly?.map { item -> + item?.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 - ) - } - } + weather = it.weather.toStorageWeather(), ) } - } + }, + lastUpdated = lastUpdated, ) - } - /** - * 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 - ) - } + private fun AirQualityEntity.toAirQualityData() = + AirQualityData(id, aqi, co, no2, o3, so2, pm10, pm25) + + private fun AirQualityData.toAirQualityEntity() = + AirQualityEntity(id, aqi, co, no2, o3, so2, pm10, pm25) } 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..7fc903de --- /dev/null +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthToken.kt @@ -0,0 +1,12 @@ +package bose.ankush.storage.room + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@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..fa61d1ef --- /dev/null +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthTokenDao.kt @@ -0,0 +1,22 @@ +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 + +@Dao +interface AuthTokenDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun saveToken(token: AuthToken): Long + + @Query("SELECT * FROM auth_tokens WHERE id = 1 LIMIT 1") + fun getToken(): AuthToken? + + @Query("SELECT EXISTS(SELECT 1 FROM auth_tokens WHERE id = 1 LIMIT 1)") + fun hasToken(): Flow + + @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..4e1576a1 --- /dev/null +++ b/storage/src/commonMain/kotlin/bose/ankush/storage/api/TokenStorage.kt @@ -0,0 +1,13 @@ +package bose.ankush.storage.api + +import kotlinx.coroutines.flow.Flow + +interface TokenStorage { + suspend fun saveToken(token: String) + + suspend fun getToken(): String? + + fun hasToken(): Flow + + 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..fa17f8e7 100644 --- a/storage/src/commonMain/kotlin/bose/ankush/storage/api/WeatherStorage.kt +++ b/storage/src/commonMain/kotlin/bose/ankush/storage/api/WeatherStorage.kt @@ -1,42 +1,25 @@ package bose.ankush.storage.api +import bose.ankush.storage.model.AirQualityData +import bose.ankush.storage.model.WeatherData 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: - * - Retrieving weather and air quality data - * - Refreshing data from the network - * - Tracking the last update time - */ interface WeatherStorage { - /** - * Get weather forecast data for a location - * @param coordinates Pair of latitude and longitude - * @return Flow of weather forecast data - */ - fun getWeatherReport(coordinates: Pair): Flow + fun getWeatherReport(coordinates: Pair): Flow - /** - * Get air quality data for a location - * @param coordinates Pair of latitude and longitude - * @return Flow of air quality data - */ - fun getAirQualityReport(coordinates: Pair): Flow + fun getAirQualityReport(coordinates: Pair): Flow - /** - * Refresh weather data from the network and store it - * @param coordinates Pair of latitude and longitude - * @throws Exception if there's an error refreshing the data - */ - suspend fun refreshWeatherData(coordinates: Pair) + suspend fun getLastWeatherUpdateTime(coordinates: Pair): Long - /** - * Get the timestamp of the last weather data update - * @return Timestamp in milliseconds - */ - suspend fun getLastWeatherUpdateTime(): Long + suspend fun saveLastWeatherUpdateTime( + coordinates: Pair, + time: Long, + ) + + suspend fun saveWeatherData( + weatherData: WeatherData, + airQualityData: AirQualityData, + ) + + 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..ed756450 --- /dev/null +++ b/storage/src/commonMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt @@ -0,0 +1,14 @@ +package bose.ankush.storage.impl + +import bose.ankush.storage.api.TokenStorage +import kotlinx.coroutines.flow.Flow + +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/commonMain/kotlin/bose/ankush/storage/model/AirQualityData.kt b/storage/src/commonMain/kotlin/bose/ankush/storage/model/AirQualityData.kt new file mode 100644 index 00000000..cf7e3e44 --- /dev/null +++ b/storage/src/commonMain/kotlin/bose/ankush/storage/model/AirQualityData.kt @@ -0,0 +1,12 @@ +package bose.ankush.storage.model + +data class AirQualityData( + 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, +) diff --git a/storage/src/commonMain/kotlin/bose/ankush/storage/model/WeatherData.kt b/storage/src/commonMain/kotlin/bose/ankush/storage/model/WeatherData.kt new file mode 100644 index 00000000..80017e2d --- /dev/null +++ b/storage/src/commonMain/kotlin/bose/ankush/storage/model/WeatherData.kt @@ -0,0 +1,77 @@ +@file:Suppress("ConstructorParameterNaming") + +package bose.ankush.storage.model + +data class WeatherData( + val id: Long, + val alerts: List? = listOf(), + val current: Current? = null, + val daily: List? = listOf(), + val hourly: List? = listOf(), + val lastUpdated: Long = 0L, +) { + data class Alert( + val description: String?, + val end: Long?, + val event: String?, + val sender_name: String?, + val start: Long?, + ) + + data class Current( + val clouds: Int?, + val dt: Long?, + val feels_like: Double?, + val humidity: Int?, + val pressure: 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?, + ) + + data class Daily( + val clouds: Int?, + val dew_point: Double?, + val dt: Long?, + val humidity: Int?, + val pressure: Int?, + val rain: Double?, + val summary: String?, + val sunrise: Long?, + val sunset: Long?, + val temp: Temp?, + val uvi: Double?, + val weather: List? = listOf(), + val wind_gust: Double?, + val wind_speed: Double?, + ) { + data class Temp( + val day: Double?, + val eve: Double?, + val max: Double?, + val min: Double?, + val morn: Double?, + val night: Double?, + ) + } + + data class Hourly( + val clouds: Int?, + val dt: Long?, + val feels_like: Double?, + val humidity: Int?, + val temp: Double?, + val weather: List? = listOf(), + ) +} + +data class WeatherCondition( + val description: String? = null, + val icon: String? = null, + val id: Int, + val main: String? = null, +) 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..82a0bb7f --- /dev/null +++ b/storage/src/iosMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt @@ -0,0 +1,120 @@ +@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 - ) - } - } - } - } -} diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/constants/SunriseConstants.kt b/sunriseui/src/main/java/bose/ankush/sunriseui/constants/SunriseConstants.kt deleted file mode 100644 index e1f172b5..00000000 --- a/sunriseui/src/main/java/bose/ankush/sunriseui/constants/SunriseConstants.kt +++ /dev/null @@ -1,153 +0,0 @@ -package bose.ankush.sunriseui.constants - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -/** - * Configuration constants for the sunrise/sunset animation system. - * Contains timing, visual, and behavioral parameters organized into logical groups. - */ -object SunriseConstants { - - /** Animation timing constants in milliseconds. */ - object Durations { - const val INITIAL_ANIMATION = 3000 - const val STAR_TWINKLE = 2000 - const val ATMOSPHERIC_GLOW = 4000 - const val CLOUD_DRIFT = 3000 - } - - /** Size and layout dimension constants for visual elements. */ - object Dimensions { - val CORNER_RADIUS = 12.dp - const val MOON_BASE_RADIUS = 12f - const val MOON_RADIUS_VARIATION = 3f - const val SUN_BASE_RADIUS = 15f - const val SUN_RADIUS_VARIATION = 5f - const val STAR_BASE_SIZE = 2f - const val STAR_SIZE_VARIATION = 1f - const val SUN_RAY_LENGTH = 25f - const val SUN_RAY_WIDTH = 2f - const val CLOUD_WIDTH = 40f - const val CLOUD_PUFF_RADIUS = 8f - } - - /** Alpha transparency values for visual elements (0.0-1.0). */ - object Opacity { - const val MOON_BASE = 0.8f - const val MOON_VARIATION = 0.2f - const val SUN_BASE = 0.9f - const val SUN_VARIATION = 0.1f - const val STAR_BASE_BEFORE_SUNRISE = 0.8f - const val STAR_BASE_AFTER_SUNSET = 0.6f - const val TWINKLE_VARIATION = 0.3f - const val TWINKLE_BASE = 0.7f - const val CLOUD_BASE = 0.6f - const val CLOUD_VARIATION = 0.2f - } - - /** Quantity constants for animated elements. */ - object Counts { - const val SUN_RAY_COUNT = 8 - const val CLOUD_COUNT = 6 - const val CLOUD_PUFFS_PER_CLOUD = 3 - } - - /** 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 - ) - - // 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 - ) - - // 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 - ) - - // 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 - ) - - // 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 - ) - - // Celestial body colors - val MOON_COLOR = Color(0xFFF5F5DC) - val MOON_PHASE_COLOR = Color(0xFF0F0F23) - val STAR_COLOR = Color.Companion.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 - - // 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 - } - - /** Spatial positioning and movement parameters (normalized 0.0-1.0). */ - object Positioning { - const val MOON_BASE_Y = 0.25f - const val MOON_Y_VARIATION = 0.3f - const val MOON_Y_AMPLITUDE = 0.1f - const val MOON_START_X = 0.9f - const val MOON_END_X = 0.1f - const val MOON_TRAVEL_DISTANCE = 0.8f - - const val SUN_START_X = 0.1f - const val SUN_TRAVEL_DISTANCE = 0.8f - const val SUN_BASE_Y = 0.6f - const val SUN_Y_AMPLITUDE = 0.4f - - const val CLOUD_BASE_Y = 0.2f - const val CLOUD_Y_VARIATION = 0.15f - const val CLOUD_SPACING_X = 0.25f - const val CLOUD_DRIFT_SPEED = 0.08f - } - - /** Time-based transition points for animation phases (normalized 0.0-1.0). */ - object TimeThresholds { - const val DAWN_END = 0.2f - const val DUSK_START = 0.8f - const val SUN_MORNING_END = 0.1f - const val SUN_MIDMORNING_END = 0.2f - const val SUN_EVENING_START = 0.8f - const val SUN_LATE_EVENING_START = 0.9f - } - - /** 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