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\nFull 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 @@
[](https://github.com/bosankus/Compose-Weatherify/actions/workflows/check-dependecy-updates.yml)
-[](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)
+[](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)
[](https://github.com/bosankus/Compose-Weatherify/actions/workflows/code_quality.yml)
+
+-3DDC84?style=flat&logo=android&logoColor=white)
+
# 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.
-+[](https://github.com/bosankus/Compose-Weatherify/releases/latest)
+[](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