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..dd750c25 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,9 +1,9 @@
-import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.application")
id("kotlin-android")
- id("kotlin-kapt")
+ id("com.google.devtools.ksp")
id("com.google.gms.google-services")
id("dagger.hilt.android.plugin")
id("kotlin-parcelize")
@@ -23,13 +23,11 @@ android {
versionName = ConfigData.versionName
multiDexEnabled = ConfigData.multiDexEnabled
testInstrumentationRunner = "bose.ankush.weatherify.helper.HiltTestRunner"
- resourceConfigurations.addAll(listOf("en", "hi", "iw"))
}
- kapt {
- arguments {
- arg("room.schemaLocation", "$projectDir/schemas")
- }
+ @Suppress("UnstableApiUsage")
+ androidResources {
+ localeFilters.addAll(listOf("en", "hi", "iw", "bn", "kn", "ml", "ta", "te"))
}
packaging {
@@ -44,8 +42,11 @@ android {
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
+ "proguard-rules.pro",
)
+ signingConfig = signingConfigs.getByName("debug")
+ // Release signing config should be configured via gradle.properties or build command
+ // e.g., -Pandroid.injected.signing.store.file=/path/to/release.keystore
}
}
@@ -58,37 +59,30 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_17.toString()
- }
-
- kotlin {
- sourceSets.all {
- languageSettings {
- languageVersion = Versions.kotlinCompiler
- }
- }
- }
lint {
abortOnError = false
}
namespace = "bose.ankush.weatherify"
+ kotlin {
+ compilerOptions {
+ freeCompilerArgs.add("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode")
+ }
+ }
}
-composeCompiler {
- featureFlags = setOf(
- ComposeFeatureFlag.StrongSkipping.disabled()
- )
+ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
+ api(project(":common-ui"))
+ api(project(":feature-payment"))
api(project(":language"))
api(project(":storage"))
api(project(":network"))
- api(project(":sunriseui"))
// Core
implementation(Deps.androidCore)
@@ -104,13 +98,13 @@ dependencies {
implementation(Deps.dataStore)
implementation(Deps.splashScreen)
-
// Compose
implementation(platform(Deps.composeBom))
implementation(Deps.composeUi)
debugImplementation(Deps.composeUiTooling)
implementation(Deps.composeUiToolingPreview)
implementation(Deps.composeMaterial3)
+ implementation(Deps.composeIconsExtended)
// Unit Testing
testImplementation(Deps.junit)
@@ -128,31 +122,58 @@ dependencies {
androidTestImplementation(Deps.espressoCore)
androidTestImplementation(Deps.espressoContrib)
androidTestImplementation(Deps.hiltTesting)
- kaptAndroidTest(Deps.hiltDaggerAndroidCompiler)
+ kspAndroidTest(Deps.hiltDaggerAndroidCompiler)
// Networking
implementation(Deps.gson)
- // Firebase
+ // Room runtime for providing WeatherDatabase from app DI
+ implementation(Deps.room)
+ implementation(Deps.roomKtx)
+ ksp(Deps.roomCompiler)
+
+ // Firebase - BOM
implementation(platform(Deps.firebaseBom))
implementation(Deps.firebaseConfig)
implementation(Deps.firebaseAnalytics)
implementation(Deps.firebasePerformanceMonitoring)
+ implementation(Deps.firebaseMessaging)
// Coroutines
implementation(Deps.coroutinesCore)
implementation(Deps.coroutinesAndroid)
+ // Date/Time (KMP-compatible, replaces java.time)
+ implementation(Deps.kotlinxDatetime)
+
// Dependency Injection
implementation(Deps.hilt)
implementation(Deps.hiltNavigationCompose)
- kapt(Deps.hiltDaggerAndroidCompiler)
+ ksp(Deps.hiltDaggerAndroidCompiler)
+ ksp(Deps.hiltAndroidXCompiler)
// Miscellaneous
implementation(Deps.timber)
- implementation(Deps.lottieCompose)
+ // Removed Lottie dependency as per requirements
implementation(Deps.coilCompose)
// Memory leak
debugImplementation(Deps.leakCanary)
+
+ // Payment SDK (Android-only โ Razorpay checkout is launched from the app layer)
+ implementation(Deps.razorPay)
+
+ // Koin โ bridges the feature-payment Koin module with Hilt-managed singletons
+ implementation(KmmDeps.koinAndroid)
+ implementation(KmmDeps.koinAndroidCompose)
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ freeCompilerArgs.addAll(
+ "-opt-in=kotlin.RequiresOptIn",
+ "-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
+ )
+ }
}
diff --git a/app/src/androidTest/java/bose/ankush/weatherify/helper/HiltTestApplication.kt b/app/src/androidTest/java/bose/ankush/weatherify/helper/HiltTestApplication.kt
deleted file mode 100644
index 2becd798..00000000
--- a/app/src/androidTest/java/bose/ankush/weatherify/helper/HiltTestApplication.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package bose.ankush.weatherify.helper
-
-import bose.ankush.weatherify.WeatherifyApplicationCore
-import dagger.hilt.android.testing.CustomTestApplication
-
-@CustomTestApplication(WeatherifyApplicationCore::class)
-interface HiltTestApplication
\ No newline at end of file
diff --git a/app/src/androidTest/java/bose/ankush/weatherify/helper/HiltTestRunner.kt b/app/src/androidTest/java/bose/ankush/weatherify/helper/HiltTestRunner.kt
deleted file mode 100644
index 184b2f70..00000000
--- a/app/src/androidTest/java/bose/ankush/weatherify/helper/HiltTestRunner.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package bose.ankush.weatherify.helper
-
-import android.app.Application
-import android.content.Context
-import androidx.test.runner.AndroidJUnitRunner
-
-class HiltTestRunner : AndroidJUnitRunner() {
-
- override fun newApplication(
- cl: ClassLoader?,
- className: String?,
- context: Context?
- ): Application =
- super.newApplication(cl, HiltTestApplication_Application::class.java.name, context)
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/bose/ankush/weatherify/presentation/MainActivityTest.kt b/app/src/androidTest/java/bose/ankush/weatherify/presentation/MainActivityTest.kt
deleted file mode 100644
index d01acd60..00000000
--- a/app/src/androidTest/java/bose/ankush/weatherify/presentation/MainActivityTest.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package bose.ankush.weatherify.presentation
-
-import dagger.hilt.android.testing.HiltAndroidTest
-
-@HiltAndroidTest
-class MainActivityTest {
-
- /*@get: Rule(order = 1)
- val hiltAndroidRule = HiltAndroidRule(this)
-
- @get:Rule(order = 2)
- val createComposeRule = createAndroidComposeRule()
-
- private lateinit var viewModel: MainViewModel
-
- @Before
- fun setup() {
- hiltAndroidRule.inject()
- }
-
- @Test
- fun verify_HomeScreen_isShown() {
- createComposeRule.activity.setContent {
- viewModel = hiltViewModel()
- WeatherifyTheme { AppNavigation() }
- }
- }*/
-}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8127e848..899c46c2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,9 +10,13 @@
-
-
+
+
+
+
+
@@ -46,6 +52,23 @@
android:name="autoStoreLocales"
android:value="true" />
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/assets/countryConfig.json b/app/src/main/assets/countryConfig.json
index affee109..361fdf66 100644
--- a/app/src/main/assets/countryConfig.json
+++ b/app/src/main/assets/countryConfig.json
@@ -4,7 +4,12 @@
"languages": [
"en-IN",
"hi-IN",
- "iw-IL"
+ "iw-IL",
+ "kn-IN",
+ "ta-IN",
+ "te-IN",
+ "bn-IN",
+ "ml-IN"
],
"defaultLanguage": "en-IN",
"localCurrency": ["INR"]
diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/di/StorageModule.kt b/app/src/main/java/bose/ankush/storage/di/StorageModule.kt
similarity index 50%
rename from storage/src/androidMain/kotlin/bose/ankush/storage/di/StorageModule.kt
rename to app/src/main/java/bose/ankush/storage/di/StorageModule.kt
index 5c27145c..d3a5852f 100644
--- a/storage/src/androidMain/kotlin/bose/ankush/storage/di/StorageModule.kt
+++ b/app/src/main/java/bose/ankush/storage/di/StorageModule.kt
@@ -2,13 +2,14 @@ package bose.ankush.storage.di
import android.content.Context
import androidx.room.Room
-import bose.ankush.network.repository.WeatherRepository as NetworkWeatherRepository
+import bose.ankush.storage.api.TokenStorage
import bose.ankush.storage.api.WeatherStorage
import bose.ankush.storage.common.WEATHER_DATABASE_NAME
+import bose.ankush.storage.impl.EncryptedTokenStorageImpl
import bose.ankush.storage.impl.WeatherStorageImpl
import bose.ankush.storage.room.JsonParser
-import bose.ankush.storage.room.WeatherDatabase
import bose.ankush.storage.room.WeatherDataModelConverters
+import bose.ankush.storage.room.WeatherDatabase
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
@@ -20,7 +21,6 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object StorageModule {
-
@Provides
@Singleton
fun provideGson(): Gson = Gson()
@@ -31,32 +31,40 @@ object StorageModule {
@Provides
@Singleton
- fun provideWeatherDataModelConverters(jsonParser: JsonParser): WeatherDataModelConverters {
- return WeatherDataModelConverters(jsonParser)
- }
+ fun provideWeatherDataModelConverters(jsonParser: JsonParser): WeatherDataModelConverters =
+ WeatherDataModelConverters(jsonParser)
@Provides
@Singleton
fun provideWeatherDatabase(
@ApplicationContext context: Context,
- converters: WeatherDataModelConverters
- ): WeatherDatabase {
- return Room.databaseBuilder(
- context,
- WeatherDatabase::class.java,
- WEATHER_DATABASE_NAME
- )
- .addTypeConverter(converters)
- .fallbackToDestructiveMigration()
+ converters: WeatherDataModelConverters,
+ ): WeatherDatabase =
+ Room
+ .databaseBuilder(
+ context,
+ WeatherDatabase::class.java,
+ WEATHER_DATABASE_NAME,
+ ).addTypeConverter(converters)
+ .fallbackToDestructiveMigration(false)
.build()
+
+ @Provides
+ @Singleton
+ fun provideWeatherStorage(weatherDatabase: WeatherDatabase): WeatherStorage {
+ // Storage module is responsible ONLY for database operations
+ // Network synchronization is handled by WeatherRepository in the orchestration layer
+ return WeatherStorageImpl(weatherDatabase)
}
@Provides
@Singleton
- fun provideWeatherStorage(
- networkRepository: NetworkWeatherRepository,
- weatherDatabase: WeatherDatabase
- ): WeatherStorage {
- return WeatherStorageImpl(networkRepository, weatherDatabase)
+ fun provideTokenStorage(
+ @ApplicationContext context: Context,
+ ): TokenStorage {
+ // SECURITY: Initialize Android context for platform-specific token storage
+ bose.ankush.storage.impl
+ .setApplicationContext(context)
+ return EncryptedTokenStorageImpl()
}
}
diff --git a/app/src/main/java/bose/ankush/weatherify/WeatherifyApplication.kt b/app/src/main/java/bose/ankush/weatherify/WeatherifyApplication.kt
index 591e5d11..eb00750d 100644
--- a/app/src/main/java/bose/ankush/weatherify/WeatherifyApplication.kt
+++ b/app/src/main/java/bose/ankush/weatherify/WeatherifyApplication.kt
@@ -2,11 +2,16 @@ package bose.ankush.weatherify
import android.app.NotificationChannel
import android.app.NotificationManager
-import android.content.Context
+import bose.ankush.payment.di.featurePaymentModules
import bose.ankush.weatherify.base.location.LocationService.Companion.NOTIFICATION_CHANNEL_ID
import bose.ankush.weatherify.base.location.LocationService.Companion.NOTIFICATION_NAME
+import bose.ankush.weatherify.di.appPaymentKoinModule
import bose.ankush.weatherify.domain.remote_config.RemoteConfigService
+import com.google.firebase.FirebaseApp
+import com.google.firebase.messaging.FirebaseMessaging
import dagger.hilt.android.HiltAndroidApp
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.context.startKoin
import timber.log.Timber
import javax.inject.Inject
@@ -17,15 +22,41 @@ Date: 05,May,2021
@HiltAndroidApp
class WeatherifyApplication : WeatherifyApplicationCore() {
-
@Inject
lateinit var remoteConfigService: RemoteConfigService
override fun onCreate() {
- super.onCreate()
+ super.onCreate() // Hilt initializes here โ EntryPointAccessors is safe after this call
+ initKoin()
enableTimber()
+ initializeFirebase()
createNotificationChannel()
initializeRemoteConfig()
+ subscribeToTopics()
+ }
+
+ private fun initKoin() {
+ startKoin {
+ androidContext(this@WeatherifyApplication)
+ modules(featurePaymentModules + appPaymentKoinModule(this@WeatherifyApplication))
+ }
+ }
+
+ private fun initializeFirebase() {
+ FirebaseApp.initializeApp(this)
+ }
+
+ private fun subscribeToTopics() {
+ FirebaseMessaging
+ .getInstance()
+ .subscribeToTopic("weather_alerts")
+ .addOnCompleteListener { task ->
+ if (!task.isSuccessful) {
+ Timber.e(task.exception, "Failed to subscribe to weather_alerts topic")
+ } else {
+ Timber.d("Successfully subscribed to weather_alerts topic")
+ }
+ }
}
private fun initializeRemoteConfig() {
@@ -33,17 +64,44 @@ class WeatherifyApplication : WeatherifyApplicationCore() {
}
private fun enableTimber() {
- Timber.plant(Timber.DebugTree())
+ if (BuildConfig.DEBUG) {
+ Timber.plant(Timber.DebugTree())
+ } else {
+ Timber.plant(
+ object : Timber.Tree() {
+ override fun log(
+ priority: Int,
+ tag: String?,
+ message: String,
+ t: Throwable?,
+ ) {
+ // Only log WARN, ERROR, and WTF in release; avoid verbose/debug/info
+ val isLowPriority =
+ priority == android.util.Log.VERBOSE ||
+ priority == android.util.Log.DEBUG ||
+ priority == android.util.Log.INFO
+ if (isLowPriority) return
+ android.util.Log.println(priority, tag, message)
+ }
+ },
+ )
+ }
}
private fun createNotificationChannel() {
- val channel = NotificationChannel(
- NOTIFICATION_CHANNEL_ID,
- NOTIFICATION_NAME,
- NotificationManager.IMPORTANCE_HIGH
- )
+ val channel =
+ NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ NOTIFICATION_NAME,
+ NotificationManager.IMPORTANCE_HIGH,
+ ).apply {
+ description = "Channel for weather alerts and updates"
+ enableVibration(true)
+ }
+
val notificationManager =
- getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
+ Timber.d("Notification channel created: $NOTIFICATION_CHANNEL_ID")
}
}
diff --git a/app/src/main/java/bose/ankush/weatherify/WeatherifyApplicationCore.kt b/app/src/main/java/bose/ankush/weatherify/WeatherifyApplicationCore.kt
index 5905443c..3923d127 100644
--- a/app/src/main/java/bose/ankush/weatherify/WeatherifyApplicationCore.kt
+++ b/app/src/main/java/bose/ankush/weatherify/WeatherifyApplicationCore.kt
@@ -2,4 +2,4 @@ package bose.ankush.weatherify
import android.app.Application
-open class WeatherifyApplicationCore: Application()
\ No newline at end of file
+open class WeatherifyApplicationCore : Application()
diff --git a/app/src/main/java/bose/ankush/weatherify/base/AssetLoader.kt b/app/src/main/java/bose/ankush/weatherify/base/AssetLoader.kt
deleted file mode 100644
index 0c9aff20..00000000
--- a/app/src/main/java/bose/ankush/weatherify/base/AssetLoader.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package bose.ankush.weatherify.base
-
-import android.content.Context
-import com.google.gson.Gson
-import java.io.IOException
-
-class AssetLoader(val context: Context) {
- @Throws(IOException::class)
- inline fun loadJSONAndConvertToObject(fileName: String): T {
- return context.assets.open(fileName).use { inputStream ->
- Gson().fromJson(inputStream.bufferedReader().use { it.readText() }, T::class.java)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/bose/ankush/weatherify/base/DateTimeUtils.kt b/app/src/main/java/bose/ankush/weatherify/base/DateTimeUtils.kt
index 1fd21285..75bb7deb 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/DateTimeUtils.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/DateTimeUtils.kt
@@ -4,30 +4,24 @@ import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
-import java.util.Date
import java.util.Calendar
+import java.util.Date
import java.util.Locale
/**
* Singleton class to provide utility values related to date and time throughout all the modules.
*/
object DateTimeUtils {
-
- /**
- * Returns current timestamp as per device in String
- */
- fun getCurrentTimestamp(): String = Instant.now().toEpochMilli().toString()
-
/**
* Returns numbers of days between today and given time on argument
*/
- fun getDayWiseDifferenceFromToday(day: Int): Int {
+ fun getDayWiseDifferenceFromToday(day: Long): Int {
val todayDate = getTodayDateInCalenderFormat()
- val givenDate = Date(day.toLong() * 1000)
+ val givenDate = Date(day * 1000)
val calenderForGivenDate = Calendar.getInstance()
calenderForGivenDate.time = givenDate
- val givenDateNumber = calenderForGivenDate.get(Calendar.DAY_OF_MONTH + 1)
- val todayDateNumber = todayDate.get(Calendar.DAY_OF_MONTH + 1)
+ val givenDateNumber = calenderForGivenDate.get(Calendar.DAY_OF_MONTH)
+ val todayDateNumber = todayDate.get(Calendar.DAY_OF_MONTH)
return givenDateNumber - todayDateNumber
}
@@ -60,41 +54,18 @@ object DateTimeUtils {
return calendarForToday
}
- /**
- * Returns time from given epoch in String.
- * Takes epoch in Integer format and device zone in String, as the arguments
- */
- fun getTimeFromEpoch(epoch: Int?, zone: String = "Asia/Kolkata"): String {
- val format = "K:mm a"
- return epoch?.let {
- val zoneId = ZoneId.of(zone)
- val instant = Instant.ofEpochSecond(epoch.toLong())
- val formatter = DateTimeFormatter.ofPattern(format, Locale.ENGLISH)
- instant.atZone(zoneId).format(formatter)
- }.toString()
- }
-
fun Long.toFormattedTime(zone: String = "Asia/Kolkata"): String {
val format = "K:mm a"
val zoneId = ZoneId.of(zone)
val instant = Instant.ofEpochSecond(this)
val formatter = DateTimeFormatter.ofPattern(format, Locale.ENGLISH)
- return instant.atZone(zoneId).format(formatter).toString()
+ return instant.atZone(zoneId).format(formatter)
}
fun getFormattedDateTimeFromEpoch(epoch: Long?): String {
- epoch?.let {
- val instant = Instant.ofEpochSecond(it)
- val zoneId = ZoneId.systemDefault()
-
- // convert instant to local date time
- val localDateTime = LocalDateTime.ofInstant(instant, zoneId)
-
- // creating desired date time format
- val dateTimeFormat = DateTimeFormatter.ofPattern("EEE, dd MMM")
-
- return dateTimeFormat.format(localDateTime)
- } ?:
- return "Date & Time is unavailable at this moment"
+ epoch ?: return "Date & Time is unavailable at this moment"
+ val instant = Instant.ofEpochSecond(epoch)
+ val localDateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault())
+ return DateTimeFormatter.ofPattern("EEE, dd MMM").format(localDateTime)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/LocaleConfigMapper.kt b/app/src/main/java/bose/ankush/weatherify/base/LocaleConfigMapper.kt
index 02dfeb09..150829f0 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/LocaleConfigMapper.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/LocaleConfigMapper.kt
@@ -5,19 +5,23 @@ import com.google.gson.Gson
import com.google.gson.GsonBuilder
object LocaleConfigMapper {
-
- fun getAvailableLanguagesFromJson(jsonFile: String, context: Context): Array {
+ fun getAvailableLanguagesFromJson(
+ jsonFile: String,
+ context: Context,
+ ): Array {
// read JSON file
- val jsonString: String = context.assets.open(jsonFile)
- .bufferedReader()
- .use { it.readText() }
+ val jsonString: String =
+ context.assets
+ .open(jsonFile)
+ .bufferedReader()
+ .use { it.readText() }
// convert JSON to Map
val gson: Gson = GsonBuilder().setPrettyPrinting().create()
val map = gson.fromJson(jsonString, Map::class.java)
-
// extract language data from JSON and return as Array
+ @Suppress("UNCHECKED_CAST")
val languages = map["languages"] as List
return languages.toTypedArray()
}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/AirQualityIndexAnalyser.kt b/app/src/main/java/bose/ankush/weatherify/base/common/AirQualityIndexAnalyser.kt
index 3973ef3c..7b1ac45c 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/common/AirQualityIndexAnalyser.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/common/AirQualityIndexAnalyser.kt
@@ -1,13 +1,12 @@
package bose.ankush.weatherify.base.common
object AirQualityIndexAnalyser {
-
/**
* Used to analyse the air quality index number,
* and generate a string accordingly for UI to show
*/
- internal fun getAQIAnalysedText(aqi: Int): Pair {
- return when (aqi) {
+ internal fun getAQIAnalysedText(aqi: Int): Pair =
+ when (aqi) {
1 -> Pair("Air quality is Good", aqi)
2 -> Pair("Air quality is fair", aqi)
3 -> Pair("Air quality is moderate", aqi)
@@ -15,14 +14,15 @@ object AirQualityIndexAnalyser {
5 -> Pair("Air quality is Very Unhealthy", aqi)
else -> Pair("Air quality is Hazardous", aqi)
}
- }
/**
* This method is actually for making look pretty by adding
* adding `0` to single digit number
*/
- internal fun Int.getFormattedAQI(): String {
- return if (this in 0..9) "0$this"
- else "$this"
- }
-}
\ No newline at end of file
+ internal fun Int.getFormattedAQI(): String =
+ if (this in 0..9) {
+ "0$this"
+ } else {
+ "$this"
+ }
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/AndroidDeviceInfoProvider.kt b/app/src/main/java/bose/ankush/weatherify/base/common/AndroidDeviceInfoProvider.kt
new file mode 100644
index 00000000..c55dc9b6
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/base/common/AndroidDeviceInfoProvider.kt
@@ -0,0 +1,20 @@
+package bose.ankush.weatherify.base.common
+
+/** Android implementation of [DeviceInfoProvider] backed by the existing Extension helpers. */
+class AndroidDeviceInfoProvider : DeviceInfoProvider {
+ override fun getDeviceModel(): String = Extension.getDeviceModel()
+
+ override fun getOperatingSystem(): String = Extension.getOperatingSystem()
+
+ override fun getOsVersion(): String = Extension.getOsVersion()
+
+ override fun getAppVersion(): String = Extension.getAppVersion()
+
+ override fun getRegistrationSource(): String = Extension.getRegistrationSource()
+
+ override fun getIpAddress(): String? = Extension.getIpAddress()
+
+ override fun getCurrentUtcTimestamp(): String = Extension.getCurrentUtcTimestamp()
+
+ override suspend fun getFirebaseToken(): String? = Extension.getFirebaseToken()
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/ConnectivityManager.kt b/app/src/main/java/bose/ankush/weatherify/base/common/ConnectivityManager.kt
deleted file mode 100644
index 62abd0ea..00000000
--- a/app/src/main/java/bose/ankush/weatherify/base/common/ConnectivityManager.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-@file:Suppress("DEPRECATION")
-
-package bose.ankush.weatherify.base.common
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.net.ConnectivityManager
-import android.net.NetworkCapabilities
-
-object ConnectivityManager {
-
- @SuppressLint("ServiceCast")
- fun isNetworkAvailable(context: Context): Boolean {
- val connManager =
- context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
- val networkCapabilities = connManager.activeNetwork ?: return false
- val activeNetwork =
- connManager.getNetworkCapabilities(networkCapabilities) ?: return false
- return when {
- activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
- activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
- else -> false
- }
- }
-}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/Constants.kt b/app/src/main/java/bose/ankush/weatherify/base/common/Constants.kt
index 3b11899a..533f9bd4 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/common/Constants.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/common/Constants.kt
@@ -6,35 +6,28 @@ import android.annotation.SuppressLint
Author: Ankush Bose
Date: 05,May,2021
**/
-
-/*General constants*/
-const val WEATHER_BASE_URL = "https://data.androidplay.in/"
const val WEATHER_IMG_URL = "https://openweathermap.org/img/wn/"
const val APP_UPDATE_REQ_CODE = 111
-/*Shared Preference Keys*/
+// Shared Preference Keys
const val APP_PREFERENCE_KEY = "app_preferences"
-/*Fallback user location coordinates*/
+// Fallback user location coordinates
const val DEFAULT_CITY_NAME = "New Delhi"
-/* Permission constants */
+// Permission constants
const val ACCESS_FINE_LOCATION = android.Manifest.permission.ACCESS_FINE_LOCATION
const val ACCESS_COARSE_LOCATION = android.Manifest.permission.ACCESS_COARSE_LOCATION
-const val ACCESS_PHONE_CALL = android.Manifest.permission.CALL_PHONE
+
@SuppressLint("InlinedApi")
const val ACCESS_NOTIFICATION = android.Manifest.permission.POST_NOTIFICATIONS
-val PERMISSIONS_TO_REQUEST = arrayOf(
- ACCESS_FINE_LOCATION,
- ACCESS_COARSE_LOCATION
-)
-
-/*Room central db name*/
-const val WEATHER_DATABASE_NAME = "central_weather_table"
-const val AQ_DATABASE_NAME = "central_aq_table"
-const val PHONE_NUMBER = "tel:+91XXXXXXXXX"
+val PERMISSIONS_TO_REQUEST =
+ arrayOf(
+ ACCESS_FINE_LOCATION,
+ ACCESS_COARSE_LOCATION,
+ )
-/*Remote keys*/
+// Remote keys
const val ENABLE_NOTIFICATION = "enable_notification"
diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/DeviceInfoProvider.kt b/app/src/main/java/bose/ankush/weatherify/base/common/DeviceInfoProvider.kt
new file mode 100644
index 00000000..ffb2303d
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/base/common/DeviceInfoProvider.kt
@@ -0,0 +1,26 @@
+package bose.ankush.weatherify.base.common
+
+/**
+ * Platform-agnostic interface for device and app metadata.
+ * Replaces direct Extension.* calls in shared/common code to enable KMP compatibility.
+ *
+ * Android provides an implementation backed by android.os.Build, BuildConfig, and Firebase.
+ * iOS (or other KMP targets) would provide their own implementation.
+ */
+interface DeviceInfoProvider {
+ fun getDeviceModel(): String
+
+ fun getOperatingSystem(): String
+
+ fun getOsVersion(): String
+
+ fun getAppVersion(): String
+
+ fun getRegistrationSource(): String
+
+ fun getIpAddress(): String?
+
+ fun getCurrentUtcTimestamp(): String
+
+ suspend fun getFirebaseToken(): String?
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/Extension.kt b/app/src/main/java/bose/ankush/weatherify/base/common/Extension.kt
index e1128194..d58ef9b5 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/common/Extension.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/common/Extension.kt
@@ -1,14 +1,22 @@
package bose.ankush.weatherify.base.common
+import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
-import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
-import androidx.core.net.toUri
+import bose.ankush.weatherify.BuildConfig
+import com.google.firebase.messaging.FirebaseMessaging
+import kotlinx.coroutines.suspendCancellableCoroutine
+import java.net.NetworkInterface
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+import kotlin.coroutines.resume
import kotlin.math.roundToInt
/**Created by
@@ -17,7 +25,6 @@ Date: 06,May,2021
**/
object Extension {
-
fun Double.toCelsius() = (this - 273).roundToInt().toString()
fun String.getIconUrl(size: String = "@2x.png") = "$WEATHER_IMG_URL$this$size"
@@ -26,39 +33,90 @@ object Extension {
fun isDeviceSDKAndroid13OrAbove() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
- fun Context.openAppSystemSettings() = startActivity(
- Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
- data = Uri.fromParts("package", packageName, null)
+ fun Context.openAppSystemSettings() =
+ startActivity(
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = Uri.fromParts("package", packageName, null)
+ },
+ )
+
+ fun Context.openLocationSettings() =
+ startActivity(
+ Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS),
+ )
+
+ @SuppressLint("QueryPermissionsNeeded")
+ fun Context.openAppLocaleSettings() {
+ // Try opening the per-app language settings if available, otherwise fall back safely
+ val pm = packageManager
+ // Primary: Per-app language settings (Android 13+)
+ val appLocaleIntent =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply {
+ data = Uri.fromParts("package", packageName, null)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ } else {
+ Intent(Settings.ACTION_LOCALE_SETTINGS).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ }
+
+ try {
+ val canHandleAppLocale = appLocaleIntent.resolveActivity(pm) != null
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && canHandleAppLocale) {
+ startActivity(appLocaleIntent)
+ return
+ }
+ } catch (_: Exception) {
+ // Ignore and try fallbacks
}
- )
- @RequiresApi(Build.VERSION_CODES.TIRAMISU)
- fun Context.openAppLocaleSettings() = startActivity(
- Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply {
- data = Uri.fromParts("package", packageName, null)
+ // Fallback 1: App details/settings screen
+ val appDetailsIntent =
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = Uri.fromParts("package", packageName, null)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ try {
+ val canHandleAppDetails = appDetailsIntent.resolveActivity(pm) != null
+ if (canHandleAppDetails) {
+ startActivity(appDetailsIntent)
+ return
+ }
+ } catch (_: Exception) {
+ // Ignore and try next fallback
}
- )
- fun Context.hasLocationPermission(): Boolean = listOf(
- android.Manifest.permission.ACCESS_COARSE_LOCATION,
- android.Manifest.permission.ACCESS_FINE_LOCATION
- ).all { permission ->
- ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
+ // Fallback 2: System language settings
+ val localeSettingsIntent =
+ Intent(Settings.ACTION_LOCALE_SETTINGS).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ try {
+ val canHandleLocaleSettings = localeSettingsIntent.resolveActivity(pm) != null
+ if (canHandleLocaleSettings) {
+ startActivity(localeSettingsIntent)
+ return
+ }
+ } catch (_: Exception) {
+ // Final fallback: do nothing; avoid crash
+ }
}
- private fun Context.hasPhoneCallPermission(): Boolean {
- return ContextCompat.checkSelfPermission(
- this,
- ACCESS_PHONE_CALL
- ) == PackageManager.PERMISSION_GRANTED
- }
+ fun Context.hasLocationPermission(): Boolean =
+ listOf(
+ android.Manifest.permission.ACCESS_COARSE_LOCATION,
+ android.Manifest.permission.ACCESS_FINE_LOCATION,
+ ).all { permission ->
+ ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
+ }
- fun Context.hasNotificationPermission(): Boolean {
- return ContextCompat.checkSelfPermission(
+ fun Context.hasNotificationPermission(): Boolean =
+ ContextCompat.checkSelfPermission(
this,
- ACCESS_NOTIFICATION
+ ACCESS_NOTIFICATION,
) == PackageManager.PERMISSION_GRANTED
- }
fun String.wrapText(): String {
val words: List = this.split(" ")
@@ -69,11 +127,89 @@ object Extension {
}
}
- fun Context.callNumber(): Boolean = hasPhoneCallPermission().also { hasPermission ->
- if (hasPermission) startActivity(
- Intent(Intent.ACTION_CALL).apply {
- data = PHONE_NUMBER.toUri()
+ /**
+ * Gets the device model (e.g., "Pixel 7 Pro", "iPhone 15")
+ * @return The device model name
+ */
+ fun getDeviceModel(): String = Build.MODEL
+
+ /**
+ * Gets the operating system name (e.g., "Android")
+ * @return The operating system name
+ */
+ fun getOperatingSystem(): String = "Android"
+
+ /**
+ * Gets the operating system version (e.g., "14", "13.1")
+ * @return The operating system version
+ */
+ fun getOsVersion(): String = Build.VERSION.RELEASE
+
+ /**
+ * Gets the app version from BuildConfig
+ * @return The app version
+ */
+ fun getAppVersion(): String = BuildConfig.VERSION_NAME
+
+ /**
+ * Gets the current UTC timestamp in ISO 8601 format
+ * @return The current UTC timestamp
+ */
+ fun getCurrentUtcTimestamp(): String {
+ val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
+ dateFormat.timeZone = TimeZone.getTimeZone("UTC")
+ return dateFormat.format(Date())
+ }
+
+ /**
+ * Gets the registration source
+ * @return The registration source (e.g., "Android App")
+ */
+ fun getRegistrationSource(): String = "Android App"
+
+ /**
+ * Attempts to get the device's IP address
+ * Note: This is a best-effort approach and may not always return the correct IP
+ * @return The IP address or null if not available
+ */
+ fun getIpAddress(): String? {
+ try {
+ val networkInterfaces = NetworkInterface.getNetworkInterfaces()
+ while (networkInterfaces.hasMoreElements()) {
+ val networkInterface = networkInterfaces.nextElement()
+ val inetAddresses = networkInterface.inetAddresses
+ while (inetAddresses.hasMoreElements()) {
+ val inetAddress = inetAddresses.nextElement()
+ if (!inetAddress.isLoopbackAddress && !inetAddress.isLinkLocalAddress) {
+ return inetAddress.hostAddress
+ }
+ }
}
- )
+ } catch (_: Exception) {
+ // Ignore exceptions and return null
+ }
+ return null
}
+
+ /**
+ * Best-effort fetch of Firebase Cloud Messaging registration token
+ * Kept here to follow the same Extension helper pattern as other device/app info getters
+ */
+ suspend fun getFirebaseToken(): String? =
+ try {
+ suspendCancellableCoroutine { cont ->
+ try {
+ FirebaseMessaging
+ .getInstance()
+ .token
+ .addOnCompleteListener { task: com.google.android.gms.tasks.Task ->
+ if (cont.isActive) cont.resume(if (task.isSuccessful) task.result else null)
+ }
+ } catch (_: Exception) {
+ if (cont.isActive) cont.resume(null)
+ }
+ }
+ } catch (_: Exception) {
+ null
+ }
}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/InAppUpdateManager.kt b/app/src/main/java/bose/ankush/weatherify/base/common/InAppUpdateManager.kt
index e962204b..acade7fe 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/common/InAppUpdateManager.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/common/InAppUpdateManager.kt
@@ -9,26 +9,24 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
@ExperimentalCoroutinesApi
fun startInAppUpdate(activity: Activity) {
-
val appUpdateManager = AppUpdateManagerFactory.create(activity)
val appUpdateInfoTask = appUpdateManager.appUpdateInfo
appUpdateInfoTask.addOnSuccessListener { appUpdateInfo ->
- if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE
- && appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
+ if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
+ appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
) {
try {
+ @Suppress("DEPRECATION")
appUpdateManager.startUpdateFlowForResult(
appUpdateInfo,
AppUpdateType.IMMEDIATE,
activity,
- APP_UPDATE_REQ_CODE
+ APP_UPDATE_REQ_CODE,
)
-
} catch (exception: IntentSender.SendIntentException) {
}
}
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/Logger.kt b/app/src/main/java/bose/ankush/weatherify/base/common/Logger.kt
new file mode 100644
index 00000000..4a07bb79
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/base/common/Logger.kt
@@ -0,0 +1,28 @@
+package bose.ankush.weatherify.base.common
+
+/**
+ * Platform-agnostic logging interface.
+ * Replaces direct Timber usage in shared/common code to enable KMP compatibility.
+ */
+interface Logger {
+ fun d(message: String)
+
+ fun i(message: String)
+
+ fun w(message: String)
+
+ fun e(
+ message: String,
+ throwable: Throwable? = null,
+ )
+
+ fun v(message: String)
+}
+
+/**
+ * Factory that creates [Logger] instances scoped to a specific tag.
+ * Android provides a Timber-backed implementation; other platforms can provide their own.
+ */
+interface LoggerFactory {
+ fun create(tag: String): Logger
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/TimberLogger.kt b/app/src/main/java/bose/ankush/weatherify/base/common/TimberLogger.kt
new file mode 100644
index 00000000..643c3d20
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/base/common/TimberLogger.kt
@@ -0,0 +1,32 @@
+package bose.ankush.weatherify.base.common
+
+import timber.log.Timber
+
+/** Timber-backed [Logger] implementation for Android. */
+class TimberLogger(
+ private val tag: String,
+) : Logger {
+ override fun d(message: String) = Timber.tag(tag).d(message)
+
+ override fun i(message: String) = Timber.tag(tag).i(message)
+
+ override fun w(message: String) = Timber.tag(tag).w(message)
+
+ override fun e(
+ message: String,
+ throwable: Throwable?,
+ ) {
+ if (throwable != null) {
+ Timber.tag(tag).e(throwable, message)
+ } else {
+ Timber.tag(tag).e(message)
+ }
+ }
+
+ override fun v(message: String) = Timber.tag(tag).v(message)
+}
+
+/** [LoggerFactory] that creates [TimberLogger] instances. */
+class TimberLoggerFactory : LoggerFactory {
+ override fun create(tag: String): Logger = TimberLogger(tag)
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/UiText.kt b/app/src/main/java/bose/ankush/weatherify/base/common/UiText.kt
index f8eae807..7e1c4518 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/common/UiText.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/common/UiText.kt
@@ -2,25 +2,57 @@ package bose.ankush.weatherify.base.common
import android.content.Context
import androidx.annotation.StringRes
+import bose.ankush.network.common.NetworkException
import bose.ankush.weatherify.R
sealed class UiText {
- data class DynamicText(val value: String) : UiText()
- class StringResource(@StringRes val resId: Int, vararg val args: String) : UiText()
+ data class DynamicText(
+ val value: String,
+ ) : UiText()
- fun asString(context: Context): String {
- return when (this) {
+ class StringResource(
+ @StringRes val resId: Int,
+ vararg val args: String,
+ ) : UiText()
+
+ fun asString(context: Context): String =
+ when (this) {
is DynamicText -> value
is StringResource -> context.getString(resId, *args)
}
- }
}
-fun errorResponse(errorCode: Int): UiText.StringResource {
- return when (errorCode) {
- 401 -> UiText.StringResource(resId = R.string.unauthorised_access_txt)
- 400, 404 -> UiText.StringResource(resId = R.string.city_error_txt)
- 500 -> UiText.StringResource(resId = R.string.server_error_txt)
+/**
+ * Maps error codes to user-friendly messages
+ * @param errorCode The HTTP or custom error code
+ * @return A user-friendly error message as a StringResource
+ */
+fun errorResponse(errorCode: Int): UiText.StringResource =
+ when (errorCode) {
+ // HTTP error codes
+ NetworkException.BAD_REQUEST -> UiText.StringResource(resId = R.string.city_error_txt)
+ NetworkException.UNAUTHORIZED -> UiText.StringResource(resId = R.string.unauthorised_access_txt)
+ NetworkException.FORBIDDEN -> UiText.StringResource(resId = R.string.unauthorised_access_txt)
+ NetworkException.NOT_FOUND -> UiText.StringResource(resId = R.string.city_error_txt)
+ NetworkException.SERVER_ERROR -> UiText.StringResource(resId = R.string.server_error_txt)
+ NetworkException.SERVICE_UNAVAILABLE -> UiText.StringResource(resId = R.string.server_error_txt)
+
+ // Network-specific error codes
+ NetworkException.NETWORK_UNAVAILABLE -> UiText.StringResource(resId = R.string.network_unavailable_txt)
+ NetworkException.TIMEOUT -> UiText.StringResource(resId = R.string.network_timeout_txt)
+ NetworkException.UNKNOWN_HOST -> UiText.StringResource(resId = R.string.network_unavailable_txt)
+
+ // Default case
+ else -> UiText.StringResource(resId = R.string.general_error_txt)
+ }
+
+/**
+ * Maps an exception to a user-friendly message
+ * @param exception The exception to map
+ * @return A user-friendly error message
+ */
+fun errorResponseFromException(exception: Exception): UiText =
+ when (exception) {
+ is NetworkException -> errorResponse(exception.errorCode)
else -> UiText.StringResource(resId = R.string.general_error_txt)
}
-}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/common/component/ScreenTopAppBar.kt b/app/src/main/java/bose/ankush/weatherify/base/common/component/ScreenTopAppBar.kt
index 707bb5c8..0744fb5e 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/common/component/ScreenTopAppBar.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/common/component/ScreenTopAppBar.kt
@@ -28,7 +28,7 @@ fun ScreenTopAppBar(
text = stringResource(id = headlineId),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
- modifier = Modifier.padding(start = 16.dp)
+ modifier = Modifier.padding(start = 16.dp),
)
},
navigationIcon = {
@@ -36,11 +36,12 @@ fun ScreenTopAppBar(
painter = painterResource(id = R.drawable.ic_back),
tint = MaterialTheme.colorScheme.onBackground,
contentDescription = stringResource(id = R.string.close_icon_content),
- modifier = Modifier
- .clip(CircleShape)
- .clickable { navIconAction.invoke() }
- .padding(all = 3.dp)
+ modifier =
+ Modifier
+ .clip(CircleShape)
+ .clickable { navIconAction.invoke() }
+ .padding(all = 3.dp),
)
- }
+ },
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/config/AndroidAppConfig.kt b/app/src/main/java/bose/ankush/weatherify/base/config/AndroidAppConfig.kt
new file mode 100644
index 00000000..e20f7f99
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/base/config/AndroidAppConfig.kt
@@ -0,0 +1,8 @@
+package bose.ankush.weatherify.base.config
+
+import bose.ankush.weatherify.BuildConfig
+
+/** Android implementation of [AppConfig] backed by BuildConfig generated values. */
+class AndroidAppConfig : AppConfig {
+ override val razorpayKey: String get() = BuildConfig.RAZORPAY_KEY
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/config/AppConfig.kt b/app/src/main/java/bose/ankush/weatherify/base/config/AppConfig.kt
new file mode 100644
index 00000000..60a80a0f
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/base/config/AppConfig.kt
@@ -0,0 +1,9 @@
+package bose.ankush.weatherify.base.config
+
+/**
+ * Platform-agnostic interface for build/environment configuration values.
+ * Replaces direct BuildConfig references in shared/common code to enable KMP compatibility.
+ */
+interface AppConfig {
+ val razorpayKey: String
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/dispatcher/AppDispatcher.kt b/app/src/main/java/bose/ankush/weatherify/base/dispatcher/AppDispatcher.kt
index c9f20eb3..6df7f706 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/dispatcher/AppDispatcher.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/dispatcher/AppDispatcher.kt
@@ -4,7 +4,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
class AppDispatcher : DispatcherProvider {
-
override val main: CoroutineDispatcher
get() = Dispatchers.Main
@@ -16,4 +15,4 @@ class AppDispatcher : DispatcherProvider {
override val unconfined: CoroutineDispatcher
get() = Dispatchers.Unconfined
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/dispatcher/DispatcherProvider.kt b/app/src/main/java/bose/ankush/weatherify/base/dispatcher/DispatcherProvider.kt
index 0c52eaa1..fafa5f1b 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/dispatcher/DispatcherProvider.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/dispatcher/DispatcherProvider.kt
@@ -3,7 +3,6 @@ package bose.ankush.weatherify.base.dispatcher
import kotlinx.coroutines.CoroutineDispatcher
interface DispatcherProvider {
-
val main: CoroutineDispatcher
val io: CoroutineDispatcher
@@ -11,4 +10,4 @@ interface DispatcherProvider {
val default: CoroutineDispatcher
val unconfined: CoroutineDispatcher
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/location/Coordinates.kt b/app/src/main/java/bose/ankush/weatherify/base/location/Coordinates.kt
new file mode 100644
index 00000000..e79d3edd
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/base/location/Coordinates.kt
@@ -0,0 +1,10 @@
+package bose.ankush.weatherify.base.location
+
+/**
+ * Platform-agnostic representation of a geographic coordinate.
+ * Used instead of android.location.Location to enable KMP compatibility.
+ */
+data class Coordinates(
+ val latitude: Double,
+ val longitude: Double,
+)
diff --git a/app/src/main/java/bose/ankush/weatherify/base/location/DeviceLocationClient.kt b/app/src/main/java/bose/ankush/weatherify/base/location/DeviceLocationClient.kt
index ea9677ee..a8e6914c 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/location/DeviceLocationClient.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/location/DeviceLocationClient.kt
@@ -2,7 +2,6 @@ package bose.ankush.weatherify.base.location
import android.annotation.SuppressLint
import android.content.Context
-import android.location.Location
import android.location.LocationManager
import android.os.Looper
import bose.ankush.weatherify.base.common.Extension.hasLocationPermission
@@ -12,21 +11,24 @@ import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.Priority
+import com.google.android.gms.tasks.CancellationTokenSource
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
-import kotlin.coroutines.resume
import javax.inject.Inject
import javax.inject.Singleton
+import kotlin.coroutines.resume
@Singleton
-class DeviceLocationClient @Inject constructor(
+class DeviceLocationClient
+@Inject
+constructor(
private val context: Context,
- private val client: FusedLocationProviderClient
+ private val client: FusedLocationProviderClient,
) : LocationClient {
-
private fun checkLocationPermission() {
// if user did not give location permission
if (!context.hasLocationPermission()) {
@@ -48,64 +50,74 @@ class DeviceLocationClient @Inject constructor(
}
@SuppressLint("MissingPermission")
- override fun getLocationUpdates(interval: Long): Flow {
- return callbackFlow {
+ override fun getLocationUpdates(interval: Long): Flow =
+ callbackFlow {
checkLocationPermission()
checkGpsEnabled()
- val request = LocationRequest.Builder(
- Priority.PRIORITY_HIGH_ACCURACY,
- interval
- ).apply {
- setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
- setWaitForAccurateLocation(true)
- }.build()
+ val request =
+ LocationRequest
+ .Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ interval,
+ ).apply {
+ setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
+ setWaitForAccurateLocation(true)
+ }.build()
- val locationCallback = object : LocationCallback() {
- override fun onLocationResult(result: LocationResult) {
- super.onLocationResult(result)
- result.locations.lastOrNull()?.let { location ->
- launch { send(location) }
+ val locationCallback =
+ object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ super.onLocationResult(result)
+ result.locations.lastOrNull()?.let { location ->
+ launch { send(location) }
+ }
}
}
- }
client.requestLocationUpdates(
request,
locationCallback,
- Looper.getMainLooper()
+ Looper.getMainLooper(),
)
awaitClose { client.removeLocationUpdates(locationCallback) }
- }
- }
+ }.map { loc -> Coordinates(loc.latitude, loc.longitude) }
@SuppressLint("MissingPermission")
- override suspend fun getCurrentLocation(): Result = suspendCancellableCoroutine { continuation ->
- try {
- checkLocationPermission()
- checkGpsEnabled()
- } catch (e: LocationClient.LocationException) {
- continuation.resume(Result.failure(e))
- return@suspendCancellableCoroutine
- }
+ override suspend fun getCurrentLocation(): Result =
+ suspendCancellableCoroutine { continuation ->
+ try {
+ checkLocationPermission()
+ checkGpsEnabled()
+ } catch (e: LocationClient.LocationException) {
+ continuation.resume(Result.failure(e))
+ return@suspendCancellableCoroutine
+ }
- client.lastLocation
- .addOnSuccessListener { location ->
- if (location != null) {
- continuation.resume(Result.success(location))
- } else {
- continuation.resume(Result.failure(LocationClient.LocationException("Location is null")))
+ // Create a cancellation token source to allow cancellation of the location request
+ val cts = CancellationTokenSource()
+
+ client
+ .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, cts.token)
+ .addOnSuccessListener { location ->
+ if (location != null) {
+ val coords = Coordinates(location.latitude, location.longitude)
+ continuation.resume(Result.success(coords))
+ } else {
+ val ex = LocationClient.LocationException("Location is null")
+ continuation.resume(Result.failure(ex))
+ }
+ }.addOnFailureListener { e ->
+ val ex = LocationClient.LocationException(e.message ?: "Unknown error")
+ continuation.resume(Result.failure(ex))
}
- }
- .addOnFailureListener { e ->
- continuation.resume(Result.failure(LocationClient.LocationException(e.message ?: "Unknown error")))
- }
- continuation.invokeOnCancellation {
- // No need to cancel anything for lastLocation as it's a one-time operation
+ continuation.invokeOnCancellation {
+ // Cancel the Play Services location request when the coroutine is cancelled
+ cts.cancel()
+ }
}
- }
override fun hasLocationPermission(): Boolean = context.hasLocationPermission()
}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/location/LocationClient.kt b/app/src/main/java/bose/ankush/weatherify/base/location/LocationClient.kt
index 63d83862..3d58b1c2 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/location/LocationClient.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/location/LocationClient.kt
@@ -1,15 +1,19 @@
package bose.ankush.weatherify.base.location
-import android.location.Location
import kotlinx.coroutines.flow.Flow
+/**
+ * Platform-agnostic location client interface.
+ * Uses [Coordinates] instead of android.location.Location to enable KMP compatibility.
+ */
interface LocationClient {
+ fun getLocationUpdates(interval: Long): Flow
- fun getLocationUpdates(interval: Long): Flow
-
- suspend fun getCurrentLocation(): Result
+ suspend fun getCurrentLocation(): Result
fun hasLocationPermission(): Boolean
- class LocationException(message: String): Exception()
+ class LocationException(
+ message: String,
+ ) : Exception(message)
}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/location/LocationPermissions.kt b/app/src/main/java/bose/ankush/weatherify/base/location/LocationPermissions.kt
new file mode 100644
index 00000000..75e9b7d6
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/base/location/LocationPermissions.kt
@@ -0,0 +1,10 @@
+package bose.ankush.weatherify.base.location
+
+/**
+ * Platform-agnostic location permission string constants.
+ * Avoids importing android.Manifest in shared/common ViewModel code.
+ */
+object LocationPermissions {
+ const val FINE_LOCATION = "android.permission.ACCESS_FINE_LOCATION"
+ const val COARSE_LOCATION = "android.permission.ACCESS_COARSE_LOCATION"
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/location/LocationService.kt b/app/src/main/java/bose/ankush/weatherify/base/location/LocationService.kt
index 85de20d9..f4bb2202 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/location/LocationService.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/location/LocationService.kt
@@ -2,7 +2,6 @@ package bose.ankush.weatherify.base.location
import android.app.NotificationManager
import android.app.Service
-import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
@@ -15,26 +14,23 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
+import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class LocationService : Service() {
-
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Inject
lateinit var locationClient: LocationClient
- override fun onBind(intent: Intent?): IBinder? {
- return null
- }
+ override fun onBind(intent: Intent?): IBinder? = null
- override fun onCreate() {
- super.onCreate()
- }
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ override fun onStartCommand(
+ intent: Intent?,
+ flags: Int,
+ startId: Int,
+ ): Int {
when (intent?.action) {
ACTION_START -> start()
ACTION_STOP -> stop()
@@ -43,21 +39,22 @@ class LocationService : Service() {
}
private fun start() {
- val notification = NotificationCompat.Builder(
- this,
- NOTIFICATION_CHANNEL_ID
- )
- .setContentTitle(NOTIFICATION_TITLE)
- .setContentText("Location: null")
- .setSmallIcon(R.drawable.ic_profile)
- .setOngoing(true)
+ val notification =
+ NotificationCompat
+ .Builder(
+ this,
+ NOTIFICATION_CHANNEL_ID,
+ ).setContentTitle(NOTIFICATION_TITLE)
+ .setContentText("Location: null")
+ .setSmallIcon(R.drawable.ic_profile)
+ .setOngoing(true)
val notificationManager =
- getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ getSystemService(NOTIFICATION_SERVICE) as NotificationManager
locationClient
.getLocationUpdates(interval = 1000L)
- .catch { exception -> println(exception.printStackTrace()) }
+ .catch { e -> Timber.e(e, "Location updates error") }
.onEach { location ->
val lat = location.latitude.toString().take(4)
val long = location.longitude.toString().take(4)
@@ -65,14 +62,13 @@ class LocationService : Service() {
notificationManager.notify(
NOTIFICATION_ID,
- updatedNotification.build()
+ updatedNotification.build(),
)
- }
- .launchIn(serviceScope)
+ }.launchIn(serviceScope)
startForeground(
NOTIFICATION_ID,
- notification.build()
+ notification.build(),
)
}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/notification/NotificationHelper.kt b/app/src/main/java/bose/ankush/weatherify/base/notification/NotificationHelper.kt
new file mode 100644
index 00000000..0a2a9566
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/base/notification/NotificationHelper.kt
@@ -0,0 +1,31 @@
+package bose.ankush.weatherify.base.notification
+
+import android.content.Context
+import androidx.core.app.NotificationCompat
+import bose.ankush.weatherify.R
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class NotificationHelper
+@Inject
+constructor(
+ private val context: Context,
+) {
+ fun getNotificationBuilder(
+ channelId: String,
+ title: String,
+ message: String,
+ ): NotificationCompat.Builder =
+ NotificationCompat
+ .Builder(context, channelId)
+ .setSmallIcon(R.drawable.ic_home)
+ .setContentTitle(title)
+ .setContentText(message)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setAutoCancel(true)
+
+ companion object {
+ const val DEFAULT_CHANNEL_ID = "weatherify_notifications"
+ }
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/notification/WeatherifyMessagingService.kt b/app/src/main/java/bose/ankush/weatherify/base/notification/WeatherifyMessagingService.kt
new file mode 100644
index 00000000..5898fb67
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/base/notification/WeatherifyMessagingService.kt
@@ -0,0 +1,97 @@
+package bose.ankush.weatherify.base.notification
+
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import bose.ankush.weatherify.R
+import bose.ankush.weatherify.presentation.MainActivity
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import timber.log.Timber
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class WeatherifyMessagingService : FirebaseMessagingService() {
+ @Inject
+ lateinit var notificationHelper: NotificationHelper
+
+ private val notificationManager by lazy {
+ getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ }
+
+ override fun onNewToken(token: String) {
+ super.onNewToken(token)
+ Timber.d("Refreshed FCM token: $token")
+ // TODO: Send token to your server if needed
+ }
+
+ override fun onMessageReceived(remoteMessage: RemoteMessage) {
+ Timber.d("Message data payload: ${remoteMessage.data}")
+
+ // Handle both notification and data messages
+ val title =
+ remoteMessage.notification?.title
+ ?: remoteMessage.data["title"]
+ ?: getString(R.string.app_name)
+
+ val message =
+ remoteMessage.notification?.body
+ ?: remoteMessage.data["message"]
+ ?: remoteMessage.data.values.firstOrNull()
+ ?: ""
+
+ // Handle data payload if needed
+ val customData = remoteMessage.data.filterKeys { it != "title" && it != "message" }
+ if (customData.isNotEmpty()) {
+ Timber.d("Custom data payload: $customData")
+ // Process your custom data here
+ }
+
+ // Always show notification if there's a message
+ if (message.isNotBlank()) {
+ sendNotification(title, message)
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private fun sendNotification(
+ title: String,
+ message: String,
+ ) {
+ val intent =
+ Intent(this, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ // You can add extras here if needed
+ // putExtra("key", "value")
+ }
+
+ val pendingIntent =
+ PendingIntent.getActivity(
+ this,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ val notificationBuilder =
+ notificationHelper
+ .getNotificationBuilder(
+ channelId = NotificationHelper.DEFAULT_CHANNEL_ID,
+ title = title,
+ message = message,
+ ).setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+
+ // Generate unique ID for each notification
+ val notificationId = System.currentTimeMillis().toInt()
+ notificationManager.notify(notificationId, notificationBuilder.build())
+ }
+
+ companion object {
+ // Removed static NOTIFICATION_ID to allow multiple notifications
+ }
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/permissions/LocationPermissionTextProvider.kt b/app/src/main/java/bose/ankush/weatherify/base/permissions/LocationPermissionTextProvider.kt
index 97bdd4f1..c262d516 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/permissions/LocationPermissionTextProvider.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/permissions/LocationPermissionTextProvider.kt
@@ -1,23 +1,21 @@
package bose.ankush.weatherify.base.permissions
class FineLocationPermissionTextProvider : PermissionTextProvider {
-
- override fun getDescription(isPermanentlyDeclined: Boolean): String {
- return if (isPermanentlyDeclined) {
- "It seems you have permanently declined fine location permission. You can go to app permission settings to enable it."
+ override fun getDescription(isPermanentlyDeclined: Boolean): String =
+ if (isPermanentlyDeclined) {
+ "It seems you have permanently declined fine location permission. " +
+ "You can go to app permission settings to enable it."
} else {
"Precise Location permissions are required to tracking your run path following precise location."
}
- }
}
class CoarseLocationPermissionTextProvider : PermissionTextProvider {
-
- override fun getDescription(isPermanentlyDeclined: Boolean): String {
- return if (isPermanentlyDeclined) {
- "It seems you have permanently declined coarse location permission. You can go to app permission settings to enable it."
+ override fun getDescription(isPermanentlyDeclined: Boolean): String =
+ if (isPermanentlyDeclined) {
+ "It seems you have permanently declined coarse location permission. " +
+ "You can go to app permission settings to enable it."
} else {
"Approximate location permissions are required to show weather & air quality of your approximate location."
}
- }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionAlertDialog.kt b/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionAlertDialog.kt
deleted file mode 100644
index 4cf79d81..00000000
--- a/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionAlertDialog.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package bose.ankush.weatherify.base.permissions
-
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-
-@Composable
-fun PermissionAlertDialog(
- permissionTextProvider: PermissionTextProvider,
- isPermanentlyDeclined: Boolean,
- onDismissClick: () -> Unit,
- onOkClick: () -> Unit,
- onGoToAppSettingClick: () -> Unit,
-) {
- AlertDialog(
- onDismissRequest = onDismissClick,
- title = { Text(text = "Permissions required") },
- text = { Text(text = permissionTextProvider.getDescription(isPermanentlyDeclined)) },
- confirmButton = {
- TextButton(onClick = if (isPermanentlyDeclined) onGoToAppSettingClick else onOkClick) {
- Text(text = if(isPermanentlyDeclined) "Grant Permission" else "Ok")
- }
- },
- dismissButton = { }
- )
-}
diff --git a/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionTextProvider.kt b/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionTextProvider.kt
index fc7b7ffe..68e44495 100644
--- a/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionTextProvider.kt
+++ b/app/src/main/java/bose/ankush/weatherify/base/permissions/PermissionTextProvider.kt
@@ -2,4 +2,4 @@ package bose.ankush.weatherify.base.permissions
interface PermissionTextProvider {
fun getDescription(isPermanentlyDeclined: Boolean): String
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/data/mapper/AirQualityMapper.kt b/app/src/main/java/bose/ankush/weatherify/data/mapper/AirQualityMapper.kt
index 12f46b8f..f2d3b610 100644
--- a/app/src/main/java/bose/ankush/weatherify/data/mapper/AirQualityMapper.kt
+++ b/app/src/main/java/bose/ankush/weatherify/data/mapper/AirQualityMapper.kt
@@ -1,18 +1,17 @@
package bose.ankush.weatherify.data.mapper
-import bose.ankush.storage.room.AirQualityEntity as StorageAirQualityEntity
import bose.ankush.weatherify.domain.model.AirQuality
+import bose.ankush.storage.room.AirQualityEntity as StorageAirQualityEntity
/**
* Mapper class to convert between AirQualityEntity (data layer) and AirQuality (domain layer)
*/
object AirQualityMapper {
-
/**
* Maps a storage AirQualityEntity to an AirQuality domain model
*/
- fun mapToDomain(entity: StorageAirQualityEntity): AirQuality {
- return AirQuality(
+ fun mapToDomain(entity: StorageAirQualityEntity): AirQuality =
+ AirQuality(
id = entity.id,
aqi = entity.aqi ?: 0,
co = entity.co ?: 0.0,
@@ -20,7 +19,6 @@ object AirQualityMapper {
o3 = entity.o3 ?: 0.0,
so2 = entity.so2 ?: 0.0,
pm10 = entity.pm10 ?: 0.0,
- pm25 = entity.pm25 ?: 0.0
+ pm25 = entity.pm25 ?: 0.0,
)
- }
}
diff --git a/app/src/main/java/bose/ankush/weatherify/data/mapper/NetworkToStorageMapper.kt b/app/src/main/java/bose/ankush/weatherify/data/mapper/NetworkToStorageMapper.kt
new file mode 100644
index 00000000..1f89a9b3
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/data/mapper/NetworkToStorageMapper.kt
@@ -0,0 +1,149 @@
+package bose.ankush.weatherify.data.mapper
+
+import bose.ankush.storage.room.AirQualityEntity
+import bose.ankush.storage.room.Weather
+import bose.ankush.storage.room.WeatherEntity
+import bose.ankush.network.model.AirQuality as NetworkAirQuality
+import bose.ankush.network.model.WeatherForecast as NetworkWeatherForecast
+
+/**
+ * Mapper to convert Network layer models to Storage layer entities.
+ *
+ * This is the network โ storage transformation layer.
+ * Maps API responses to persistent database models.
+ */
+object NetworkToStorageMapper {
+ /**
+ * Maps NetworkWeatherForecast (API model) to WeatherEntity (database model)
+ */
+ fun mapWeatherToStorageEntity(weatherData: NetworkWeatherForecast): WeatherEntity {
+ val data = weatherData.data
+
+ return WeatherEntity(
+ id = 0,
+ lastUpdated = System.currentTimeMillis(),
+ current =
+ data?.current?.let { current ->
+ WeatherEntity.Current(
+ clouds = current.clouds,
+ dt = current.dt,
+ feels_like = current.feelsLike,
+ humidity = current.humidity,
+ pressure = current.pressure,
+ sunrise = current.sunrise,
+ sunset = current.sunset,
+ temp = current.temp,
+ uvi = current.uvi,
+ weather =
+ current.weather?.mapNotNull { info ->
+ info?.let {
+ Weather(
+ description = it.description,
+ icon = it.icon,
+ id = it.id,
+ main = it.main,
+ )
+ }
+ },
+ wind_gust = current.windGust,
+ wind_speed = current.windSpeed,
+ )
+ },
+ daily =
+ data?.daily?.map { daily ->
+ daily?.let {
+ WeatherEntity.Daily(
+ clouds = it.clouds,
+ dew_point = it.dewPoint,
+ dt = it.dt,
+ humidity = it.humidity,
+ pressure = it.pressure,
+ rain = it.rain,
+ summary = it.summary,
+ sunrise = it.sunrise,
+ sunset = it.sunset,
+ temp =
+ it.temp?.let { temp ->
+ WeatherEntity.Daily.Temp(
+ day = temp.day,
+ eve = temp.eve,
+ max = temp.max,
+ min = temp.min,
+ morn = temp.morn,
+ night = temp.night,
+ )
+ },
+ uvi = it.uvi,
+ weather =
+ it.weather?.mapNotNull { info ->
+ info?.let {
+ Weather(
+ description = it.description,
+ icon = it.icon,
+ id = it.id,
+ main = it.main,
+ )
+ }
+ },
+ wind_gust = it.windGust,
+ wind_speed = it.windSpeed,
+ )
+ }
+ },
+ hourly =
+ data?.hourly?.map { hourly ->
+ hourly?.let { it ->
+ WeatherEntity.Hourly(
+ clouds = it.clouds,
+ dt = it.dt,
+ feels_like = it.feelsLike,
+ humidity = it.humidity,
+ temp = it.temp,
+ weather =
+ it.weather?.mapNotNull { info ->
+ info?.let {
+ Weather(
+ description = it.description,
+ icon = it.icon,
+ id = it.id,
+ main = it.main,
+ )
+ }
+ },
+ )
+ }
+ },
+ alerts =
+ data?.alerts?.mapNotNull { alert ->
+ alert?.let {
+ WeatherEntity.Alert(
+ description = it.description,
+ end = it.end,
+ event = it.event,
+ sender_name = it.senderName,
+ start = it.start,
+ )
+ }
+ } ?: emptyList(),
+ )
+ }
+
+ /**
+ * Maps the air quality data embedded in the unified weather response to AirQualityEntity.
+ * When [airQualityData] is null (free tier โ air quality not included), stores a default
+ * entity so existing storage contracts are preserved.
+ */
+ fun mapAirQualityToStorageEntity(airQualityData: NetworkAirQuality.Data?): AirQualityEntity {
+ val entry = airQualityData?.list?.firstOrNull()
+ return AirQualityEntity(
+ id = null,
+ aqi = entry?.main?.aqi,
+ co = entry?.components?.co,
+ no2 = entry?.components?.no2,
+ o3 = entry?.components?.o3,
+ so2 = entry?.components?.so2,
+ pm10 = entry?.components?.pm10,
+ pm25 = entry?.components?.pm25,
+ )
+ }
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/data/mapper/WeatherMapper.kt b/app/src/main/java/bose/ankush/weatherify/data/mapper/WeatherMapper.kt
index 94fa5d07..adf75eab 100644
--- a/app/src/main/java/bose/ankush/weatherify/data/mapper/WeatherMapper.kt
+++ b/app/src/main/java/bose/ankush/weatherify/data/mapper/WeatherMapper.kt
@@ -1,116 +1,122 @@
package bose.ankush.weatherify.data.mapper
-import bose.ankush.storage.room.Weather as StorageWeather
-import bose.ankush.storage.room.WeatherEntity as StorageWeatherEntity
+import bose.ankush.storage.room.WeatherEntity
import bose.ankush.weatherify.domain.model.WeatherCondition
import bose.ankush.weatherify.domain.model.WeatherForecast
+import bose.ankush.storage.room.Weather as StorageWeather
/**
* Mapper class to convert between WeatherEntity (data layer) and WeatherForecast (domain layer)
*/
object WeatherMapper {
-
/**
* Maps a Storage Weather entity to a WeatherCondition domain model
*/
- private fun mapStorageWeatherToDomain(weather: StorageWeather): WeatherCondition {
- return WeatherCondition(
- description = weather.description,
- icon = weather.icon,
+ private fun mapStorageWeatherToDomain(weather: StorageWeather): WeatherCondition =
+ WeatherCondition(
+ description = weather.description ?: "",
+ icon = weather.icon ?: "",
id = weather.id,
- main = weather.main
+ main = weather.main ?: "",
)
- }
/**
* Maps a Storage WeatherEntity to a WeatherForecast domain model
*/
- fun mapToDomain(entity: StorageWeatherEntity?): WeatherForecast? {
+ fun mapToDomain(entity: WeatherEntity?): WeatherForecast? {
if (entity == null) return null
return WeatherForecast(
id = entity.id,
- alerts = entity.alerts?.map { alert ->
- alert?.let {
- WeatherForecast.Alert(
- description = it.description,
- end = it.end,
- event = it.event,
- sender_name = it.sender_name,
- start = it.start
- )
- }
- },
- current = entity.current?.let { current ->
- WeatherForecast.Current(
- clouds = current.clouds,
- dt = current.dt,
- feels_like = current.feels_like,
- humidity = current.humidity,
- pressure = current.pressure,
- sunrise = current.sunrise,
- sunset = current.sunset,
- temp = current.temp,
- uvi = current.uvi,
- weather = current.weather?.map { weather ->
- weather?.let {
- mapStorageWeatherToDomain(it)
- }
- },
- wind_gust = current.wind_gust,
- wind_speed = current.wind_speed
- )
- },
- daily = entity.daily?.map { daily ->
- daily?.let {
- WeatherForecast.Daily(
- clouds = it.clouds,
- dew_point = it.dew_point,
- dt = it.dt,
- humidity = it.humidity,
- pressure = it.pressure,
- rain = it.rain,
- summary = it.summary,
- sunrise = it.sunrise,
- sunset = it.sunset,
- temp = it.temp?.let { temp ->
- WeatherForecast.Daily.Temp(
- day = temp.day,
- eve = temp.eve,
- max = temp.max,
- min = temp.min,
- morn = temp.morn,
- night = temp.night
- )
- },
- uvi = it.uvi,
- weather = it.weather?.map { weather ->
- weather?.let {
- mapStorageWeatherToDomain(it)
- }
- },
- wind_gust = it.wind_gust,
- wind_speed = it.wind_speed
- )
- }
- },
- hourly = entity.hourly?.map { hourly ->
- hourly?.let {
- WeatherForecast.Hourly(
- clouds = it.clouds,
- dt = it.dt,
- feels_like = it.feels_like,
- humidity = it.humidity,
- temp = it.temp,
- weather = it.weather?.map { weather ->
- weather?.let {
- mapStorageWeatherToDomain(it)
- }
- }
+ alerts =
+ entity.alerts?.map { alert ->
+ alert?.let {
+ WeatherForecast.Alert(
+ description = it.description,
+ end = it.end,
+ event = it.event,
+ sender_name = it.sender_name,
+ start = it.start,
+ )
+ }
+ },
+ current =
+ entity.current?.let { current ->
+ WeatherForecast.Current(
+ clouds = current.clouds,
+ dt = current.dt,
+ feels_like = current.feels_like,
+ humidity = current.humidity,
+ pressure = current.pressure,
+ sunrise = current.sunrise,
+ sunset = current.sunset,
+ temp = current.temp,
+ uvi = current.uvi,
+ weather =
+ current.weather?.map { weather ->
+ weather?.let {
+ mapStorageWeatherToDomain(it)
+ }
+ },
+ wind_gust = current.wind_gust,
+ wind_speed = current.wind_speed,
)
- }
- },
- lastUpdated = entity.lastUpdated
+ },
+ daily =
+ entity.daily?.map { daily ->
+ daily?.let {
+ WeatherForecast.Daily(
+ clouds = it.clouds,
+ dew_point = it.dew_point,
+ dt = it.dt,
+ humidity = it.humidity,
+ pressure = it.pressure,
+ rain = it.rain,
+ summary = it.summary,
+ sunrise = it.sunrise,
+ sunset = it.sunset,
+ temp =
+ it.temp?.let { temp ->
+ WeatherForecast.Daily.Temp(
+ day = temp.day,
+ eve = temp.eve,
+ max = temp.max,
+ min = temp.min,
+ morn = temp.morn,
+ night = temp.night,
+ )
+ },
+ uvi = it.uvi,
+ weather =
+ it.weather?.map { weather ->
+ weather?.let {
+ mapStorageWeatherToDomain(it)
+ }
+ },
+ wind_gust = it.wind_gust,
+ wind_speed = it.wind_speed,
+ )
+ }
+ },
+ hourly =
+ entity.hourly?.map { hourly ->
+ hourly?.let {
+ WeatherForecast.Hourly(
+ clouds = it.clouds,
+ dt = it.dt,
+ feels_like = it.feels_like,
+ humidity = it.humidity,
+ temp = it.temp,
+ weather =
+ it.weather?.map { weather ->
+ weather?.let {
+ mapStorageWeatherToDomain(it)
+ }
+ },
+ )
+ }
+ },
+ lastUpdated = entity.lastUpdated,
)
}
}
diff --git a/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManager.kt b/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManager.kt
deleted file mode 100644
index 9e49a549..00000000
--- a/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManager.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package bose.ankush.weatherify.data.preference
-
-import android.content.Context
-import androidx.datastore.core.DataStore
-import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.core.edit
-import androidx.datastore.preferences.preferencesDataStore
-import bose.ankush.weatherify.base.common.APP_PREFERENCE_KEY
-import bose.ankush.weatherify.domain.preference.PreferenceManager
-import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.flow.Flow
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Implementation of PreferenceManager that uses DataStore
- */
-@Singleton
-class PreferenceManagerImpl @Inject constructor(@ApplicationContext private val context: Context) : PreferenceManager {
-
- private val Context.dataStore: DataStore by preferencesDataStore(name = APP_PREFERENCE_KEY)
-
- override fun getLocationPreferenceFlow(): Flow = context.dataStore.data
-
- override suspend fun saveLocationPreferences(coordinates: Pair) {
- context.dataStore.edit { preferences ->
- preferences[PreferenceManager.USER_LAT_LOCATION] = coordinates.first
- preferences[PreferenceManager.USER_LON_LOCATION] = coordinates.second
- }
- }
-}
diff --git a/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManagerImpl.kt b/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManagerImpl.kt
new file mode 100644
index 00000000..77d6a557
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManagerImpl.kt
@@ -0,0 +1,85 @@
+package bose.ankush.weatherify.data.preference
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.preferencesDataStore
+import bose.ankush.weatherify.base.common.APP_PREFERENCE_KEY
+import bose.ankush.weatherify.domain.preference.PreferenceManager
+import bose.ankush.weatherify.domain.preference.UserPreferences
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Implementation of PreferenceManager that uses DataStore
+ */
+@Singleton
+class PreferenceManagerImpl
+@Inject
+constructor(
+ @get:ApplicationContext private val context: Context,
+) : PreferenceManager {
+ private val Context.dataStore: DataStore by preferencesDataStore(name = APP_PREFERENCE_KEY)
+
+ override fun getUserPreferencesFlow(): Flow =
+ context.dataStore.data.map { preferences ->
+ UserPreferences(
+ latitude = preferences[PreferenceManager.USER_LAT_LOCATION],
+ longitude = preferences[PreferenceManager.USER_LON_LOCATION],
+ isPremium = preferences[PreferenceManager.IS_PREMIUM] ?: false,
+ premiumExpiry = preferences[PreferenceManager.PREMIUM_EXPIRY],
+ isLocationOverridden = preferences[PreferenceManager.IS_LOCATION_OVERRIDDEN]
+ ?: false,
+ overrideLat = preferences[PreferenceManager.OVERRIDE_LAT],
+ overrideLon = preferences[PreferenceManager.OVERRIDE_LON],
+ overrideLocationName = preferences[PreferenceManager.OVERRIDE_LOCATION_NAME],
+ )
+ }
+
+ override suspend fun saveLocationPreferences(coordinates: Pair) {
+ context.dataStore.edit { preferences ->
+ preferences[PreferenceManager.USER_LAT_LOCATION] = coordinates.first
+ preferences[PreferenceManager.USER_LON_LOCATION] = coordinates.second
+ }
+ }
+
+ override suspend fun savePremiumStatus(
+ isPremium: Boolean,
+ expiryMillis: Long?,
+ ) {
+ context.dataStore.edit { preferences ->
+ preferences[PreferenceManager.IS_PREMIUM] = isPremium
+ if (expiryMillis != null) {
+ preferences[PreferenceManager.PREMIUM_EXPIRY] = expiryMillis
+ } else {
+ preferences.remove(PreferenceManager.PREMIUM_EXPIRY)
+ }
+ }
+ }
+
+ override suspend fun saveLocationOverride(lat: Double, lon: Double, name: String) {
+ context.dataStore.edit { preferences ->
+ preferences[PreferenceManager.OVERRIDE_LAT] = lat
+ preferences[PreferenceManager.OVERRIDE_LON] = lon
+ preferences[PreferenceManager.OVERRIDE_LOCATION_NAME] = name
+ preferences[PreferenceManager.IS_LOCATION_OVERRIDDEN] = true
+ }
+ }
+
+ override suspend fun clearLocationOverride() {
+ context.dataStore.edit { preferences ->
+ preferences.remove(PreferenceManager.OVERRIDE_LAT)
+ preferences.remove(PreferenceManager.OVERRIDE_LON)
+ preferences.remove(PreferenceManager.OVERRIDE_LOCATION_NAME)
+ preferences[PreferenceManager.IS_LOCATION_OVERRIDDEN] = false
+ }
+ }
+
+ override suspend fun clearAll() {
+ context.dataStore.edit { it.clear() }
+ }
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/data/remote/dto/CityDto.kt b/app/src/main/java/bose/ankush/weatherify/data/remote/dto/CityDto.kt
index e682b9c5..2ed2d9cb 100644
--- a/app/src/main/java/bose/ankush/weatherify/data/remote/dto/CityDto.kt
+++ b/app/src/main/java/bose/ankush/weatherify/data/remote/dto/CityDto.kt
@@ -5,11 +5,10 @@ import bose.ankush.weatherify.domain.model.CityName
data class CityDto(
val id: String? = "",
val name: String = "",
- val state: String? = ""
+ val state: String? = "",
)
-fun CityDto.toCityName(): CityName {
- return CityName(
- name = name
+fun CityDto.toCityName(): CityName =
+ CityName(
+ name = name,
)
-}
diff --git a/app/src/main/java/bose/ankush/weatherify/data/remote_config/FirebaseRemoteConfigService.kt b/app/src/main/java/bose/ankush/weatherify/data/remote_config/FirebaseRemoteConfigService.kt
index 83f91b05..0b2b8b5a 100644
--- a/app/src/main/java/bose/ankush/weatherify/data/remote_config/FirebaseRemoteConfigService.kt
+++ b/app/src/main/java/bose/ankush/weatherify/data/remote_config/FirebaseRemoteConfigService.kt
@@ -2,10 +2,10 @@ package bose.ankush.weatherify.data.remote_config
import bose.ankush.weatherify.R
import bose.ankush.weatherify.domain.remote_config.RemoteConfigService
-import com.google.firebase.ktx.Firebase
+import com.google.firebase.Firebase
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
-import com.google.firebase.remoteconfig.ktx.remoteConfig
-import com.google.firebase.remoteconfig.ktx.remoteConfigSettings
+import com.google.firebase.remoteconfig.remoteConfig
+import com.google.firebase.remoteconfig.remoteConfigSettings
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -15,8 +15,9 @@ import javax.inject.Singleton
* This class handles all interactions with Firebase Remote Config.
*/
@Singleton
-class FirebaseRemoteConfigService @Inject constructor() : RemoteConfigService {
-
+class FirebaseRemoteConfigService
+@Inject
+constructor() : RemoteConfigService {
private val remoteConfig: FirebaseRemoteConfig = Firebase.remoteConfig
private val tag = "${FirebaseRemoteConfigService::class.simpleName} ->"
@@ -25,9 +26,10 @@ class FirebaseRemoteConfigService @Inject constructor() : RemoteConfigService {
* Sets default values from the XML resource file.
*/
override fun initialize() {
- val configSettings = remoteConfigSettings {
- minimumFetchIntervalInSeconds = DEFAULT_MINIMUM_FETCH_INTERVAL_SECONDS
- }
+ val configSettings =
+ remoteConfigSettings {
+ minimumFetchIntervalInSeconds = DEFAULT_MINIMUM_FETCH_INTERVAL_SECONDS
+ }
remoteConfig.apply {
setConfigSettingsAsync(configSettings)
@@ -43,14 +45,16 @@ class FirebaseRemoteConfigService @Inject constructor() : RemoteConfigService {
* @param defaultValue The default value to return if the key is not found
* @return The boolean value from Firebase Remote Config, or the default value if not found
*/
- override fun getBoolean(key: String, defaultValue: Boolean): Boolean {
- return try {
+ override fun getBoolean(
+ key: String,
+ defaultValue: Boolean,
+ ): Boolean =
+ try {
remoteConfig.getBoolean(key)
} catch (e: Exception) {
Timber.tag(tag).e(e, "Error getting boolean value for key: $key")
defaultValue
}
- }
companion object {
private const val DEFAULT_MINIMUM_FETCH_INTERVAL_SECONDS = 3600L // 1 hour
diff --git a/app/src/main/java/bose/ankush/weatherify/data/repository/CityRepositoryImpl.kt b/app/src/main/java/bose/ankush/weatherify/data/repository/CityRepositoryImpl.kt
index 3568df42..823e318c 100644
--- a/app/src/main/java/bose/ankush/weatherify/data/repository/CityRepositoryImpl.kt
+++ b/app/src/main/java/bose/ankush/weatherify/data/repository/CityRepositoryImpl.kt
@@ -7,19 +7,21 @@ import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import javax.inject.Inject
-class CityRepositoryImpl @Inject constructor(
- private val context: Context
+class CityRepositoryImpl
+@Inject
+constructor(
+ private val context: Context,
) : CityRepository {
-
override fun getCityNames(): List {
- val jsonString: String = context.assets.open("city_names.json")
- .bufferedReader()
- .use { it.readText() }
+ val jsonString: String =
+ context.assets
+ .open("city_names.json")
+ .bufferedReader()
+ .use { it.readText() }
val cityNameListType = object : TypeToken>() {}.type
- return Gson().fromJson?>(jsonString, cityNameListType).sortedBy { it.name }
+ return (Gson().fromJson?>(jsonString, cityNameListType)
+ ?: emptyList()).sortedBy { it.name }
}
-
}
-
diff --git a/app/src/main/java/bose/ankush/weatherify/data/repository/WeatherRepositoryImpl.kt b/app/src/main/java/bose/ankush/weatherify/data/repository/WeatherRepositoryImpl.kt
index e77e6150..bf208943 100644
--- a/app/src/main/java/bose/ankush/weatherify/data/repository/WeatherRepositoryImpl.kt
+++ b/app/src/main/java/bose/ankush/weatherify/data/repository/WeatherRepositoryImpl.kt
@@ -2,63 +2,91 @@ package bose.ankush.weatherify.data.repository
import bose.ankush.storage.api.WeatherStorage
import bose.ankush.storage.room.AirQualityEntity
-import bose.ankush.storage.room.WeatherEntity as StorageWeatherEntity
+import bose.ankush.storage.room.WeatherEntity
import bose.ankush.weatherify.base.dispatcher.DispatcherProvider
import bose.ankush.weatherify.data.mapper.AirQualityMapper
+import bose.ankush.weatherify.data.mapper.NetworkToStorageMapper
import bose.ankush.weatherify.data.mapper.WeatherMapper
import bose.ankush.weatherify.domain.model.AirQuality
import bose.ankush.weatherify.domain.model.WeatherForecast
import bose.ankush.weatherify.domain.repository.WeatherRepository
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import javax.inject.Inject
+import bose.ankush.network.repository.WeatherRepository as NetworkWeatherRepository
/**
- * Implementation of WeatherRepository that uses the KMM storage module
- * for data access and refresh operations
+ * Domain-layer repository that orchestrates between network and storage modules.
+ *
+ * Responsibilities:
+ * - Fetch unified weather data from network (single /weather call)
+ * - Extract both weather and air quality from the unified response
+ * - Map network models to storage entities
+ * - Save to local storage (WeatherStorage)
+ * - Provide domain models to UI layer (via mappers)
*/
-class WeatherRepositoryImpl @Inject constructor(
+class WeatherRepositoryImpl
+@Inject
+constructor(
+ private val networkRepository: NetworkWeatherRepository,
private val weatherStorage: WeatherStorage,
- private val dispatcher: DispatcherProvider
+ private val dispatcher: DispatcherProvider,
) : WeatherRepository {
-
override fun getAirQualityReport(coordinates: Pair): Flow =
weatherStorage.getAirQualityReport(coordinates).map { entity ->
- AirQualityMapper.mapToDomain(entity as AirQualityEntity)
+ (entity as? AirQualityEntity)?.let { AirQualityMapper.mapToDomain(it) } ?: AirQuality()
}
override fun getWeatherReport(location: Pair): Flow =
weatherStorage.getWeatherReport(location).map { entity ->
- WeatherMapper.mapToDomain(entity as StorageWeatherEntity)
+ (entity as? WeatherEntity)?.let { WeatherMapper.mapToDomain(it) }
}
/**
- * Method used by view-model when UI sends refresh weather event.
- * Delegates to the storage module for refreshing data.
- *
- * The app module controls when to refresh based on business rules
- * (e.g., data staleness, user pull-to-refresh)
+ * Orchestrates data refresh: fetch unified response from network โ extract weather + air
+ * quality โ map โ save to storage.
+ *
+ * Air quality is now embedded in the /weather response and may be null for free-tier users.
+ * In that case an empty AirQualityEntity is stored to satisfy the storage contract.
*/
- override suspend fun refreshWeatherData(coordinates: Pair) {
+ override suspend fun refreshWeatherData(
+ coordinates: Pair,
+ forceRefresh: Boolean,
+ ) {
withContext(dispatcher.io) {
- try {
- // Check if data is stale (older than 1 hour)
- val lastUpdateTime = weatherStorage.getLastWeatherUpdateTime()
- val currentTime = System.currentTimeMillis()
- val isDataStale = (currentTime - lastUpdateTime) > ONE_HOUR_IN_MILLIS
+ val lastUpdateTime = weatherStorage.getLastWeatherUpdateTime(coordinates)
+ val currentTime = System.currentTimeMillis()
+ val isDataStale = forceRefresh || (currentTime - lastUpdateTime) > ONE_HOUR_IN_MILLIS
+
+ if (isDataStale) {
+ // Single unified API call โ includes air quality for premium users
+ networkRepository.refreshWeatherData(coordinates)
- // Refresh data if it's stale or if this is a forced refresh
- if (isDataStale) {
- weatherStorage.refreshWeatherData(coordinates)
+ val weatherData = networkRepository.getWeatherReport(coordinates).firstOrNull()
+
+ if (weatherData != null) {
+ val weatherEntity =
+ NetworkToStorageMapper.mapWeatherToStorageEntity(weatherData)
+ // Air quality is inside data.airQuality; null for free tier โ stores defaults
+ val airQualityEntity =
+ NetworkToStorageMapper.mapAirQualityToStorageEntity(
+ weatherData.data?.airQuality,
+ )
+ weatherStorage.saveWeatherData(weatherEntity, airQualityEntity)
+ weatherStorage.saveLastWeatherUpdateTime(coordinates, currentTime)
}
- } catch (e: Exception) {
- // If there's an error, throw a more descriptive exception
- throw Exception("Failed to refresh weather data: ${e.message}", e)
}
}
}
+ override suspend fun clearAllData() {
+ withContext(dispatcher.io) {
+ weatherStorage.clearAllData()
+ }
+ }
+
companion object {
private const val ONE_HOUR_IN_MILLIS = 60 * 60 * 1000L
}
diff --git a/app/src/main/java/bose/ankush/weatherify/di/AppModule.kt b/app/src/main/java/bose/ankush/weatherify/di/AppModule.kt
index 710b4db5..422a1174 100644
--- a/app/src/main/java/bose/ankush/weatherify/di/AppModule.kt
+++ b/app/src/main/java/bose/ankush/weatherify/di/AppModule.kt
@@ -2,6 +2,12 @@ package bose.ankush.weatherify.di
import android.app.Application
import android.content.Context
+import bose.ankush.weatherify.base.common.AndroidDeviceInfoProvider
+import bose.ankush.weatherify.base.common.DeviceInfoProvider
+import bose.ankush.weatherify.base.common.LoggerFactory
+import bose.ankush.weatherify.base.common.TimberLoggerFactory
+import bose.ankush.weatherify.base.config.AndroidAppConfig
+import bose.ankush.weatherify.base.config.AppConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -11,9 +17,19 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
+ @Provides
+ @Singleton
+ fun provideContext(application: Application): Context = application.applicationContext
+
+ @Provides
+ @Singleton
+ fun provideLoggerFactory(): LoggerFactory = TimberLoggerFactory()
+
+ @Provides
+ @Singleton
+ fun provideDeviceInfoProvider(): DeviceInfoProvider = AndroidDeviceInfoProvider()
@Provides
@Singleton
- fun provideContext(application: Application): Context =
- application.applicationContext
+ fun provideAppConfig(): AppConfig = AndroidAppConfig()
}
diff --git a/app/src/main/java/bose/ankush/weatherify/di/DispatcherModule.kt b/app/src/main/java/bose/ankush/weatherify/di/DispatcherModule.kt
index 3143521e..d08b86a0 100644
--- a/app/src/main/java/bose/ankush/weatherify/di/DispatcherModule.kt
+++ b/app/src/main/java/bose/ankush/weatherify/di/DispatcherModule.kt
@@ -11,8 +11,7 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {
-
@Singleton
@Provides
fun provideDispatcherProvider(): DispatcherProvider = AppDispatcher()
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/di/LocationModule.kt b/app/src/main/java/bose/ankush/weatherify/di/LocationModule.kt
index bc77f0d0..9a1f6901 100644
--- a/app/src/main/java/bose/ankush/weatherify/di/LocationModule.kt
+++ b/app/src/main/java/bose/ankush/weatherify/di/LocationModule.kt
@@ -14,7 +14,6 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object LocationModule {
-
@Singleton
@Provides
fun provideFusedLocationProviderClient(context: Context): FusedLocationProviderClient =
@@ -24,7 +23,6 @@ object LocationModule {
@Provides
fun provideLocationClient(
context: Context,
- fusedLocationProviderClient: FusedLocationProviderClient
- ) : LocationClient =
- DeviceLocationClient(context, fusedLocationProviderClient)
+ fusedLocationProviderClient: FusedLocationProviderClient,
+ ): LocationClient = DeviceLocationClient(context, fusedLocationProviderClient)
}
diff --git a/app/src/main/java/bose/ankush/weatherify/di/NetworkModule.kt b/app/src/main/java/bose/ankush/weatherify/di/NetworkModule.kt
index d02f75cc..668f4102 100644
--- a/app/src/main/java/bose/ankush/weatherify/di/NetworkModule.kt
+++ b/app/src/main/java/bose/ankush/weatherify/di/NetworkModule.kt
@@ -1,10 +1,23 @@
package bose.ankush.weatherify.di
import android.content.Context
+import bose.ankush.network.auth.repository.AuthRepository
+import bose.ankush.network.auth.token.TokenManager
import bose.ankush.network.common.AndroidNetworkConnectivity
import bose.ankush.network.common.NetworkConnectivity
+import bose.ankush.network.di.createAuthRepository
+import bose.ankush.network.di.createFeedbackRepository
+import bose.ankush.network.di.createLocationRepository
+import bose.ankush.network.di.createServiceRepository
+import bose.ankush.network.di.createTokenManager
import bose.ankush.network.di.createWeatherRepository
+import bose.ankush.network.domain.SavedLocationsUseCase
+import bose.ankush.network.domain.SearchPlacesUseCase
+import bose.ankush.network.repository.FeedbackRepository
+import bose.ankush.network.repository.LocationRepository
+import bose.ankush.network.repository.ServiceRepository
import bose.ankush.network.repository.WeatherRepository
+import bose.ankush.storage.api.TokenStorage
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -18,26 +31,84 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
-
/**
* Provides NetworkConnectivity implementation
*/
@Provides
@Singleton
fun provideNetworkConnectivity(
- @ApplicationContext context: Context
- ): NetworkConnectivity {
- return AndroidNetworkConnectivity(context)
- }
+ @ApplicationContext context: Context,
+ ): NetworkConnectivity = AndroidNetworkConnectivity(context)
/**
* Provides WeatherRepository implementation from the network module
+ * Uses TokenStorage for JWT authentication in API requests
*/
@Provides
@Singleton
fun provideWeatherRepository(
- networkConnectivity: NetworkConnectivity
- ): WeatherRepository {
- return createWeatherRepository(networkConnectivity)
- }
-}
\ No newline at end of file
+ networkConnectivity: NetworkConnectivity,
+ tokenStorage: TokenStorage,
+ ): WeatherRepository = createWeatherRepository(networkConnectivity, tokenStorage)
+
+ /**
+ * Provides AuthRepository implementation from the network module
+ */
+ @Provides
+ @Singleton
+ fun provideAuthRepository(tokenStorage: TokenStorage): AuthRepository =
+ createAuthRepository(tokenStorage)
+
+ /**
+ * Provides TokenManager singleton for use in ViewModels.
+ * Shares the same AuthRepository singleton used elsewhere in the app.
+ */
+ @Provides
+ @Singleton
+ fun provideTokenManager(
+ tokenStorage: TokenStorage,
+ authRepository: AuthRepository,
+ ): TokenManager = createTokenManager(tokenStorage, authRepository)
+
+ /**
+ * Provides FeedbackRepository implementation from the network module
+ */
+ @Provides
+ @Singleton
+ fun provideFeedbackRepository(
+ networkConnectivity: NetworkConnectivity,
+ tokenStorage: TokenStorage,
+ ): FeedbackRepository = createFeedbackRepository(networkConnectivity, tokenStorage)
+
+ /**
+ * Provides LocationRepository for saved favourite locations (premium feature).
+ */
+ @Provides
+ @Singleton
+ fun provideLocationRepository(tokenStorage: TokenStorage): LocationRepository =
+ createLocationRepository(tokenStorage)
+
+ /**
+ * Provides ServiceRepository for premium service subscriptions.
+ * Uses basic HTTP client; /services/public endpoint requires no authentication.
+ */
+ @Provides
+ @Singleton
+ fun provideServiceRepository(): ServiceRepository = createServiceRepository()
+
+ /**
+ * Provides SearchPlacesUseCase for searching places.
+ */
+ @Provides
+ @Singleton
+ fun provideSearchPlacesUseCase(repository: LocationRepository): SearchPlacesUseCase =
+ SearchPlacesUseCase(repository)
+
+ /**
+ * Provides SavedLocationsUseCase for managing saved locations.
+ */
+ @Provides
+ @Singleton
+ fun provideSavedLocationsUseCase(repository: LocationRepository): SavedLocationsUseCase =
+ SavedLocationsUseCase(repository)
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/di/PaymentKoinBridgeEntryPoint.kt b/app/src/main/java/bose/ankush/weatherify/di/PaymentKoinBridgeEntryPoint.kt
new file mode 100644
index 00000000..83e10e73
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/di/PaymentKoinBridgeEntryPoint.kt
@@ -0,0 +1,22 @@
+package bose.ankush.weatherify.di
+
+import bose.ankush.storage.api.TokenStorage
+import bose.ankush.weatherify.base.config.AppConfig
+import bose.ankush.weatherify.domain.preference.PreferenceManager
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+/**
+ * Hilt EntryPoint that exposes singletons needed to configure the Koin payment module.
+ * Used in WeatherifyApplication after Hilt has initialized (i.e. after super.onCreate()).
+ */
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface PaymentKoinBridgeEntryPoint {
+ fun tokenStorage(): TokenStorage
+
+ fun preferenceManager(): PreferenceManager
+
+ fun appConfig(): AppConfig
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/di/PaymentKoinModule.kt b/app/src/main/java/bose/ankush/weatherify/di/PaymentKoinModule.kt
new file mode 100644
index 00000000..17535937
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/di/PaymentKoinModule.kt
@@ -0,0 +1,47 @@
+package bose.ankush.weatherify.di
+
+import android.content.Context
+import bose.ankush.network.api.PaymentApiService
+import bose.ankush.network.common.AndroidNetworkConnectivity
+import bose.ankush.network.common.NetworkConnectivity
+import bose.ankush.network.di.createPaymentApiService
+import bose.ankush.payment.domain.config.PaymentConfig
+import bose.ankush.payment.domain.store.PremiumStore
+import bose.ankush.weatherify.payment.config.AppConfigPaymentConfig
+import bose.ankush.weatherify.payment.store.PreferenceManagerPremiumStore
+import dagger.hilt.android.EntryPointAccessors
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.module.Module
+import org.koin.dsl.module
+
+/**
+ * Builds the app-level Koin module that provides platform-specific bindings required by
+ * [bose.ankush.payment.di.featurePaymentModules].
+ *
+ * Called from [bose.ankush.weatherify.WeatherifyApplication] **after** Hilt is initialized
+ * (i.e. after super.onCreate()), so [EntryPointAccessors] is safe to use here.
+ */
+fun appPaymentKoinModule(context: Context): Module {
+ val bridge =
+ EntryPointAccessors.fromApplication(
+ context,
+ PaymentKoinBridgeEntryPoint::class.java,
+ )
+ val tokenStorage = bridge.tokenStorage()
+ val preferenceManager = bridge.preferenceManager()
+ val appConfig = bridge.appConfig()
+
+ return module {
+ // NetworkConnectivity: stateless โ safe to create a fresh instance for Koin
+ single { AndroidNetworkConnectivity(androidContext()) }
+
+ // PaymentApiService: uses authenticated HTTP client from the network module
+ single { createPaymentApiService(tokenStorage) }
+
+ // PremiumStore: wraps the Hilt-managed PreferenceManager singleton
+ single { PreferenceManagerPremiumStore(preferenceManager) }
+
+ // PaymentConfig: wraps the Hilt-managed AppConfig singleton
+ single { AppConfigPaymentConfig(appConfig) }
+ }
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/di/PreferenceModule.kt b/app/src/main/java/bose/ankush/weatherify/di/PreferenceModule.kt
index 743714ce..6a9b77bc 100644
--- a/app/src/main/java/bose/ankush/weatherify/di/PreferenceModule.kt
+++ b/app/src/main/java/bose/ankush/weatherify/di/PreferenceModule.kt
@@ -14,13 +14,8 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class PreferenceModule {
-
- /**
- * Binds PreferenceManagerImpl to PreferenceManager interface
- */
+ @Suppress("unused")
@Binds
@Singleton
- abstract fun bindPreferenceManager(
- preferenceManagerImpl: PreferenceManagerImpl
- ): PreferenceManager
-}
\ No newline at end of file
+ abstract fun bindPreferenceManager(preferenceManagerImpl: PreferenceManagerImpl): PreferenceManager
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/di/RemoteConfigModule.kt b/app/src/main/java/bose/ankush/weatherify/di/RemoteConfigModule.kt
index 66fc3ce7..7bc13979 100644
--- a/app/src/main/java/bose/ankush/weatherify/di/RemoteConfigModule.kt
+++ b/app/src/main/java/bose/ankush/weatherify/di/RemoteConfigModule.kt
@@ -14,13 +14,8 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RemoteConfigModule {
-
- /**
- * Binds FirebaseRemoteConfigService to RemoteConfigService interface
- */
+ @Suppress("unused")
@Binds
@Singleton
- abstract fun bindRemoteConfigService(
- firebaseRemoteConfigService: FirebaseRemoteConfigService
- ): RemoteConfigService
-}
\ No newline at end of file
+ abstract fun bindRemoteConfigService(firebaseRemoteConfigService: FirebaseRemoteConfigService): RemoteConfigService
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/di/RepoModule.kt b/app/src/main/java/bose/ankush/weatherify/di/RepoModule.kt
index 81770fb7..768c210a 100644
--- a/app/src/main/java/bose/ankush/weatherify/di/RepoModule.kt
+++ b/app/src/main/java/bose/ankush/weatherify/di/RepoModule.kt
@@ -12,27 +12,30 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
-
+import bose.ankush.network.repository.WeatherRepository as NetworkWeatherRepository
@Module
@InstallIn(SingletonComponent::class)
object RepoModule {
-
+ /**
+ * Provides the domain-layer WeatherRepository that orchestrates network and storage.
+ *
+ * This is the single source of truth for weather data operations in the app.
+ */
@Singleton
@Provides
fun provideWeatherRepository(
+ networkRepository: NetworkWeatherRepository,
weatherStorage: WeatherStorage,
- dispatcherProvider: DispatcherProvider
+ dispatcherProvider: DispatcherProvider,
): WeatherRepository =
WeatherRepositoryImpl(
+ networkRepository,
weatherStorage,
- dispatcherProvider
+ dispatcherProvider,
)
@Singleton
@Provides
- fun provideCityRepository(
- context: Context
- ): CityRepository =
- CityRepositoryImpl(context)
+ fun provideCityRepository(context: Context): CityRepository = CityRepositoryImpl(context)
}
diff --git a/app/src/main/java/bose/ankush/weatherify/domain/model/CityName.kt b/app/src/main/java/bose/ankush/weatherify/domain/model/CityName.kt
index d768f491..3f2d824e 100644
--- a/app/src/main/java/bose/ankush/weatherify/domain/model/CityName.kt
+++ b/app/src/main/java/bose/ankush/weatherify/domain/model/CityName.kt
@@ -1,14 +1,15 @@
package bose.ankush.weatherify.domain.model
data class CityName(
- val name: String?
+ val name: String?,
) {
fun doesMatchSearchQuery(query: String): Boolean {
- val matchingCombinations = listOf(
- "$name",
- "${name?.first()}",
- "${name?.last()}"
- )
+ val matchingCombinations =
+ listOf(
+ "$name",
+ "${name?.first()}",
+ "${name?.last()}",
+ )
return matchingCombinations.any {
it.contains(query, ignoreCase = true)
}
diff --git a/app/src/main/java/bose/ankush/weatherify/domain/model/Country.kt b/app/src/main/java/bose/ankush/weatherify/domain/model/Country.kt
index 80b2f4e9..02e05c1b 100644
--- a/app/src/main/java/bose/ankush/weatherify/domain/model/Country.kt
+++ b/app/src/main/java/bose/ankush/weatherify/domain/model/Country.kt
@@ -16,5 +16,5 @@ data class Country(
val codeA2: String = "in",
val defaultLanguage: String? = "en",
val languages: List = listOf("en"),
- val localCurrency: List = listOf("INR")
-): Parcelable
\ No newline at end of file
+ val localCurrency: List = listOf("INR"),
+) : Parcelable
diff --git a/app/src/main/java/bose/ankush/weatherify/domain/model/Weather.kt b/app/src/main/java/bose/ankush/weatherify/domain/model/Weather.kt
deleted file mode 100644
index 287b4385..00000000
--- a/app/src/main/java/bose/ankush/weatherify/domain/model/Weather.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package bose.ankush.weatherify.domain.model
-
-data class Weather(
- val cod: Int = 0,
- val temp: Double? = 0.0,
- val wind: Double? = 0.0,
- val windAngle: Int? = 0,
- val humidity: Int? = 0,
- val name: String? = "",
- val icon: String? = "",
- val description: String? = ""
-)
\ No newline at end of file
diff --git a/app/src/main/java/bose/ankush/weatherify/domain/model/WeatherForecast.kt b/app/src/main/java/bose/ankush/weatherify/domain/model/WeatherForecast.kt
index bc7bdf32..7c1b8ba9 100644
--- a/app/src/main/java/bose/ankush/weatherify/domain/model/WeatherForecast.kt
+++ b/app/src/main/java/bose/ankush/weatherify/domain/model/WeatherForecast.kt
@@ -13,10 +13,10 @@ data class WeatherForecast(
) {
data class Alert(
val description: String?,
- val end: Int?,
+ val end: Long?,
val event: String?,
val sender_name: String?,
- val start: Int?,
+ val start: Long?,
)
data class Current(
@@ -25,13 +25,13 @@ data class WeatherForecast(
val feels_like: Double?,
val humidity: Int?,
val pressure: Int?,
- val sunrise: Int?,
- val sunset: Int?,
+ val sunrise: Long?,
+ val sunset: Long?,
val temp: Double?,
val uvi: Double?,
val weather: List? = listOf(),
val wind_gust: Double?,
- val wind_speed: Double?
+ val wind_speed: Double?,
)
data class Daily(
@@ -42,13 +42,13 @@ data class WeatherForecast(
val pressure: Int?,
val rain: Double?,
val summary: String?,
- val sunrise: Int?,
- val sunset: Int?,
+ val sunrise: Long?,
+ val sunset: Long?,
val temp: Temp?,
val uvi: Double?,
val weather: List? = listOf(),
val wind_gust: Double?,
- val wind_speed: Double?
+ val wind_speed: Double?,
) {
data class Temp(
val day: Double?,
@@ -56,7 +56,7 @@ data class WeatherForecast(
val max: Double?,
val min: Double?,
val morn: Double?,
- val night: Double?
+ val night: Double?,
)
}
@@ -74,8 +74,8 @@ data class WeatherForecast(
* Domain model for weather condition
*/
data class WeatherCondition(
- val description: String,
- val icon: String,
+ val description: String = "",
+ val icon: String = "",
val id: Int,
- val main: String
+ val main: String = "",
)
diff --git a/app/src/main/java/bose/ankush/weatherify/domain/preference/PreferenceManager.kt b/app/src/main/java/bose/ankush/weatherify/domain/preference/PreferenceManager.kt
index f21f4238..0d896675 100644
--- a/app/src/main/java/bose/ankush/weatherify/domain/preference/PreferenceManager.kt
+++ b/app/src/main/java/bose/ankush/weatherify/domain/preference/PreferenceManager.kt
@@ -1,6 +1,9 @@
package bose.ankush.weatherify.domain.preference
-import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.doublePreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
/**
@@ -8,9 +11,9 @@ import kotlinx.coroutines.flow.Flow
*/
interface PreferenceManager {
/**
- * Get the flow of location preferences
+ * Get the flow of user preferences
*/
- fun getLocationPreferenceFlow(): Flow
+ fun getUserPreferencesFlow(): Flow
/**
* Save location coordinates to preferences
@@ -18,11 +21,55 @@ interface PreferenceManager {
*/
suspend fun saveLocationPreferences(coordinates: Pair)
+ /**
+ * Save premium subscription status and expiry
+ */
+ suspend fun savePremiumStatus(
+ isPremium: Boolean,
+ expiryMillis: Long?,
+ )
+
+ /**
+ * Save a pinned location override that replaces live GPS as the weather source.
+ */
+ suspend fun saveLocationOverride(lat: Double, lon: Double, name: String)
+
+ /**
+ * Clear the pinned location override, reverting to live GPS.
+ */
+ suspend fun clearLocationOverride()
+
+ /**
+ * Clear all stored preferences (location, premium status, expiry).
+ * Must be called on logout so no stale state survives into the next session.
+ */
+ suspend fun clearAll()
+
/**
* Preference keys
*/
companion object PreferenceKeys {
- val USER_LAT_LOCATION = androidx.datastore.preferences.core.doublePreferencesKey("latitude")
- val USER_LON_LOCATION = androidx.datastore.preferences.core.doublePreferencesKey("longitude")
+ val USER_LAT_LOCATION = doublePreferencesKey("latitude")
+ val USER_LON_LOCATION = doublePreferencesKey("longitude")
+ val IS_PREMIUM = booleanPreferencesKey("is_premium")
+ val PREMIUM_EXPIRY = longPreferencesKey("premium_expiry")
+ val OVERRIDE_LAT = doublePreferencesKey("override_lat")
+ val OVERRIDE_LON = doublePreferencesKey("override_lon")
+ val OVERRIDE_LOCATION_NAME = stringPreferencesKey("override_location_name")
+ val IS_LOCATION_OVERRIDDEN = booleanPreferencesKey("is_location_overridden")
}
-}
\ No newline at end of file
+}
+
+/**
+ * Data class representing all user-specific preferences.
+ */
+data class UserPreferences(
+ val latitude: Double? = null,
+ val longitude: Double? = null,
+ val isPremium: Boolean = false,
+ val premiumExpiry: Long? = null,
+ val isLocationOverridden: Boolean = false,
+ val overrideLat: Double? = null,
+ val overrideLon: Double? = null,
+ val overrideLocationName: String? = null,
+)
diff --git a/app/src/main/java/bose/ankush/weatherify/domain/remote_config/RemoteConfigService.kt b/app/src/main/java/bose/ankush/weatherify/domain/remote_config/RemoteConfigService.kt
index 5869fbf0..a9612945 100644
--- a/app/src/main/java/bose/ankush/weatherify/domain/remote_config/RemoteConfigService.kt
+++ b/app/src/main/java/bose/ankush/weatherify/domain/remote_config/RemoteConfigService.kt
@@ -6,7 +6,6 @@ package bose.ankush.weatherify.domain.remote_config
* allowing for easier testing and flexibility in implementation.
*/
interface RemoteConfigService {
-
/**
* Initializes the remote configuration service.
* This should be called early in the application lifecycle.
@@ -19,5 +18,8 @@ interface RemoteConfigService {
* @param defaultValue The default value to return if the key is not found
* @return The boolean value from remote configuration, or the default value if not found
*/
- fun getBoolean(key: String, defaultValue: Boolean = false): Boolean
-}
\ No newline at end of file
+ fun getBoolean(
+ key: String,
+ defaultValue: Boolean = false,
+ ): Boolean
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/domain/repository/CityRepository.kt b/app/src/main/java/bose/ankush/weatherify/domain/repository/CityRepository.kt
index 45d17bd0..f7eebb56 100644
--- a/app/src/main/java/bose/ankush/weatherify/domain/repository/CityRepository.kt
+++ b/app/src/main/java/bose/ankush/weatherify/domain/repository/CityRepository.kt
@@ -4,4 +4,4 @@ import bose.ankush.weatherify.data.remote.dto.CityDto
interface CityRepository {
fun getCityNames(): List
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/domain/repository/WeatherRepository.kt b/app/src/main/java/bose/ankush/weatherify/domain/repository/WeatherRepository.kt
index 213f369a..ab2b89ce 100644
--- a/app/src/main/java/bose/ankush/weatherify/domain/repository/WeatherRepository.kt
+++ b/app/src/main/java/bose/ankush/weatherify/domain/repository/WeatherRepository.kt
@@ -10,10 +10,14 @@ Date: 05,May,2021
**/
interface WeatherRepository {
-
fun getAirQualityReport(coordinates: Pair): Flow
fun getWeatherReport(location: Pair): Flow
- suspend fun refreshWeatherData(coordinates: Pair)
+ suspend fun refreshWeatherData(
+ coordinates: Pair,
+ forceRefresh: Boolean = false,
+ )
+
+ suspend fun clearAllData()
}
diff --git a/app/src/main/java/bose/ankush/weatherify/domain/use_case/feedback/SubmitFeedback.kt b/app/src/main/java/bose/ankush/weatherify/domain/use_case/feedback/SubmitFeedback.kt
new file mode 100644
index 00000000..cddd0a17
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/feedback/SubmitFeedback.kt
@@ -0,0 +1,15 @@
+package bose.ankush.weatherify.domain.use_case.feedback
+
+import bose.ankush.network.model.FeedbackRequest
+import bose.ankush.network.model.FeedbackResponse
+import bose.ankush.network.repository.FeedbackRepository
+import javax.inject.Inject
+
+class SubmitFeedback
+@Inject
+constructor(
+ private val repository: FeedbackRepository,
+) {
+ suspend operator fun invoke(request: FeedbackRequest): Result =
+ repository.submitFeedback(request)
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_air_quality/GetAirQuality.kt b/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_air_quality/GetAirQuality.kt
index 7653f718..8b752460 100644
--- a/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_air_quality/GetAirQuality.kt
+++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_air_quality/GetAirQuality.kt
@@ -5,9 +5,13 @@ import bose.ankush.weatherify.domain.repository.WeatherRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
-class GetAirQuality @Inject constructor(
- private val weatherRepository: WeatherRepository
+class GetAirQuality
+@Inject
+constructor(
+ private val weatherRepository: WeatherRepository,
) {
- operator fun invoke(lat: Double, lang: Double): Flow =
- weatherRepository.getAirQualityReport(Pair(lat, lang))
+ operator fun invoke(
+ lat: Double,
+ lang: Double,
+ ): Flow = weatherRepository.getAirQualityReport(Pair(lat, lang))
}
diff --git a/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_cities/GetCityNames.kt b/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_cities/GetCityNames.kt
index 29efc7a1..fe65f80b 100644
--- a/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_cities/GetCityNames.kt
+++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_cities/GetCityNames.kt
@@ -5,13 +5,16 @@ import bose.ankush.weatherify.domain.model.CityName
import bose.ankush.weatherify.domain.repository.CityRepository
import javax.inject.Inject
-class GetCityNames @Inject constructor(
- private val repository: CityRepository
+class GetCityNames
+@Inject
+constructor(
+ private val repository: CityRepository,
) {
- operator fun invoke(): List = try {
- val cityNames = repository.getCityNames().map { it.toCityName() }
- cityNames.ifEmpty { emptyList() }
- } catch (e: Exception) {
- emptyList()
- }
-}
\ No newline at end of file
+ operator fun invoke(): List =
+ try {
+ val cityNames = repository.getCityNames().map { it.toCityName() }
+ cityNames.ifEmpty { emptyList() }
+ } catch (_: Exception) {
+ emptyList()
+ }
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_weather_reports/GetWeatherReport.kt b/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_weather_reports/GetWeatherReport.kt
index 457ab932..3bfe2268 100644
--- a/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_weather_reports/GetWeatherReport.kt
+++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/get_weather_reports/GetWeatherReport.kt
@@ -3,7 +3,10 @@ package bose.ankush.weatherify.domain.use_case.get_weather_reports
import bose.ankush.weatherify.domain.repository.WeatherRepository
import javax.inject.Inject
-class GetWeatherReport @Inject constructor(private val repository: WeatherRepository) {
-
- suspend operator fun invoke(location: Pair) = repository.getWeatherReport(location)
+class GetWeatherReport
+@Inject
+constructor(
+ private val repository: WeatherRepository,
+) {
+ operator fun invoke(location: Pair) = repository.getWeatherReport(location)
}
diff --git a/app/src/main/java/bose/ankush/weatherify/domain/use_case/refresh_weather_reports/RefreshWeatherReport.kt b/app/src/main/java/bose/ankush/weatherify/domain/use_case/refresh_weather_reports/RefreshWeatherReport.kt
index 4404165c..5f13d7a7 100644
--- a/app/src/main/java/bose/ankush/weatherify/domain/use_case/refresh_weather_reports/RefreshWeatherReport.kt
+++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/refresh_weather_reports/RefreshWeatherReport.kt
@@ -3,10 +3,15 @@ package bose.ankush.weatherify.domain.use_case.refresh_weather_reports
import bose.ankush.weatherify.domain.repository.WeatherRepository
import javax.inject.Inject
-class RefreshWeatherReport @Inject constructor(
- private val repository: WeatherRepository
+class RefreshWeatherReport
+@Inject
+constructor(
+ private val repository: WeatherRepository,
) {
- suspend operator fun invoke(coordinates: Pair) {
- repository.refreshWeatherData(coordinates)
+ suspend operator fun invoke(
+ coordinates: Pair,
+ forceRefresh: Boolean = false,
+ ) {
+ repository.refreshWeatherData(coordinates, forceRefresh)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/payment/config/AppConfigPaymentConfig.kt b/app/src/main/java/bose/ankush/weatherify/payment/config/AppConfigPaymentConfig.kt
new file mode 100644
index 00000000..06d29fcb
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/payment/config/AppConfigPaymentConfig.kt
@@ -0,0 +1,14 @@
+package bose.ankush.weatherify.payment.config
+
+import bose.ankush.payment.domain.config.PaymentConfig
+import bose.ankush.weatherify.base.config.AppConfig
+
+/**
+ * Bridges [AppConfig] (Hilt-managed) into the [PaymentConfig] interface
+ * consumed by [bose.ankush.payment.presentation.PaymentViewModel] (Koin-managed).
+ */
+internal class AppConfigPaymentConfig(
+ private val appConfig: AppConfig,
+) : PaymentConfig {
+ override val razorpayKey: String get() = appConfig.razorpayKey
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/payment/store/PreferenceManagerPremiumStore.kt b/app/src/main/java/bose/ankush/weatherify/payment/store/PreferenceManagerPremiumStore.kt
new file mode 100644
index 00000000..41b9cb60
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/payment/store/PreferenceManagerPremiumStore.kt
@@ -0,0 +1,28 @@
+package bose.ankush.weatherify.payment.store
+
+import bose.ankush.payment.domain.store.PremiumStatus
+import bose.ankush.payment.domain.store.PremiumStore
+import bose.ankush.weatherify.domain.preference.PreferenceManager
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+/**
+ * Bridges [PreferenceManager] (Hilt-managed) into the [PremiumStore] interface
+ * consumed by [bose.ankush.payment.presentation.PaymentViewModel] (Koin-managed).
+ */
+internal class PreferenceManagerPremiumStore(
+ private val preferenceManager: PreferenceManager,
+) : PremiumStore {
+ override fun observePremiumStatus(): Flow =
+ preferenceManager.getUserPreferencesFlow().map { prefs ->
+ PremiumStatus(
+ isPremium = prefs.isPremium,
+ expiryMillis = prefs.premiumExpiry,
+ )
+ }
+
+ override suspend fun savePremiumStatus(
+ isPremium: Boolean,
+ expiryMillis: Long?,
+ ) = preferenceManager.savePremiumStatus(isPremium, expiryMillis)
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/MainActivity.kt b/app/src/main/java/bose/ankush/weatherify/presentation/MainActivity.kt
index 44cbc6bc..c986ef9e 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/MainActivity.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/MainActivity.kt
@@ -2,28 +2,49 @@ package bose.ankush.weatherify.presentation
import android.Manifest
import android.content.Context
+import android.location.LocationManager
import android.os.Bundle
import android.widget.Toast
+import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
-import androidx.core.view.WindowCompat
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.core.view.WindowCompat
+import bose.ankush.commonui.auth.LoginScreen
+import bose.ankush.commonui.components.NotificationToast
+import bose.ankush.commonui.components.ToastType
+import bose.ankush.commonui.components.rememberToastAnchorState
+import bose.ankush.commonui.permissions.PermissionAlertDialog
+import bose.ankush.commonui.web.InAppWebView
+import bose.ankush.payment.presentation.PaymentViewModel
import bose.ankush.weatherify.base.common.ACCESS_NOTIFICATION
-import bose.ankush.weatherify.base.common.ACCESS_PHONE_CALL
-import bose.ankush.weatherify.base.common.Extension.callNumber
import bose.ankush.weatherify.base.common.Extension.hasNotificationPermission
import bose.ankush.weatherify.base.common.Extension.openAppSystemSettings
import bose.ankush.weatherify.base.common.PERMISSIONS_TO_REQUEST
@@ -31,23 +52,35 @@ import bose.ankush.weatherify.base.common.startInAppUpdate
import bose.ankush.weatherify.base.location.LocationClient
import bose.ankush.weatherify.base.permissions.CoarseLocationPermissionTextProvider
import bose.ankush.weatherify.base.permissions.FineLocationPermissionTextProvider
-import bose.ankush.weatherify.base.permissions.PermissionAlertDialog
import bose.ankush.weatherify.presentation.navigation.AppNavigation
import bose.ankush.weatherify.presentation.theme.WeatherifyTheme
+import com.google.accompanist.systemuicontroller.rememberSystemUiController
+import com.razorpay.Checkout
+import com.razorpay.PaymentData
+import com.razorpay.PaymentResultWithDataListener
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.json.JSONObject
import javax.inject.Inject
+import org.koin.androidx.viewmodel.ext.android.viewModel as koinViewModel
@ExperimentalCoroutinesApi
@ExperimentalAnimationApi
@AndroidEntryPoint
-class MainActivity : AppCompatActivity() {
-
+class MainActivity :
+ AppCompatActivity(),
+ PaymentResultWithDataListener {
private val viewModel: MainViewModel by viewModels()
+ // Koin-managed: owns payment state and Razorpay flow
+ private val paymentViewModel: PaymentViewModel by koinViewModel()
+
@Inject
lateinit var locationClient: LocationClient
+ // Hold a reference to the Checkout instance only during payment
+ private var razorpayCheckout: Checkout? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
@@ -57,119 +90,282 @@ class MainActivity : AppCompatActivity() {
setContent {
WeatherifyTheme {
- val context: Context = LocalContext.current
- val launchPhoneCallPermissionState =
- viewModel.launchPhoneCallPermission.collectAsState()
- val launchNotificationPermissionState =
- viewModel.launchNotificationPermission.collectAsState()
- if (locationClient.hasLocationPermission()) {
- // if permission granted already then fetch and save location coordinates
- viewModel.fetchAndSaveLocationCoordinates()
- } else {
- // request location permission
- RequestLocationPermission(context)
+ val context = LocalContext.current
+ val isLoggedIn by viewModel.isLoggedIn.collectAsState()
+ val authState by viewModel.authState.collectAsState()
+ val isAuthInitialized by viewModel.isAuthInitialized.collectAsState()
+
+ // Set status bar color and icon color based on background
+ @Suppress("DEPRECATION")
+ val systemUiController = rememberSystemUiController()
+ val bgColor = MaterialTheme.colorScheme.background
+ val useDarkIcons = bgColor.luminance() > 0.5f
+ SideEffect {
+ systemUiController.setStatusBarColor(
+ color = Color.Transparent,
+ darkIcons = useDarkIcons,
+ )
}
- if (launchPhoneCallPermissionState.value) {
- // request phone call permission
- RequestPhoneCallPermission(context)
+
+ // Toast anchor state โ auto-measures bottom bar height
+ val toastAnchorState = rememberToastAnchorState()
+
+ // NotificationToast state
+ var toastVisible by remember { mutableStateOf(false) }
+ var toastMessage by remember { mutableStateOf("") }
+ var toastTitle by remember { mutableStateOf("") }
+ var toastType by remember { mutableStateOf(ToastType.ERROR) }
+
+ fun showToast(
+ message: String,
+ title: String = "Error",
+ type: ToastType = ToastType.ERROR,
+ ) {
+ toastMessage = message
+ toastTitle = title
+ toastType = type
+ toastVisible = true
}
- if (launchNotificationPermissionState.value) {
- // request notification permission
- RequestNotificationPermission(context)
+
+ // Handle authentication state changes
+ LaunchedEffect(authState) {
+ when (authState) {
+ is AuthState.Error -> {
+ showToast((authState as AuthState.Error).message.asString(this@MainActivity))
+ viewModel.resetAuthState()
+ }
+ is AuthState.Success -> {
+ showToast("Authentication successful", "Success", ToastType.SUCCESS)
+ viewModel.resetAuthState()
+ }
+ else -> Unit
+ }
}
- /**
- * For Settings screen:
- * notification item should be invisible if notification permission is already granted.
- */
- LaunchedEffect(key1 = launchNotificationPermissionState) {
- if (!context.hasNotificationPermission()) {
- viewModel.updateShowNotificationBannerState(true)
- } else {
- viewModel.updateShowNotificationBannerState(false)
+ // Listen for Unauthorized events
+ LaunchedEffect(Unit) {
+ bose.ankush.network.auth.events.AuthEventBus.events.collect { event ->
+ if (event is bose.ankush.network.auth.events.AuthEvent.Unauthorized) {
+ showToast(
+ message =
+ event.message.ifBlank {
+ "You need to log in again to continue using the app for security purposes."
+ },
+ title = "Session Expired",
+ type = ToastType.WARNING,
+ )
+ }
}
}
- // main container holding all app composable screens
+ // Collect checkout params from PaymentViewModel and launch Razorpay
+ LaunchedEffect(Unit) {
+ paymentViewModel.checkoutParams.collect { params ->
+ try {
+ Checkout.preload(applicationContext)
+ razorpayCheckout = Checkout()
+ razorpayCheckout?.setKeyID(params.keyId)
+ val options =
+ JSONObject().apply {
+ put("name", params.name)
+ put("description", params.description)
+ put("order_id", params.orderId)
+ put("currency", params.currency)
+ put("amount", params.amount)
+ val prefill =
+ JSONObject().apply {
+ params.email?.let { put("email", it) }
+ params.contact?.let { put("contact", it) }
+ }
+ put("prefill", prefill)
+ }
+ razorpayCheckout?.open(this@MainActivity, options)
+ } catch (e: Exception) {
+ paymentViewModel.onPaymentFailed(
+ e.message ?: "Unable to open payment checkout",
+ )
+ Checkout.clearUserData(context)
+ razorpayCheckout = null
+ }
+ }
+ }
+
+ // Main content
Box(
- modifier = Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.background)
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .windowInsetsPadding(
+ WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal),
+ ),
) {
- AppNavigation(viewModel)
+ when {
+ !isAuthInitialized -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) { CircularProgressIndicator() }
+ }
+ isLoggedIn -> {
+ // Only fetch location once after login, not on every recomposition
+ val launchNotificationPermissionState =
+ viewModel.launchNotificationPermission.collectAsState()
+ LaunchedEffect(isLoggedIn) {
+ if (locationClient.hasLocationPermission()) {
+ viewModel.fetchAndSaveLocationCoordinates()
+ }
+ }
+ // If location permission is missing, request it on first launch
+ if (!locationClient.hasLocationPermission()) {
+ RequestLocationPermission(context)
+ }
+ if (launchNotificationPermissionState.value) {
+ RequestNotificationPermission(context)
+ }
+ LaunchedEffect(launchNotificationPermissionState.value) {
+ viewModel.updateShowNotificationBannerState(!context.hasNotificationPermission())
+ }
+ AppNavigation(viewModel, paymentViewModel, toastAnchorState)
+ }
+ else -> {
+ // State for web view
+ var currentWebUrl by remember { mutableStateOf(null) }
+
+ // Show web view if a URL is selected
+ if (currentWebUrl != null) {
+ InAppWebView(
+ url = currentWebUrl!!,
+ onClose = { currentWebUrl = null },
+ )
+ } else {
+ // Only show login screen if not logged in and auth is initialized
+ LoginScreen(
+ onLoginClick = { email, password ->
+ viewModel.login(
+ email,
+ password,
+ )
+ },
+ onRegisterClick = { email, password ->
+ viewModel.register(
+ email,
+ password,
+ )
+ },
+ onWebUrlClick = { url -> currentWebUrl = url },
+ isLoading = authState is AuthState.Loading,
+ )
+ }
+ }
+ }
+ NotificationToast(
+ modifier = Modifier.align(Alignment.BottomCenter),
+ message = toastMessage,
+ title = toastTitle,
+ type = toastType,
+ isVisible = toastVisible,
+ onDismiss = { toastVisible = false },
+ anchorState = toastAnchorState,
+ )
}
}
}
}
+ /**
+ * Request location permissions using Compose dialog and launcher.
+ */
@Composable
fun RequestLocationPermission(context: Context) {
val permissionQueue = viewModel.permissionDialogQueue
-
val locationPermissionsResultLauncher =
- rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestMultiplePermissions(),
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { permissionMap ->
PERMISSIONS_TO_REQUEST.forEach { permission ->
viewModel.onPermissionResult(
permission = permission,
- isGranted = permissionMap[permission] == true
+ isGranted = permissionMap[permission] == true,
)
}
- })
+ },
+ )
permissionQueue.reversed().forEach { permission ->
- PermissionAlertDialog(permissionTextProvider = when (permission) {
- Manifest.permission.ACCESS_FINE_LOCATION -> FineLocationPermissionTextProvider()
- Manifest.permission.ACCESS_COARSE_LOCATION -> CoarseLocationPermissionTextProvider()
- else -> return@forEach
- },
- isPermanentlyDeclined = shouldShowRequestPermissionRationale(permission),
- onDismissClick = viewModel::dismissDialog,
- onOkClick = {
- viewModel.dismissDialog()
- locationPermissionsResultLauncher.launch(PERMISSIONS_TO_REQUEST)
- },
- onGoToAppSettingClick = { context.openAppSystemSettings() })
- }
+ val isPermanentlyDeclined = !shouldShowRequestPermissionRationale(permission)
+ val textProvider =
+ when (permission) {
+ Manifest.permission.ACCESS_FINE_LOCATION -> FineLocationPermissionTextProvider()
+ Manifest.permission.ACCESS_COARSE_LOCATION -> CoarseLocationPermissionTextProvider()
+ else -> return@forEach
+ }
- LaunchedEffect(key1 = Unit) {
- locationPermissionsResultLauncher.launch(PERMISSIONS_TO_REQUEST)
- }
- }
+ // Consumer owns back-press: exit the app when permanently declined
+ BackHandler(enabled = isPermanentlyDeclined) { finish() }
- @Composable
- fun RequestPhoneCallPermission(context: Context) {
- val phoneCallPermissionResultLauncher =
- rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(),
- onResult = { isGranted ->
- // TODO: Hardcoded task to call phone number as it is triggered from 1 place [AppNavigation]
- if (isGranted) context.callNumber()
- }
+ PermissionAlertDialog(
+ descriptionText = textProvider.getDescription(isPermanentlyDeclined),
+ isPermanentlyDeclined = isPermanentlyDeclined,
+ onPositiveAction =
+ if (isPermanentlyDeclined) {
+ { context.openAppSystemSettings() }
+ } else {
+ {
+ viewModel.dismissDialog()
+ locationPermissionsResultLauncher.launch(PERMISSIONS_TO_REQUEST)
+ }
+ },
+ onNegativeAction = { finish() },
+ positiveButtonLabel = if (isPermanentlyDeclined) "Grant Permission" else "OK",
+ negativeButtonLabel = "Exit",
)
+ }
- LaunchedEffect(key1 = Unit) {
- phoneCallPermissionResultLauncher.launch(ACCESS_PHONE_CALL)
+ // Launch initial permission request if missing and queue is empty (first-launch scenario)
+ LaunchedEffect(Unit) {
+ if (permissionQueue.isEmpty() && !locationClient.hasLocationPermission()) {
+ locationPermissionsResultLauncher.launch(PERMISSIONS_TO_REQUEST)
+ }
+ }
+
+ // Re-launch only when rationale should be shown (not permanently declined)
+ LaunchedEffect(permissionQueue.size) {
+ val hasRationalePermission =
+ permissionQueue.any { shouldShowRequestPermissionRationale(it) }
+ if (permissionQueue.isNotEmpty() && hasRationalePermission) {
+ locationPermissionsResultLauncher.launch(PERMISSIONS_TO_REQUEST)
+ }
}
}
+ /**
+ * Request notification permission using Compose launcher.
+ */
@Composable
fun RequestNotificationPermission(context: Context) {
val notificationPermissionResultLauncher =
- rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(),
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
onResult = { isGranted ->
+ viewModel.updateShowNotificationBannerState(!isGranted)
if (isGranted) {
- Toast.makeText(
- context,
- "Notification permission granted",
- Toast.LENGTH_SHORT
- ).show()
- // hide notification banner on settings screen
- viewModel.updateShowNotificationBannerState(false)
+ Toast
+ .makeText(
+ context,
+ "Notification permission granted",
+ Toast.LENGTH_SHORT,
+ ).show()
+ } else {
+ val isPermanentlyDeclined =
+ !shouldShowRequestPermissionRationale(ACCESS_NOTIFICATION)
+ viewModel.updateNotificationPermissionPermanentlyDeclined(
+ isPermanentlyDeclined,
+ )
}
- }
+ },
)
-
- LaunchedEffect(key1 = Unit) {
+ LaunchedEffect(Unit) {
notificationPermissionResultLauncher.launch(ACCESS_NOTIFICATION)
}
}
@@ -177,5 +373,60 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
startInAppUpdate(this)
+ viewModel.refreshTokenOnForeground()
+ // If user granted a permission via system Settings and returned, clear it from the queue
+ val granted =
+ viewModel.permissionDialogQueue.filter { permission ->
+ checkSelfPermission(permission) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ }
+ if (granted.isNotEmpty()) {
+ viewModel.removeGrantedPermissions(granted)
+ }
+ // If GPS was disabled and user returned from location settings, retry location fetch
+ if (viewModel.uiState.value.isGpsDisabled && locationClient.hasLocationPermission()) {
+ val locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+ val isLocationAvailable =
+ locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
+ locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
+ if (isLocationAvailable) {
+ viewModel.fetchAndSaveLocationCoordinates()
+ }
+ }
+ }
+
+ /**
+ * Razorpay payment success callback โ delegates to [PaymentViewModel].
+ */
+ override fun onPaymentSuccess(
+ razorpayPaymentID: String?,
+ paymentData: PaymentData?,
+ ) {
+ val orderId = paymentData?.orderId.orEmpty()
+ val paymentId = paymentData?.paymentId ?: razorpayPaymentID.orEmpty()
+ val signature = paymentData?.signature.orEmpty()
+ if (orderId.isNotBlank() && paymentId.isNotBlank() && signature.isNotBlank()) {
+ paymentViewModel.verifyPayment(orderId, paymentId, signature)
+ } else {
+ paymentViewModel.onPaymentFailed("Payment succeeded but missing data")
+ }
+ razorpayCheckout = null
+ }
+
+ /**
+ * Razorpay payment error callback โ delegates to [PaymentViewModel].
+ */
+ override fun onPaymentError(
+ code: Int,
+ response: String?,
+ paymentData: PaymentData?,
+ ) {
+ val message = response ?: "Payment failed with code $code"
+ paymentViewModel.onPaymentFailed(message)
+ razorpayCheckout = null
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ razorpayCheckout = null
}
}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/MainViewModel.kt b/app/src/main/java/bose/ankush/weatherify/presentation/MainViewModel.kt
index e21a662f..a1062e72 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/MainViewModel.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/MainViewModel.kt
@@ -3,303 +3,822 @@ package bose.ankush.weatherify.presentation
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import bose.ankush.commonui.locations.PlaceSearchUiState
+import bose.ankush.commonui.locations.SavedLocationsUiState
+import bose.ankush.network.auth.events.AuthEvent
+import bose.ankush.network.auth.events.AuthEventBus.emit
+import bose.ankush.network.auth.model.AuthResponse
+import bose.ankush.network.auth.repository.AuthRepository
+import bose.ankush.network.auth.token.TokenManager
+import bose.ankush.network.auth.token.TokenResult
+import bose.ankush.network.auth.utils.isPremiumActive
+import bose.ankush.network.domain.SavedLocationsUseCase
+import bose.ankush.network.domain.SearchPlacesUseCase
import bose.ankush.weatherify.R
+import bose.ankush.weatherify.base.common.DeviceInfoProvider
import bose.ankush.weatherify.base.common.ENABLE_NOTIFICATION
+import bose.ankush.weatherify.base.common.LoggerFactory
import bose.ankush.weatherify.base.common.UiText
+import bose.ankush.weatherify.base.common.errorResponseFromException
import bose.ankush.weatherify.base.dispatcher.DispatcherProvider
import bose.ankush.weatherify.base.location.LocationClient
+import bose.ankush.weatherify.base.location.LocationPermissions
import bose.ankush.weatherify.domain.preference.PreferenceManager
import bose.ankush.weatherify.domain.remote_config.RemoteConfigService
+import bose.ankush.weatherify.domain.repository.WeatherRepository
import bose.ankush.weatherify.domain.use_case.get_air_quality.GetAirQuality
import bose.ankush.weatherify.domain.use_case.get_weather_reports.GetWeatherReport
import bose.ankush.weatherify.domain.use_case.refresh_weather_reports.RefreshWeatherReport
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import timber.log.Timber
+import kotlinx.coroutines.withContext
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
import javax.inject.Inject
/**
- * Main ViewModel for the Weatherify application.
- *
- * This ViewModel is responsible for:
- * - Managing the UI state for weather and air quality data
- * - Handling location permissions and coordinates
- * - Managing notification settings and permissions
- * - Coordinating data loading from repositories
+ * Main ViewModel for Weatherify.
+ * Handles UI state, authentication, location, and notifications.
*
- * @property refreshWeatherReport Use case for refreshing weather data from remote source
- * @property getWeatherReport Use case for retrieving weather data from local database
- * @property getAirQuality Use case for retrieving air quality data
- * @property locationClient Client for accessing device location
- * @property preferenceManager Manager for user preferences storage
- * @property dispatchers Provider for coroutine dispatchers
- * @property remoteConfigService Service for accessing remote configuration
+ * Payment state and logic lives in [bose.ankush.payment.presentation.PaymentViewModel].
+ *
+ * All platform-specific dependencies are injected via interfaces so this class
+ * is ready to be moved to a KMP commonMain source set with minimal changes.
+ *
+ * Remaining KMP TODO: [UiText.StringResource] still references Android R.string resources.
+ * When migrating UiText to a KMP-compatible text-resource system, replace the
+ * [UiText.StringResource] usages below with the new type.
*/
+@OptIn(FlowPreview::class)
@HiltViewModel
-class MainViewModel @Inject constructor(
+class MainViewModel
+@Inject
+constructor(
private val refreshWeatherReport: RefreshWeatherReport,
private val getWeatherReport: GetWeatherReport,
private val getAirQuality: GetAirQuality,
+ private val weatherRepository: WeatherRepository,
private val locationClient: LocationClient,
private val preferenceManager: PreferenceManager,
private val dispatchers: DispatcherProvider,
- private val remoteConfigService: RemoteConfigService
+ private val remoteConfigService: RemoteConfigService,
+ private val authRepository: AuthRepository,
+ private val tokenManager: TokenManager,
+ private val searchPlacesUseCase: SearchPlacesUseCase,
+ private val savedLocationsUseCase: SavedLocationsUseCase,
+ loggerFactory: LoggerFactory,
+ private val deviceInfoProvider: DeviceInfoProvider,
) : ViewModel() {
+ private val logger = loggerFactory.create("${MainViewModel::class.simpleName} ->")
- /**
- * Queue of permissions that need to be requested from the user.
- * This is exposed to the UI to show appropriate permission dialogs.
- */
+ // Permission dialog queue for UI
var permissionDialogQueue = mutableStateListOf()
private set
+ // UI state flows
private val _uiState = MutableStateFlow(UIState(isLoading = true))
- /**
- * The current UI state containing weather data, air quality, and loading status.
- */
val uiState = _uiState.asStateFlow()
- private val _launchPhoneCallPermission = MutableStateFlow(false)
- /**
- * Flag indicating whether the phone call permission dialog should be shown.
- */
- val launchPhoneCallPermission = _launchPhoneCallPermission.asStateFlow()
-
private val _launchNotificationPermission = MutableStateFlow(false)
- /**
- * Flag indicating whether the notification permission dialog should be shown.
- */
val launchNotificationPermission = _launchNotificationPermission.asStateFlow()
private val _showNotificationCardItem = MutableStateFlow(false)
- /**
- * Flag indicating whether the notification card should be shown in the UI.
- */
val showNotificationCardItem = _showNotificationCardItem.asStateFlow()
- /**
- * Exception handler for data fetching operations.
- * Updates the UI state with an error message when an exception occurs.
- */
- private val dataFetchExceptionHandler = CoroutineExceptionHandler { _, e ->
- if (e !is CancellationException) {
- _uiState.update { UIState(error = UiText.DynamicText(e.message.toString())) }
- }
- }
+ private val _isNotificationPermissionPermanentlyDeclined = MutableStateFlow(false)
+ val isNotificationPermissionPermanentlyDeclined =
+ _isNotificationPermissionPermanentlyDeclined.asStateFlow()
+
+ // Auth state flows
+ private val _authState = MutableStateFlow(AuthState.Initial)
+ val authState: StateFlow = _authState.asStateFlow()
- private val tag = "${MainViewModel::class.simpleName} ->"
+ private val _isLoggedIn = MutableStateFlow(false)
+ val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow()
- // Track active jobs for proper cancellation
+ private val _isAuthInitialized = MutableStateFlow(false)
+ val isAuthInitialized: StateFlow = _isAuthInitialized.asStateFlow()
+
+ // Location state flows
+ private val _savedLocationsState = MutableStateFlow(SavedLocationsUiState())
+ val savedLocationsState: StateFlow = _savedLocationsState.asStateFlow()
+
+ private val _placeSearchState = MutableStateFlow(PlaceSearchUiState())
+ val placeSearchState: StateFlow = _placeSearchState.asStateFlow()
+
+ private val _queryFlow = MutableStateFlow("")
+
+ // Coroutine jobs
private var notificationBannerJob: Job? = null
private var locationJob: Job? = null
private var dataLoadingJob: Job? = null
- /**
- * Dismisses the current permission dialog by removing it from the queue.
- * This should be called when the user has responded to a permission request.
- */
+ // Exception handler for data fetch
+ private val dataFetchExceptionHandler =
+ CoroutineExceptionHandler { _, e ->
+ if (e !is CancellationException) {
+ val error =
+ if (e is Exception) {
+ errorResponseFromException(e)
+ } else {
+ UiText.StringResource(resId = R.string.general_error_txt)
+ }
+ _uiState.update { UIState(error = error) }
+ }
+ }
+
+ init {
+ logger.d("MainViewModel initialized")
+
+ viewModelScope.launch {
+ var initialized = false
+ authRepository.isLoggedIn().collectLatest { loggedIn ->
+ _isLoggedIn.value = loggedIn
+ logger.d("Auth state changed - isLoggedIn: $loggedIn")
+ if (!initialized) {
+ if (loggedIn) silentTokenRefresh()
+ _isAuthInitialized.value = true
+ initialized = true
+ logger.d("Auth initialization completed")
+ }
+ }
+ }
+
+ // Reactively refresh weather data when premium tier changes (activation or expiry).
+ // drop(1) skips the initial emission so we only react to actual changes.
+ viewModelScope.launch {
+ preferenceManager
+ .getUserPreferencesFlow()
+ .map { it.isPremium }
+ .distinctUntilChanged()
+ .drop(1)
+ .collect { isPremium ->
+ logger.d("Premium status changed to $isPremium โ forcing weather data refresh")
+ performInitialDataLoading(forceRefresh = true)
+ }
+ }
+
+ // Setup debounced place search
+ viewModelScope.launch(dispatchers.io) {
+ _queryFlow
+ .debounce(400L)
+ .filter { it.length >= 2 }
+ .distinctUntilChanged()
+ .collect { query -> fetchPlaceSuggestions(query) }
+ }
+
+ // Load saved locations and premium status on init
+ viewModelScope.launch(dispatchers.io) {
+ preferenceManager.getUserPreferencesFlow().collect { prefs ->
+ val premiumActive =
+ isPremiumActive(
+ prefs.premiumExpiry?.let { millis ->
+ Instant.fromEpochMilliseconds(millis).toString()
+ },
+ )
+ val wasPremium = _savedLocationsState.value.isPremium
+ _savedLocationsState.update { it.copy(isPremium = premiumActive) }
+ if (premiumActive && !wasPremium) loadSavedLocations()
+ }
+ }
+ }
+
+ /** Remove first permission dialog from queue. */
fun dismissDialog() {
- permissionDialogQueue.removeAt(0)
+ if (permissionDialogQueue.isNotEmpty()) {
+ val dismissed = permissionDialogQueue.removeAt(0)
+ logger.d("Dismissed permission dialog: $dismissed")
+ }
}
- /**
- * Handles the result of a permission request.
- * If permission is denied, adds it to the dialog queue to show a rationale.
- * If permission is granted, proceeds with fetching location coordinates.
- *
- * @param permission The permission that was requested
- * @param isGranted Whether the permission was granted by the user
- */
+ /** Remove all permissions from the queue that have been granted (e.g. via system Settings).
+ * Also triggers location fetch if a location permission was among those granted. */
+ fun removeGrantedPermissions(grantedPermissions: List) {
+ var locationGranted = false
+ grantedPermissions.forEach { permission ->
+ permissionDialogQueue.remove(permission)
+ logger.d("Removed granted permission from queue: $permission")
+ if (permission == LocationPermissions.FINE_LOCATION ||
+ permission == LocationPermissions.COARSE_LOCATION
+ ) {
+ locationGranted = true
+ }
+ }
+ if (locationGranted) {
+ logger.d("Location permission granted via Settings, fetching location")
+ fetchAndSaveLocationCoordinates()
+ }
+ }
+
+ /** Handle permission result, fetch location if granted. */
fun onPermissionResult(
permission: String,
isGranted: Boolean,
) {
- if (!isGranted && !permissionDialogQueue.contains(permission)) {
- permissionDialogQueue.add(permission)
- } else {
+ logger.d("Permission result - permission: $permission, granted: $isGranted")
+ if (isGranted) {
+ logger.d("Permission granted, fetching location")
fetchAndSaveLocationCoordinates()
+ } else if (!permissionDialogQueue.contains(permission)) {
+ permissionDialogQueue.add(permission)
+ logger.w("Permission denied: $permission, added to queue")
}
}
- /**
- * Updates the state of the phone call permission dialog.
- *
- * @param launchState True to show the permission dialog, false to hide it
- */
- fun updatePhoneCallPermission(launchState: Boolean) {
- _launchPhoneCallPermission.update { launchState }
- }
-
- /**
- * Updates the state of the notification permission dialog.
- *
- * @param launchState True to show the permission dialog, false to hide it
- */
+ /** Show/hide notification permission dialog. */
fun updateNotificationPermission(launchState: Boolean) {
+ logger.d("Updating notification permission dialog - show: $launchState")
_launchNotificationPermission.update { launchState }
}
- /**
- * Updates the state of the notification banner based on the remote configuration.
- * If notifications are disabled, the banner visibility will be false.
- *
- * @param launchState True to show the notification banner if enabled in remote config, false to hide it
- */
+ /** Show/hide notification banner based on remote config. */
fun updateShowNotificationBannerState(launchState: Boolean) {
- // Cancel previous job if it exists
notificationBannerJob?.cancel()
-
- notificationBannerJob = viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) {
- try {
- if (remoteConfigService.getBoolean(ENABLE_NOTIFICATION)) {
- _showNotificationCardItem.update { launchState }
- Timber.tag(tag).d("Notification feature is enabled")
- } else {
- _showNotificationCardItem.update { false }
- Timber.tag(tag).d("Notification feature is disabled")
+ notificationBannerJob =
+ viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) {
+ try {
+ val enabled = remoteConfigService.getBoolean(ENABLE_NOTIFICATION)
+ _showNotificationCardItem.update { enabled && launchState }
+ logger.d("Notification feature is ${if (enabled) "enabled" else "disabled"}")
+ } catch (_: CancellationException) {
+ throw CancellationException()
+ } catch (e: Exception) {
+ logger.e("Error updating notification banner state", e)
+ _uiState.update { it.copy(error = errorResponseFromException(e)) }
}
- } catch (e: CancellationException) {
- throw e // Rethrow cancellation exceptions
- } catch (e: Exception) {
- Timber.tag(tag).e(e, "Error updating notification banner state")
- _uiState.update { it.copy(error = UiText.DynamicText(e.message.toString())) }
}
- }
}
- /**
- * Fetches the user's current location coordinates and saves them to preferences.
- * Once coordinates are obtained, triggers initial data loading for weather and air quality.
- * This method handles errors and updates the UI state accordingly.
- */
+ /** Update whether notification permission is permanently declined. */
+ fun updateNotificationPermissionPermanentlyDeclined(isPermanentlyDeclined: Boolean) {
+ logger.d("Notification permission permanently declined: $isPermanentlyDeclined")
+ _isNotificationPermissionPermanentlyDeclined.update { isPermanentlyDeclined }
+ }
+
+ /** Fetch and save user location, then load initial data. Skips GPS when override is active. */
fun fetchAndSaveLocationCoordinates() {
- // Cancel previous job if it exists
+ logger.d("Starting location fetch")
+ _uiState.update { UIState(isLoading = true) }
locationJob?.cancel()
+ locationJob =
+ viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) {
+ val prefs = preferenceManager.getUserPreferencesFlow().first()
+ if (prefs.isLocationOverridden) {
+ logger.d("Location override active โ skipping GPS, using saved location")
+ performInitialDataLoading()
+ return@launch
+ }
+ try {
+ locationClient.getCurrentLocation().fold(
+ onSuccess = { loc ->
+ logger.i("Location fetched successfully - lat: ${loc.latitude}, lon: ${loc.longitude}")
+ preferenceManager.saveLocationPreferences(loc.latitude to loc.longitude)
+ logger.d("Location preferences saved")
+ performInitialDataLoading()
+ },
+ onFailure = { e ->
+ logger.e("Location fetch failed", e)
+ val isGpsDisabled =
+ e is LocationClient.LocationException &&
+ e.message?.contains(
+ "GPS is disabled",
+ ignoreCase = true
+ ) == true
+ val error =
+ if (isGpsDisabled) {
+ UiText.StringResource(resId = R.string.gps_disabled_error_txt)
+ } else if (e is Exception) {
+ errorResponseFromException(e)
+ } else {
+ UiText.StringResource(resId = R.string.general_error_txt)
+ }
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = error,
+ isGpsDisabled = isGpsDisabled,
+ )
+ }
+ },
+ )
+ } catch (_: CancellationException) {
+ logger.d("Location fetch cancelled")
+ } catch (e: Exception) {
+ logger.e("Error fetching location coordinates", e)
+ _uiState.update {
+ it.copy(isLoading = false, error = errorResponseFromException(e))
+ }
+ }
+ }
+ }
- locationJob = viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) {
- try {
- locationClient.getCurrentLocation().fold(
- onSuccess = { location ->
- val coordinates = Pair(first = location.latitude, second = location.longitude)
- // storing location on shared preference
- preferenceManager.saveLocationPreferences(coordinates)
- // load initial data when coordinates received
- performInitialDataLoading()
- },
- onFailure = { e ->
- _uiState.update { UIState(error = UiText.DynamicText(e.message.toString())) }
+ /** Refresh weather data without clearing the existing UI (pull-to-refresh). */
+ fun refreshWeatherData() {
+ logger.d("Starting pull-to-refresh")
+ _uiState.update { it.copy(isRefreshing = true) }
+ locationJob?.cancel()
+ locationJob =
+ viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) {
+ val prefs = preferenceManager.getUserPreferencesFlow().first()
+ if (prefs.isLocationOverridden) {
+ logger.d("Location override active โ refreshing with saved location")
+ performInitialDataLoading(forceRefresh = true)
+ return@launch
+ }
+ try {
+ locationClient.getCurrentLocation().fold(
+ onSuccess = { loc ->
+ logger.i("Location fetched for refresh - lat: ${loc.latitude}, lon: ${loc.longitude}")
+ preferenceManager.saveLocationPreferences(loc.latitude to loc.longitude)
+ performInitialDataLoading(forceRefresh = true)
+ },
+ onFailure = { e ->
+ logger.e("Location fetch failed during refresh", e)
+ val isGpsDisabled =
+ e is LocationClient.LocationException &&
+ e.message?.contains(
+ "GPS is disabled",
+ ignoreCase = true
+ ) == true
+ val error =
+ if (isGpsDisabled) {
+ UiText.StringResource(resId = R.string.gps_disabled_error_txt)
+ } else if (e is Exception) {
+ errorResponseFromException(e)
+ } else {
+ UiText.StringResource(resId = R.string.general_error_txt)
+ }
+ _uiState.update {
+ it.copy(
+ isRefreshing = false,
+ error = error,
+ isGpsDisabled = isGpsDisabled
+ )
+ }
+ },
+ )
+ } catch (_: CancellationException) {
+ logger.d("Pull-to-refresh cancelled")
+ } catch (e: Exception) {
+ logger.e("Error during pull-to-refresh", e)
+ _uiState.update {
+ it.copy(isRefreshing = false, error = errorResponseFromException(e))
+ }
+ }
+ }
+ }
+
+ /** Load weather and air quality data for UI. Uses override coordinates when active. */
+ private fun performInitialDataLoading(forceRefresh: Boolean = false) {
+ logger.d("Starting initial data loading (forceRefresh=$forceRefresh)")
+ dataLoadingJob?.cancel()
+ dataLoadingJob =
+ viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) {
+ try {
+ val prefs = preferenceManager.getUserPreferencesFlow().first()
+ val isOverridden = prefs.isLocationOverridden &&
+ prefs.overrideLat != null && prefs.overrideLon != null
+ val lat = if (isOverridden) prefs.overrideLat else prefs.latitude
+ val lon = if (isOverridden) prefs.overrideLon else prefs.longitude
+ val overrideName = if (isOverridden) prefs.overrideLocationName else null
+
+ if (lat != null && lon != null) {
+ fetchWeatherData(lat, lon, isOverridden, overrideName, forceRefresh)
+ } else {
+ logger.w("Location coordinates not found in preferences")
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ isRefreshing = false,
+ error = UiText.StringResource(R.string.default_coordinates_txt),
+ )
+ }
}
+ } catch (_: CancellationException) {
+ logger.d("Initial data loading cancelled")
+ } catch (e: Exception) {
+ logger.e("Error in initial data loading", e)
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ isRefreshing = false,
+ error = errorResponseFromException(e)
+ )
+ }
+ }
+ }
+ }
+
+ /** Fetches weather + air quality for the given coordinates and updates [_uiState]. */
+ private suspend fun fetchWeatherData(
+ lat: Double,
+ lon: Double,
+ isOverridden: Boolean,
+ overrideName: String?,
+ forceRefresh: Boolean,
+ ) {
+ val location = lat to lon
+ logger.d("Loading data for location - lat: $lat, lon: $lon, overridden: $isOverridden")
+
+ refreshWeatherReport(location, forceRefresh)
+ logger.v("Refreshed weather report cache")
+
+ getAirQuality(location.first, location.second)
+ .combine(getWeatherReport(location)) { air, weather ->
+ logger.d("Data loaded successfully")
+ UIState(
+ isLoading = false,
+ userLocation = location,
+ weatherData = weather,
+ airQualityData = air,
+ error = null,
+ isLocationOverridden = isOverridden,
+ activeLocationName = overrideName,
)
- } catch (e: CancellationException) {
- throw e // Rethrow cancellation exceptions
+ }.flowOn(dispatchers.io)
+ .catch { e ->
+ if (e is CancellationException) throw e
+ logger.e("Error loading weather data", e)
+ val error =
+ if (e is Exception) errorResponseFromException(e)
+ else UiText.StringResource(resId = R.string.general_error_txt)
+ _uiState.update { it.copy(isLoading = false, isRefreshing = false, error = error) }
+ }.collectLatest { state -> _uiState.value = state }
+ }
+
+ /** Login with email and password. */
+ fun login(
+ email: String,
+ password: String,
+ ) = launchAuth("Login", email) {
+ authRepository.login(email, password)
+ }
+
+ /** Register with email and password. */
+ fun register(
+ email: String,
+ password: String,
+ ) = launchAuth("Registration", email) {
+ authRepository.register(
+ email = email,
+ password = password,
+ timestampOfRegistration = deviceInfoProvider.getCurrentUtcTimestamp(),
+ deviceModel = deviceInfoProvider.getDeviceModel(),
+ operatingSystem = deviceInfoProvider.getOperatingSystem(),
+ osVersion = deviceInfoProvider.getOsVersion(),
+ appVersion = deviceInfoProvider.getAppVersion(),
+ registrationSource = deviceInfoProvider.getRegistrationSource(),
+ firebaseToken = deviceInfoProvider.getFirebaseToken(),
+ )
+ }
+
+ private fun launchAuth(
+ actionName: String,
+ email: String,
+ block: suspend () -> AuthResponse,
+ ) = viewModelScope.launch(dispatchers.io) {
+ logger.d("$actionName attempt for email: $email")
+ _authState.value = AuthState.Loading
+ try {
+ handleAuthResponse(block())
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ logger.e("$actionName failed for email: $email", e)
+ _authState.value =
+ AuthState.Error(UiText.DynamicText(e.message ?: "$actionName failed"))
+ }
+ }
+
+ /** Logout user. */
+ fun logout() =
+ viewModelScope.launch(dispatchers.io) {
+ logger.d("Logout initiated")
+ _authState.value = AuthState.LogoutLoading
+ authRepository.logout().fold(
+ onSuccess = {
+ logger.i("Logout successful")
+ weatherRepository.clearAllData()
+ preferenceManager.clearAll()
+ _authState.value = AuthState.LoggedOut
+ },
+ onFailure = { e ->
+ logger.e("Logout failed", e)
+ _authState.value =
+ AuthState.Error(UiText.DynamicText(e.message ?: "Logout failed"))
+ },
+ )
+ }
+
+ private suspend fun silentTokenRefresh() =
+ withContext(dispatchers.io) {
+ logger.d("Starting silent token refresh")
+ when (val result = tokenManager.refreshToken()) {
+ is TokenResult.Valid -> logger.i("Token refreshed successfully")
+ is TokenResult.NoToken -> {
+ tokenManager.forceLogout()
+ emit(AuthEvent.Unauthorized("Your session has expired. Please log in again."))
+ }
+
+ is TokenResult.InvalidToken -> {
+ tokenManager.forceLogout()
+ emit(AuthEvent.Unauthorized("Your session has expired. Please log in again."))
+ }
+
+ is TokenResult.Error -> {
+ logger.e("Silent token refresh error", result.exception)
+ emit(AuthEvent.Unauthorized("Your session has expired. Please log in again."))
+ }
+ }
+ }
+
+ /** Called on every app foreground to sync token and premium status with the server. */
+ fun refreshTokenOnForeground() =
+ viewModelScope.launch(dispatchers.io) {
+ try {
+ val response = authRepository.refreshToken() ?: return@launch
+ if (response.isSuccess()) {
+ val expiryMillis = response.data?.premiumExpiresAt?.let { parseIsoToMillis(it) }
+ val active = isPremiumActive(response.data?.premiumExpiresAt)
+ preferenceManager.savePremiumStatus(
+ isPremium = active,
+ expiryMillis = expiryMillis
+ )
+ } else {
+ // 400 Bad Request โ token is invalid, force logout
+ tokenManager.forceLogout()
+ emit(AuthEvent.Unauthorized("Your session has expired. Please log in again."))
+ }
+ } catch (_: CancellationException) {
+ // ignore
} catch (e: Exception) {
- Timber.tag(tag).e(e, "Error fetching location coordinates")
- _uiState.update { it.copy(error = UiText.DynamicText(e.message.toString())) }
+ logger.e("Foreground token refresh error", e)
+ }
+ }
+
+ private fun handleAuthResponse(response: AuthResponse) {
+ val data =
+ response.data
+ ?.takeIf { response.isSuccess() && it.token.isNotBlank() }
+ ?: run {
+ logger.w("Authentication failed - success: ${response.isSuccess()}")
+ _authState.value =
+ AuthState.Error(
+ UiText.DynamicText(
+ response.message ?: "Authentication failed"
+ )
+ )
+ return
+ }
+
+ logger.i("Authentication successful")
+
+ val premiumActive = isPremiumActive(data.premiumExpiresAt)
+ val expiryMillis = data.premiumExpiresAt?.let { parseIsoToMillis(it) }
+
+ if (!premiumActive) {
+ viewModelScope.launch(dispatchers.io) {
+ preferenceManager.savePremiumStatus(isPremium = false, expiryMillis = expiryMillis)
+ }
+ _authState.value = AuthState.Success
+ return
+ }
+
+ // Save premium status to preferences so PaymentViewModel observes the update reactively.
+ viewModelScope.launch(dispatchers.io) {
+ logger.i("User is premium, saving premium status")
+ val millis =
+ expiryMillis
+ ?: (Clock.System.now().toEpochMilliseconds() + 365L * 24 * 60 * 60 * 1000)
+ preferenceManager.savePremiumStatus(isPremium = true, expiryMillis = millis)
+ withContext(dispatchers.main) {
+ _authState.value = AuthState.Success
}
}
}
+ private fun parseIsoToMillis(isoDate: String): Long? =
+ try {
+ Instant.parse(isoDate).toEpochMilliseconds()
+ } catch (_: Exception) {
+ null
+ }
- /**
- * Performs initial data loading to prepare weather and air quality data for the UI.
- *
- * This method:
- * 1. Retrieves user location coordinates from preferences
- * 2. Refreshes weather data from remote source and saves to local database
- * 3. Combines air quality and weather data streams
- * 4. Updates the UI state with the combined data
- *
- * The method handles various error cases:
- * - Missing coordinates
- * - Network errors
- * - Data processing errors
- */
- private fun performInitialDataLoading() {
- // Cancel previous job if it exists
- dataLoadingJob?.cancel()
+ /** Reset authentication state. */
+ fun resetAuthState() {
+ logger.d("Auth state reset to Initial")
+ _authState.value = AuthState.Initial
+ }
- dataLoadingJob = viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) {
- try {
- // Get coordinates from preference
- val preferences = preferenceManager.getLocationPreferenceFlow().first()
- val latitude = preferences[PreferenceManager.USER_LAT_LOCATION]
- val longitude = preferences[PreferenceManager.USER_LON_LOCATION]
-
- if (latitude != null && longitude != null) {
- val location = Pair(latitude, longitude)
-
- // fetch and save weather report from remote to ROOM DB
- refreshWeatherReport(location)
-
- // zip both data streams and collect to populate on UI state data class.
- // Also update UI state about user's location coordinates
- getAirQuality(location.first, location.second)
- .combine(getWeatherReport.invoke(location)) { air, weather ->
- UIState(
+ // ============ Location Management ============
+
+ /** Load saved locations for the current user. */
+ fun loadSavedLocations() {
+ viewModelScope.launch(dispatchers.io) {
+ _savedLocationsState.update { it.copy(isLoading = true, error = null) }
+ savedLocationsUseCase.getSavedLocations().fold(
+ onSuccess = { locations ->
+ _savedLocationsState.update {
+ it.copy(
+ isLoading = false,
+ locations = locations,
+ )
+ }
+ },
+ onFailure = { e ->
+ if (e !is CancellationException) {
+ _savedLocationsState.update {
+ it.copy(
isLoading = false,
- userLocation = location,
- weatherData = weather,
- airQualityData = air,
- error = null
+ error = "Unable to load saved locations. Please try again later.",
)
}
- .flowOn(dispatchers.io)
- .catch { e ->
- if (e is CancellationException) throw e
- Timber.tag(tag).e(e, "Error loading weather data")
- _uiState.update {
- it.copy(
- isLoading = false,
- error = UiText.DynamicText(e.message.toString())
- )
- }
+ }
+ },
+ )
+ }
+ }
+
+ /** Save a new location. */
+ fun saveLocation(
+ name: String,
+ lat: Double,
+ lon: Double,
+ ) {
+ viewModelScope.launch(dispatchers.io) {
+ _savedLocationsState.update { it.copy(isLoading = true, error = null) }
+ savedLocationsUseCase.saveLocation(name, lat, lon).fold(
+ onSuccess = {
+ _savedLocationsState.update {
+ it.copy(
+ isLoading = false,
+ successMessage = "Location saved successfully",
+ )
+ }
+ loadSavedLocations()
+ },
+ onFailure = { e ->
+ if (e !is CancellationException) {
+ _savedLocationsState.update {
+ it.copy(
+ isLoading = false,
+ error = "Unable to save location. Please try again later.",
+ )
}
- .onEach { newState -> _uiState.update { newState } }
- .launchIn(this)
- } else {
- // in case we don't have coordinates, update UI state with appropriate error message
- _uiState.update {
- UIState(
- isLoading = false,
- error = UiText.StringResource(R.string.default_coordinates_txt)
- )
}
- }
- } catch (e: CancellationException) {
- throw e // Rethrow cancellation exceptions
+ },
+ )
+ }
+ }
+
+ /** Delete a saved location by ID. */
+ fun deleteLocation(id: String) {
+ viewModelScope.launch(dispatchers.io) {
+ _savedLocationsState.update { it.copy(isLoading = true, error = null) }
+ savedLocationsUseCase.deleteLocation(id).fold(
+ onSuccess = {
+ _savedLocationsState.update {
+ it.copy(
+ isLoading = false,
+ successMessage = "Location deleted successfully",
+ )
+ }
+ loadSavedLocations()
+ },
+ onFailure = { e ->
+ if (e !is CancellationException) {
+ _savedLocationsState.update {
+ it.copy(
+ isLoading = false,
+ error = "Unable to delete location. Please try again later.",
+ )
+ }
+ }
+ },
+ )
+ }
+ }
+
+ /** Update place search query. */
+ fun onPlaceSearchQueryChanged(query: String) {
+ _placeSearchState.update { it.copy(searchQuery = query, error = null) }
+ _queryFlow.value = query
+ if (query.length < 2) {
+ _placeSearchState.update { it.copy(results = emptyList(), isLoading = false) }
+ }
+ }
+
+ /** Clear place search results. */
+ fun clearPlaceSearch() {
+ _placeSearchState.value = PlaceSearchUiState()
+ _queryFlow.value = ""
+ }
+
+ /** Clear location success/error messages. */
+ fun clearLocationMessage() {
+ _savedLocationsState.update { it.copy(error = null, successMessage = null) }
+ }
+
+ /** Pin a saved location as the default weather source and reload weather data. */
+ fun setDefaultLocation(lat: Double, lon: Double, name: String) {
+ _uiState.update { UIState(isLoading = true) }
+ dataLoadingJob?.cancel()
+ dataLoadingJob = viewModelScope.launch(dataFetchExceptionHandler + dispatchers.io) {
+ try {
+ logger.i("Setting default location override: $name ($lat, $lon)")
+ preferenceManager.saveLocationOverride(lat, lon, name)
+ // Use coordinates directly โ avoids DataStore re-read race condition
+ fetchWeatherData(
+ lat,
+ lon,
+ isOverridden = true,
+ overrideName = name,
+ forceRefresh = true
+ )
+ } catch (_: CancellationException) {
+ logger.d("setDefaultLocation cancelled")
} catch (e: Exception) {
- Timber.tag(tag).e(e, "Error in initial data loading")
- _uiState.update {
+ logger.e("Error setting default location", e)
+ _uiState.update {
it.copy(
isLoading = false,
- error = UiText.DynamicText(e.message.toString())
- )
+ error = errorResponseFromException(e)
+ )
}
}
}
}
- /**
- * Cleans up resources when the ViewModel is cleared.
- * Cancels all active coroutine jobs to prevent memory leaks and unnecessary work.
- */
+ /** Clear the pinned location override and revert to live GPS. */
+ fun clearLocationOverride() {
+ viewModelScope.launch(dispatchers.io) {
+ logger.i("Clearing location override โ reverting to GPS")
+ preferenceManager.clearLocationOverride()
+ withContext(dispatchers.main) {
+ fetchAndSaveLocationCoordinates()
+ }
+ }
+ }
+
+ /** Fetch place suggestions for given query. */
+ private suspend fun fetchPlaceSuggestions(query: String) {
+ _placeSearchState.update { it.copy(isLoading = true, error = null) }
+ searchPlacesUseCase(query).fold(
+ onSuccess = { suggestions ->
+ _placeSearchState.update { it.copy(isLoading = false, results = suggestions) }
+ },
+ onFailure = { e ->
+ if (e !is CancellationException) {
+ _placeSearchState.update {
+ it.copy(
+ isLoading = false,
+ error = "Unable to fetch places. Please try again.",
+ )
+ }
+ }
+ },
+ )
+ }
+
override fun onCleared() {
super.onCleared()
- // Cancel all active jobs when ViewModel is cleared
+ logger.d("MainViewModel cleared - cancelling all jobs")
notificationBannerJob?.cancel()
locationJob?.cancel()
dataLoadingJob?.cancel()
}
}
+
+/** Authentication state. */
+sealed class AuthState {
+ object Initial : AuthState()
+
+ object Loading : AuthState()
+
+ object LogoutLoading : AuthState()
+
+ object Success : AuthState()
+
+ object LoggedOut : AuthState()
+
+ data class Error(
+ val message: UiText,
+ ) : AuthState()
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/SettingsViewModel.kt b/app/src/main/java/bose/ankush/weatherify/presentation/SettingsViewModel.kt
new file mode 100644
index 00000000..11c9d445
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/SettingsViewModel.kt
@@ -0,0 +1,78 @@
+package bose.ankush.weatherify.presentation
+
+import androidx.lifecycle.ViewModel
+import bose.ankush.commonui.settings.SettingsScreenState
+import bose.ankush.commonui.viewmodel.ServiceSubscriptionViewModel
+import bose.ankush.network.repository.ServiceRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import javax.inject.Inject
+
+sealed class SettingsEvent {
+ data object OpenPremiumSheet : SettingsEvent()
+
+ data object ClosePremiumSheet : SettingsEvent()
+
+ data object OpenLogoutDialog : SettingsEvent()
+
+ data object CloseLogoutDialog : SettingsEvent()
+
+ data object DismissPremiumToast : SettingsEvent()
+
+ data class OpenWebUrl(
+ val url: String,
+ ) : SettingsEvent()
+
+ data object CloseWebView : SettingsEvent()
+}
+
+@HiltViewModel
+class SettingsViewModel
+@Inject
+constructor(
+ private val serviceRepository: ServiceRepository,
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(SettingsScreenState())
+ val uiState: StateFlow = _uiState
+
+ val serviceSubscriptionViewModel by lazy {
+ ServiceSubscriptionViewModel(repository = serviceRepository)
+ }
+
+ fun handleEvent(event: SettingsEvent) {
+ when (event) {
+ SettingsEvent.OpenPremiumSheet -> {
+ _uiState.value = _uiState.value.copy(showPremiumBottomSheet = true)
+ }
+
+ SettingsEvent.ClosePremiumSheet -> {
+ _uiState.value = _uiState.value.copy(showPremiumBottomSheet = false)
+ }
+
+ SettingsEvent.OpenLogoutDialog -> {
+ _uiState.value = _uiState.value.copy(showLogoutDialog = true)
+ }
+
+ SettingsEvent.CloseLogoutDialog -> {
+ _uiState.value = _uiState.value.copy(showLogoutDialog = false)
+ }
+
+ SettingsEvent.DismissPremiumToast -> {
+ _uiState.value = _uiState.value.copy(showPremiumActivationToast = false)
+ }
+
+ is SettingsEvent.OpenWebUrl -> {
+ _uiState.value = _uiState.value.copy(currentWebUrl = event.url)
+ }
+
+ SettingsEvent.CloseWebView -> {
+ _uiState.value = _uiState.value.copy(currentWebUrl = null)
+ }
+ }
+ }
+
+ fun showPremiumActivationToast() {
+ _uiState.value = _uiState.value.copy(showPremiumActivationToast = true)
+ }
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/UIState.kt b/app/src/main/java/bose/ankush/weatherify/presentation/UIState.kt
index 0c2b4966..27e54b51 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/UIState.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/UIState.kt
@@ -1,8 +1,8 @@
package bose.ankush.weatherify.presentation
import bose.ankush.weatherify.base.common.UiText
-import bose.ankush.weatherify.domain.model.WeatherForecast
import bose.ankush.weatherify.domain.model.AirQuality
+import bose.ankush.weatherify.domain.model.WeatherForecast
/**
* Data class representing the UI state for the weather application.
@@ -16,8 +16,12 @@ import bose.ankush.weatherify.domain.model.AirQuality
*/
data class UIState(
val isLoading: Boolean = false,
+ val isRefreshing: Boolean = false,
val userLocation: Pair? = null,
val weatherData: WeatherForecast? = null,
val airQualityData: AirQuality? = null,
- val error: UiText? = null
+ val error: UiText? = null,
+ val isGpsDisabled: Boolean = false,
+ val isLocationOverridden: Boolean = false,
+ val activeLocationName: String? = null,
)
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesListScreen.kt b/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesListScreen.kt
index 12e4b33a..ec686e74 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesListScreen.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesListScreen.kt
@@ -1,9 +1,19 @@
package bose.ankush.weatherify.presentation.cities
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.*
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -21,11 +31,9 @@ import bose.ankush.weatherify.presentation.home.state.ShowLoading
import bose.ankush.weatherify.presentation.navigation.Screen
@Composable
-fun CitiesListScreen(
- navController: NavController,
-) {
+fun CitiesListScreen(navController: NavController) {
Box(
- modifier = Modifier.fillMaxSize()
+ modifier = Modifier.fillMaxSize(),
) {
Scaffold(
topBar = {
@@ -36,11 +44,11 @@ fun CitiesListScreen(
},
content = { innerPadding ->
Column(
- modifier = Modifier.padding(innerPadding)
+ modifier = Modifier.padding(innerPadding),
) {
CityNameSearchBarWithList(navController)
}
- }
+ },
)
}
}
@@ -53,34 +61,38 @@ private fun CityNameSearchBarWithList(navController: NavController) {
val cityName by viewModels.cityName.collectAsState()
Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(10.dp)
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(10.dp),
) {
TextField(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(10.dp)),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(10.dp)),
value = searchText,
onValueChange = viewModels::onSearchTextChange,
placeholder = { Text(text = stringResource(id = R.string.select_city) + "...") },
- colors = TextFieldDefaults.colors(
- focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
- focusedIndicatorColor = Color.Transparent,
- unfocusedIndicatorColor = Color.Transparent,
- cursorColor = MaterialTheme.colorScheme.onSecondaryContainer,
- focusedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
- focusedPlaceholderColor = MaterialTheme.colorScheme.onSecondaryContainer
- )
+ colors =
+ TextFieldDefaults.colors(
+ focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ cursorColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ focusedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ focusedPlaceholderColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ ),
)
Spacer(modifier = Modifier.height(10.dp))
if (isSearching) {
ShowLoading(modifier = Modifier.fillMaxSize())
} else {
LazyColumn(
- modifier = Modifier
- .fillMaxWidth()
- .weight(1f)
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .weight(1f),
) {
items(cityName.size) {
CityListItem(cityNameList = cityName, position = it) { _, name ->
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesViewModel.kt b/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesViewModel.kt
index 3194b130..4dfdf036 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesViewModel.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/cities/CitiesViewModel.kt
@@ -6,14 +6,22 @@ import bose.ankush.weatherify.domain.model.CityName
import bose.ankush.weatherify.domain.use_case.get_cities.GetCityNames
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
import javax.inject.Inject
@HiltViewModel
-class CitiesViewModel @Inject constructor(
- getCityNames: GetCityNames
+class CitiesViewModel
+@Inject
+constructor(
+ getCityNames: GetCityNames,
) : ViewModel() {
-
var searchText = MutableStateFlow("")
private set
@@ -23,21 +31,24 @@ class CitiesViewModel @Inject constructor(
private val cityNameList = MutableStateFlow(getCityNames())
@OptIn(FlowPreview::class)
- val cityName: StateFlow> = searchText
- .debounce(500L)
- .onEach { isSearching.update { true } }
- .combine(cityNameList) { text, city ->
- if (text.isBlank()) city
- else city.filter { it.doesMatchSearchQuery(text) }
- }
- .onEach { isSearching.update { false } }
- .stateIn(
- viewModelScope,
- SharingStarted.WhileSubscribed(5000),
- cityNameList.value
- )
+ val cityName: StateFlow> =
+ searchText
+ .debounce(500L)
+ .onEach { isSearching.update { true } }
+ .combine(cityNameList) { text, city ->
+ if (text.isBlank()) {
+ city
+ } else {
+ city.filter { it.doesMatchSearchQuery(text) }
+ }
+ }.onEach { isSearching.update { false } }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5000),
+ cityNameList.value,
+ )
fun onSearchTextChange(text: String) {
searchText.value = text
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/cities/component/CityListItem.kt b/app/src/main/java/bose/ankush/weatherify/presentation/cities/component/CityListItem.kt
index 700dc1b8..4f330468 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/cities/component/CityListItem.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/cities/component/CityListItem.kt
@@ -6,7 +6,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
-import androidx.compose.runtime.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@@ -17,23 +21,25 @@ import bose.ankush.weatherify.domain.model.CityName
internal fun CityListItem(
cityNameList: List,
position: Int,
- onItemClick: (Int, String) -> Unit
+ onItemClick: (Int, String) -> Unit,
) {
var selectedItem: Int? by remember { mutableStateOf(null) }
val cityName = cityNameList[position].name ?: DEFAULT_CITY_NAME
+ val bgColor =
+ if (selectedItem != position) Color.Transparent else MaterialTheme.colorScheme.inversePrimary
Text(
text = cityName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 13.dp, end = 16.dp)
- .clickable {
- selectedItem = position
- onItemClick(position, cityName)
- }
- .background(if (selectedItem != position) Color.Transparent else MaterialTheme.colorScheme.inversePrimary)
- .padding(start = 3.dp, top = 10.dp, bottom = 10.dp)
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 13.dp, end = 16.dp)
+ .clickable {
+ selectedItem = position
+ onItemClick(position, cityName)
+ }
+ .background(bgColor)
+ .padding(start = 3.dp, top = 10.dp, bottom = 10.dp),
)
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/AirQualityDetailsScreen.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/AirQualityDetailsScreen.kt
deleted file mode 100644
index 5f68d6d7..00000000
--- a/app/src/main/java/bose/ankush/weatherify/presentation/home/AirQualityDetailsScreen.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package bose.ankush.weatherify.presentation.home
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.navigation.NavController
-import bose.ankush.weatherify.R
-import bose.ankush.weatherify.base.common.component.ScreenTopAppBar
-import bose.ankush.weatherify.presentation.MainViewModel
-
-@Composable
-internal fun AirQualityDetailsScreen(
- viewModel: MainViewModel,
- navController: NavController
-) {
- val userLocation by rememberSaveable { mutableStateOf(viewModel.uiState.value.userLocation) }
- Box(
- contentAlignment = Alignment.Center,
- modifier = Modifier.fillMaxSize()
- ) {
- Scaffold(
- topBar = {
- ScreenTopAppBar(
- headlineId = R.string.air_quality,
- navIconAction = { navController.popBackStack() }
- )
- },
- content = { innerPadding ->
- Column(
- modifier = Modifier.padding(innerPadding),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- if (userLocation != null) {
- Text(
- text = "Your current location coordinate is: ${userLocation?.first}, ${userLocation?.second}",
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onBackground,
- )
- }
- }
- }
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/HomeScreen.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/HomeScreen.kt
index 77a40016..1a32f5e8 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/home/HomeScreen.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/HomeScreen.kt
@@ -12,24 +12,40 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.LocationOn
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.AssistChipDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
-import bose.ankush.sunriseui.components.SunriseSunsetCombinedAnimation
+import bose.ankush.commonui.components.SunriseSunsetCombinedAnimation
+import bose.ankush.commonui.components.ToastAnchorState
+import bose.ankush.commonui.permissions.PermissionAlertDialog
import bose.ankush.weatherify.R
+import bose.ankush.weatherify.base.common.Extension.openLocationSettings
import bose.ankush.weatherify.base.common.UiText
import bose.ankush.weatherify.presentation.MainViewModel
import bose.ankush.weatherify.presentation.UIState
@@ -37,6 +53,8 @@ import bose.ankush.weatherify.presentation.home.component.BriefAirQualityReportC
import bose.ankush.weatherify.presentation.home.component.CurrentWeatherReportLayout
import bose.ankush.weatherify.presentation.home.component.DailyWeatherForecastReportLayout
import bose.ankush.weatherify.presentation.home.component.HourlyWeatherForecastReportLayout
+import bose.ankush.weatherify.presentation.home.component.WeatherAlertLayout
+import bose.ankush.weatherify.presentation.home.state.ErrorBackgroundAnimation
import bose.ankush.weatherify.presentation.home.state.ShowError
import bose.ankush.weatherify.presentation.home.state.ShowLoading
import bose.ankush.weatherify.presentation.navigation.AppBottomBar
@@ -45,25 +63,44 @@ import kotlinx.coroutines.delay
@Composable
fun HomeScreen(
viewModel: MainViewModel,
- navController: NavController
+ navController: NavController,
+ toastAnchorState: ToastAnchorState? = null,
) {
val context: Context = LocalContext.current
val uiState: UIState = viewModel.uiState.collectAsState().value
+ val showNotificationCard = viewModel.showNotificationCardItem.collectAsState().value
+ val isNotificationPermissionPermanentlyDeclined =
+ viewModel.isNotificationPermissionPermanentlyDeclined.collectAsState().value
// reacting as per response state change
when {
!uiState.error?.asString(context).isNullOrEmpty() -> {
// Screen error handler
HandleScreenError(
- context,
- uiState.error
+ context = context,
+ errorText = uiState.error,
+ isLoading = uiState.isLoading,
+ isGpsDisabled = uiState.isGpsDisabled,
) { viewModel.fetchAndSaveLocationCoordinates() }
}
- uiState.weatherData?.current?.weather?.isNotEmpty() == true ||
+ uiState.weatherData
+ ?.current
+ ?.weather
+ ?.isNotEmpty() == true ||
uiState.airQualityData != null -> {
// Show data on UI
- ShowUIContainer(uiState, navController)
+ ShowUIContainer(
+ uiState = uiState,
+ navController = navController,
+ toastAnchorState = toastAnchorState,
+ showNotificationCard = showNotificationCard,
+ isNotificationPermissionPermanentlyDeclined = isNotificationPermissionPermanentlyDeclined,
+ onEnableNotificationClick = { viewModel.updateNotificationPermission(true) },
+ onDismissNotificationClick = { viewModel.updateShowNotificationBannerState(false) },
+ onRefresh = { viewModel.refreshWeatherData() },
+ onResetLocationOverride = { viewModel.clearLocationOverride() },
+ )
}
else -> {
@@ -87,28 +124,57 @@ fun HandleScreenLoading() {
fun HandleScreenError(
context: Context,
errorText: UiText?,
- onErrorAction: () -> Unit
+ isLoading: Boolean = false,
+ isGpsDisabled: Boolean = false,
+ onErrorAction: () -> Unit,
) {
- ShowError(
- modifier = Modifier
- .fillMaxSize()
- .padding(all = 16.dp),
- msg = errorText?.asString(context),
- buttonText = stringResource(id = R.string.retry_btn_txt),
- buttonAction = onErrorAction
- )
+ Box(modifier = Modifier.fillMaxSize()) {
+ ErrorBackgroundAnimation()
+
+ ShowError(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(all = 16.dp),
+ msg = errorText?.asString(context),
+ buttonText =
+ if (isGpsDisabled) {
+ stringResource(id = R.string.enable_gps_btn_txt)
+ } else {
+ stringResource(id = R.string.retry_btn_txt)
+ },
+ isLoading = isLoading,
+ buttonAction =
+ if (isGpsDisabled) {
+ { context.openLocationSettings() }
+ } else {
+ onErrorAction
+ },
+ )
+ }
}
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ShowUIContainer(
uiState: UIState,
- navController: NavController
+ navController: NavController,
+ toastAnchorState: ToastAnchorState? = null,
+ showNotificationCard: Boolean = false,
+ isNotificationPermissionPermanentlyDeclined: Boolean = false,
+ onEnableNotificationClick: () -> Unit = {},
+ onDismissNotificationClick: () -> Unit = {},
+ onRefresh: () -> Unit = {},
+ onResetLocationOverride: () -> Unit = {},
) {
val weatherReports = uiState.weatherData
val airQualityReports = uiState.airQualityData
+ val pullToRefreshState = rememberPullToRefreshState()
+
// Create transition states for animations
val currentWeatherTransitionState = remember { MutableTransitionState(false) }
+ val alertsTransitionState = remember { MutableTransitionState(false) }
val airQualityTransitionState = remember { MutableTransitionState(false) }
val hourlyForecastTransitionState = remember { MutableTransitionState(false) }
val dailyForecastTransitionState = remember { MutableTransitionState(false) }
@@ -117,6 +183,7 @@ private fun ShowUIContainer(
LaunchedEffect(weatherReports, airQualityReports) {
// Reset states first
currentWeatherTransitionState.targetState = false
+ alertsTransitionState.targetState = false
airQualityTransitionState.targetState = false
hourlyForecastTransitionState.targetState = false
dailyForecastTransitionState.targetState = false
@@ -125,13 +192,16 @@ private fun ShowUIContainer(
delay(100) // Small initial delay
currentWeatherTransitionState.targetState = true
- delay(200) // Delay for air quality
+ delay(150) // Delay for alerts (prioritize showing alerts early)
+ alertsTransitionState.targetState = true
+
+ delay(150) // Delay for air quality
airQualityTransitionState.targetState = true
- delay(300) // Delay for hourly forecast
+ delay(150) // Delay for hourly forecast
hourlyForecastTransitionState.targetState = true
- delay(400) // Delay for daily forecast
+ delay(150) // Delay for daily forecast
dailyForecastTransitionState.targetState = true
}
@@ -139,99 +209,182 @@ private fun ShowUIContainer(
// Add the SunriseSunsetCombinedAnimation as a full-screen background
weatherReports?.current?.let { currentWeather ->
SunriseSunsetCombinedAnimation(
- sunriseTimestamp = currentWeather.sunrise?.toLong(),
- sunsetTimestamp = currentWeather.sunset?.toLong(),
- currentTimestamp = System.currentTimeMillis() / 1000
+ sunriseTimestamp = currentWeather.sunrise,
+ sunsetTimestamp = currentWeather.sunset,
+ currentTimestamp = System.currentTimeMillis() / 1000,
+ )
+ }
+
+ if (showNotificationCard) {
+ PermissionAlertDialog(
+ descriptionText = stringResource(R.string.notification_permission_message),
+ isPermanentlyDeclined = isNotificationPermissionPermanentlyDeclined,
+ onPositiveAction = onEnableNotificationClick,
+ onNegativeAction = onDismissNotificationClick,
+ positiveButtonLabel = stringResource(R.string.enable_notification_btn),
+ negativeButtonLabel = stringResource(R.string.cancel_btn_txt),
)
}
Scaffold(
containerColor = Color.Transparent, // Make the scaffold background transparent
content = { innerPadding ->
- LazyColumn(
+ PullToRefreshBox(
+ isRefreshing = uiState.isRefreshing,
+ onRefresh = onRefresh,
+ state = pullToRefreshState,
modifier = Modifier.fillMaxSize(),
- contentPadding = innerPadding,
- verticalArrangement = Arrangement.spacedBy(8.dp),
- // Add state key to prevent unnecessary recompositions
- state = rememberLazyListState()
) {
- // Show current weather report - prioritize loading this first
- item(key = "current_weather") {
- weatherReports?.current?.let {
- AnimatedVisibility(
- visibleState = currentWeatherTransitionState,
- enter = fadeIn(animationSpec = tween(durationMillis = 500)) +
- slideInVertically(
- animationSpec = tween(durationMillis = 500),
- initialOffsetY = { it / 3 }
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = innerPadding,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ // Add state key to prevent unnecessary recompositions
+ state = rememberLazyListState(),
+ ) {
+ // Show override chip when a saved location is pinned
+ if (uiState.isLocationOverridden && uiState.activeLocationName != null) {
+ item(key = "location_override_chip") {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ AssistChip(
+ onClick = onResetLocationOverride,
+ label = {
+ Text(
+ text = "${uiState.activeLocationName} ยท ${
+ stringResource(
+ R.string.location_override_reset_btn
+ )
+ }",
+ )
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.LocationOn,
+ contentDescription = stringResource(
+ R.string.location_override_chip_content_desc,
+ uiState.activeLocationName,
+ ),
+ modifier = Modifier.size(AssistChipDefaults.IconSize),
+ )
+ },
+ colors = AssistChipDefaults.assistChipColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ labelColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ leadingIconContentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
- exit = fadeOut()
- ) {
- CurrentWeatherReportLayout(
- it,
- uiState.userLocation,
- weatherReports.daily?.firstOrNull()?.summary
- )
+ )
+ }
}
}
- }
- // Show brief air quality report
- item(key = "air_quality") {
- airQualityReports?.let {
- AnimatedVisibility(
- visibleState = airQualityTransitionState,
- enter = fadeIn(animationSpec = tween(durationMillis = 500)) +
- slideInVertically(
- animationSpec = tween(durationMillis = 500),
- initialOffsetY = { it / 3 }
- ),
- exit = fadeOut()
- ) {
- BriefAirQualityReportCardLayout(airQualityReports, navController)
+ // Show current weather report - prioritize loading this first
+ item(key = "current_weather") {
+ weatherReports?.current?.let {
+ AnimatedVisibility(
+ visibleState = currentWeatherTransitionState,
+ enter =
+ fadeIn(animationSpec = tween(durationMillis = 500)) +
+ slideInVertically(
+ animationSpec = tween(durationMillis = 500),
+ initialOffsetY = { it / 3 },
+ ),
+ exit = fadeOut(),
+ ) {
+ CurrentWeatherReportLayout(
+ it,
+ uiState.userLocation,
+ weatherReports.daily?.firstOrNull()?.summary,
+ )
+ }
}
}
- }
- // Show hourly weather forecast report
- item(key = "hourly_forecast") {
- weatherReports?.hourly?.let {
- AnimatedVisibility(
- visibleState = hourlyForecastTransitionState,
- enter = fadeIn(animationSpec = tween(durationMillis = 500)) +
- slideInVertically(
- animationSpec = tween(durationMillis = 500),
- initialOffsetY = { it / 3 }
- ),
- exit = fadeOut()
- ) {
- HourlyWeatherForecastReportLayout(it)
+ // Show weather alerts if available
+ item(key = "weather_alerts") {
+ weatherReports?.alerts?.let { alerts ->
+ AnimatedVisibility(
+ visibleState = alertsTransitionState,
+ enter =
+ fadeIn(animationSpec = tween(durationMillis = 500)) +
+ slideInVertically(
+ animationSpec = tween(durationMillis = 500),
+ initialOffsetY = { it / 3 },
+ ),
+ exit = fadeOut(),
+ ) {
+ WeatherAlertLayout(alerts = alerts)
+ }
}
}
- }
- // Show next 8 day's weather forecast report
- item(key = "daily_forecast") {
- weatherReports?.daily?.let { list ->
- AnimatedVisibility(
- visibleState = dailyForecastTransitionState,
- enter = fadeIn(animationSpec = tween(durationMillis = 500)) +
- slideInVertically(
- animationSpec = tween(durationMillis = 500),
- initialOffsetY = { it / 3 }
- ),
- exit = fadeOut()
- ) {
- DailyWeatherForecastReportLayout(list)
+ // Show brief air quality report
+ item(key = "air_quality") {
+ airQualityReports?.takeIf { it.aqi > 0 }?.let { aq ->
+ AnimatedVisibility(
+ visibleState = airQualityTransitionState,
+ enter =
+ fadeIn(animationSpec = tween(durationMillis = 500)) +
+ slideInVertically(
+ animationSpec = tween(durationMillis = 500),
+ initialOffsetY = { it / 3 },
+ ),
+ exit = fadeOut(),
+ ) {
+ BriefAirQualityReportCardLayout(aq)
+ }
+ }
+ }
+
+ // Show hourly weather forecast report
+ item(key = "hourly_forecast") {
+ weatherReports?.hourly?.let {
+ AnimatedVisibility(
+ visibleState = hourlyForecastTransitionState,
+ enter =
+ fadeIn(animationSpec = tween(durationMillis = 500)) +
+ slideInVertically(
+ animationSpec = tween(durationMillis = 500),
+ initialOffsetY = { it / 3 },
+ ),
+ exit = fadeOut(),
+ ) {
+ HourlyWeatherForecastReportLayout(it)
+ }
+ }
+ }
+
+ // Show next 8 day's weather forecast report
+ item(key = "daily_forecast") {
+ weatherReports?.daily?.let { list ->
+ AnimatedVisibility(
+ visibleState = dailyForecastTransitionState,
+ enter =
+ fadeIn(animationSpec = tween(durationMillis = 500)) +
+ slideInVertically(
+ animationSpec = tween(durationMillis = 500),
+ initialOffsetY = { it / 3 },
+ ),
+ exit = fadeOut(),
+ ) {
+ DailyWeatherForecastReportLayout(list)
+ }
}
}
}
- }
- }, bottomBar = {
+ } // end PullToRefreshBox
+ },
+ bottomBar = {
AppBottomBar(
isVisible = rememberSaveable { mutableStateOf(true) },
- navController = navController
+ navController = navController,
+ toastAnchorState = toastAnchorState,
)
- })
+ },
+ )
}
}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/BriefAirQualityReportCardLayout.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/BriefAirQualityReportCardLayout.kt
index cbd63494..f205b2bc 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/BriefAirQualityReportCardLayout.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/BriefAirQualityReportCardLayout.kt
@@ -1,37 +1,62 @@
package bose.ankush.weatherify.presentation.home.component
import android.annotation.SuppressLint
+import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.Divider
+import androidx.compose.material3.DividerDefaults
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
-import androidx.compose.material3.surfaceColorAtElevation
+import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.navigation.NavController
-import bose.ankush.weatherify.R
import bose.ankush.weatherify.base.common.AirQualityIndexAnalyser.getAQIAnalysedText
import bose.ankush.weatherify.base.common.AirQualityIndexAnalyser.getFormattedAQI
import bose.ankush.weatherify.domain.model.AirQuality
-import bose.ankush.weatherify.presentation.navigation.Screen
+
+private data class AqiUiState(
+ val statusText: String,
+ val qualityColor: Color,
+ val formattedAqi: String,
+)
+
+@Composable
+private fun rememberAqiUiState(aqi: Int): AqiUiState =
+ remember(aqi) {
+ val (fullStatusText, _) = getAQIAnalysedText(aqi)
+ // Convert OpenWeatherMap 1-6 scale to EPA 0-500 scale for color mapping
+ val epaAqi = convertOwmAqiToEpa(aqi)
+ AqiUiState(
+ statusText = fullStatusText.split(" at").firstOrNull() ?: "",
+ qualityColor = getAirQualityColor(epaAqi),
+ formattedAqi = aqi.getFormattedAQI(),
+ )
+ }
/**
* This composable is response to show air quality card on HomeScreen.
@@ -39,174 +64,223 @@ import bose.ankush.weatherify.presentation.navigation.Screen
*/
@SuppressLint("MissingPermission")
@Composable
-internal fun BriefAirQualityReportCardLayout(
- airQuality: AirQuality,
- navController: NavController
-) {
- ShowUI(
- aq = airQuality,
- onItemClick = { navController.navigate(Screen.AirQualityDetailsScreen.route) }
- )
-}
-
-/**
- * Air quality UI composable
- * This composable has onClick listener, with action to navigate to AirQualityDetailsScreen,
- * and carry latitude and longitude as navigation arguments
- */
-@Composable
-private fun ShowUI(
- aq: AirQuality, onItemClick: () -> Unit
-) {
- // Pre-calculate values that don't change during composition
- // Use remember to cache these values based on aq.aqi
- val (fullStatusText, _) = remember(aq.aqi) { getAQIAnalysedText(aq.aqi) }
- val qualityColor = remember(aq.aqi) { getAirQualityColor(aq.aqi) }
- val statusText = remember(fullStatusText) { fullStatusText.split(" at")[0] }
- val qualityColorAlpha = remember(qualityColor) { qualityColor.copy(alpha = 0.2f) }
-
- // Pre-calculate pollutant values
- val pm25Value = remember(aq.pm25) { "${aq.pm25.toInt()} ฮผg/mยณ" }
- val coValue = remember(aq.co) { "${aq.co.toInt()} ฮผg/mยณ" }
- val o3Value = remember(aq.o3) { "${aq.o3.toInt()} ฮผg/mยณ" }
-
- // Pre-calculate formatted AQI
- val formattedAQI = remember(aq.aqi) { aq.aqi.getFormattedAQI() }
+internal fun BriefAirQualityReportCardLayout(airQuality: AirQuality) {
+ val aqiUiState = rememberAqiUiState(airQuality.aqi)
+ var isExpanded by remember { mutableStateOf(false) }
Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp)
- .clickable { onItemClick() },
- shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
- )
+ onClick = { isExpanded = !isExpanded },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .animateContentSize(),
+ shape = RoundedCornerShape(24.dp),
) {
Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(20.dp),
- horizontalAlignment = Alignment.CenterHorizontally
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
) {
- // Air Quality Status Indicator
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth()
- ) {
- Box(
- modifier = Modifier
- .size(16.dp)
- .clip(CircleShape)
- .background(qualityColor)
- )
-
- Spacer(modifier = Modifier.width(8.dp))
-
- Text(
- text = "Air Quality",
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface,
- fontWeight = FontWeight.Medium
- )
- }
-
+ AqiSummary(aqiUiState = aqiUiState, isExpanded = isExpanded)
Spacer(modifier = Modifier.height(16.dp))
+ HorizontalDivider(
+ Modifier,
+ DividerDefaults.Thickness,
+ color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ if (isExpanded) {
+ ExpandedPollutantsDetails(airQuality = airQuality)
+ } else {
+ KeyPollutants(airQuality = airQuality)
+ }
+ }
+ }
+}
- // AQI Value and Status
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column {
- Text(
- text = formattedAQI,
- style = MaterialTheme.typography.displayMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface
- )
-
- Text(
- text = "AQI",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
- )
- }
+@Composable
+private fun AqiSummary(
+ aqiUiState: AqiUiState,
+ isExpanded: Boolean,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .size(72.dp)
+ .background(aqiUiState.qualityColor, shape = CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = aqiUiState.formattedAqi,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = contentColorFor(backgroundColor = aqiUiState.qualityColor),
+ )
+ }
- Surface(
- color = qualityColorAlpha,
- shape = RoundedCornerShape(8.dp)
- ) {
- Text(
- text = statusText,
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.Medium,
- color = qualityColor,
- modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
- )
- }
- }
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = "Air Quality",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = aqiUiState.statusText,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = if (isExpanded) "Collapse" else "Expand",
+ modifier = Modifier.rotate(if (isExpanded) 180f else 0f),
+ )
+ }
+}
- Spacer(modifier = Modifier.height(16.dp))
+@Composable
+private fun KeyPollutants(airQuality: AirQuality) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceAround,
+ ) {
+ PollutantItem(name = "PM2.5", value = airQuality.pm25.toInt().toString())
+ PollutantItem(name = "CO", value = airQuality.co.toInt().toString())
+ PollutantItem(name = "Oโ", value = airQuality.o3.toInt().toString())
+ }
+ }
+}
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- PollutantItem(name = "PM2.5", value = pm25Value)
- PollutantItem(name = "CO", value = coValue)
- PollutantItem(name = "Oโ", value = o3Value)
- }
+@Composable
+fun ExpandedPollutantsDetails(airQuality: AirQuality) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ PollutantItem(
+ modifier = Modifier.weight(1f),
+ name = "CO",
+ value = airQuality.co.toInt().toString(),
+ )
+ PollutantItem(
+ modifier = Modifier.weight(1f),
+ name = "NOโ",
+ value = airQuality.no2.toInt().toString(),
+ )
}
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ PollutantItem(
+ modifier = Modifier.weight(1f),
+ name = "Oโ",
+ value = airQuality.o3.toInt().toString(),
+ )
+ PollutantItem(
+ modifier = Modifier.weight(1f),
+ name = "SOโ",
+ value = airQuality.so2.toInt().toString(),
+ )
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ PollutantItem(
+ modifier = Modifier.weight(1f),
+ name = "PM10",
+ value = airQuality.pm10.toInt().toString(),
+ )
+ PollutantItem(
+ modifier = Modifier.weight(1f),
+ name = "PM2.5",
+ value = airQuality.pm25.toInt().toString(),
+ )
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "Concentration in ฮผg/mยณ",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ )
}
}
+/**
+ * Converts OpenWeatherMap AQI scale (1-6) to EPA AQI scale (0-500)
+ *
+ * OWM Scale:
+ * - 1: Good
+ * - 2: Fair
+ * - 3: Moderate
+ * - 4: Poor
+ * - 5: Very Poor
+ * - 6: Extreme
+ *
+ * EPA Scale:
+ * - 0-50: Good (Green)
+ * - 51-100: Moderate (Yellow)
+ * - 101-150: Unhealthy for Sensitive Groups (Orange)
+ * - 151-200: Unhealthy (Red)
+ * - 201-300: Very Unhealthy (Purple)
+ * - 301+: Hazardous (Dark Red)
+ */
+private fun convertOwmAqiToEpa(owmAqi: Int): Int =
+ when (owmAqi) {
+ 1 -> 25 // Good
+ 2 -> 75 // Fair -> Moderate
+ 3 -> 125 // Moderate -> Unhealthy for Sensitive Groups
+ 4 -> 175 // Poor -> Unhealthy
+ 5 -> 250 // Very Poor -> Very Unhealthy
+ 6 -> 425 // Extreme -> Hazardous
+ else -> owmAqi.coerceIn(0, 500) // Fallback for invalid values
+ }
+
/**
* Returns a color based on the air quality index value
* Not a composable function since it doesn't use any composable functions
*/
-private fun getAirQualityColor(aqi: Int): Color {
- return when (aqi) {
- in 0..50 -> Color(0xFF4CAF50) // Good - Green
- in 51..100 -> Color(0xFFFFEB3B) // Moderate - Yellow
- in 101..150 -> Color(0xFFFF9800) // Unhealthy for sensitive groups - Orange
- in 151..200 -> Color(0xFFE53935) // Unhealthy - Red
- in 201..300 -> Color(0xFF9C27B0) // Very Unhealthy - Purple
- else -> Color(0xFF7E0023) // Hazardous - Dark Red
+private fun getAirQualityColor(aqi: Int): Color =
+ when (aqi) {
+ in 0..50 -> Color(0xFF4CAF50) // Good - Green
+ in 51..100 -> Color(0xFFFFEB3B) // Moderate - Yellow
+ in 101..150 -> Color(0xFFFF9800) // Unhealthy for sensitive groups - Orange
+ in 151..200 -> Color(0xFFE53935) // Unhealthy - Red
+ in 201..300 -> Color(0xFF9C27B0) // Very Unhealthy - Purple
+ else -> Color(0xFF7E0023) // Hazardous - Dark Red
}
-}
/**
* Displays a single pollutant item with name and value
- * Optimized to use Box instead of Surface for better performance
*/
@Composable
-private fun PollutantItem(name: String, value: String) {
+private fun PollutantItem(
+ name: String,
+ value: String,
+ modifier: Modifier = Modifier,
+) {
Column(
- horizontalAlignment = Alignment.CenterHorizontally
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
) {
- // Use Box instead of Surface for better performance
- Box(
- modifier = Modifier
- .clip(RoundedCornerShape(4.dp))
- .background(MaterialTheme.colorScheme.surfaceVariant)
- .padding(horizontal = 8.dp, vertical = 4.dp)
- ) {
- Text(
- text = name,
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
-
- Spacer(modifier = Modifier.height(4.dp))
-
Text(
text = value,
- style = MaterialTheme.typography.bodyMedium,
+ style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = name,
+ style = MaterialTheme.typography.bodySmall, // smaller for de-emphasis
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/CurrentWeatherReportLayout.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/CurrentWeatherReportLayout.kt
index 70844f24..2754b060 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/CurrentWeatherReportLayout.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/CurrentWeatherReportLayout.kt
@@ -1,5 +1,6 @@
package bose.ankush.weatherify.presentation.home.component
+import android.annotation.SuppressLint
import android.location.Geocoder
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -45,6 +46,7 @@ import bose.ankush.weatherify.domain.model.WeatherForecast
import coil.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import timber.log.Timber
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -53,21 +55,24 @@ import java.util.Locale
internal fun CurrentWeatherReportLayout(
currentWeather: WeatherForecast.Current,
userLocation: Pair? = null,
- summary: String? = null
+ summary: String? = null,
) {
Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
- )
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp),
+ ),
) {
Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(20.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Location and date
@@ -94,21 +99,23 @@ internal fun CurrentWeatherReportLayout(
Spacer(modifier = Modifier.height(16.dp))
Card(
shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp)
- )
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp),
+ ),
) {
Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Today's Forecast",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface
+ color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
@@ -118,7 +125,7 @@ internal fun CurrentWeatherReportLayout(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
textAlign = TextAlign.Center,
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -131,7 +138,7 @@ internal fun CurrentWeatherReportLayout(
@Composable
private fun LocationAndDateHeader(
currentWeather: WeatherForecast.Current,
- userLocation: Pair? = null
+ userLocation: Pair? = null,
) {
val context = LocalContext.current
// Use remember to avoid recreating the state on each recomposition
@@ -142,52 +149,55 @@ private fun LocationAndDateHeader(
if (userLocation != null) {
try {
// Use IO dispatcher for background processing
- val result = withContext(Dispatchers.IO) {
- val geocoder = Geocoder(context, Locale.getDefault())
-
- @Suppress("DEPRECATION")
- val addresses = geocoder.getFromLocation(
- userLocation.first,
- userLocation.second,
- 1
- )
-
- if (!addresses.isNullOrEmpty()) {
- val address = addresses.firstOrNull()
- val cityName = address?.locality ?: address?.subAdminArea
- val countryName = address?.countryName
-
- when {
- cityName != null -> "$cityName, $countryName"
- else -> countryName ?: "Current Location"
+ val result =
+ withContext(Dispatchers.IO) {
+ val geocoder = Geocoder(context, Locale.getDefault())
+
+ @Suppress("DEPRECATION")
+ val addresses =
+ geocoder.getFromLocation(
+ userLocation.first,
+ userLocation.second,
+ 1,
+ )
+
+ if (!addresses.isNullOrEmpty()) {
+ val address = addresses.firstOrNull()
+ val cityName = address?.locality ?: address?.subAdminArea
+ val countryName = address?.countryName
+
+ when {
+ cityName != null -> "$cityName, $countryName"
+ else -> countryName ?: "Current Location"
+ }
+ } else {
+ "Current Location"
}
- } else {
- "Current Location"
}
- }
// Update state only once after background processing is complete
locationName = result
} catch (e: Exception) {
// If geocoding fails, keep the default "Current Location"
- e.printStackTrace()
+ Timber.e(e, "Geocoding failed; using default location label")
}
}
}
// Pre-calculate the formatted date to avoid doing it during composition
- val formattedDate = remember(currentWeather.dt) {
- DateTimeUtils.getFormattedDateTimeFromEpoch(currentWeather.dt)
- }
+ val formattedDate =
+ remember(currentWeather.dt) {
+ DateTimeUtils.getFormattedDateTimeFromEpoch(currentWeather.dt)
+ }
Column(
- horizontalAlignment = Alignment.CenterHorizontally
+ horizontalAlignment = Alignment.CenterHorizontally,
) {
// Display the location name
Text(
text = locationName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
+ color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(4.dp))
@@ -205,38 +215,41 @@ private fun LocationAndDateHeader(
private fun CurrentWeatherVisualization(currentWeather: WeatherForecast.Current) {
// Cache the first weather condition to avoid multiple get(0) calls and potential crashes
val firstWeather = currentWeather.weather?.firstOrNull()
- val weatherDescription = (firstWeather?.description ?: stringResource(id = R.string.not_available))
- .formatTextCapitalization()
+ val weatherDescription =
+ (firstWeather?.description ?: stringResource(id = R.string.not_available))
+ .formatTextCapitalization()
val weatherIconUrl = firstWeather?.icon?.getIconUrl()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
+ verticalAlignment = Alignment.CenterVertically,
) {
// Temperature display
Column(
horizontalAlignment = Alignment.Start,
- modifier = Modifier.weight(1f)
+ modifier = Modifier.weight(1f),
) {
Text(
- text = stringResource(
- id = R.string.degree,
- currentWeather.temp?.toCelsius() ?: stringResource(id = R.string.not_available)
- ),
+ text =
+ stringResource(
+ id = R.string.degree,
+ currentWeather.temp?.toCelsius()
+ ?: stringResource(id = R.string.not_available),
+ ),
style = MaterialTheme.typography.displayLarge,
fontSize = 80.sp,
fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onBackground
+ color = MaterialTheme.colorScheme.onBackground,
)
Row(
- verticalAlignment = Alignment.CenterVertically
+ verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Feels like ${currentWeather.feels_like?.toCelsius()}ยฐ",
style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
)
}
}
@@ -244,20 +257,21 @@ private fun CurrentWeatherVisualization(currentWeather: WeatherForecast.Current)
// Weather icon and description
Column(
horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier.weight(1f)
+ modifier = Modifier.weight(1f),
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
- modifier = Modifier.size(100.dp)
+ modifier = Modifier.size(100.dp),
) {
AsyncImage(
model = weatherIconUrl,
placeholder = painterResource(id = R.drawable.ic_sunny),
contentDescription = stringResource(id = R.string.weather_icon_content),
- modifier = Modifier
- .padding(16.dp)
- .size(64.dp)
+ modifier =
+ Modifier
+ .padding(16.dp)
+ .size(64.dp),
)
}
@@ -268,7 +282,7 @@ private fun CurrentWeatherVisualization(currentWeather: WeatherForecast.Current)
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onBackground,
- textAlign = TextAlign.Center
+ textAlign = TextAlign.Center,
)
}
}
@@ -277,37 +291,39 @@ private fun CurrentWeatherVisualization(currentWeather: WeatherForecast.Current)
@Composable
private fun WeatherMetricsGrid(weatherData: WeatherForecast.Current) {
// First row metrics
- val firstRowMetrics = listOf(
- WeatherMetric(
- icon = R.drawable.ic_humidity,
- value = "${weatherData.humidity}%",
- label = "Humidity"
- ),
- WeatherMetric(
- icon = R.drawable.ic_wind,
- value = "${weatherData.wind_speed} m/s",
- label = "Wind"
- ),
- WeatherMetric(
- icon = R.drawable.ic_uv,
- value = "${weatherData.uvi}",
- label = "UV Index"
+ val firstRowMetrics =
+ listOf(
+ WeatherMetric(
+ icon = R.drawable.ic_humidity,
+ value = "${weatherData.humidity}%",
+ label = "Humidity",
+ ),
+ WeatherMetric(
+ icon = R.drawable.ic_wind,
+ value = "${weatherData.wind_speed} m/s",
+ label = "Wind",
+ ),
+ WeatherMetric(
+ icon = R.drawable.ic_uv,
+ value = "${weatherData.uvi}",
+ label = "UV Index",
+ ),
)
- )
// Second row metrics
- val secondRowMetrics = mutableListOf(
- WeatherMetric(
- icon = R.drawable.ic_humidity, // Using humidity icon for pressure as it's more appropriate than sunny
- value = "${weatherData.pressure} hPa",
- label = "Pressure"
- ),
- WeatherMetric(
- icon = R.drawable.ic_humidity, // Using humidity icon for clouds as it's more appropriate than sunny
- value = "${weatherData.clouds}%",
- label = "Clouds"
+ val secondRowMetrics =
+ mutableListOf(
+ WeatherMetric(
+ icon = R.drawable.ic_humidity, // Using humidity icon for pressure as it's more appropriate than sunny
+ value = "${weatherData.pressure} hPa",
+ label = "Pressure",
+ ),
+ WeatherMetric(
+ icon = R.drawable.ic_humidity, // Using humidity icon for clouds as it's more appropriate than sunny
+ value = "${weatherData.clouds}%",
+ label = "Clouds",
+ ),
)
- )
// Add wind gust if available
if (weatherData.wind_gust != null) {
@@ -315,8 +331,8 @@ private fun WeatherMetricsGrid(weatherData: WeatherForecast.Current) {
WeatherMetric(
icon = R.drawable.ic_wind,
value = "${weatherData.wind_gust} m/s",
- label = "Wind Gust"
- )
+ label = "Wind Gust",
+ ),
)
}
@@ -333,23 +349,23 @@ private fun WeatherMetricItem(
icon: Int,
value: String,
label: String,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
- modifier = modifier.padding(horizontal = 4.dp)
+ modifier = modifier.padding(horizontal = 4.dp),
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer,
- modifier = Modifier.size(40.dp)
+ modifier = Modifier.size(40.dp),
) {
Icon(
painter = painterResource(id = icon),
contentDescription = label,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
- modifier = Modifier.padding(8.dp)
+ modifier = Modifier.padding(8.dp),
)
}
@@ -359,13 +375,13 @@ private fun WeatherMetricItem(
text = value,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
+ color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
}
@@ -374,78 +390,87 @@ private fun WeatherMetricItem(
private fun SunriseSunsetInfo(weatherData: WeatherForecast.Current) {
Card(
shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp)
- )
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp),
+ ),
) {
Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 16.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
+ verticalAlignment = Alignment.CenterVertically,
) {
// Sunrise
Column(
horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier.weight(1f)
+ modifier = Modifier.weight(1f),
) {
Text(
text = "Sunrise",
style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
Text(
- text = formatTimeWithAmPm(
- weatherData.sunrise,
- true
- ), // Force AM for sunrise
+ text =
+ formatTimeWithAmPm(
+ weatherData.sunrise,
+ true,
+ ),
+ // Force AM for sunrise
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface
+ color = MaterialTheme.colorScheme.onSurface,
)
}
// Divider
Box(
- modifier = Modifier
- .height(40.dp)
- .width(1.dp)
- .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))
+ modifier =
+ Modifier
+ .height(40.dp)
+ .width(1.dp)
+ .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)),
)
// Sunset
Column(
horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier.weight(1f)
+ modifier = Modifier.weight(1f),
) {
Text(
text = "Sunset",
style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
Text(
text = formatTimeWithAmPm(weatherData.sunset, false), // Force PM for sunset
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface
+ color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
+@SuppressLint("ConstantLocale")
private val hourMinuteFormatter = SimpleDateFormat("h:mm", Locale.getDefault())
@Composable
-private fun formatTimeWithAmPm(timestamp: Int?, isSunrise: Boolean): String {
+private fun formatTimeWithAmPm(
+ timestamp: Long?,
+ isSunrise: Boolean,
+): String {
if (timestamp == null) return "N/A"
// Use remember to cache the formatted time based on the timestamp and isSunrise flag
return remember(timestamp, isSunrise) {
- val date = Date(timestamp.toLong() * 1000)
+ val date = Date(timestamp * 1000)
val timeWithoutAmPm = hourMinuteFormatter.format(date)
// Force AM for sunrise, PM for sunset
@@ -461,24 +486,24 @@ private fun formatTimeWithAmPm(timestamp: Int?, isSunrise: Boolean): String {
private data class WeatherMetric(
val icon: Int,
val value: String,
- val label: String
+ val label: String,
)
@Composable
private fun MetricsRow(
metrics: List,
- fillEmptySpace: Boolean = false
+ fillEmptySpace: Boolean = false,
) {
Row(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween
+ horizontalArrangement = Arrangement.SpaceBetween,
) {
metrics.forEach { metric ->
WeatherMetricItem(
icon = metric.icon,
value = metric.value,
label = metric.label,
- modifier = Modifier.weight(1f)
+ modifier = Modifier.weight(1f),
)
}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/DailyWeatherForecastReportLayout.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/DailyWeatherForecastReportLayout.kt
index 8daf5c85..300cb49c 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/DailyWeatherForecastReportLayout.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/DailyWeatherForecastReportLayout.kt
@@ -11,8 +11,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import bose.ankush.sunriseui.components.AnimatedWeatherIcon
-import bose.ankush.sunriseui.components.WeatherDayCard
+import bose.ankush.commonui.components.AnimatedWeatherIcon
+import bose.ankush.commonui.components.WeatherDayCard
import bose.ankush.weatherify.R
import bose.ankush.weatherify.base.DateTimeUtils.dayName
import bose.ankush.weatherify.base.common.Extension.toCelsius
@@ -27,20 +27,21 @@ internal fun DailyWeatherForecastReportLayout(list: List
if (list.isNotEmpty()) {
Column(
horizontalAlignment = Alignment.Start,
- verticalArrangement = Arrangement.Center
+ verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(id = R.string.daily_forecast_heading_txt),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground,
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 16.dp, end = 16.dp, top = 16.dp)
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 16.dp),
)
// Use Column instead of LazyColumn to avoid nested scrollable containers
Column(
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier.fillMaxWidth(),
) {
list.forEachIndexed { index, _ ->
DailyWeatherForecastItem(list, index)
@@ -56,7 +57,10 @@ internal fun DailyWeatherForecastReportLayout(list: List
* Uses the WeatherDayCard from the sunriseui module.
*/
@Composable
-internal fun DailyWeatherForecastItem(list: List, item: Int) {
+internal fun DailyWeatherForecastItem(
+ list: List,
+ item: Int,
+) {
val dayName = list[item]?.dt?.dayName() ?: stringResource(id = R.string.not_available)
val minTemperature = "${list[item]?.temp?.min?.toCelsius()}ยฐ"
val maxTemperature = "${list[item]?.temp?.max?.toCelsius()}ยฐ"
@@ -72,8 +76,8 @@ internal fun DailyWeatherForecastItem(list: List, item:
iconContent = {
AnimatedWeatherIcon(
weatherDescription = weatherDescription,
- modifier = Modifier.padding(4.dp)
+ modifier = Modifier.padding(4.dp),
)
- }
+ },
)
}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/HourlyWeatherForecastReportLayout.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/HourlyWeatherForecastReportLayout.kt
index 623b7421..6647aa17 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/HourlyWeatherForecastReportLayout.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/HourlyWeatherForecastReportLayout.kt
@@ -25,7 +25,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import bose.ankush.sunriseui.components.WeatherHourCard
+import bose.ankush.commonui.components.WeatherHourCard
import bose.ankush.weatherify.R
import bose.ankush.weatherify.base.DateTimeUtils.toFormattedTime
import bose.ankush.weatherify.base.common.Extension.formatTextCapitalization
@@ -36,38 +36,42 @@ import bose.ankush.weatherify.domain.model.WeatherForecast
import coil.compose.AsyncImage
@Composable
-internal fun HourlyWeatherForecastReportLayout(
- hourlyWeatherForecasts: List
-) {
+internal fun HourlyWeatherForecastReportLayout(hourlyWeatherForecasts: List) {
if (hourlyWeatherForecasts.isNotEmpty()) {
Column(
horizontalAlignment = Alignment.Start,
- verticalArrangement = Arrangement.Center
+ verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(id = R.string.hourly_forecast_heading_txt),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground,
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 16.dp, end = 16.dp, top = 16.dp)
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 16.dp),
)
Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
- )
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp),
+ ),
) {
Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(20.dp)
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
) {
- FutureForecastListItem(hourlyWeatherForecasts) { /* Item click action will be implemented in future */ }
+ FutureForecastListItem(hourlyWeatherForecasts) {
+ // Item click action will be implemented in future
+ }
}
}
}
@@ -76,42 +80,45 @@ internal fun HourlyWeatherForecastReportLayout(
}
}
-
@Composable
private fun FutureForecastListItem(
weatherForecast: List,
- onItemClick: (Int) -> Unit
+ onItemClick: (Int) -> Unit,
) {
var selectedItem by remember { mutableStateOf(0) }
// Limit the number of items to display for better performance
- val limitedForecast = remember(weatherForecast) {
- weatherForecast.take(24) // Show only 24 hours
- }
+ val limitedForecast =
+ remember(weatherForecast) {
+ weatherForecast.take(24) // Show only 24 hours
+ }
LazyRow(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 8.dp, end = 8.dp, top = 16.dp),
- state = rememberLazyListState() // Add state to prevent unnecessary recompositions
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 8.dp, end = 8.dp, top = 16.dp),
+ state = rememberLazyListState(), // Add state to prevent unnecessary recompositions
) {
items(
items = limitedForecast,
- key = { item -> item?.dt ?: 0 } // Use unique key for each item
+ key = { item -> item?.dt ?: 0 }, // Use unique key for each item
) { item ->
val index = limitedForecast.indexOf(item)
val isSelected = selectedItem == index
val time = item?.dt?.toFormattedTime() ?: stringResource(id = R.string.not_available)
- val temperature = stringResource(
- id = R.string.celsius,
- item?.temp?.toCelsius() ?: stringResource(id = R.string.not_available)
- )
+ val temperature =
+ stringResource(
+ id = R.string.celsius,
+ item?.temp?.toCelsius() ?: stringResource(id = R.string.not_available),
+ )
val firstWeather = item?.weather?.firstOrNull()
val description =
(firstWeather?.description ?: stringResource(id = R.string.not_available))
- .wrapText().formatTextCapitalization()
+ .wrapText()
+ .formatTextCapitalization()
val weatherIconUrl = firstWeather?.icon?.getIconUrl()
WeatherHourCard(
@@ -130,7 +137,7 @@ private fun FutureForecastListItem(
error = painterResource(id = R.drawable.ic_sunny),
contentDescription = stringResource(id = R.string.weather_icon_content),
)
- }
+ },
)
}
}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/component/WeatherAlertLayout.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/WeatherAlertLayout.kt
new file mode 100644
index 00000000..0697c0c3
--- /dev/null
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/component/WeatherAlertLayout.kt
@@ -0,0 +1,36 @@
+package bose.ankush.weatherify.presentation.home.component
+
+import androidx.compose.runtime.Composable
+import bose.ankush.commonui.components.WeatherAlertCard
+import bose.ankush.weatherify.domain.model.WeatherForecast
+
+/**
+ * This composable is responsible for displaying weather alerts on the HomeScreen.
+ * It handles the case when there are no alerts by not rendering anything.
+ *
+ * @param alerts The list of alerts from the WeatherForecast data
+ * @param onReadMoreClick Optional callback for when the "Read More" button is clicked
+ */
+@Composable
+fun WeatherAlertLayout(
+ alerts: List?,
+ onReadMoreClick: (() -> Unit)? = null,
+) {
+ // If the alerts list is null or empty, don't render anything
+ if (alerts.isNullOrEmpty()) {
+ return
+ }
+
+ // Get the first alert (most recent/important)
+ val firstAlert = alerts.firstOrNull() ?: return
+
+ // Render the alert card
+ WeatherAlertCard(
+ title = firstAlert.event,
+ description = firstAlert.description,
+ startTime = firstAlert.start,
+ endTime = firstAlert.end,
+ source = firstAlert.sender_name,
+ onReadMoreClick = onReadMoreClick,
+ )
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/state/ErrorComponent.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/state/ErrorComponent.kt
index 5a5c42d2..e3b97396 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/home/state/ErrorComponent.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/state/ErrorComponent.kt
@@ -1,12 +1,18 @@
package bose.ankush.weatherify.presentation.home.state
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -20,47 +26,93 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import bose.ankush.weatherify.R
+/**
+ * Creates a simple background for the error screen
+ */
+@Composable
+fun ErrorBackgroundAnimation() {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ )
+}
+
+/**
+ * Displays an error message with a retry button
+ *
+ * @param modifier Modifier for the container
+ * @param msg Error message to display
+ * @param buttonText Text for the retry button
+ * @param isLoading Whether the retry operation is in progress
+ * @param buttonAction Action to perform when the retry button is clicked
+ */
@Composable
fun ShowError(
modifier: Modifier,
msg: String?,
- buttonText: String = stringResource(id = R.string.go_back),
- buttonAction: () -> Unit
+ buttonText: String = stringResource(id = R.string.retry_btn_txt),
+ isLoading: Boolean = false,
+ buttonAction: () -> Unit,
) {
Box(
modifier = modifier,
- contentAlignment = Alignment.Center
+ contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(horizontal = 24.dp),
) {
+ // Error icon
Icon(
painter = painterResource(id = R.drawable.ic_error),
contentDescription = stringResource(id = R.string.error_icon_content),
- modifier = Modifier.size(36.dp),
- tint = MaterialTheme.colorScheme.error
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.error,
)
+
+ Spacer(modifier = Modifier.padding(top = 16.dp))
+
+ // Main error message
Text(
text = msg ?: stringResource(id = R.string.general_error_txt),
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onBackground,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
- modifier = Modifier.padding(top = 16.dp)
)
+
+ Spacer(modifier = Modifier.padding(top = 8.dp))
+
+ // Retry button
Button(
onClick = buttonAction,
- colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ ),
modifier = Modifier.padding(top = 16.dp),
- elevation = ButtonDefaults.buttonElevation(
- disabledElevation = 0.dp,
- defaultElevation = 30.dp,
- pressedElevation = 10.dp
- )
+ enabled = !isLoading,
) {
- Text(text = buttonText, color = MaterialTheme.colorScheme.onError)
+ if (isLoading) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ color = MaterialTheme.colorScheme.onError,
+ strokeWidth = 2.dp,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(text = buttonText)
+ }
+ } else {
+ Text(text = buttonText)
+ }
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/home/state/LoadingComponent.kt b/app/src/main/java/bose/ankush/weatherify/presentation/home/state/LoadingComponent.kt
index bfe064c9..409cee5c 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/home/state/LoadingComponent.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/home/state/LoadingComponent.kt
@@ -10,15 +10,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
-fun ShowLoading(
- modifier: Modifier
-) {
+fun ShowLoading(modifier: Modifier) {
Box(modifier = modifier) {
CircularProgressIndicator(
- modifier = Modifier
- .size(26.dp)
- .align(Alignment.Center),
- color = MaterialTheme.colorScheme.primary
+ modifier =
+ Modifier
+ .size(26.dp)
+ .align(Alignment.Center),
+ color = MaterialTheme.colorScheme.primary,
)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppBottomBar.kt b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppBottomBar.kt
index 7c8d9b7a..d349bf1e 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppBottomBar.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppBottomBar.kt
@@ -8,6 +8,8 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
@@ -17,8 +19,6 @@ import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
@@ -30,65 +30,78 @@ import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
+import bose.ankush.commonui.components.ToastAnchorState
+import bose.ankush.commonui.components.toastAnchor
import bose.ankush.weatherify.R
@Composable
fun AppBottomBar(
isVisible: MutableState,
- navController: NavController
+ navController: NavController,
+ toastAnchorState: ToastAnchorState? = null,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
- val selectedItem = remember { mutableIntStateOf(0) }
- val screenItems = listOf(
- Screen.HomeNestedNav,
- Screen.ProfileNestedNav
- )
+ val screenItems =
+ listOf(
+ Screen.HomeNestedNav,
+ Screen.SavedLocationsNestedNav,
+ Screen.ProfileNestedNav,
+ )
AnimatedVisibility(
+ modifier = if (toastAnchorState != null) Modifier.toastAnchor(toastAnchorState) else Modifier,
visible = isVisible.value,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
) {
// Enhanced Glassmorphic Navigation Bar
NavigationBar(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
- .height(64.dp)
- .shadow(
- elevation = 6.dp,
- shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
- spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
- )
- .background(
- MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp).copy(alpha = 0.8f)
- )
- .border(
- width = 0.5.dp,
- color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
- shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
- ),
- containerColor = Color.Transparent // Make the container transparent to show our custom background
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
+ .height(64.dp)
+ .shadow(
+ elevation = 6.dp,
+ shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
+ spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
+ )
+ .background(
+ MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp).copy(alpha = 0.8f),
+ )
+ .border(
+ width = 0.5.dp,
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
+ shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
+ ),
+ containerColor = Color.Transparent, // Make the container transparent to show our custom background
) {
- screenItems.forEachIndexed { index, screen ->
+ screenItems.forEachIndexed { _, screen ->
NavigationBarItem(
icon = {
when (screen.resourceId) {
- R.string.home_nested_nav -> Icon(
- painter = painterResource(id = R.drawable.ic_home),
- contentDescription = stringResource(id = screen.resourceId)
- )
+ R.string.home_nested_nav ->
+ Icon(
+ painter = painterResource(id = R.drawable.ic_home),
+ contentDescription = stringResource(id = screen.resourceId),
+ )
+
+ R.string.saved_locations_nested_nav ->
+ Icon(
+ imageVector = Icons.Outlined.BookmarkBorder,
+ contentDescription = stringResource(id = R.string.saved_locations_icon_content),
+ )
- R.string.profile_nested_nav -> Icon(
- painter = painterResource(id = R.drawable.ic_profile),
- contentDescription = stringResource(id = screen.resourceId)
- )
+ R.string.profile_nested_nav ->
+ Icon(
+ painter = painterResource(id = R.drawable.ic_profile),
+ contentDescription = stringResource(id = screen.resourceId),
+ )
}
},
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
- selectedItem.intValue = index
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
@@ -97,11 +110,12 @@ fun AppBottomBar(
restoreState = true
}
},
- colors = NavigationBarItemDefaults.colors(
- indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f),
- selectedIconColor = MaterialTheme.colorScheme.primary,
- unselectedIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
- )
+ colors =
+ NavigationBarItemDefaults.colors(
+ indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f),
+ selectedIconColor = MaterialTheme.colorScheme.primary,
+ unselectedIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
+ ),
)
}
}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppNavigation.kt b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppNavigation.kt
index c4a5d556..5fa4c9eb 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppNavigation.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppNavigation.kt
@@ -1,50 +1,72 @@
package bose.ankush.weatherify.presentation.navigation
import android.annotation.SuppressLint
+import android.widget.Toast
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navigation
+import bose.ankush.commonui.components.ToastAnchorState
+import bose.ankush.commonui.locations.SavedLocationsScreen
+import bose.ankush.commonui.locations.SavedLocationsStrings
+import bose.ankush.commonui.settings.SettingsScreen
+import bose.ankush.commonui.settings.SettingsScreenStrings
import bose.ankush.language.presentation.LanguageScreen
-import bose.ankush.weatherify.base.common.Extension.callNumber
+import bose.ankush.payment.presentation.PaymentViewModel
+import bose.ankush.weatherify.BuildConfig
+import bose.ankush.weatherify.R
+import bose.ankush.weatherify.base.LocaleConfigMapper
import bose.ankush.weatherify.base.common.Extension.hasNotificationPermission
import bose.ankush.weatherify.base.common.Extension.isDeviceSDKAndroid13OrAbove
import bose.ankush.weatherify.base.common.Extension.openAppLocaleSettings
+import bose.ankush.weatherify.presentation.AuthState
import bose.ankush.weatherify.presentation.MainViewModel
+import bose.ankush.weatherify.presentation.SettingsEvent
+import bose.ankush.weatherify.presentation.SettingsViewModel
import bose.ankush.weatherify.presentation.cities.CitiesListScreen
-import bose.ankush.weatherify.presentation.home.AirQualityDetailsScreen
import bose.ankush.weatherify.presentation.home.HomeScreen
-import bose.ankush.weatherify.presentation.settings.SettingsScreen
const val LANGUAGE_ARGUMENT_KEY = "country_config"
@SuppressLint("NewApi")
@ExperimentalAnimationApi
@Composable
-fun AppNavigation(viewModel: MainViewModel) {
+fun AppNavigation(
+ viewModel: MainViewModel,
+ paymentViewModel: PaymentViewModel,
+ toastAnchorState: ToastAnchorState? = null,
+) {
val navController = rememberNavController()
val context = LocalContext.current
NavHost(
navController = navController,
- startDestination = Screen.HomeNestedNav.route
+ startDestination = Screen.HomeNestedNav.route,
) {
- /*Home Screens*/
+ // Home Screens
navigation(
startDestination = Screen.HomeScreen.route,
- route = Screen.HomeNestedNav.route
+ route = Screen.HomeNestedNav.route,
) {
composable(
route = Screen.HomeScreen.route,
) {
HomeScreen(
viewModel = viewModel,
- navController = navController
+ navController = navController,
+ toastAnchorState = toastAnchorState,
)
}
composable(
@@ -52,78 +74,184 @@ fun AppNavigation(viewModel: MainViewModel) {
enterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Down,
- animationSpec = tween(500)
+ animationSpec = tween(500),
)
},
popEnterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Down,
- animationSpec = tween(500)
+ animationSpec = tween(500),
)
},
exitTransition = {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Up,
- animationSpec = tween(500)
+ animationSpec = tween(500),
)
},
popExitTransition = {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Up,
- animationSpec = tween(500)
+ animationSpec = tween(500),
)
},
) {
CitiesListScreen(navController = navController)
}
- composable(
- route = Screen.AirQualityDetailsScreen.route,
- enterTransition = {
- slideIntoContainer(
- towards = AnimatedContentTransitionScope.SlideDirection.Left,
- animationSpec = tween(500)
- )
- },
- popEnterTransition = {
- slideIntoContainer(
- towards = AnimatedContentTransitionScope.SlideDirection.Left,
- animationSpec = tween(500)
- )
- },
- exitTransition = {
- slideOutOfContainer(
- towards = AnimatedContentTransitionScope.SlideDirection.Right,
- animationSpec = tween(500)
- )
- },
- popExitTransition = {
- slideOutOfContainer(
- towards = AnimatedContentTransitionScope.SlideDirection.Right,
- animationSpec = tween(500)
- )
- }
- ) {
- AirQualityDetailsScreen(
- viewModel = viewModel,
- navController = navController
+ }
+
+ // Saved Locations (Premium Feature)
+ navigation(
+ startDestination = Screen.SavedLocationsScreen.route,
+ route = Screen.SavedLocationsNestedNav.route,
+ ) {
+ composable(route = Screen.SavedLocationsScreen.route) {
+ val locationsState = viewModel.savedLocationsState.collectAsState().value
+ val searchState = viewModel.placeSearchState.collectAsState().value
+ val noResultsTemplate = stringResource(R.string.place_search_no_results)
+ val setAsDefaultBodyTemplate = stringResource(R.string.set_as_default_dialog_body)
+
+ SavedLocationsScreen(
+ locationsState = locationsState,
+ searchState = searchState,
+ onQueryChanged = { query -> viewModel.onPlaceSearchQueryChanged(query) },
+ onClearSearch = { viewModel.clearPlaceSearch() },
+ onSaveLocation = { name, lat, lon -> viewModel.saveLocation(name, lat, lon) },
+ onDeleteLocation = { id -> viewModel.deleteLocation(id) },
+ onLocationSelected = { location ->
+ viewModel.setDefaultLocation(location.lat, location.lon, location.name)
+ },
+ onMessageShown = { viewModel.clearLocationMessage() },
+ strings =
+ SavedLocationsStrings(
+ title = stringResource(R.string.saved_locations_title),
+ premiumTitle = stringResource(R.string.saved_locations_premium_title),
+ premiumDesc = stringResource(R.string.saved_locations_premium_desc),
+ emptyText = stringResource(R.string.saved_locations_empty_txt),
+ searchHint = stringResource(R.string.place_search_hint),
+ searchDialogTitle = stringResource(R.string.place_search_dialog_title),
+ noResults = { query -> noResultsTemplate.replace("%1\$s", query) },
+ deleteContentDesc = stringResource(R.string.delete_icon_content),
+ addContentDesc = stringResource(R.string.add_icon_content),
+ cancelBtn = stringResource(R.string.cancel_btn_txt),
+ saveSuccessMsg = stringResource(R.string.saved_locations_save_success),
+ deleteSuccessMsg = stringResource(R.string.saved_locations_delete_success),
+ setAsDefaultDialogTitle = stringResource(R.string.set_as_default_dialog_title),
+ setAsDefaultDialogBody = { name ->
+ setAsDefaultBodyTemplate.replace("%1\$s", name)
+ },
+ setAsDefaultDialogWarning = stringResource(R.string.set_as_default_dialog_warning),
+ setAsDefaultConfirmBtn = stringResource(R.string.set_as_default_confirm_btn),
+ ),
+ bottomBar = {
+ AppBottomBar(
+ isVisible = rememberSaveable { mutableStateOf(true) },
+ navController = navController,
+ toastAnchorState = toastAnchorState,
+ )
+ },
)
}
}
- /*Account/Profile Screens*/
+ // Account/Profile Screens
navigation(
startDestination = Screen.SettingsScreen.route,
- route = Screen.ProfileNestedNav.route
+ route = Screen.ProfileNestedNav.route,
) {
composable(
route = Screen.SettingsScreen.route,
) {
+ val authState = viewModel.authState.collectAsState().value
+ val paymentUiState = paymentViewModel.uiState.collectAsState().value
+ val localeErrorMessage = stringResource(R.string.locale_config_error_txt)
+ val showLocaleError = remember { mutableStateOf(false) }
+ val languageList =
+ remember(context) {
+ try {
+ LocaleConfigMapper.getAvailableLanguagesFromJson(
+ jsonFile = "countryConfig.json",
+ context = context,
+ )
+ } catch (_: Exception) {
+ showLocaleError.value = true
+ emptyArray()
+ }
+ }
+
+ LaunchedEffect(showLocaleError.value) {
+ if (showLocaleError.value) {
+ Toast.makeText(context, localeErrorMessage, Toast.LENGTH_SHORT).show()
+ showLocaleError.value = false
+ }
+ }
+
+ val settingsViewModel: SettingsViewModel = hiltViewModel()
+ val settingsUiState = settingsViewModel.uiState.collectAsState().value
+ val serviceSubscriptionBottomSheetUiState =
+ settingsViewModel.serviceSubscriptionViewModel.uiState
+ .collectAsState()
+ .value
+ val isBottomBarVisible = rememberSaveable { mutableStateOf(true) }
+
+ LaunchedEffect(paymentUiState.stage) {
+ if (paymentUiState.stage == bose.ankush.payment.presentation.PaymentStage.Success) {
+ settingsViewModel.showPremiumActivationToast()
+ }
+ }
+
SettingsScreen(
- viewModel = viewModel,
- navController = navController,
- onLanguageNavAction = {
+ paymentUiState = paymentUiState,
+ isLoggingOut = authState is AuthState.LogoutLoading,
+ isLoggedOut = authState is AuthState.LoggedOut,
+ versionName = BuildConfig.VERSION_NAME,
+ shouldShowNotificationItem = isDeviceSDKAndroid13OrAbove() && !context.hasNotificationPermission(),
+ languageList = languageList,
+ uiState = settingsUiState,
+ strings =
+ SettingsScreenStrings(
+ profileTitle = stringResource(R.string.profile_title),
+ logout = stringResource(R.string.logout_btn_txt),
+ logoutConfirmation = stringResource(R.string.logout_confirmation_txt),
+ confirm = stringResource(R.string.confirm_btn_txt),
+ cancel = stringResource(R.string.cancel_btn_txt),
+ getPremium = stringResource(R.string.premium_get_txt),
+ processing = stringResource(R.string.premium_processing_txt),
+ processingDescription = stringResource(R.string.premium_processing_desc_txt),
+ unlockDescription = stringResource(R.string.premium_unlock_desc_txt),
+ upgradeNow = stringResource(R.string.premium_upgrade_btn_txt),
+ premiumActive = stringResource(R.string.premium_active_txt),
+ premiumExpires = stringResource(R.string.premium_expires_txt),
+ premiumActiveStatus = stringResource(R.string.premium_active_status_txt),
+ notificationsTitle = stringResource(R.string.settings_notifications_txt),
+ languageTitle = stringResource(R.string.settings_language_txt),
+ privacyPolicy = stringResource(R.string.legal_privacy_policy_txt),
+ termsOfUse = stringResource(R.string.legal_terms_of_use_txt),
+ appVersion = stringResource(R.string.legal_app_version_txt),
+ backButtonDesc = stringResource(R.string.back_button_content),
+ arrowRightDesc = stringResource(R.string.arrow_right_icon_content),
+ premiumActivatedTitle = stringResource(R.string.premium_activated_title_txt),
+ premiumActivatedMessage = stringResource(R.string.premium_activated_msg_txt),
+ ),
+ serviceSubscriptionBottomSheetUiState = serviceSubscriptionBottomSheetUiState,
+ onLogout = { viewModel.logout() },
+ onLoggedOutHandled = { viewModel.resetAuthState() },
+ onStartPayment = { amountPaise -> paymentViewModel.startPayment(amountPaise) },
+ onLoadServices = { settingsViewModel.serviceSubscriptionViewModel.loadServices() },
+ onServiceSelected = { service ->
+ settingsViewModel.serviceSubscriptionViewModel.selectService(
+ service,
+ )
+ },
+ onTierSelected = { tier ->
+ settingsViewModel.serviceSubscriptionViewModel.selectPricingTier(
+ tier,
+ )
+ },
+ onBackNavAction = { navController.popBackStack() },
+ onLanguageNavAction = { list ->
if (isDeviceSDKAndroid13OrAbove()) {
- navController.navigate(Screen.LanguageScreen.withArgs(it))
+ navController.navigate(Screen.LanguageScreen.withArgs(list))
} else {
context.openAppLocaleSettings()
}
@@ -133,49 +261,95 @@ fun AppNavigation(viewModel: MainViewModel) {
viewModel.updateNotificationPermission(launchState = true)
}
},
- onAvatarNavAction = {
- if (!context.callNumber()) {
- viewModel.updatePhoneCallPermission(launchState = true)
+ onStateChange = { newState ->
+ when {
+ newState.showPremiumBottomSheet != settingsUiState.showPremiumBottomSheet -> {
+ if (!newState.showPremiumBottomSheet) {
+ settingsViewModel.serviceSubscriptionViewModel.resetState()
+ }
+ settingsViewModel.handleEvent(
+ if (newState.showPremiumBottomSheet) {
+ SettingsEvent.OpenPremiumSheet
+ } else {
+ SettingsEvent.ClosePremiumSheet
+ },
+ )
+ }
+
+ newState.showLogoutDialog != settingsUiState.showLogoutDialog ->
+ settingsViewModel.handleEvent(
+ if (newState.showLogoutDialog) {
+ SettingsEvent.OpenLogoutDialog
+ } else {
+ SettingsEvent.CloseLogoutDialog
+ },
+ )
+
+ newState.showPremiumActivationToast != settingsUiState.showPremiumActivationToast ->
+ settingsViewModel.handleEvent(SettingsEvent.DismissPremiumToast)
+
+ newState.currentWebUrl != settingsUiState.currentWebUrl -> {
+ val url = newState.currentWebUrl
+ if (url != null) {
+ settingsViewModel.handleEvent(SettingsEvent.OpenWebUrl(url))
+ } else {
+ settingsViewModel.handleEvent(SettingsEvent.CloseWebView)
+ }
+ }
}
- }
+ },
+ onBottomBarVisibilityChange = { isVisible ->
+ isBottomBarVisible.value = isVisible
+ },
+ toastAnchorState = toastAnchorState,
+ bottomBar = {
+ AppBottomBar(
+ isVisible = isBottomBarVisible,
+ navController = navController,
+ toastAnchorState = toastAnchorState,
+ )
+ },
)
}
composable(
route = Screen.LanguageScreen.route + "/{$LANGUAGE_ARGUMENT_KEY}",
- arguments = listOf(navArgument(LANGUAGE_ARGUMENT_KEY) {
- type = StringListType()
- nullable = false
- }),
+ arguments =
+ listOf(
+ navArgument(LANGUAGE_ARGUMENT_KEY) {
+ type = StringListType()
+ nullable = false
+ },
+ ),
enterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Left,
- animationSpec = tween(500)
+ animationSpec = tween(500),
)
},
popEnterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Left,
- animationSpec = tween(500)
+ animationSpec = tween(500),
)
},
exitTransition = {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Right,
- animationSpec = tween(500)
+ animationSpec = tween(500),
)
},
popExitTransition = {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Right,
- animationSpec = tween(500)
+ animationSpec = tween(500),
)
- }
+ },
) { entry ->
entry.arguments?.let {
it.getStringArray(LANGUAGE_ARGUMENT_KEY)?.let { listOfString ->
LanguageScreen(
languages = listOfString,
- navAction = { navController.popBackStack() }
+ navAction = { navController.popBackStack() },
)
}
}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/CustomNavType.kt b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/CustomNavType.kt
index 8b1a224c..5f925841 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/CustomNavType.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/CustomNavType.kt
@@ -3,27 +3,22 @@ package bose.ankush.weatherify.presentation.navigation
import android.os.Bundle
import androidx.navigation.NavType
-class DoubleNavType : NavType(isNullableAllowed = false) {
- override fun get(bundle: Bundle, key: String): Double = bundle.getDouble(key)
-
- override fun parseValue(value: String): Double = value.toDouble()
-
- override fun put(bundle: Bundle, key: String, value: Double) {
- bundle.putDouble(key, value)
- }
-}
-
class StringListType : NavType>(isNullableAllowed = false) {
- override fun get(bundle: Bundle, key: String): List {
+ override fun get(
+ bundle: Bundle,
+ key: String,
+ ): List {
val stringArray = bundle.getStringArray(key)
return stringArray?.toList() ?: emptyList()
}
- override fun parseValue(value: String): List {
- return value.split(",").map { it.trim() }
- }
+ override fun parseValue(value: String): List = value.split(",").map { it.trim() }
- override fun put(bundle: Bundle, key: String, value: List) {
+ override fun put(
+ bundle: Bundle,
+ key: String,
+ value: List,
+ ) {
bundle.putStringArray(key, value.toTypedArray())
}
}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/Screen.kt b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/Screen.kt
index 0c6f9b73..f91c2fe4 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/navigation/Screen.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/Screen.kt
@@ -3,34 +3,44 @@ package bose.ankush.weatherify.presentation.navigation
import androidx.annotation.StringRes
import bose.ankush.weatherify.R
-sealed class Screen(val route: String, @StringRes val resourceId: Int) {
-
- /*Home Screens*/
+sealed class Screen(
+ val route: String,
+ @StringRes val resourceId: Int,
+) {
+ // Home Screens
data object HomeNestedNav : Screen("home_nav", R.string.home_nested_nav)
+
data object HomeScreen : Screen("home_screen", R.string.home_screen)
+
data object CitiesListScreen : Screen("city_list_screen", R.string.city_screen)
- data object AirQualityDetailsScreen : Screen("air_quality_details_screen", R.string.aq_screen)
- /*Account/Profile Screens*/
+ // Saved Locations (Premium)
+ data object SavedLocationsNestedNav :
+ Screen("saved_locations_nav", R.string.saved_locations_nested_nav)
+
+ data object SavedLocationsScreen :
+ Screen("saved_locations_screen", R.string.saved_locations_screen)
+
+ // Account/Profile Screens
data object ProfileNestedNav : Screen("profile_nav", R.string.profile_nested_nav)
+
data object SettingsScreen : Screen("settings_screen", R.string.settings_screen)
+
data object LanguageScreen : Screen("language_screen", R.string.account_screen)
- fun withArgs(vararg args: String?): String {
- return buildString {
+ fun withArgs(vararg args: String?): String =
+ buildString {
append(route)
args.forEach { arg ->
append("/$arg")
}
}
- }
- fun withArgs(vararg args: Array): String {
- return buildString {
+ fun withArgs(vararg args: Array): String =
+ buildString {
append(route)
args.forEach { arg ->
append("/${arg.joinToString(",")}")
}
}
- }
}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/profile/ProfileScreen.kt b/app/src/main/java/bose/ankush/weatherify/presentation/profile/ProfileScreen.kt
deleted file mode 100644
index 3a2b9005..00000000
--- a/app/src/main/java/bose/ankush/weatherify/presentation/profile/ProfileScreen.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package bose.ankush.weatherify.presentation.profile
-
-import androidx.compose.runtime.Composable
-
-@Composable
-fun ProfileScreen() {
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/settings/SettingsScreen.kt b/app/src/main/java/bose/ankush/weatherify/presentation/settings/SettingsScreen.kt
deleted file mode 100644
index 186eedd0..00000000
--- a/app/src/main/java/bose/ankush/weatherify/presentation/settings/SettingsScreen.kt
+++ /dev/null
@@ -1,613 +0,0 @@
-package bose.ankush.weatherify.presentation.settings
-
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.MutableTransitionState
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.KeyboardArrowRight
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.RichTooltipBox
-import androidx.compose.material3.RichTooltipState
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.rememberModalBottomSheetState
-import androidx.compose.material3.surfaceColorAtElevation
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import androidx.navigation.NavController
-import bose.ankush.weatherify.R
-import bose.ankush.weatherify.base.LocaleConfigMapper
-import bose.ankush.weatherify.presentation.MainViewModel
-import bose.ankush.weatherify.presentation.navigation.AppBottomBar
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-internal fun SettingsScreen(
- viewModel: MainViewModel,
- navController: NavController,
- onLanguageNavAction: (Array) -> Unit,
- onNotificationNavAction: () -> Unit,
- onAvatarNavAction: () -> Unit,
-) {
- val isNotificationBannerVisible = viewModel.showNotificationCardItem.collectAsState().value
- val scope = rememberCoroutineScope()
- val languageList = LocaleConfigMapper.getAvailableLanguagesFromJson(
- jsonFile = "countryConfig.json",
- context = LocalContext.current
- )
-
- // State for Premium bottom sheet
- val showPremiumBottomSheet = remember { mutableStateOf(false) }
- val bottomSheetState = rememberModalBottomSheetState()
-
- Scaffold(
- modifier = Modifier.fillMaxSize(),
- topBar = {
- ScreenHeader(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 16.dp, end = 16.dp, top = 50.dp),
- onAvatarNavAction = onAvatarNavAction,
- scope = scope
- )
- },
- content = { innerPadding ->
- Column(modifier = Modifier.padding(innerPadding)) {
- // Notification block
- if (isNotificationBannerVisible) {
- // Create a transition state for the animation
- val transitionState = remember { MutableTransitionState(false) }
-
- // Start the animation when the component is first displayed
- LaunchedEffect(Unit) {
- delay(100) // Small delay for better visual effect
- transitionState.targetState = true
- }
-
- AnimatedVisibility(
- visibleState = transitionState,
- enter = fadeIn(animationSpec = tween(durationMillis = 500)) +
- slideInVertically(
- animationSpec = tween(durationMillis = 500),
- initialOffsetY = { it / 2 }
- ),
- exit = fadeOut()
- ) {
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 16.dp, end = 16.dp, top = 30.dp),
- shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
- )
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(all = 20.dp),
- verticalArrangement = Arrangement.SpaceBetween,
- horizontalAlignment = Alignment.Start
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth()
- ) {
- Box(
- modifier = Modifier
- .size(16.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.primary)
- )
-
- Spacer(modifier = Modifier.width(8.dp))
-
- Text(
- text = "Notification",
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface,
- fontWeight = FontWeight.Medium
- )
- }
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Text(
- modifier = Modifier.padding(top = 8.dp),
- text = "Turn on notification permission to get weather updates on the go.",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
- )
-
- Button(
- modifier = Modifier
- .padding(top = 16.dp)
- .align(Alignment.End),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primary
- ),
- shape = RoundedCornerShape(8.dp),
- onClick = { onNotificationNavAction.invoke() }
- ) {
- Text(
- text = "Turn on",
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.Medium
- )
- }
- }
- }
- }
- }
-
- // Language block
- // Create a transition state for the animation
- val languageTransitionState = remember { MutableTransitionState(false) }
-
- // Start the animation when the component is first displayed
- LaunchedEffect(Unit) {
- delay(200) // Small delay for staggered effect
- languageTransitionState.targetState = true
- }
-
- AnimatedVisibility(
- visibleState = languageTransitionState,
- enter = fadeIn(animationSpec = tween(durationMillis = 500)) +
- slideInVertically(
- animationSpec = tween(durationMillis = 500),
- initialOffsetY = { it / 2 }
- ),
- exit = fadeOut()
- ) {
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 16.dp, end = 16.dp, top = 16.dp)
- .clickable { onLanguageNavAction.invoke(languageList) },
- shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
- )
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(all = 20.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Row(
- verticalAlignment = Alignment.CenterVertically
- ) {
- Box(
- modifier = Modifier
- .size(16.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.secondary)
- )
-
- Spacer(modifier = Modifier.width(8.dp))
-
- Text(
- text = "Language",
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface,
- fontWeight = FontWeight.Medium
- )
- }
-
- Spacer(modifier = Modifier.height(8.dp))
-
- Text(
- modifier = Modifier.padding(start = 24.dp),
- text = "Select your preferred language for a personalized experience.",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
- )
- }
-
- Surface(
- shape = CircleShape,
- color = MaterialTheme.colorScheme.secondaryContainer,
- modifier = Modifier.size(36.dp)
- ) {
- Icon(
- imageVector = Icons.Filled.KeyboardArrowRight,
- contentDescription = "Navigate to language selection",
- tint = MaterialTheme.colorScheme.onSecondaryContainer,
- modifier = Modifier.padding(8.dp)
- )
- }
- }
- }
- }
-
- // Get Premium block
- // Create a transition state for the animation
- val premiumTransitionState = remember { MutableTransitionState(false) }
-
- // Start the animation when the component is first displayed
- LaunchedEffect(Unit) {
- delay(300) // Small delay for staggered effect
- premiumTransitionState.targetState = true
- }
-
- AnimatedVisibility(
- visibleState = premiumTransitionState,
- enter = fadeIn(animationSpec = tween(durationMillis = 500)) +
- slideInVertically(
- animationSpec = tween(durationMillis = 500),
- initialOffsetY = { it / 2 }
- ),
- exit = fadeOut()
- ) {
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 16.dp, end = 16.dp, top = 16.dp)
- .clickable { showPremiumBottomSheet.value = true },
- shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
- )
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(all = 20.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Row(
- verticalAlignment = Alignment.CenterVertically
- ) {
- Box(
- modifier = Modifier
- .size(16.dp)
- .clip(CircleShape)
- .background(Color(0xFFFFB74D))
- )
-
- Spacer(modifier = Modifier.width(8.dp))
-
- Text(
- text = "Get Premium",
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface,
- fontWeight = FontWeight.Medium
- )
- }
-
- Spacer(modifier = Modifier.height(8.dp))
-
- Text(
- modifier = Modifier.padding(start = 24.dp),
- text = "Upgrade to Premium and unlock exclusive features, priority support, and an ad-free experience.",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
- )
- }
-
- Surface(
- shape = CircleShape,
- color = Color(0xFFFFB74D).copy(alpha = 0.2f),
- modifier = Modifier.size(36.dp)
- ) {
- Icon(
- imageVector = Icons.Filled.KeyboardArrowRight,
- contentDescription = "Show premium information",
- tint = Color(0xFFFFB74D),
- modifier = Modifier.padding(8.dp)
- )
- }
- }
- }
-
- // Premium Bottom Sheet
- if (showPremiumBottomSheet.value) {
- ModalBottomSheet(
- onDismissRequest = { showPremiumBottomSheet.value = false },
- sheetState = bottomSheetState,
- containerColor = MaterialTheme.colorScheme.surface,
- dragHandle = {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 16.dp),
- contentAlignment = Alignment.Center
- ) {
- Box(
- modifier = Modifier
- .width(40.dp)
- .height(4.dp)
- .background(
- color = MaterialTheme.colorScheme.onSurface.copy(
- alpha = 0.3f
- ),
- shape = RoundedCornerShape(2.dp)
- )
- )
- }
- }
- ) {
- PremiumBottomSheetContent(
- onDismiss = { showPremiumBottomSheet.value = false }
- )
- }
- }
- }
- }
- },
- bottomBar = {
- AppBottomBar(
- isVisible = rememberSaveable { mutableStateOf(true) },
- navController = navController
- )
- }
- )
-}
-
-@Composable
-private fun PremiumBottomSheetContent(
- onDismiss: () -> Unit
-) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp, vertical = 16.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- // Simplified Header
- Text(
- text = "Premium",
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface
- )
-
- Spacer(modifier = Modifier.height(16.dp))
-
- // Condensed Features List
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
- ),
- shape = RoundedCornerShape(12.dp)
- ) {
- Column(
- modifier = Modifier.padding(16.dp)
- ) {
- SimplePremiumFeature("Ad-Free Experience")
- SimplePremiumFeature("Extended 15-day Forecasts")
- SimplePremiumFeature("Severe Weather Alerts")
- SimplePremiumFeature("Detailed Air Quality Data")
- }
- }
-
- Spacer(modifier = Modifier.height(24.dp))
-
- // Simplified Pricing
- Text(
- text = "$4.99/month",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface
- )
-
- Text(
- text = "7-day free trial, cancel anytime",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
- modifier = Modifier.padding(top = 4.dp)
- )
-
- Spacer(modifier = Modifier.height(24.dp))
-
- // Subscribe Button
- Button(
- onClick = { onDismiss() },
- modifier = Modifier
- .fillMaxWidth()
- .height(48.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = Color(0xFFFFB74D)
- ),
- shape = RoundedCornerShape(8.dp)
- ) {
- Text(
- text = "Subscribe",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Medium,
- color = Color.White
- )
- }
-
- Spacer(modifier = Modifier.height(8.dp))
-
- // Cancel Button
- Text(
- text = "No Thanks",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier
- .clickable { onDismiss() }
- .padding(vertical = 8.dp)
- )
- }
-}
-
-@Composable
-private fun SimplePremiumFeature(
- feature: String
-) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 6.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Box(
- modifier = Modifier
- .size(6.dp)
- .clip(CircleShape)
- .background(Color(0xFFFFB74D))
- )
-
- Spacer(modifier = Modifier.width(12.dp))
-
- Text(
- text = feature,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface
- )
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun ScreenHeader(
- modifier: Modifier = Modifier,
- onAvatarNavAction: () -> Unit,
- scope: CoroutineScope,
-) {
- val tooltipState = remember { RichTooltipState() }
-
- // Create a transition state for the animation
- val headerTransitionState = remember { MutableTransitionState(false) }
-
- // Start the animation when the component is first displayed
- LaunchedEffect(Unit) {
- headerTransitionState.targetState = true
- }
-
- AnimatedVisibility(
- visibleState = headerTransitionState,
- enter = fadeIn(animationSpec = tween(durationMillis = 500)) +
- slideInVertically(
- animationSpec = tween(durationMillis = 500),
- initialOffsetY = { -it / 2 }
- ),
- exit = fadeOut()
- ) {
- Row(
- modifier = modifier,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier.weight(1f)
- ) {
- Text(
- text = stringResource(id = R.string.settings_screen),
- style = MaterialTheme.typography.headlineLarge,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onBackground
- )
-
- Text(
- text = "Customize your app experience",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
- modifier = Modifier.padding(top = 4.dp)
- )
- }
-
- RichTooltipBox(
- tooltipState = tooltipState,
- title = {
- Text(
- text = "Hi Maa,",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold
- )
- },
- text = {
- Text(
- text = "Baba sends you love, kisses and hug โค\uFE0F",
- style = MaterialTheme.typography.bodyMedium
- )
- },
- action = {
- Text(
- text = "Call him",
- color = MaterialTheme.colorScheme.primary,
- fontWeight = FontWeight.Medium,
- modifier = Modifier
- .padding(top = 8.dp, bottom = 8.dp, end = 16.dp)
- .clickable {
- scope.launch {
- tooltipState.dismiss()
- onAvatarNavAction.invoke()
- }
- }
- )
- }
- ) {
- Surface(
- shape = CircleShape,
- modifier = Modifier
- .size(48.dp)
- .shadow(elevation = 4.dp, shape = CircleShape)
- ) {
- Image(
- painter = painterResource(id = R.drawable.zobo),
- contentDescription = "Profile avatar",
- contentScale = ContentScale.Crop,
- modifier = Modifier
- .fillMaxSize()
- .clip(CircleShape)
- .clickable { scope.launch { tooltipState.show() } }
- )
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/theme/Theme.kt b/app/src/main/java/bose/ankush/weatherify/presentation/theme/Theme.kt
index 978c0106..be21f9f9 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/theme/Theme.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/theme/Theme.kt
@@ -1,5 +1,6 @@
package bose.ankush.weatherify.presentation.theme
+import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
@@ -10,15 +11,17 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
-import androidx.compose.ui.graphics.Color.Companion.Transparent
import androidx.compose.ui.platform.LocalContext
-import com.google.accompanist.systemuicontroller.rememberSystemUiController
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
@Composable
fun WeatherifyTheme(
isDynamicColor: Boolean = true,
darkTheme: Boolean = isSystemInDarkTheme(),
- content: @Composable () -> Unit
+ content: @Composable () -> Unit,
) {
// Cache dynamic color check to avoid recalculating it
val dynamicColor = isDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@@ -26,41 +29,38 @@ fun WeatherifyTheme(
// Cache the color scheme calculation to avoid recalculating it on each recomposition
// Only recalculate when darkTheme or dynamicColor changes
- val colors = remember(darkTheme, dynamicColor) {
- when {
- darkTheme && dynamicColor -> dynamicDarkColorScheme(context)
- darkTheme -> darkColorPalette
- dynamicColor -> dynamicLightColorScheme(context)
- else -> lightColorPalette
+ val colors =
+ remember(darkTheme, dynamicColor) {
+ when {
+ darkTheme && dynamicColor -> dynamicDarkColorScheme(context)
+ darkTheme -> darkColorPalette
+ dynamicColor -> dynamicLightColorScheme(context)
+ else -> lightColorPalette
+ }
}
- }
-
- // Cache the system UI controller to avoid recreating it
- val systemUiController = rememberSystemUiController()
- // Only update system UI colors when colors or darkTheme changes
- SideEffect {
- with(systemUiController) {
- // Set both status bar and navigation bar in a single batch update
- setSystemBarsColor(
- color = Transparent,
- darkIcons = !darkTheme
- )
- isNavigationBarVisible = false
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ val controller = WindowCompat.getInsetsController(window, view)
+ controller.isAppearanceLightStatusBars = !darkTheme
+ controller.isAppearanceLightNavigationBars = !darkTheme
+ controller.hide(WindowInsetsCompat.Type.navigationBars())
+ controller.systemBarsBehavior =
+ WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
MaterialTheme(
colorScheme = colors,
typography = AppTypography,
- content = content
+ content = content,
)
}
-private val darkColorPalette = darkColorScheme(
-
-)
-
-private val lightColorPalette = lightColorScheme(
+private val darkColorPalette =
+ darkColorScheme()
-)
+private val lightColorPalette =
+ lightColorScheme()
diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/theme/Type.kt b/app/src/main/java/bose/ankush/weatherify/presentation/theme/Type.kt
index 7e635ab9..e8cd9cbb 100644
--- a/app/src/main/java/bose/ankush/weatherify/presentation/theme/Type.kt
+++ b/app/src/main/java/bose/ankush/weatherify/presentation/theme/Type.kt
@@ -2,116 +2,142 @@ package bose.ankush.weatherify.presentation.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
+import bose.ankush.weatherify.R
-// Define the Typography with the system default font (San Francisco on iOS, Roboto on Android)
-// This is a modern approach used by many contemporary apps
-val AppTypography = Typography(
- displayLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Bold,
- fontSize = 57.sp,
- lineHeight = 64.sp,
- letterSpacing = (-0.25).sp
- ),
- displayMedium = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Bold,
- fontSize = 45.sp,
- lineHeight = 52.sp,
- letterSpacing = 0.sp
- ),
- displaySmall = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Bold,
- fontSize = 36.sp,
- lineHeight = 44.sp,
- letterSpacing = 0.sp
- ),
- headlineLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.SemiBold,
- fontSize = 32.sp,
- lineHeight = 40.sp,
- letterSpacing = 0.sp
- ),
- headlineMedium = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.SemiBold,
- fontSize = 28.sp,
- lineHeight = 36.sp,
- letterSpacing = 0.sp
- ),
- headlineSmall = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.SemiBold,
- fontSize = 24.sp,
- lineHeight = 32.sp,
- letterSpacing = 0.sp
- ),
- titleLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.SemiBold,
- fontSize = 22.sp,
- lineHeight = 28.sp,
- letterSpacing = 0.sp
- ),
- titleMedium = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.SemiBold,
- fontSize = 16.sp,
- lineHeight = 24.sp,
- letterSpacing = 0.15.sp
- ),
- titleSmall = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Medium,
- fontSize = 14.sp,
- lineHeight = 20.sp,
- letterSpacing = 0.1.sp
- ),
- bodyLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Medium,
- fontSize = 16.sp,
- lineHeight = 24.sp,
- letterSpacing = 0.15.sp
- ),
- bodyMedium = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Medium,
- fontSize = 14.sp,
- lineHeight = 20.sp,
- letterSpacing = 0.25.sp
- ),
- bodySmall = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
- fontSize = 12.sp,
- lineHeight = 16.sp,
- letterSpacing = 0.4.sp
- ),
- labelLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Medium,
- fontSize = 14.sp,
- lineHeight = 20.sp,
- letterSpacing = 0.1.sp
- ),
- labelMedium = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Medium,
- fontSize = 12.sp,
- lineHeight = 16.sp,
- letterSpacing = 0.5.sp
- ),
- labelSmall = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Medium,
- fontSize = 11.sp,
- lineHeight = 16.sp,
- letterSpacing = 0.5.sp
+// App typography aligned to the design mock: clean, friendly sans-serif similar to the screenshot.
+// We use the bundled Inter font to achieve a modern look consistently across the app.
+private val InterFamily =
+ FontFamily(
+ Font(R.font.inter_regular, FontWeight.Normal),
+ Font(R.font.inter_regular, FontWeight.Medium),
+ Font(R.font.inter_regular, FontWeight.SemiBold),
+ Font(R.font.inter_regular, FontWeight.Bold),
+ )
+
+val AppTypography =
+ Typography(
+ displayLarge =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 57.sp,
+ lineHeight = 64.sp,
+ letterSpacing = (-0.25).sp,
+ ),
+ displayMedium =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 45.sp,
+ lineHeight = 52.sp,
+ letterSpacing = 0.sp,
+ ),
+ displaySmall =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 36.sp,
+ lineHeight = 44.sp,
+ letterSpacing = 0.sp,
+ ),
+ headlineLarge =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 32.sp,
+ lineHeight = 40.sp,
+ letterSpacing = 0.sp,
+ ),
+ headlineMedium =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 28.sp,
+ lineHeight = 36.sp,
+ letterSpacing = 0.sp,
+ ),
+ headlineSmall =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 24.sp,
+ lineHeight = 32.sp,
+ letterSpacing = 0.sp,
+ ),
+ titleLarge =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp,
+ ),
+ titleMedium =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ titleSmall =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ bodyLarge =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.15.sp,
+ ),
+ bodyMedium =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.2.sp,
+ ),
+ bodySmall =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.3.sp,
+ ),
+ labelLarge =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ labelMedium =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.4.sp,
+ ),
+ labelSmall =
+ TextStyle(
+ fontFamily = InterFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.4.sp,
+ ),
)
-)
diff --git a/app/src/main/res/drawable/zobo.png b/app/src/main/res/drawable/zobo.png
deleted file mode 100644
index be40e5e1..00000000
Binary files a/app/src/main/res/drawable/zobo.png and /dev/null differ
diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml
new file mode 100644
index 00000000..a1130e4c
--- /dev/null
+++ b/app/src/main/res/values-bn/strings.xml
@@ -0,0 +1,119 @@
+
+
+
+ เฆธเงเฆชเงเฆฒเงเฆฏเฆพเฆถ
+ เฆนเงเฆฎ
+ เฆฌเฆพเฆฏเฆผเง เฆฎเฆพเฆจ
+ เฆถเฆนเฆฐเฆเงเฆฒเง
+ เฆชเงเฆฐเงเฆซเฆพเฆเฆฒ
+ เฆธเงเฆเฆฟเฆเฆธ
+
+
+ home_nested_nav
+ profile_nested_nav
+
+
+ Weatherify
+ เฆชเงเฆจเฆฐเฆพเฆฏเฆผ เฆเงเฆทเงเฆเฆพ เฆเฆฐเงเฆจ
+ เฆเฆฃเงเฆเฆพ เฆ
เฆจเงเฆฏเฆพเฆฏเฆผเง เฆชเงเฆฐเงเฆฌเฆพเฆญเฆพเฆธ
+ เฆฆเงเฆจเฆฟเฆ เฆชเงเฆฐเงเฆฌเฆพเฆญเฆพเฆธ
+ %1$sเฆเฆฟเฆฎเฆฟ/เฆ
+ %1$sยฐ เฆเฆฐ เฆฎเฆคเง เฆฎเฆจเง เฆนเฆเงเฆเง
+ เฆถเฆนเฆฐ เฆจเฆฟเฆฐเงเฆฌเฆพเฆเฆจ เฆเฆฐเงเฆจ
+ เฆฌเฆพเฆฏเฆผเง เฆฎเฆพเฆจ
+ เฆซเฆฟเฆฐเง เฆฏเฆพเฆจ
+ เฆฌเงเฆเงเฆเฆฟ
+
+
+ เฆ
เฆจเฆจเงเฆฎเงเฆฆเฆฟเฆค เฆ
เงเฆฏเฆพเฆเงเฆธเงเฆธ!
+ เฆถเฆนเฆฐ เฆชเฆพเฆเฆฏเฆผเฆพ เฆฏเฆพเฆฏเฆผเฆจเฆฟ!
+ เฆธเฆพเฆฐเงเฆญเฆพเฆฐเง เฆธเฆฎเฆธเงเฆฏเฆพ เฆนเฆเงเฆเง เฆฎเฆจเง เฆนเฆเงเฆเง!
+ เฆเฆจเงเฆเฆพเฆฐเฆจเงเฆ เฆธเฆเฆฏเงเฆ เฆชเงเฆจเฆฐเฆพเฆฏเฆผ เฆชเฆฐเงเฆเงเฆทเฆพ เฆเฆฐเฆคเง เฆชเฆพเฆฐเฆฌเงเฆจ!
+ เฆเฆจเงเฆเฆพเฆฐเฆจเงเฆ เฆธเฆเฆฏเงเฆ เฆชเงเฆจเฆฐเฆพเฆฏเฆผ เฆชเฆฐเงเฆเงเฆทเฆพ เฆเฆฐเฆคเง เฆชเฆพเฆฐเฆฌเงเฆจ!
+ เฆ
เฆจเงเฆฐเงเฆงเงเฆฐ เฆธเฆฎเฆฏเฆผ เฆถเงเฆท เฆนเฆฏเฆผเง เฆเงเฆเงเฅค เฆชเฆฐเง เฆเฆฌเฆพเฆฐ เฆเงเฆทเงเฆเฆพ เฆเฆฐเงเฆจเฅค
+ เฆเฆฐเง!.. เฆเฆฟเฆเง เฆเฆเฆเฆพ เฆญเงเฆฒ เฆนเฆฏเฆผเงเฆเงเฅค
+ เฆฒเงเฆเงเฆถเฆจ เฆธเงเฆฌเฆพ เฆฌเฆจเงเฆง เฆเฆเงเฅค เฆธเงเฆฅเฆพเฆจเงเฆฏเฆผ เฆเฆฌเฆนเฆพเฆเฆฏเฆผเฆพ เฆชเงเฆคเง GPS เฆเฆพเฆฒเง เฆเฆฐเงเฆจเฅค
+ GPS เฆเฆพเฆฒเง เฆเฆฐเงเฆจ
+ เฆ
เฆฌเฆธเงเฆฅเฆพเฆจ เฆธเงเฆฅเฆพเฆจเฆพเฆเงเฆ เฆเฆเฆจเง เฆเฆชเฆกเงเฆ เฆนเฆฏเฆผเฆจเฆฟเฅค
+ เฆเฆ เฆฎเงเฆนเงเฆฐเงเฆคเง เฆเงเฆจเง เฆถเฆนเฆฐ เฆชเฆพเฆเฆฏเฆผเฆพ เฆฏเฆพเฆฏเฆผเฆจเฆฟเฅค เฆชเฆฐเง เฆฆเงเฆเงเฆจ
+ เฆเงเฆจเง เฆฌเฆฟเฆฌเฆฐเฆฃ เฆชเฆพเฆเฆฏเฆผเฆพ เฆฏเฆพเฆฏเฆผเฆจเฆฟเฅค เฆเฆฟเฆเงเฆเงเฆทเฆฃ เฆชเฆฐเง เฆเงเฆทเงเฆเฆพ เฆเฆฐเงเฆจเฅค
+
+
+ เฆชเงเฆฐเงเฆซเฆพเฆเฆฒ
+ เฆฒเฆ เฆเฆเฆ
+ เฆเฆชเฆจเฆฟ เฆเฆฟ เฆจเฆฟเฆถเงเฆเฆฟเฆคเฆญเฆพเฆฌเง เฆฒเฆ เฆเฆเฆ เฆเฆฐเฆคเง เฆเฆพเฆจ?
+ เฆเฆชเฆจเฆพเฆฐ เฆ
เงเฆฏเฆพเฆเฆพเฆเฆจเงเฆ เฆ
เงเฆฏเฆพเฆเงเฆธเงเฆธ เฆเฆฐเฆคเง เฆเฆฌเฆพเฆฐ เฆฒเฆเฆเฆจ เฆเฆฐเฆคเง เฆนเฆฌเงเฅค
+ เฆจเฆฟเฆถเงเฆเฆฟเฆค เฆเฆฐเงเฆจ
+ เฆฌเฆพเฆคเฆฟเฆฒ เฆเฆฐเงเฆจ
+
+ Get Premium
+ Processingโฆ
+ Please wait while we activate your premium subscription.
+ Unlock all features and enjoy an ad-free experience.
+ Upgrade Now
+ You are a Premium User
+ Expires %1$s
+ Active
+ เฆฌเฆฟเฆเงเฆเฆชเงเฆคเฆฟ
+ เฆญเฆพเฆทเฆพ
+ เฆเงเฆชเฆจเงเฆฏเฆผเฆคเฆพ เฆจเงเฆคเฆฟ
+ เฆฌเงเฆฏเฆฌเฆนเฆพเฆฐเงเฆฐ เฆถเฆฐเงเฆคเฆพเฆฌเฆฒเง
+ เฆ
เงเฆฏเฆพเฆช เฆธเฆเฆธเงเฆเฆฐเฆฃ
+ เฆธเงเฆเฆฟเฆเฆธ
+ เฆเฆเฆจเฆฟ
+
+
+ เฆเฆฌเฆนเฆพเฆเฆฏเฆผเฆพ เฆ
เฆฌเฆธเงเฆฅเฆพเฆฐ เฆเฆเฆเฆจ
+ เฆญเฆพเฆทเฆพ เฆเฆเฆเฆจ
+ เฆฎเงเฆจเง เฆเฆเฆเฆจ
+ เฆฌเฆพเฆฏเฆผเง เฆเฆเฆเฆจ
+ เฆเฆฐเงเฆฆเงเฆฐเฆคเฆพ เฆเฆเฆเฆจ
+ เฆคเงเฆฐเงเฆเฆฟ เฆเฆเฆเฆจ
+ เฆฌเฆจเงเฆง เฆเฆฐเงเฆจ เฆเฆเฆเฆจ
+ เฆชเงเฆเฆจเง เฆฌเฆพเฆเฆจ
+ เฆฌเฆฟเฆเงเฆเฆชเงเฆคเฆฟ เฆธเงเฆเฆฟเฆเฆธ เฆเฆเฆเฆจ
+ เฆเงเฆชเฆจเงเฆฏเฆผเฆคเฆพ เฆจเงเฆคเฆฟ เฆเฆเฆเฆจ
+ เฆฌเงเฆฏเฆฌเฆนเฆพเฆฐเงเฆฐ เฆถเฆฐเงเฆคเฆพเฆฌเฆฒเง เฆเฆเฆเฆจ
+ เฆคเฆฅเงเฆฏ เฆเฆเฆเฆจ
+ เฆชเฆฐเฆฌเฆฐเงเฆคเง เฆธเงเฆเงเฆฐเฆฟเฆจเง เฆจเงเฆญเฆฟเฆเงเฆ เฆเฆฐเงเฆจ
+ เฆญเฆพเฆทเฆพ เฆเฆจเฆซเฆฟเฆเฆพเฆฐเงเฆถเฆจ เฆฒเงเฆก เฆเฆฐเฆคเง เฆฌเงเฆฏเฆฐเงเฆฅ เฆนเฆฏเฆผเงเฆเงเฅค เฆกเฆฟเฆซเฆฒเงเฆ เฆญเฆพเฆทเฆพ เฆฌเงเฆฏเฆฌเฆนเฆพเฆฐ เฆเฆฐเฆพ เฆนเฆเงเฆเงเฅค
+
+
+ เฆเฆชเฆกเงเฆ เฆฅเฆพเฆเงเฆจ
+ เฆเฆฌเฆนเฆพเฆเฆฏเฆผเฆพ เฆธเฆคเฆฐเงเฆเฆคเฆพเฆฐ เฆเฆจเงเฆฏ เฆฌเฆฟเฆเงเฆเฆชเงเฆคเฆฟ เฆธเฆเงเฆฐเฆฟเฆฏเฆผ เฆเฆฐเงเฆจ
+ เฆธเฆเงเฆฐเฆฟเฆฏเฆผ เฆเฆฐเงเฆจ
+
+
+ เฆชเงเฆฐเฆฟเฆฎเฆฟเฆฏเฆผเฆพเฆฎ เฆธเฆเงเฆฐเฆฟเฆฏเฆผ
+ เฆเฆชเฆจเฆพเฆฐ เฆชเงเฆฐเฆฟเฆฎเฆฟเฆฏเฆผเฆพเฆฎ เฆธเฆพเฆฌเฆธเงเฆเงเฆฐเฆฟเฆชเฆถเฆจ เฆเฆเฆจ เฆธเฆเงเฆฐเฆฟเฆฏเฆผ!
+
+
+ เฆธเฆเฆฐเฆเงเฆทเฆฟเฆค เฆ
เฆฌเฆธเงเฆฅเฆพเฆจ
+ saved_locations_nested_nav
+ เฆธเฆเฆฐเฆเงเฆทเฆฟเฆค เฆ
เฆฌเฆธเงเฆฅเฆพเฆจ
+ เฆเฆเฆจเง เฆเงเฆจเง เฆธเฆเฆฐเฆเงเฆทเฆฟเฆค เฆ
เฆฌเฆธเงเฆฅเฆพเฆจ เฆจเงเฆเฅค เฆฏเงเฆ เฆเฆฐเฆคเง + เฆเงเฆฏเฆพเฆช เฆเฆฐเงเฆจเฅค
+ เฆ
เฆฌเฆธเงเฆฅเฆพเฆจ เฆฏเงเฆ เฆเฆฐเงเฆจ
+ เฆฎเงเฆเงเฆจ
+ เฆธเฆเฆฐเฆเงเฆทเฆฃ เฆเฆฐเงเฆจ
+ เฆ
เฆฌเฆธเงเฆฅเฆพเฆจเงเฆฐ เฆจเฆพเฆฎ (เฆฏเงเฆฎเฆจ เฆฌเฆพเฆกเฆผเฆฟ)
+ เฆชเงเฆฐเฆฟเฆฎเฆฟเฆฏเฆผเฆพเฆฎ เฆฌเงเฆถเฆฟเฆทเงเฆเงเฆฏ
+ เฆเฆชเฆจเฆพเฆฐ เฆชเงเฆฐเฆฟเฆฏเฆผ เฆ
เฆฌเฆธเงเฆฅเฆพเฆจเฆเงเฆฒเฆฟ เฆธเฆเฆฐเฆเงเฆทเฆฃ เฆเฆฐเงเฆจ เฆเฆฌเฆ เฆคเฆพเงเฆเงเฆทเฆฃเฆฟเฆเฆญเฆพเฆฌเง เฆ
เงเฆฏเฆพเฆเงเฆธเงเฆธ เฆเฆฐเงเฆจเฅค เฆเฆ เฆฌเงเฆถเฆฟเฆทเงเฆเงเฆฏเฆเฆฟ เฆเฆจเฆฒเฆ เฆเฆฐเฆคเง เฆชเงเฆฐเฆฟเฆฎเฆฟเฆฏเฆผเฆพเฆฎเง เฆเฆชเฆเงเฆฐเงเฆก เฆเฆฐเงเฆจเฅค
+ เฆธเฆเฆฐเฆเงเฆทเฆฟเฆค เฆ
เฆฌเฆธเงเฆฅเฆพเฆจ เฆฒเงเฆก เฆเฆฐเฆคเง เฆฌเงเฆฏเฆฐเงเฆฅเฅค
+ เฆ
เฆฌเฆธเงเฆฅเฆพเฆจ เฆธเฆซเฆฒเฆญเฆพเฆฌเง เฆธเฆเฆฐเฆเงเฆทเฆฟเฆค เฆนเฆฏเฆผเงเฆเงเฅค
+ เฆ
เฆฌเฆธเงเฆฅเฆพเฆจ เฆ
เฆชเฆธเฆพเฆฐเฆฃ เฆเฆฐเฆพ เฆนเฆฏเฆผเงเฆเงเฅค
+ เฆธเฆเฆฐเฆเงเฆทเฆฟเฆค เฆ
เฆฌเฆธเงเฆฅเฆพเฆจ
+ เฆจเฆคเงเฆจ เฆ
เฆฌเฆธเงเฆฅเฆพเฆจ เฆฏเงเฆ เฆเฆฐเงเฆจ
+ เฆ
เฆฌเฆธเงเฆฅเฆพเฆจ เฆฎเงเฆเงเฆจ
+
+
+ เฆเงเฆจเง เฆธเงเฆฅเฆพเฆจ เฆเงเฆเฆเงเฆจ
+ เฆถเฆนเฆฐ เฆฌเฆพ เฆ เฆฟเฆเฆพเฆจเฆพ เฆเฆพเฆเฆช เฆเฆฐเงเฆจโฆ
+ \'%1$s\'เฆเฆฐ เฆเฆจเงเฆฏ เฆเงเฆจเง เฆธเงเฆฅเฆพเฆจ เฆชเฆพเฆเฆฏเฆผเฆพ เฆฏเฆพเฆฏเฆผเฆจเฆฟ
+ เฆเฆฌเฆนเฆพเฆเฆฏเฆผเฆพเฆฐ เฆ
เฆฌเฆธเงเฆฅเฆพเฆจ เฆนเฆฟเฆธเงเฆฌเง เฆฌเงเฆฏเฆฌเฆนเฆพเฆฐ เฆเฆฐเฆฌเงเฆจ?
+ เฆเฆชเฆจเฆพเฆฐ เฆฌเฆฐเงเฆคเฆฎเฆพเฆจ GPS เฆ
เฆฌเฆธเงเฆฅเฆพเฆจเงเฆฐ เฆชเฆฐเฆฟเฆฌเฆฐเงเฆคเง %1$s-เฆเฆฐ เฆเฆจเงเฆฏ เฆเฆฌเฆนเฆพเฆเฆฏเฆผเฆพ เฆคเฆฅเงเฆฏ เฆฆเงเฆเฆพเฆฌเงเฅค
+ เฆเฆเฆฟ เฆธเฆเงเฆฐเฆฟเฆฏเฆผ เฆฅเฆพเฆเฆพ เฆ
เฆฌเฆธเงเฆฅเฆพเฆฏเฆผ เฆเฆชเฆจเฆพเฆฐ เฆฒเฆพเฆเฆญ GPS เฆ
เฆฌเฆธเงเฆฅเฆพเฆจ เฆเฆชเฆกเงเฆ เฆนเฆฌเง เฆจเฆพเฅค
+ เฆกเฆฟเฆซเฆฒเงเฆ เฆนเฆฟเฆธเงเฆฌเง เฆธเงเฆ เฆเฆฐเงเฆจ
+ %1$s เฆฌเงเฆฏเฆฌเฆนเฆพเฆฐ เฆเฆฐเฆพ เฆนเฆเงเฆเง
+ GPS-เฆ เฆฐเฆฟเฆธเงเฆ เฆเฆฐเงเฆจ
+ เฆฌเฆฐเงเฆคเฆฎเฆพเฆจเง %1$s-เฆเฆฐ เฆเฆฌเฆนเฆพเฆเฆฏเฆผเฆพ เฆฆเงเฆเฆพเฆจเง เฆนเฆเงเฆเงเฅค GPS-เฆ เฆฐเฆฟเฆธเงเฆ เฆเฆฐเฆคเง เฆเงเฆฏเฆพเฆช เฆเฆฐเงเฆจเฅค
+
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 76e03445..77be5556 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -34,11 +34,39 @@
เคถเคนเคฐ เคจเคนเฅเค เคฎเคฟเคฒเคพ!
เคฒเคเคคเคพ เคนเฅ เคธเคฐเฅเคตเคฐ เคฎเฅเค เคธเคฎเคธเฅเคฏเคพ เคนเฅ!
เคเฅเคฏเคพ เคเคช เคเคเคเคฐเคจเฅเค เคเคจเฅเคเฅเคเคฟเคตเคฟเคเฅ เคเฅ เคฆเฅเคฌเคพเคฐเคพ เคเคพเคเค เคเคฐ เคธเคเคคเฅ เคนเฅเค!
+ เคเฅเคฏเคพ เคเคช เคเคเคเคฐเคจเฅเค เคเคจเฅเคเฅเคเคฟเคตเคฟเคเฅ เคเฅ เคฆเฅเคฌเคพเคฐเคพ เคเคพเคเค เคเคฐ เคธเคเคคเฅ เคนเฅเค!
+ เค
เคจเฅเคฐเฅเคง เคเคพ เคธเคฎเคฏ เคธเคฎเคพเคชเฅเคค เคนเฅ เคเคฏเคพเฅค เคเฅเคชเคฏเคพ เคฌเคพเคฆ เคฎเฅเค เคชเฅเคจเค เคชเฅเคฐเคฏเคพเคธ เคเคฐเฅเคเฅค
เคเคน! เคเฅเค เคเคฒเคค เคนเฅ เคเคฏเคพ เคนเฅเฅค
+ เคธเฅเคฅเคพเคจ เคธเฅเคตเคพเคเค เคฌเคเคฆ เคนเฅเคเฅค เค
เคชเคจเคพ เคธเฅเคฅเคพเคจเฅเคฏ เคฎเฅเคธเคฎ เคชเคพเคจเฅ เคเฅ เคฒเคฟเค GPS เคเคพเคฒเฅ เคเคฐเฅเคเฅค
+ GPS เคเคพเคฒเฅ เคเคฐเฅเค
เคธเฅเคฅเคพเคจ เคจเคฟเคฐเฅเคฆเฅเคถเคพเคเค เค
เคญเฅ เคคเค เค
เคชเคกเฅเค เคจเคนเฅเค เคเคฟเค เคเค เคนเฅเคเฅค
เคเคน เคคเฅเคฐเฅ! เคเคธ เคธเคฎเคฏ เคเฅเค เคถเคนเคฐ เคจเคนเฅเค เคฎเคฟเคฒเคพเฅค เคฌเคพเคฆ เคฎเฅเค เคเคพเคเคเฅเค
เคเฅเค เคตเคฟเคตเคฐเคฃ เคจเคนเฅเค เคฎเคฟเคฒเคพเฅค เคเฅเค เคฆเฅเคฐ เคฌเคพเคฆ เคเฅเคถเคฟเคถ เคเคฐเฅเคเฅค
+
+ เคชเฅเคฐเฅเคซเคผเคพเคเคฒ
+ เคฒเฅเคเคเคเค
+ เคเฅเคฏเคพ เคเคช เคฒเฅเคเคเคเค เคเคฐเคจเคพ เคเคพเคนเคคเฅ เคนเฅเค?
+ เคเคชเคเฅ เค
เคชเคจเฅ เคเคพเคคเฅ เคเฅ เคเคเฅเคธเฅเคธ เคเคฐเคจเฅ เคเฅ เคฒเคฟเค เคซเคฟเคฐ เคธเฅ เคฒเฅเคเคฟเคจ เคเคฐเคจเคพ เคนเฅเคเคพเฅค
+ เคชเฅเคทเฅเคเคฟ เคเคฐเฅเค
+ เคฐเคฆเฅเคฆ เคเคฐเฅเค
+
+ เคชเฅเคฐเฅเคฎเคฟเคฏเคฎ เคชเฅเคฐเคพเคชเฅเคค เคเคฐเฅเค
+ เคชเฅเคฐเคเฅเคฐเคฟเคฏเคพ เคฎเฅเคโฆ
+ เคเฅเคชเคฏเคพ เคชเฅเคฐเคคเฅเคเฅเคทเคพ เคเคฐเฅเค เคเคฌเคเคฟ เคนเคฎ เคเคชเคเฅ เคชเฅเคฐเฅเคฎเคฟเคฏเคฎ เคธเคฆเคธเฅเคฏเคคเคพ เคธเคเฅเคฐเคฟเคฏ เคเคฐเคคเฅ เคนเฅเคเฅค
+ เคธเคญเฅ เคธเฅเคตเคฟเคงเคพเคเค เคเฅ เค
เคจเคฒเฅเค เคเคฐเฅเค เคเคฐ เคตเคฟเคเฅเคเคพเคชเคจ-เคฎเฅเคเฅเคค เค
เคจเฅเคญเคต เคเคพ เคเคจเคเคฆ เคฒเฅเคเฅค
+ เค
เคญเฅ เค
เคชเคเฅเคฐเฅเคก เคเคฐเฅเค
+ เคเคช เคชเฅเคฐเฅเคฎเคฟเคฏเคฎ เคเคชเคฏเฅเคเคเคฐเฅเคคเคพ เคนเฅเค
+ %1$s เคเฅ เคธเคฎเคพเคชเฅเคค เคนเฅเคคเคพ เคนเฅ
+ เคธเคเฅเคฐเคฟเคฏ
+ เคธเฅเคเคจเคพเคเค
+ เคญเคพเคทเคพ
+ เคเฅเคชเคจเฅเคฏเคคเคพ เคจเฅเคคเคฟ
+ เคเคชเคฏเฅเค เคเฅ เคถเคฐเฅเคคเฅเค
+ เคเคช เคธเคเคธเฅเคเคฐเคฃ
+ เคธเฅเคเคฟเคเคเฅเคธ
+ เคเคพเคจเฅเคจเฅ
+
เคฎเฅเคธเคฎ เคเฅ เคธเฅเคฅเคฟเคคเคฟ เคเคเคเคจ
เคญเคพเคทเคพ เคชเคฐเคฟเคตเคฐเฅเคคเคจ เคเคฟเคนเฅเคจ
@@ -48,4 +76,50 @@
เคคเฅเคฐเฅเคเคฟ เคเคฟเคนเฅ
เคฌเคเคฆ เคเคฐเฅเค เคเคฟเคนเฅ
เคฆเฅเคจเคฟเค เคชเฅเคฐเฅเคตเคพเคจเฅเคฎเคพเคจ
+ เคตเคพเคชเคธ เคฌเคเคจ
+ เคธเฅเคเคจเคพ เคธเฅเคเคฟเคเคเฅเคธ เคเคเคเคจ
+ เคเฅเคชเคจเฅเคฏเคคเคพ เคจเฅเคคเคฟ เคเคเคเคจ
+ เคเคชเคฏเฅเค เคเฅ เคถเคฐเฅเคคเฅเค เคเคเคเคจ
+ เคเคพเคจเคเคพเคฐเฅ เคเคเคเคจ
+ เค
เคเคฒเฅ เคธเฅเคเฅเคฐเฅเคจ เคชเคฐ เคจเฅเคตเคฟเคเฅเค เคเคฐเฅเค
+ เคญเคพเคทเคพ เคเฅเคจเฅเคซเคผเคฟเคเคฐเฅเคถเคจ เคฒเฅเคก เคเคฐเคจเฅ เคฎเฅเค เคตเคฟเคซเคฒเฅค เคกเคฟเคซเคผเฅเคฒเฅเค เคญเคพเคทเคพ เคเคพ เคเคชเคฏเฅเค เคเคฐ เคฐเคนเฅ เคนเฅเคเฅค
+
+
+ เค
เคชเคกเฅเค เคฐเคนเฅเค
+ เคฎเฅเคธเคฎ เคธเคคเคฐเฅเคเคคเคพ เคเฅ เคฒเคฟเค เคธเฅเคเคจเคพเคเค เคธเคเฅเคทเคฎ เคเคฐเฅเค
+ เคธเคเฅเคทเคฎ เคเคฐเฅเค
+
+
+ เคชเฅเคฐเฅเคฎเคฟเคฏเคฎ เคธเคเฅเคฐเคฟเคฏ
+ เคเคชเคเฅ เคชเฅเคฐเฅเคฎเคฟเคฏเคฎ เคธเคฆเคธเฅเคฏเคคเคพ เค
เคฌ เคธเคเฅเคฐเคฟเคฏ เคนเฅ!
+
+
+ เคธเคนเฅเคเฅ เคเค เคธเฅเคฅเคพเคจ
+ saved_locations_nested_nav
+ เคธเคนเฅเคเฅ เคเค เคธเฅเคฅเคพเคจ
+ เคเฅเค เคธเคนเฅเคเคพ เคเคฏเคพ เคธเฅเคฅเคพเคจ เคจเคนเฅเค เคนเฅเฅค เคเฅเคกเคผเคจเฅ เคเฅ เคฒเคฟเค + เคเฅเคช เคเคฐเฅเคเฅค
+ เคธเฅเคฅเคพเคจ เคเฅเคกเคผเฅเค
+ เคนเคเคพเคเค
+ เคธเคนเฅเคเฅเค
+ เคธเฅเคฅเคพเคจ เคเคพ เคจเคพเคฎ (เคเฅเคธเฅ เคเคฐ)
+ เคชเฅเคฐเฅเคฎเคฟเคฏเคฎ เคธเฅเคตเคฟเคงเคพ
+ เค
เคชเคจเฅ เคชเคธเคเคฆเฅเคฆเคพ เคธเฅเคฅเคพเคจเฅเค เคเฅ เคธเคนเฅเคเฅเค เคเคฐ เคคเฅเคฐเคเคค เคเคเฅเคธเฅเคธ เคเคฐเฅเคเฅค เคเคธ เคธเฅเคตเคฟเคงเคพ เคเฅ เค
เคจเคฒเฅเค เคเคฐเคจเฅ เคเฅ เคฒเคฟเค เคชเฅเคฐเฅเคฎเคฟเคฏเคฎ เคฎเฅเค เค
เคชเคเฅเคฐเฅเคก เคเคฐเฅเคเฅค
+ เคธเคนเฅเคเฅ เคเค เคธเฅเคฅเคพเคจ เคฒเฅเคก เคเคฐเคจเฅ เคฎเฅเค เคตเคฟเคซเคฒเฅค
+ เคธเฅเคฅเคพเคจ เคธเคซเคฒเคคเคพเคชเฅเคฐเฅเคตเค เคธเคนเฅเคเคพ เคเคฏเคพเฅค
+ เคธเฅเคฅเคพเคจ เคนเคเคพ เคฆเคฟเคฏเคพ เคเคฏเคพเฅค
+ เคธเคนเฅเคเฅ เคเค เคธเฅเคฅเคพเคจ
+ เคจเคฏเคพ เคธเฅเคฅเคพเคจ เคเฅเคกเคผเฅเค
+ เคธเฅเคฅเคพเคจ เคนเคเคพเคเค
+
+
+ เคเคฟเคธเฅ เคธเฅเคฅเคพเคจ เคเฅ เคเฅเคเฅเค
+ เคถเคนเคฐ เคฏเคพ เคชเคคเคพ เคเคพเคเคช เคเคฐเฅเคโฆ
+ \'%1$s\' เคเฅ เคฒเคฟเค เคเฅเค เคธเฅเคฅเคพเคจ เคจเคนเฅเค เคฎเคฟเคฒเคพ
+ เคฎเฅเคธเคฎ เคธเฅเคฅเคพเคจ เคเฅ เคฐเฅเคช เคฎเฅเค เคเคชเคฏเฅเค เคเคฐเฅเค?
+ เคเคชเคเฅ เคตเคฐเฅเคคเคฎเคพเคจ GPS เคธเฅเคฅเคฟเคคเคฟ เคเฅ เคฌเคเคพเคฏ %1$s เคเคพ เคฎเฅเคธเคฎ เคกเฅเคเคพ เคฆเคฟเคเคพเคฏเคพ เคเคพเคเคเคพเฅค
+ เคเคธ เคฆเฅเคฐเคพเคจ เคเคชเคเฅ เคฒเคพเคเคต GPS เคธเฅเคฅเคพเคจ เค
เคชเคกเฅเค เคจเคนเฅเค เคนเฅเคเฅเฅค
+ เคกเคฟเคซเคผเฅเคฒเฅเค เคเฅ เคฐเฅเคช เคฎเฅเค เคธเฅเค เคเคฐเฅเค
+ %1$s เคเคพ เคเคชเคฏเฅเค เคนเฅ เคฐเคนเคพ เคนเฅ
+ GPS เคชเคฐ เคฐเฅเคธเฅเค เคเคฐเฅเค
+ เคตเคฐเฅเคคเคฎเคพเคจ เคฎเฅเค %1$s เคเคพ เคฎเฅเคธเคฎ เคฆเคฟเคเคพเคฏเคพ เคเคพ เคฐเคนเคพ เคนเฅเฅค GPS เคชเคฐ เคฐเฅเคธเฅเค เคเคฐเคจเฅ เคเฅ เคฒเคฟเค เคเฅเคช เคเคฐเฅเคเฅค
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml
index 978b0392..aa5cdcd5 100644
--- a/app/src/main/res/values-iw/strings.xml
+++ b/app/src/main/res/values-iw/strings.xml
@@ -34,11 +34,30 @@
ืืขืืจ ืื ื ืืฆืื!
ืืชืืืื ืขื ืืขืื ืืฉืจืช
ืืื ืชืืื ืืืืืง ืืืืฉ ืืช ืืืืืจ ืืืื ืืจื ื!
+ ืืื ืชืืื ืืืืืง ืืืืฉ ืืช ืืืืืจ ืืืื ืืจื ื!
+ ืคื ืืืื ืืงืฆืื ืืืงืฉื. ืื ื ื ืกื ืฉืื ืืืืืจ ืืืชืจ.
ืืืคืก!..ืืฉืื ืืฉืชืืฉ.
+ ืฉืืจืืชื ืืืืงืื ืืืืืื. ืืคืขื GPS ืืื ืืงืื ืืช ืืื ืืืืืืจ ืืืงืืื.
+ ืืคืขื GPS
ืงืืืืจืืื ืืืช ืืืืงืื ืขืืืื ืื ืขืืืื ื.
ืืืฉื ืื ืืืจืฉืืช!
ืื ื ืืฆืื ืคืจืืื. ื ืกื ืฉืื ืืืืืจ ืืืชืจ
+
+ ืคืจืืคืื
+ ืืชื ืชืงืืช
+ ืืื ืืชื ืืืื ืฉืืจืฆืื ื ืืืชื ืชืง?
+ ืืืื ืขููื ืืืชืืืจ ืฉืื ืืื ืืืฉืช ืืืฉืืื ื.
+ ืืืฉืืจ
+ ืืืืื
+ ืืืืขืืช
+ ืฉืคื
+ ืืืื ืืืช ืคืจืืืืช
+ ืชื ืื ืฉืืืืฉ
+ ืืจืกื ืืคืืืงืฆืื
+ ืืืืจืืช
+ ืืฉืคืื
+
ืกืื ืืฆื ืืื ืืืืืืจ
ืกืื ืฉืคื
@@ -48,4 +67,58 @@
ืกืื ืฉืืืื
ืกืื ืกืืืจื
ืชืืืืช ืืืืืช
+ ืืคืชืืจ ืืืจื
+ ืกืื ืืืืจืืช ืืชืจืื
+ ืกืื ืืืื ืืืช ืคืจืืืืช
+ ืกืื ืชื ืื ืืฉืืืืฉ
+ ืกืื ืืืืข
+ ื ืืื ืืืกื ืืื
+ ืืืฉืืื ืืืขืื ืช ืชืฆืืจืช ืฉืคื. ืฉืืืืฉ ืืฉืคื ืืจืืจืช ืืืื.
+
+
+ ืืืืฉืืจ ืืขืืืื
+ ืืคืื ืืชืจืืืช ืืืฉืืช ืืื ืืืืืจ
+ ืืคืื
+
+
+ ืงืื ืคืจืืืืื
+ ืืขืืืืโฆ
+ ืื ื ืืืชื ืืืื ืฉืื ื ืืคืขืืืื ืืช ืื ืื ืืคืจืืืืื ืฉืื.
+ ืืื ืืช ื ืขืืืช ืื ืืชืืื ืืช ืืื ืื ื ืืืืืื ืืื ืืืืขืืช.
+ ืฉืืจื ืืขืช
+ ืืชื ืืฉืชืืฉ ืคืจืืืืื
+ ืคื ืืชืืงืฃ %1$s
+ ืคืขืื
+ ืคืจืืืืื ืืืคืขื
+ ืื ืื ืืคืจืืืืื ืฉืื ืคืขืื ืืขืช!
+
+
+ ืืืงืืืื ืฉืืืจืื
+ saved_locations_nested_nav
+ ืืืงืืืื ืฉืืืจืื
+ ืืื ืืืงืืืื ืฉืืืจืื ืขืืืื. ืืงืฉ + ืืื ืืืืกืืฃ ืืื.
+ ืืืกืฃ ืืืงืื
+ ืืืง
+ ืฉืืืจ
+ ืฉื ืืืงืื (ืืืฉื ืืืช)
+ ืชืืื ืช ืคืจืืืืื
+ ืฉืืืจ ืืืฉ ืื ืืืงืืืื ืืืืขืืคืื ืืืืคื ืืืืื. ืฉืืจื ืืคืจืืืืื ืืื ืืืฉืื ื ืขืืืช ืชืืื ื ืื.
+ ืืืฉืืื ืืืขืื ืช ืืืงืืืื ืฉืืืจืื.
+ ืืืงืื ื ืฉืืจ ืืืฆืืื.
+ ืืืงืื ืืืกืจ.
+ ืืืงืืืื ืฉืืืจืื
+ ืืืกืฃ ืืืงืื ืืืฉ
+ ืืืง ืืืงืื
+
+
+ ืืคืฉ ืืืงืื
+ ืืงืื ืขืืจ ืื ืืชืืืชโฆ
+ ืื ื ืืฆืื ืืืงืืืื ืขืืืจ \'%1$s\'
+ ืืืฉืชืืฉ ืืืืงืื ืืื ืืืืืืจ?
+ ื ืชืื ื ืืื ืืืืืืจ ืืืฆืื ืขืืืจ %1$s ืืืงืื ืืืงืื ื-GPS ืื ืืืื ืฉืื.
+ ืืืงืื ื-GPS ืืื ืฉืื ืื ืืชืขืืื ืื ืขืื ืื ืคืขืื.
+ ืืืืจ ืืืจืืจืช ืืืื
+ ืืฉืชืืฉ ื-%1$s
+ ืืคืก ื-GPS
+ ืืจืืข ืืืฆื ืืื ืืืืืืจ ืขืืืจ %1$s. ืืงืฉ ืืื ืืืคืก ื-GPS.
diff --git a/app/src/main/res/values-kn/strings.xml b/app/src/main/res/values-kn/strings.xml
new file mode 100644
index 00000000..4c784975
--- /dev/null
+++ b/app/src/main/res/values-kn/strings.xml
@@ -0,0 +1,118 @@
+
+
+
+ เฒธเณเฒชเณเฒฒเณเฒฏเฒพเฒทเณ
+ เฒฎเณเฒเฒชเณเฒ
+ เฒตเฒพเฒฏเณ เฒเณเฒฃเฒฎเฒเณเฒ
+ เฒจเฒเฒฐเฒเฒณเณ
+ เฒชเณเฒฐเณเฒซเณเฒฒเณ
+ เฒธเณเฒเณเฒเฒฟเฒเฒเณโเฒเฒณเณ
+
+
+ home_nested_nav
+ profile_nested_nav
+
+
+ Weatherify
+ เฒฎเฒคเณเฒคเณ เฒชเณเฒฐเฒฏเฒคเณเฒจเฒฟเฒธเฒฟ
+ เฒเฒเฒเณเฒเฒเณเฒเฒฒเณ เฒฎเณเฒจเณเฒธเณเฒเฒจเณ
+ เฒฆเณเฒจเฒเฒฆเฒฟเฒจ เฒฎเณเฒจเณเฒธเณเฒเฒจเณ
+ %1$sเฒเฒฟเฒฎเณ/เฒ
+ %1$sยฐ เฒเฒเฒฆเณ เฒ
เฒจเฒฟเฒธเณเฒคเณเฒคเฒฆเณ
+ เฒจเฒเฒฐ เฒเฒฏเณเฒเณเฒฎเฒพเฒกเฒฟ
+ เฒตเฒพเฒฏเณ เฒเณเฒฃเฒฎเฒเณเฒ
+ เฒนเฒฟเฒเฒฆเณ เฒนเณเฒเฒฟ
+ เฒ
เฒฐเณเฒฅเฒตเฒพเฒฏเฒฟเฒคเณ
+
+
+ เฒ
เฒจเฒงเฒฟเฒเณเฒค เฒชเณเฒฐเฒตเณเฒถ!
+ เฒจเฒเฒฐ เฒเฒเฒกเณเฒฌเฒฐเฒฒเฒฟเฒฒเณเฒฒ!
+ เฒธเฒฐเณเฒตเฒฐเณ เฒธเฒฎเฒธเณเฒฏเณ เฒเฒเฒกเณเฒฌเฒฐเณเฒคเณเฒคเฒฆเณ!
+ เฒเฒเฒเฒฐเณเฒจเณเฒเณ เฒธเฒเฒชเฒฐเณเฒ เฒฎเฒฐเณ-เฒชเฒฐเฒฟเฒถเณเฒฒเฒฟเฒธเฒฌเฒนเณเฒฆเณ!
+ เฒเฒเฒเฒฐเณเฒจเณเฒเณ เฒธเฒเฒชเฒฐเณเฒ เฒฎเฒฐเณ-เฒชเฒฐเฒฟเฒถเณเฒฒเฒฟเฒธเฒฌเฒนเณเฒฆเณ!
+ เฒตเฒฟเฒจเฒเฒคเฒฟเฒฏ เฒธเฒฎเฒฏ เฒฎเณเฒฐเฒฟเฒคเณ. เฒฆเฒฏเฒตเฒฟเฒเณเฒเณ เฒจเฒเฒคเฒฐ เฒฎเฒคเณเฒคเณ เฒชเณเฒฐเฒฏเฒคเณเฒจเฒฟเฒธเฒฟ.
+ เฒ
เฒฏเณเฒฏเณ!.. เฒเฒจเณ เฒคเฒชเณเฒชเฒพเฒเฒฟเฒฆเณ.
+ เฒธเณเฒฅเฒณ เฒธเณเฒตเณเฒเฒณเณ เฒเฒซเณ เฒเฒเฒฟเฒฆเณ. เฒธเณเฒฅเฒณเณเฒฏ เฒนเฒตเฒพเฒฎเฒพเฒจ เฒชเฒกเณเฒฏเฒฒเณ GPS เฒเฒจเณ เฒฎเฒพเฒกเฒฟ.
+ GPS เฒเฒจเณ เฒฎเฒพเฒกเฒฟ
+ เฒธเณเฒฅเฒณ เฒจเฒฟเฒฐเณเฒฆเณเฒถเฒพเฒเฒเฒเฒณเฒจเณเฒจเณ เฒเฒจเณเฒจเณ เฒจเฒตเณเฒเฒฐเฒฟเฒธเฒฒเฒพเฒเฒฟเฒฒเณเฒฒ.
+ เฒ เฒเณเฒทเฒฃ เฒฏเฒพเฒต เฒจเฒเฒฐเฒเฒณเณ เฒเฒเฒกเณเฒฌเฒฐเฒฒเฒฟเฒฒเณเฒฒ. เฒจเฒเฒคเฒฐ เฒฎเฒคเณเฒคเณเฒฎเณเฒฎเณ เฒชเฒฐเฒฟเฒถเณเฒฒเฒฟเฒธเฒฟ
+ เฒตเฒฟเฒตเฒฐเฒเฒณเณ เฒเฒเฒกเณเฒฌเฒฐเฒฒเฒฟเฒฒเณเฒฒ. เฒธเณเฒตเฒฒเณเฒช เฒธเฒฎเฒฏเฒฆ เฒจเฒเฒคเฒฐ เฒชเณเฒฐเฒฏเฒคเณเฒจเฒฟเฒธเฒฟ.
+
+
+ เฒชเณเฒฐเณเฒซเณเฒฒเณ
+ เฒฒเฒพเฒเณ เฒเฒเณ
+ เฒจเณเฒตเณ เฒเฒเฒฟเฒคเฒตเฒพเฒเฒฟ เฒฒเฒพเฒเณ เฒเฒเณ เฒฎเฒพเฒกเฒฒเณ เฒฌเฒฏเฒธเณเฒตเฒฟเฒฐเฒพ?
+ เฒจเฒฟเฒฎเณเฒฎ เฒเฒพเฒคเณ เฒชเณเฒฐเฒตเณเฒถเฒฟเฒธเฒฒเณ เฒฎเฒคเณเฒคเณ เฒฒเฒพเฒเฒฟเฒจเณ เฒฎเฒพเฒกเฒฌเณเฒเฒพเฒเณเฒคเณเฒคเฒฆเณ.
+ เฒฆเณเฒขเณเฒเฒฐเฒฟเฒธเฒฟ
+ เฒฐเฒฆเณเฒฆเณเฒฎเฒพเฒกเฒฟ
+ เฒ
เฒงเฒฟเฒธเณเฒเฒจเณเฒเฒณเณ
+ เฒญเฒพเฒทเณ
+ เฒเณเฒชเณเฒฏเฒคเฒพ เฒจเณเฒคเฒฟ
+ เฒฌเฒณเฒเณ เฒจเฒฟเฒฏเฒฎเฒเฒณเณ
+ เฒเณเฒฏเฒชเณ เฒเฒตเณเฒคเณเฒคเฒฟ
+ เฒธเณเฒเณเฒเฒฟเฒเฒเณโเฒเฒณเณ
+ เฒเฒพเฒจเณเฒจเณ
+
+
+ เฒนเฒตเฒพเฒฎเฒพเฒจ เฒธเณเฒฅเฒฟเฒคเฒฟ เฒเฒเฒพเฒจเณ
+ เฒญเฒพเฒทเณ เฒเฒเฒพเฒจเณ
+ เฒฎเณเฒจเณ เฒเฒเฒพเฒจเณ
+ เฒเฒพเฒณเฒฟ เฒเฒเฒพเฒจเณ
+ เฒเฒฐเณเฒฆเณเฒฐเฒคเณ เฒเฒเฒพเฒจเณ
+ เฒฆเณเฒท เฒเฒเฒพเฒจเณ
+ เฒฎเณเฒเณเฒเฒฟ เฒเฒเฒพเฒจเณ
+ เฒนเฒฟเฒเฒฆเณ เฒฌเฒเฒจเณ
+ เฒ
เฒงเฒฟเฒธเณเฒเฒจเณ เฒธเณเฒเณเฒเฒฟเฒเฒเณโเฒเฒณ เฒเฒเฒพเฒจเณ
+ เฒเณเฒชเณเฒฏเฒคเฒพ เฒจเณเฒคเฒฟ เฒเฒเฒพเฒจเณ
+ เฒฌเฒณเฒเณ เฒจเฒฟเฒฏเฒฎเฒเฒณ เฒเฒเฒพเฒจเณ
+ เฒฎเฒพเฒนเฒฟเฒคเฒฟ เฒเฒเฒพเฒจเณ
+ เฒฎเณเฒเฒฆเฒฟเฒจ เฒชเฒฐเฒฆเณเฒเณ เฒจเณเฒฏเฒพเฒตเฒฟเฒเณเฒเณ เฒฎเฒพเฒกเฒฟ
+ เฒญเฒพเฒทเฒพ เฒธเฒเฒฐเฒเฒจเณ เฒฒเณเฒกเณ เฒฎเฒพเฒกเฒฒเณ เฒตเฒฟเฒซเฒฒเฒตเฒพเฒเฒฟเฒฆเณ. เฒกเฒฟเฒซเฒพเฒฒเณเฒเณ เฒญเฒพเฒทเณ เฒฌเฒณเฒธเฒฒเฒพเฒเณเฒคเณเฒคเฒฟเฒฆเณ.
+
+
+ เฒจเฒตเณเฒเฒฐเฒฟเฒคเฒฐเฒพเฒเฒฟเฒฐเฒฟ
+ เฒนเฒตเฒพเฒฎเฒพเฒจ เฒเฒเณเฒเฒฐเฒฟเฒเณเฒเฒณเฒฟเฒเฒพเฒเฒฟ เฒ
เฒงเฒฟเฒธเณเฒเฒจเณเฒเฒณเฒจเณเฒจเณ เฒธเฒเณเฒฐเฒฟเฒฏเฒเณเฒณเฒฟเฒธเฒฟ
+ เฒธเฒเณเฒฐเฒฟเฒฏเฒเณเฒณเฒฟเฒธเฒฟ
+
+
+ เฒชเณเฒฐเณเฒฎเฒฟเฒฏเฒ เฒชเฒกเณเฒฏเฒฟเฒฐเฒฟ
+ เฒธเฒเฒธเณเฒเฒฐเฒฃเณเฒฏเฒฒเณเฒฒเฒฟโฆ
+ เฒเฒชเณเฒค เฒชเณเฒฐเณเฒฎเฒฟเฒฏเฒ เฒเณเฒฐเฒพเฒนเฒเฒคเณเฒตเฒตเฒจเณเฒจเณ เฒธเฒเณเฒฐเฒฟเฒฏเฒเณเฒณเฒฟเฒธเณเฒตเฒพเฒ เฒฆเฒฏเฒตเฒฟเฒเณเฒเณ เฒคเฒกเณเฒฆเณเฒเณเฒณเณเฒณเฒฟ.
+ เฒเฒฒเณเฒฒเฒพ เฒตเณเฒถเฒฟเฒทเณเฒเณเฒฏเฒเฒณเฒจเณเฒจเณ เฒ
เฒจเณโเฒฒเฒพเฒเณ เฒฎเฒพเฒกเฒฟ เฒฎเฒคเณเฒคเณ เฒตเฒฟเฒเณเฒเฒพเฒชเฒจ-เฒฎเณเฒเณเฒค เฒ
เฒจเณเฒญเฒตเฒตเฒจเณเฒจเณ เฒเฒจเฒเฒฆเฒฟเฒธเฒฟ.
+ เฒเฒ เฒ
เฒชเณโเฒเณเฒฐเณเฒกเณ เฒฎเฒพเฒกเฒฟ
+ เฒจเณเฒตเณ เฒชเณเฒฐเณเฒฎเฒฟเฒฏเฒ เฒฌเฒณเฒเณเฒฆเฒพเฒฐ
+ %1$s เฒเฒพเฒเฒฟ เฒฎเณเฒเณเฒคเฒพเฒฏ
+ เฒธเฒเณเฒฐเฒฟเฒฏ
+ เฒชเณเฒฐเณเฒฎเฒฟเฒฏเฒ เฒธเฒเณเฒฐเฒฟเฒฏเฒเณเฒณเฒฟเฒธเฒฒเฒพเฒเฒฟเฒฆเณ
+ เฒจเฒฟเฒฎเณเฒฎ เฒชเณเฒฐเณเฒฎเฒฟเฒฏเฒ เฒเณเฒฐเฒพเฒนเฒเฒคเณเฒต เฒเฒ เฒธเฒเณเฒฐเฒฟเฒฏเฒตเฒพเฒเฒฟเฒฆเณ!
+
+
+ เฒเฒณเฒฟเฒธเฒฒเฒพเฒฆ เฒธเณเฒฅเฒณเฒเฒณเณ
+ saved_locations_nested_nav
+ เฒเฒณเฒฟเฒธเฒฒเฒพเฒฆ เฒธเณเฒฅเฒณเฒเฒณเณ
+ เฒเฒจเณเฒจเณ เฒธเฒเฒฐเฒเณเฒทเฒฟเฒค เฒธเณเฒฅเฒณเฒเฒณเฒฟเฒฒเณเฒฒ. เฒธเณเฒฐเฒฟเฒธเฒฒเณ + เฒเณเฒฏเฒพเฒชเณ เฒฎเฒพเฒกเฒฟ.
+ เฒธเณเฒฅเฒณ เฒธเณเฒฐเฒฟเฒธเฒฟ
+ เฒ
เฒณเฒฟเฒธเฒฟ
+ เฒเฒณเฒฟเฒธเฒฟ
+ เฒธเณเฒฅเฒณ เฒนเณเฒธเฒฐเณ (เฒเฒฆเฒพ. เฒฎเฒจเณ)
+ เฒชเณเฒฐเณเฒฎเฒฟเฒฏเฒ เฒตเณเฒถเฒฟเฒทเณเฒเณเฒฏ
+ เฒจเฒฟเฒฎเณเฒฎ เฒฎเณเฒเณเฒเณเฒต เฒธเณเฒฅเฒณเฒเฒณเฒจเณเฒจเณ เฒเฒณเฒฟเฒธเฒฟ เฒฎเฒคเณเฒคเณ เฒคเฒเณเฒทเฒฃ เฒชเณเฒฐเฒตเณเฒถเฒฟเฒธเฒฟ. เฒ เฒตเณเฒถเฒฟเฒทเณเฒเณเฒฏเฒตเฒจเณเฒจเณ เฒ
เฒจเณโเฒฒเฒพเฒเณ เฒฎเฒพเฒกเฒฒเณ เฒชเณเฒฐเณเฒฎเฒฟเฒฏเฒเฒเณ เฒ
เฒชเณโเฒเณเฒฐเณเฒกเณ เฒฎเฒพเฒกเฒฟ.
+ เฒเฒณเฒฟเฒธเฒฒเฒพเฒฆ เฒธเณเฒฅเฒณเฒเฒณเฒจเณเฒจเณ เฒฒเณเฒกเณ เฒฎเฒพเฒกเฒฒเณ เฒตเฒฟเฒซเฒฒเฒตเฒพเฒเฒฟเฒฆเณ.
+ เฒธเณเฒฅเฒณ เฒฏเฒถเฒธเณเฒตเฒฟเฒฏเฒพเฒเฒฟ เฒเฒณเฒฟเฒธเฒฒเฒพเฒเฒฟเฒฆเณ.
+ เฒธเณเฒฅเฒณ เฒคเณเฒเณเฒฆเณเฒนเฒพเฒเฒฒเฒพเฒเฒฟเฒฆเณ.
+ เฒเฒณเฒฟเฒธเฒฒเฒพเฒฆ เฒธเณเฒฅเฒณเฒเฒณเณ
+ เฒนเณเฒธ เฒธเณเฒฅเฒณ เฒธเณเฒฐเฒฟเฒธเฒฟ
+ เฒธเณเฒฅเฒณ เฒ
เฒณเฒฟเฒธเฒฟ
+
+
+ เฒธเณเฒฅเฒณเฒตเฒจเณเฒจเณ เฒนเณเฒกเณเฒเฒฟ
+ เฒชเฒเณเฒเฒฃ เฒ
เฒฅเฒตเฒพ เฒตเฒฟเฒณเฒพเฒธ เฒเณเฒชเณ เฒฎเฒพเฒกเฒฟโฆ
+ \'%1$s\'เฒเณ เฒฏเฒพเฒตเณเฒฆเณ เฒธเณเฒฅเฒณเฒเฒณเณ เฒเฒเฒกเณเฒฌเฒเฒฆเฒฟเฒฒเณเฒฒ
+ เฒนเฒตเฒพเฒฎเฒพเฒจ เฒธเณเฒฅเฒณเฒตเฒพเฒเฒฟ เฒฌเฒณเฒธเณเฒตเฒฟเฒฐเฒพ?
+ เฒจเฒฟเฒฎเณเฒฎ เฒชเณเฒฐเฒธเณเฒคเณเฒค GPS เฒธเณเฒฅเฒณเฒฆ เฒฌเฒฆเฒฒเฒฟเฒเณ %1$s เฒเฒพเฒเฒฟ เฒนเฒตเฒพเฒฎเฒพเฒจ เฒกเณเฒเฒพ เฒคเณเฒฐเฒฟเฒธเฒฒเฒพเฒเณเฒคเณเฒคเฒฆเณ.
+ เฒเฒฆเณ เฒธเฒเณเฒฐเฒฟเฒฏเฒตเฒพเฒเฒฟเฒฐเณเฒตเฒพเฒ เฒจเฒฟเฒฎเณเฒฎ เฒฒเณเฒตเณ GPS เฒธเณเฒฅเฒณ เฒ
เฒชเณโเฒกเณเฒเณ เฒเฒเณเฒตเณเฒฆเฒฟเฒฒเณเฒฒ.
+ เฒกเฒฟเฒซเฒพเฒฒเณเฒเณ เฒเฒเฒฟ เฒนเณเฒเฒฆเฒฟเฒธเฒฟ
+ %1$s เฒฌเฒณเฒธเฒฒเฒพเฒเณเฒคเณเฒคเฒฟเฒฆเณ
+ GPS เฒเณ เฒฐเณเฒธเณเฒเณ เฒฎเฒพเฒกเฒฟ
+ เฒชเณเฒฐเฒธเณเฒคเณเฒค %1$s เฒเฒพเฒเฒฟ เฒนเฒตเฒพเฒฎเฒพเฒจ เฒคเณเฒฐเฒฟเฒธเฒฒเฒพเฒเณเฒคเณเฒคเฒฟเฒฆเณ. GPS เฒเณ เฒฐเณเฒธเณเฒเณ เฒฎเฒพเฒกเฒฒเณ เฒเณเฒฏเฒพเฒชเณ เฒฎเฒพเฒกเฒฟ.
+
diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml
new file mode 100644
index 00000000..8af37519
--- /dev/null
+++ b/app/src/main/res/values-ml/strings.xml
@@ -0,0 +1,118 @@
+
+
+
+ เดธเตโเดชเตเดฒเดพเดทเต
+ เดนเตเด
+ เดตเดพเดฏเต เดเตเดฃเดจเดฟเดฒเดตเดพเดฐเด
+ เดจเดเดฐเดเตเดเตพ
+ เดชเตเดฐเตเดซเตเตฝ
+ เดเตเดฐเดฎเตเดเดฐเดฃเดเตเดเตพ
+
+
+ home_nested_nav
+ profile_nested_nav
+
+
+ Weatherify
+ เดตเตเดฃเตเดเตเด เดถเตเดฐเดฎเดฟเดเตเดเตเด
+ เดฎเดฃเดฟเดเตเดเตเตผ เดคเตเดฑเตเด เดชเตเดฐเดตเดเดจเด
+ เดฆเตเดจเดเดฆเดฟเดจ เดชเตเดฐเดตเดเดจเด
+ %1$sเดเดฟ.เดฎเต/เดฎ
+ %1$sยฐ เดชเตเดฒเต เดคเตเดจเตเดจเตเดจเตเดจเต
+ เดจเดเดฐเด เดคเดฟเดฐเดเตเดเตเดเตเดเตเดเตเด
+ เดตเดพเดฏเต เดเตเดฃเดจเดฟเดฒเดตเดพเดฐเด
+ เดคเดฟเดฐเดฟเดเตเดเตเดชเตเดเตเด
+ เดฎเดจเดธเตเดธเดฟเดฒเดพเดฏเดฟ
+
+
+ เด
เดจเดงเดฟเดเตเดค เดเดเตเดธเดธเต!
+ เดจเดเดฐเด เดเดฃเตเดเตเดคเตเดคเดฟเดฏเดฟเดฒเตเดฒ!
+ เดธเตผเดตเตผ เดชเตเดฐเดถเตโเดจเด เดเดณเตเดณเดคเดพเดฏเดฟ เดคเตเดจเตเดจเตเดจเตเดจเต!
+ เดเดจเตเดฑเตผเดจเตเดฑเตเดฑเต เดเดฃเดเตเดฑเตเดฑเดฟเดตเดฟเดฑเตเดฑเดฟ เดตเตเดฃเตเดเตเด เดชเดฐเดฟเดถเตเดงเดฟเดเตเดเดพเดฎเต!
+ เดเดจเตเดฑเตผเดจเตเดฑเตเดฑเต เดเดฃเดเตเดฑเตเดฑเดฟเดตเดฟเดฑเตเดฑเดฟ เดตเตเดฃเตเดเตเด เดชเดฐเดฟเดถเตเดงเดฟเดเตเดเดพเดฎเต!
+ เด
เดญเตเดฏเตผเดฅเดจเดฏเตเดเต เดธเดฎเดฏเด เดเดดเดฟเดเตเดเต. เดฆเดฏเดตเดพเดฏเดฟ เดชเดฟเดจเตเดจเตเดเต เดตเตเดฃเตเดเตเด เดถเตเดฐเดฎเดฟเดเตเดเตเด.
+ เด
เดฏเตเดฏเต!.. เดเดจเตเดคเต เดคเดเดฐเดพเตผ เดธเดเดญเดตเดฟเดเตเดเต.
+ เดฒเตเดเตเดเตเดทเตป เดธเตเดตเดจเดเตเดเตพ เดเดซเต เดเดฃเต. เดชเตเดฐเดพเดฆเตเดถเดฟเด เดเดพเดฒเดพเดตเดธเตเดฅ เดฒเดญเดฟเดเตเดเดพเตป GPS เดเตบ เดเตเดฏเตเดฏเตเด.
+ GPS เดเตบ เดเตเดฏเตเดฏเตเด
+ เดฒเตเดเตเดเตเดทเตป เดเตเตผเดกเดฟเดจเตเดฑเตเดฑเตเดเตพ เดเดคเตเดตเดฐเต เด
เดชเตโเดกเตเดฑเตเดฑเต เดเตเดฏเตเดคเดฟเดเตเดเดฟเดฒเตเดฒ.
+ เดเดชเตเดชเตเตพ เดเดฐเต เดจเดเดฐเดตเตเด เดเดฃเตเดเตเดคเตเดคเดฟเดฏเดฟเดฒเตเดฒ. เดชเดฟเดจเตเดจเตเดเต เดชเดฐเดฟเดถเตเดงเดฟเดเตเดเตเด
+ เดตเดฟเดตเดฐเดเตเดเตพ เดเดฃเตเดเตเดคเตเดคเดฟเดฏเดฟเดฒเตเดฒ. เดเตเดฑเดเตเดเต เดธเดฎเดฏเด เดเดดเดฟเดเตเดเต เดถเตเดฐเดฎเดฟเดเตเดเตเด.
+
+
+ เดชเตเดฐเตเดซเตเตฝ
+ เดฒเตเดเต เดเดเตเดเต
+ เดจเดฟเดเตเดเตพเดเตเดเต เดเดฑเดชเตเดชเดพเดฏเตเด เดฒเตเดเต เดเดเตเดเต เดเตเดฏเตเดฏเดฃเต?
+ เดจเดฟเดเตเดเดณเตเดเต เด
เดเตเดเตเดฃเตเดเต เดเดเตเดธเดธเต เดเตเดฏเตเดฏเดพเตป เดตเตเดฃเตเดเตเด เดฒเตเดเดฟเตป เดเตเดฏเตเดฏเตเดฃเตเดเดคเดพเดฏเดฟ เดตเดฐเตเด.
+ เดเดฑเดชเตเดชเดพเดเตเดเตเด
+ เดฑเดฆเตเดฆเดพเดเตเดเตเด
+ เด
เดฑเดฟเดฏเดฟเดชเตเดชเตเดเตพ
+ เดญเดพเดท
+ เดธเตเดตเดเดพเดฐเตเดฏเดคเดพ เดจเดฏเด
+ เดเดชเดฏเตเด เดจเดฟเดฌเดจเตเดงเดจเดเตพ
+ เดเดชเตเดชเต เดชเดคเดฟเดชเตเดชเต
+ เดเตเดฐเดฎเตเดเดฐเดฃเดเตเดเตพ
+ เดจเดฟเดฏเดฎเด
+
+
+ เดเดพเดฒเดพเดตเดธเตเดฅ เดเดเตเดเตบ
+ เดญเดพเดท เดเดเตเดเตบ
+ เดฎเตเดจเต เดเดเตเดเตบ
+ เดเดพเดฑเตเดฑเต เดเดเตเดเตบ
+ เดเตผเดชเตเดชเด เดเดเตเดเตบ
+ เดชเดฟเดถเดเต เดเดเตเดเตบ
+ เด
เดเดฏเตโเดเตเดเตเด เดเดเตเดเตบ
+ เดชเดฟเดฑเดเตเดเตเดเต เดฌเดเตเดเตบ
+ เด
เดฑเดฟเดฏเดฟเดชเตเดชเต เดเตเดฐเดฎเตเดเดฐเดฃ เดเดเตเดเตบ
+ เดธเตเดตเดเดพเดฐเตเดฏเดคเดพ เดจเดฏ เดเดเตเดเตบ
+ เดเดชเดฏเตเด เดจเดฟเดฌเดจเตเดงเดจเดเตพ เดเดเตเดเตบ
+ เดตเดฟเดตเดฐเด เดเดเตเดเตบ
+ เด
เดเตเดคเตเดค เดธเตโเดเตเดฐเตเดจเดฟเดฒเตเดเตเดเต เดจเดพเดตเดฟเดเตเดฑเตเดฑเต เดเตเดฏเตเดฏเตเด
+ เดญเดพเดท เดเตเตบเดซเดฟเดเดฑเตเดทเตป เดฒเตเดกเต เดเตเดฏเตเดฏเตเดจเตเดจเดคเดฟเตฝ เดชเดฐเดพเดเดฏเดชเตเดชเตเดเตเดเต. เดกเดฟเดซเตเตพเดเตเดเต เดญเดพเดท เดเดชเดฏเตเดเดฟเดเตเดเตเดจเตเดจเต.
+
+
+ เด
เดชเตโเดกเตเดฑเตเดฑเต เดเดฏเดฟเดฐเดฟเดเตเดเตเด
+ เดเดพเดฒเดพเดตเดธเตเดฅ เด
เดฒเตเตผเดเตเดเตเดเตพเดเตเดเต เด
เดฑเดฟเดฏเดฟเดชเตเดชเตเดเตพ เดเตบ เดเตเดฏเตเดฏเตเด
+ เดเตบ เดเตเดฏเตเดฏเตเด
+
+
+ เดชเตเดฐเดฟเดฎเดฟเดฏเด เดจเตเดเตเด
+ เดชเตเดฐเตเดธเตเดธเตเดธเดฟเดเดเตโฆ
+ เดเดเตเดเตพ เดจเดฟเดเตเดเดณเตเดเต เดชเตเดฐเดฟเดฎเดฟเดฏเด เดธเดฌเตโเดธเตเดเตเดฐเดฟเดชเตเดทเตป เดธเดเตเดเดฎเดพเดเตเดเตเดจเตเดจเดคเดฟเดจเดพเดฏเดฟ เดฆเดฏเดตเดพเดฏเดฟ เดเดพเดคเตเดคเดฟเดฐเดฟเดเตเดเตเด.
+ เดเดฒเตเดฒเดพ เดซเตเดเตเดเดฑเตเดเตพ เด
เตบเดฒเตเดเตเดเต เดเตเดฏเตเดฏเตเด เดเตเดเดพเดคเต เดชเดฐเดธเตเดฏเดฐเดนเดฟเดค เด
เดจเตเดญเดตเด เดเดธเตเดตเดฆเดฟเดเตเดเตเด.
+ เดเดชเตเดชเตเตพ เด
เดชเตโเดเตเดฐเตเดกเต เดเตเดฏเตเดฏเตเด
+ เดจเดฟเดเตเดเตพ เดชเตเดฐเดฟเดฎเดฟเดฏเด เดเดชเดฏเตเดเตเดคเดพเดตเดพเดฃเต
+ %1$s เดเดฏเดฟ เดธเตเดซเตเดเดฟเดเตเดเตเดจเตเดจเต
+ เดธเดเตเดต
+ เดชเตเดฐเดฟเดฎเดฟเดฏเด เดธเดเตเดตเดฎเดพเดฃเต
+ เดจเดฟเดเตเดเดณเตเดเต เดชเตเดฐเดฟเดฎเดฟเดฏเด เดธเดฌเตโเดธเตเดเตเดฐเดฟเดชเตเดทเตป เดเดชเตเดชเตเตพ เดธเดเตเดตเดฎเดพเดฃเต!
+
+
+ เดธเดเดฐเดเตเดทเดฟเดค เดธเตเดฅเดพเดจเดเตเดเตพ
+ saved_locations_nested_nav
+ เดธเดเดฐเดเตเดทเดฟเดค เดธเตเดฅเดพเดจเดเตเดเตพ
+ เดเดคเตเดตเดฐเต เดธเดเดฐเดเตเดทเดฟเดค เดธเตเดฅเดพเดจเดเตเดเตพ เดเดฒเตเดฒ. เดเดจเตเดจเต เดเตเตผเดเตเดเดพเตป + เดเดพเดชเตเดชเต เดเตเดฏเตเดฏเตเด.
+ เดธเตเดฅเดพเดจเด เดเตเตผเดเตเดเตเด
+ เดเดฒเตเดฒเดพเดคเดพเดเตเดเตเด
+ เดธเดเดฐเดเตเดทเดฟเดเตเดเตเด
+ เดธเตเดฅเดพเดจเดคเตเดคเดฟเดจเตเดฑเต เดชเตเดฐเต (เดเดฆเดพ. เดตเตเดเต)
+ เดชเตเดฐเดฟเดฎเดฟเดฏเด เดซเตเดเตเดเตผ
+ เดจเดฟเดเตเดเดณเตเดเต เดชเตเดฐเดฟเดฏเดชเตเดชเตเดเตเด เดธเตเดฅเดพเดจเดเตเดเตพ เดธเดเดฐเดเตเดทเดฟเดเตเดเตเด เดเตเดเดพเดคเต เดเดเดจเดเดฟ เดเดเตเดธเดธเต เดเตเดฏเตเดฏเตเด. เด เดซเตเดเตเดเตผ เด
เตบเดฒเตเดเตเดเต เดเตเดฏเตเดฏเดพเตป เดชเตเดฐเดฟเดฎเดฟเดฏเดฎเดพเดฏเดฟ เด
เดชเตโเดเตเดฐเตเดกเต เดเตเดฏเตเดฏเตเด.
+ เดธเดเดฐเดเตเดทเดฟเดค เดธเตเดฅเดพเดจเดเตเดเตพ เดฒเตเดกเต เดเตเดฏเตเดฏเดพเตป เดชเดฐเดพเดเดฏเดชเตเดชเตเดเตเดเต.
+ เดธเตเดฅเดพเดจเด เดตเดฟเดเดฏเดเดฐเดฎเดพเดฏเดฟ เดธเดเดฐเดเตเดทเดฟเดเตเดเดชเตเดชเตเดเตเดเต.
+ เดธเตเดฅเดพเดจเด เดจเตเดเตเดเด เดเตเดฏเตเดฏเดชเตเดชเตเดเตเดเต.
+ เดธเดเดฐเดเตเดทเดฟเดค เดธเตเดฅเดพเดจเดเตเดเตพ
+ เดชเตเดคเดฟเดฏ เดธเตเดฅเดพเดจเด เดเตเตผเดเตเดเตเด
+ เดธเตเดฅเดพเดจเด เดเดฒเตเดฒเดพเดคเดพเดเตเดเตเด
+
+
+ เดเดฐเต เดธเตเดฅเดพเดจเด เดคเดฟเดฐเดฏเตเด
+ เดจเดเดฐเด เด
เดฒเตเดฒเตเดเตเดเดฟเตฝ เดตเดฟเดฒเดพเดธเด เดเตเดชเตเดชเต เดเตเดฏเตเดฏเตเดโฆ
+ \'%1$s\'เดเตเดเดพเดฏเดฟ เดเดฐเต เดธเตเดฅเดพเดจเดตเตเด เดเดฃเตเดเตเดคเตเดคเดฟเดฏเดฟเดฒเตเดฒ
+ เดเดพเดฒเดพเดตเดธเตเดฅ เดธเตเดฅเดพเดจเดฎเดพเดฏเดฟ เดเดชเดฏเตเดเดฟเดเตเดเดฃเต?
+ เดจเดฟเดเตเดเดณเตเดเต เดจเดฟเดฒเดตเดฟเดฒเต GPS เดธเตเดฅเดพเดจเดคเตเดคเดฟเดจเต เดชเดเดฐเด %1$s-เดจเตเดฑเต เดเดพเดฒเดพเดตเดธเตเดฅ เดกเดพเดฑเตเดฑ เดเดพเดฃเดฟเดเตเดเตเด.
+ เดเดคเต เดธเดเตเดตเดฎเดพเดฏเดฟเดฐเดฟเดเตเดเตเดฎเตเดชเตเตพ เดจเดฟเดเตเดเดณเตเดเต เดฒเตเดตเต GPS เดธเตเดฅเดพเดจเด เด
เดชเตโเดกเตเดฑเตเดฑเต เดเดเดฟเดฒเตเดฒ.
+ เดกเดฟเดซเตเตพเดเตเดเดพเดฏเดฟ เดธเดเตเดเดฎเดพเดเตเดเตเด
+ %1$s เดเดชเดฏเตเดเดฟเดเตเดเตเดจเตเดจเต
+ GPS-เดฒเตเดเตเดเต เดฑเตเดธเตเดฑเตเดฑเต เดเตเดฏเตเดฏเตเด
+ เดจเดฟเดฒเดตเดฟเตฝ %1$s-เดจเตเดฑเต เดเดพเดฒเดพเดตเดธเตเดฅ เดเดพเดฃเดฟเดเตเดเตเดจเตเดจเต. GPS-เดฒเตเดเตเดเต เดฑเตเดธเตเดฑเตเดฑเต เดเตเดฏเตเดฏเดพเตป เดเดพเดชเตเดชเต เดเตเดฏเตเดฏเตเด.
+
diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml
new file mode 100644
index 00000000..2bd960cd
--- /dev/null
+++ b/app/src/main/res/values-ta/strings.xml
@@ -0,0 +1,118 @@
+
+
+
+ เฎธเฏเฎชเฎฟเฎณเฎพเฎทเฏ
+ เฎฎเฏเฎเฎชเฏเฎชเฏ
+ เฎเฎพเฎฑเฏเฎฑเฎฟเฎฉเฏ เฎคเฎฐเฎฎเฏ
+ เฎจเฎเฎฐเฎเฏเฎเฎณเฏ
+ เฎเฏเฎฏเฎตเฎฟเฎตเฎฐเฎฎเฏ
+ เฎ
เฎฎเฏเฎชเฏเฎชเฏเฎเฎณเฏ
+
+
+ home_nested_nav
+ profile_nested_nav
+
+
+ Weatherify
+ เฎฎเฏเฎฃเฏเฎเฏเฎฎเฏ เฎฎเฏเฎฏเฎฑเฏเฎเฎฟ เฎเฏเฎฏเฏเฎ
+ เฎฎเฎฃเฎฟเฎจเฏเฎฐ เฎฎเฏเฎฉเฏเฎฉเฎฑเฎฟเฎตเฎฟเฎชเฏเฎชเฏ
+ เฎคเฎฟเฎฉเฎเฎฐเฎฟ เฎฎเฏเฎฉเฏเฎฉเฎฑเฎฟเฎตเฎฟเฎชเฏเฎชเฏ
+ %1$sเฎเฎฟเฎฎเฏ/เฎฎเฎฃเฎฟ
+ %1$sยฐ เฎชเฏเฎฒเฏ เฎเฎฃเฎฐเฏเฎเฎฟเฎฑเฎคเฏ
+ เฎจเฎเฎฐเฎคเฏเฎคเฏ เฎคเฏเฎฐเฏเฎจเฏเฎคเฏเฎเฏเฎเฏเฎเฎตเฏเฎฎเฏ
+ เฎเฎพเฎฑเฏเฎฑเฎฟเฎฉเฏ เฎคเฎฐเฎฎเฏ
+ เฎคเฎฟเฎฐเฏเฎฎเฏเฎชเฎฟ เฎเฏเฎฒเฏเฎฒเฎตเฏเฎฎเฏ
+ เฎชเฏเฎฐเฎฟเฎจเฏเฎคเฎคเฏ
+
+
+ เฎ
เฎเฏเฎเฏเฎเฎฐเฎฟเฎเฏเฎเฎชเฏเฎชเฎเฎพเฎค เฎ
เฎฃเฏเฎเฎฒเฏ!
+ เฎจเฎเฎฐเฎฎเฏ เฎเฎฃเฏเฎเฏเฎชเฎฟเฎเฎฟเฎเฏเฎเฎชเฏเฎชเฎเฎตเฎฟเฎฒเฏเฎฒเฏ!
+ เฎเฎฐเฏเฎตเฎฐเฏ เฎเฎฟเฎเฏเฎเฎฒเฏ เฎเฎฐเฏเฎชเฏเฎชเฎคเฏ เฎชเฏเฎฒเฏ เฎคเฏเฎฐเฎฟเฎเฎฟเฎฑเฎคเฏ!
+ เฎเฎฃเฏเฎฏ เฎเฎฃเฏเฎชเฏเฎชเฏ เฎฎเฏเฎฃเฏเฎเฏเฎฎเฏ เฎเฎฐเฎฟเฎชเฎพเฎฐเฏเฎเฏเฎ เฎฎเฏเฎเฎฟเฎฏเฏเฎฎเฎพ!
+ เฎเฎฃเฏเฎฏ เฎเฎฃเฏเฎชเฏเฎชเฏ เฎฎเฏเฎฃเฏเฎเฏเฎฎเฏ เฎเฎฐเฎฟเฎชเฎพเฎฐเฏเฎเฏเฎ เฎฎเฏเฎเฎฟเฎฏเฏเฎฎเฎพ!
+ เฎเฏเฎฐเฎฟเฎเฏเฎเฏ เฎจเฏเฎฐ เฎตเฎฐเฎฎเฏเฎชเฏ เฎฎเฏเฎฑเฎฟเฎฏเฎคเฏ. เฎชเฎฟเฎฑเฎเฏ เฎฎเฏเฎฃเฏเฎเฏเฎฎเฏ เฎฎเฏเฎฏเฎฑเฏเฎเฎฟเฎเฏเฎเฎตเฏเฎฎเฏ.
+ เฎ
เฎเฏเฎเฏ!.. เฎเฎคเฏ เฎคเฎตเฎฑเฏ เฎจเฎเฎจเฏเฎคเฎคเฏ.
+ เฎเฎ เฎเฏเฎตเฏเฎเฎณเฏ เฎ
เฎฃเฏเฎเฏเฎเฎชเฏเฎชเฎเฏเฎเฏเฎณเฏเฎณเฎฉ. เฎเฎณเฏเฎณเฏเฎฐเฏ เฎตเฎพเฎฉเฎฟเฎฒเฏ เฎชเฏเฎฑ GPS เฎเฎฏเฎเฏเฎเฎตเฏเฎฎเฏ.
+ GPS เฎเฎฏเฎเฏเฎเฏ
+ เฎเฎ เฎเฎฏเฎเฏเฎเฎณเฏ เฎเฎฉเฏเฎฉเฏเฎฎเฏ เฎชเฏเฎคเฏเฎชเฏเฎชเฎฟเฎเฏเฎเฎชเฏเฎชเฎเฎตเฎฟเฎฒเฏเฎฒเฏ.
+ เฎเฎชเฏเฎชเฏเฎคเฏ เฎเฎจเฏเฎค เฎจเฎเฎฐเฎฎเฏเฎฎเฏ เฎเฎฃเฏเฎเฏเฎชเฎฟเฎเฎฟเฎเฏเฎเฎชเฏเฎชเฎเฎตเฎฟเฎฒเฏเฎฒเฏ. เฎชเฎฟเฎฑเฎเฏ เฎเฎฐเฎฟเฎชเฎพเฎฐเฏเฎเฏเฎเฎตเฏเฎฎเฏ
+ เฎตเฎฟเฎตเฎฐเฎเฏเฎเฎณเฏ เฎเฎฃเฏเฎเฏเฎชเฎฟเฎเฎฟเฎเฏเฎเฎชเฏเฎชเฎเฎตเฎฟเฎฒเฏเฎฒเฏ. เฎเฎฟเฎฑเฎฟเฎคเฏ เฎจเฏเฎฐเฎฎเฏ เฎเฎดเฎฟเฎคเฏเฎคเฏ เฎฎเฏเฎฏเฎฑเฏเฎเฎฟเฎเฏเฎเฎตเฏเฎฎเฏ.
+
+
+ เฎเฏเฎฏเฎตเฎฟเฎตเฎฐเฎฎเฏ
+ เฎตเฏเฎณเฎฟเฎฏเฏเฎฑเฏ
+ เฎจเฏเฎเฏเฎเฎณเฏ เฎจเฎฟเฎเฏเฎเฎฏเฎฎเฎพเฎ เฎตเฏเฎณเฎฟเฎฏเฏเฎฑ เฎตเฎฟเฎฐเฏเฎฎเฏเฎชเฏเฎเฎฟเฎฑเฏเฎฐเฏเฎเฎณเฎพ?
+ เฎเฎเฏเฎเฎณเฏ เฎเฎฃเฎเฏเฎเฏ เฎ
เฎฃเฏเฎ เฎฎเฏเฎฃเฏเฎเฏเฎฎเฏ เฎเฎณเฏเฎจเฏเฎดเฏเฎฏ เฎตเฏเฎฃเฏเฎเฏเฎฎเฏ.
+ เฎเฎฑเฏเฎคเฎฟเฎชเฏเฎชเฎเฏเฎคเฏเฎคเฏ
+ เฎฐเฎคเฏเฎคเฏ เฎเฏเฎฏเฏ
+ เฎ
เฎฑเฎฟเฎตเฎฟเฎชเฏเฎชเฏเฎเฎณเฏ
+ เฎฎเฏเฎดเฎฟ
+ เฎคเฎฉเฎฟเฎฏเฏเฎฐเฎฟเฎฎเฏเฎเฏ เฎเฏเฎณเฏเฎเฏ
+ เฎชเฎฏเฎฉเฏเฎชเฎพเฎเฏเฎเฏ เฎตเฎฟเฎคเฎฟเฎฎเฏเฎฑเฏเฎเฎณเฏ
+ เฎเฎชเฏ เฎชเฎคเฎฟเฎชเฏเฎชเฏ
+ เฎ
เฎฎเฏเฎชเฏเฎชเฏเฎเฎณเฏ
+ เฎเฎเฏเฎเฎฎเฏ
+
+
+ เฎตเฎพเฎฉเฎฟเฎฒเฏ เฎจเฎฟเฎฒเฏ เฎเฎฟเฎฉเฏเฎฉเฎฎเฏ
+ เฎฎเฏเฎดเฎฟ เฎเฎฟเฎฉเฏเฎฉเฎฎเฏ
+ เฎฎเฏเฎฉเฏ เฎเฎฟเฎฉเฏเฎฉเฎฎเฏ
+ เฎเฎพเฎฑเฏเฎฑเฏ เฎเฎฟเฎฉเฏเฎฉเฎฎเฏ
+ เฎเฎฐเฎชเฏเฎชเฎคเฎฎเฏ เฎเฎฟเฎฉเฏเฎฉเฎฎเฏ
+ เฎชเฎฟเฎดเฏ เฎเฎฟเฎฉเฏเฎฉเฎฎเฏ
+ เฎฎเฏเฎเฏ เฎเฎฟเฎฉเฏเฎฉเฎฎเฏ
+ เฎชเฎฟเฎฉเฏเฎฉเฎพเฎฒเฏ เฎชเฏเฎคเฏเฎคเฎพเฎฉเฏ
+ เฎ
เฎฑเฎฟเฎตเฎฟเฎชเฏเฎชเฏ เฎ
เฎฎเฏเฎชเฏเฎชเฏเฎเฎณเฏ เฎเฎฟเฎฉเฏเฎฉเฎฎเฏ
+ เฎคเฎฉเฎฟเฎฏเฏเฎฐเฎฟเฎฎเฏเฎเฏ เฎเฏเฎณเฏเฎเฏ เฎเฎฟเฎฉเฏเฎฉเฎฎเฏ
+ เฎชเฎฏเฎฉเฏเฎชเฎพเฎเฏเฎเฏ เฎตเฎฟเฎคเฎฟเฎฎเฏเฎฑเฏเฎเฎณเฏ เฎเฎฟเฎฉเฏเฎฉเฎฎเฏ
+ เฎคเฎเฎตเฎฒเฏ เฎเฎฟเฎฉเฏเฎฉเฎฎเฏ
+ เฎ
เฎเฏเฎคเฏเฎค เฎคเฎฟเฎฐเฏเฎเฏเฎเฏ เฎเฏเฎฒเฏเฎฒเฎตเฏเฎฎเฏ
+ เฎฎเฏเฎดเฎฟ เฎ
เฎฎเฏเฎชเฏเฎชเฏ เฎเฎฑเฏเฎฑ เฎฎเฏเฎเฎฟเฎฏเฎตเฎฟเฎฒเฏเฎฒเฏ. เฎเฎฏเฎฒเฏเฎชเฏเฎจเฎฟเฎฒเฏ เฎฎเฏเฎดเฎฟ เฎชเฎฏเฎฉเฏเฎชเฎเฏเฎคเฏเฎคเฎชเฏเฎชเฎเฏเฎเฎฟเฎฑเฎคเฏ.
+
+
+ เฎชเฏเฎคเฏเฎชเฏเฎชเฎฟเฎคเฏเฎค เฎจเฎฟเฎฒเฏเฎฏเฎฟเฎฒเฏ เฎเฎฐเฏเฎเฏเฎเฎณเฏ
+ เฎตเฎพเฎฉเฎฟเฎฒเฏ เฎเฎเฏเฎเฎฐเฎฟเฎเฏเฎเฏเฎเฎณเฏเฎเฏเฎเฏ เฎ
เฎฑเฎฟเฎตเฎฟเฎชเฏเฎชเฏเฎเฎณเฏ เฎเฎฏเฎเฏเฎเฏเฎเฏเฎเฎณเฏ
+ เฎเฎฏเฎเฏเฎเฏ
+
+
+ เฎชเฎฟเฎฐเฏเฎฎเฎฟเฎฏเฎฎเฏ เฎชเฏเฎฑเฏเฎเฏเฎเฎณเฏ
+ เฎเฏเฎฏเฎฒเฏเฎชเฎพเฎเฏเฎเฎฟเฎฒเฏโฆ
+ เฎเฎเฏเฎเฎณเฏ เฎชเฎฟเฎฐเฏเฎฎเฎฟเฎฏเฎฎเฏ เฎเฎจเฏเฎคเฎพเฎตเฏ เฎเฏเฎฏเฎฒเฏเฎชเฎเฏเฎคเฏเฎคเฏเฎฎเฏ เฎชเฏเฎคเฏ เฎคเฎฏเฎตเฏ เฎเฏเฎฏเฏเฎคเฏ เฎเฎพเฎคเฏเฎคเฎฟเฎฐเฏเฎเฏเฎเฎณเฏ.
+ เฎ
เฎฉเฏเฎคเฏเฎคเฏ เฆฌเงเฆถเฆฟเฆทเงเฆเงเฆฏเฆเงเฎฒเฏ เฎคเฎฟเฎฑเฎจเฏเฎคเฏ เฎตเฎฟเฎณเฎฎเฏเฎชเฎฐเฎฎเฏ เฎเฎฒเฏเฎฒเฎพเฎค เฎ
เฆญเฆฟเฆเฏเฆเฆคเฆพ เฆญเฏเฎเฎฟเฎฏเฏเฎเฏเฎเฎณเฏ.
+ เฎเฎชเฏเฎชเฏเฎคเฏ เฎ
เฎชเฏเฎเฎฟเฎฐเฏเคกเฏ เฎเฏเฎฏเฏเฎฏเฏเฎเฏเฎเฎณเฏ
+ เฎจเฏเฎเฏเฎเฎณเฏ เฎเฎฐเฏ เฎชเฎฟเฎฐเฏเฎฎเฎฟเฎฏเฎฎเฏ เฎชเฎฏเฎฉเฎฐเฏ
+ %1$s เฎเฎฒเฏ เฎฎเฏเฎเฎฟเฎตเฎเฏเฎฏเฏเฎฎเฏ
+ เฎเฏเฎฏเฎฒเฎฟเฎฒเฏ
+ เฎชเฎฟเฎฐเฏเฎฎเฎฟเฎฏเฎฎเฏ เฎเฏเฎฏเฎฒเฏเฎชเฎเฏเฎคเฏเฎคเฎชเฏเฎชเฎเฏเฎเฎคเฏ
+ เฎเฎเฏเฎเฎณเฏ เฎชเฎฟเฎฐเฏเฎฎเฎฟเฎฏเฎฎเฏ เฎเฎจเฏเฎคเฎพ เฎเฎชเฏเฎชเฏเฎคเฏ เฎเฏเฎฏเฎฒเฎฟเฎฒเฏ เฎเฎณเฏเฎณเฎคเฏ!
+
+
+ เฎเฏเฎฎเฎฟเฎเฏเฎเฎชเฏเฎชเฎเฏเฎ เฎเฎเฎเฏเฎเฎณเฏ
+ saved_locations_nested_nav
+ เฎเฏเฎฎเฎฟเฎเฏเฎเฎชเฏเฎชเฎเฏเฎ เฎเฎเฎเฏเฎเฎณเฏ
+ เฎเฏเฎฎเฎฟเฎเฏเฎเฎชเฏเฎชเฎเฏเฎ เฎเฎเฎเฏเฎเฎณเฏ เฎเฎฒเฏเฎฒเฏ. เฎเฏเฎฐเฏเฎเฏเฎ + เฎ เฎคเฎเฏเฎเฎตเฏเฎฎเฏ.
+ เฎเฎเฎคเฏเฎคเฏ เฎเฏเฎฐเฏเฎเฏเฎเฎตเฏเฎฎเฏ
+ เฎจเฏเฎเฏเฎเฎตเฏเฎฎเฏ
+ เฎเฏเฎฎเฎฟเฎเฏเฎเฎตเฏเฎฎเฏ
+ เฎเฎเฎคเฏเฎคเฎฟเฎฉเฏ เฎชเฏเฎฏเฎฐเฏ (เฎ.เฎเฎพ. เฎตเฏเฎเฏ)
+ เฎชเฎฟเฎฐเฏเฎฎเฎฟเฎฏเฎฎเฏ เฎชเฎฟเฎฑเฏเฎเฏเฎฐเฏเฎเฏเฎเฏ
+ เฎเฎเฏเฎเฎณเฏ เฎชเฎฟเฎเฎฟเฎคเฏเฎคเฎฎเฎพเฎฉ เฎเฎเฎเฏเฎเฎณเฏ เฎเฏเฎฎเฎฟเฎเฏเฎเฎตเฏเฎฎเฏ เฎฎเฎฑเฏเฎฑเฏเฎฎเฏ เฎเฎเฎฉเฎเฎฟเฎฏเฎพเฎ เฎ
เฎฃเฏเฎเฎตเฏเฎฎเฏ. เฎเฎจเฏเฎค เฎชเฎฟเฎฑเฏเฎเฏเฎฐเฏเฎเฏเฎเฏเฎฏเฏ เฎคเฎฟเฎฑเฎเฏเฎ เฎชเฎฟเฎฐเฏเฎฎเฎฟเฎฏเฎฎเฎพเฎ เฎ
เฎชเฏเฎเฎฟเฎฐเฏเฎเฏ เฎเฏเฎฏเฏเฎฏเฎตเฏเฎฎเฏ.
+ เฎเฏเฎฎเฎฟเฎเฏเฎเฎชเฏเฎชเฎเฏเฎ เฎเฎเฎเฏเฎเฎณเฏ เฎเฎฑเฏเฎฑ เฎฎเฏเฎเฎฟเฎฏเฎตเฎฟเฎฒเฏเฎฒเฏ.
+ เฎเฎเฎฎเฏ เฎตเฏเฎฑเฏเฎฑเฎฟเฎเฎฐเฎฎเฎพเฎ เฎเฏเฎฎเฎฟเฎเฏเฎเฎชเฏเฎชเฎเฏเฎเฎคเฏ.
+ เฎเฎเฎฎเฏ เฎจเฏเฎเฏเฎเฎชเฏเฎชเฎเฏเฎเฎคเฏ.
+ เฎเฏเฎฎเฎฟเฎเฏเฎเฎชเฏเฎชเฎเฏเฎ เฎเฎเฎเฏเฎเฎณเฏ
+ เฎชเฏเฎคเฎฟเฎฏ เฎเฎเฎคเฏเฎคเฏ เฎเฏเฎฐเฏเฎเฏเฎเฎตเฏเฎฎเฏ
+ เฎเฎเฎคเฏเฎคเฏ เฎจเฏเฎเฏเฎเฎตเฏเฎฎเฏ
+
+
+ เฎเฎฐเฏ เฎเฎเฎคเฏเฎคเฎฟเฎฑเฏเฎเฏ เฎคเฏเฎเฏเฎ
+ เฎจเฎเฎฐเฎฎเฏ เฎ
เฎฒเฏเฎฒเฎคเฏ เฎฎเฏเฎเฎตเฎฐเฎฟ เฎเฎณเฏเฎณเฎฟเฎเฎตเฏเฎฎเฏโฆ
+ \'%1$s\'เฎเฏเฎเฏ เฎเฎเฎเฏเฎเฎณเฏ เฎเฎฟเฎเฏเฎเฏเฎเฎตเฎฟเฎฒเฏเฎฒเฏ
+ เฎตเฎพเฎฉเฎฟเฎฒเฏ เฎเฎเฎฎเฎพเฎ เฎชเฎฏเฎฉเฏเฎชเฎเฏเฎคเฏเฎคเฎตเฎพ?
+ เฎเฎเฏเฎเฎณเฏ เฎคเฎฑเฏเฎชเฏเฎคเฏเฎฏ GPS เฎจเฎฟเฎฒเฏเฎเฏเฎเฏ เฎชเฎคเฎฟเฎฒเฎพเฎ %1$s-เฎฉเฏ เฎตเฎพเฎฉเฎฟเฎฒเฏ เฎคเฎฐเฎตเฏ เฎเฎพเฎเฏเฎเฎชเฏเฎชเฎเฏเฎฎเฏ.
+ เฎเฎคเฏ เฎเฏเฎฏเฎฒเฏเฎชเฎพเฎเฏเฎเฎฟเฎฒเฏ เฎเฎฐเฏเฎเฏเฎเฏเฎฎเฏเฎชเฏเฎคเฏ เฎเฎเฏเฎเฎณเฏ เฎจเฏเฎฐเฎเฎฟ GPS เฎเฎเฎฎเฏ เฎชเฏเฎคเฏเฎชเฏเฎชเฎฟเฎเฏเฎเฎชเฏเฎชเฎเฎพเฎคเฏ.
+ เฎเฎฏเฎฒเฏเฎชเฏเฎจเฎฟเฎฒเฏเฎฏเฎพเฎ เฎ
เฎฎเฏเฎเฏเฎเฎตเฏเฎฎเฏ
+ %1$s เฎชเฎฏเฎฉเฏเฎชเฎเฏเฎคเฏเฎคเฎชเฏเฎชเฎเฏเฎเฎฟเฎฑเฎคเฏ
+ GPS-เฎเฏเฎเฏ เฎฎเฏเฎเฏเฎเฎฎเฏเฎเฏเฎเฎตเฏเฎฎเฏ
+ เฎคเฎฑเฏเฎชเฏเฎคเฏ %1$s-เฎฉเฏ เฎตเฎพเฎฉเฎฟเฎฒเฏ เฎเฎพเฎเฏเฎเฎชเฏเฎชเฎเฏเฎเฎฟเฎฑเฎคเฏ. GPS-เฎเฏเฎเฏ เฎฎเฏเฎเฏเฎเฎฎเฏเฎเฏเฎ เฎคเฎเฏเฎเฎตเฏเฎฎเฏ.
+
diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml
new file mode 100644
index 00000000..a65d301e
--- /dev/null
+++ b/app/src/main/res/values-te/strings.xml
@@ -0,0 +1,118 @@
+
+
+
+ เฐธเฑเฐชเฑเฐฒเฐพเฐทเฑ
+ เฐนเฑเฐฎเฑ
+ เฐตเฐพเฐฏเฑ เฐจเฐพเฐฃเฑเฐฏเฐค
+ เฐจเฐเฐฐเฐพเฐฒเฑ
+ เฐชเฑเฐฐเฑเฐซเฑเฐฒเฑ
+ เฐธเฑเฐเฑเฐเฐฟเฐเฐเฑเฐฒเฑ
+
+
+ home_nested_nav
+ profile_nested_nav
+
+
+ Weatherify
+ เฐฎเฐณเฑเฐณเฑ เฐชเฑเฐฐเฐฏเฐคเฑเฐจเฐฟเฐเฐเฑ
+ เฐเฐเฐเฐตเฐพเฐฐเฑ เฐธเฑเฐเฐจ
+ เฐฐเฑเฐเฑเฐตเฐพเฐฐเฑ เฐธเฑเฐเฐจ
+ %1$sเฐเฐฟ.เฐฎเฑ/เฐเฐ
+ %1$sยฐ เฐฒเฐพ เฐ
เฐจเฐฟเฐชเฐฟเฐธเฑเฐคเฑเฐเฐฆเฐฟ
+ เฐจเฐเฐฐเฐพเฐจเฑเฐจเฐฟ เฐเฐเฐเฑเฐเฑเฐเฐกเฐฟ
+ เฐตเฐพเฐฏเฑ เฐจเฐพเฐฃเฑเฐฏเฐค
+ เฐตเฑเฐจเฑเฐเฐเฑ เฐตเฑเฐณเฑเฐณเฑ
+ เฐ
เฐฐเฑเฐฅเฐฎเฑเฐเฐฆเฐฟ
+
+
+ เฐ
เฐจเฐงเฐฟเฐเฐพเฐฐ เฐชเฑเฐฐเฐตเฑเฐถเฐ!
+ เฐจเฐเฐฐเฐ เฐเฐจเฑเฐเฑเฐจเฐฌเฐกเฐฒเฑเฐฆเฑ!
+ เฐธเฐฐเฑเฐตเฐฐเฑ เฐธเฐฎเฐธเฑเฐฏ เฐเฐจเฑเฐจเฐเฑเฐฒเฑ เฐเฐจเฐฟเฐชเฐฟเฐธเฑเฐคเฑเฐเฐฆเฐฟ!
+ เฐเฐเฐเฐฐเฑเฐจเฑเฐเฑ เฐเฐจเฑเฐเฑเฐเฐฟเฐตเฐฟเฐเฑเฐจเฐฟ เฐฎเฐณเฑเฐณเฑ เฐคเฐจเฐฟเฐเฑ เฐเฑเฐฏเฐเฐฒเฐฐเฐพ!
+ เฐเฐเฐเฐฐเฑเฐจเฑเฐเฑ เฐเฐจเฑเฐเฑเฐเฐฟเฐตเฐฟเฐเฑเฐจเฐฟ เฐฎเฐณเฑเฐณเฑ เฐคเฐจเฐฟเฐเฑ เฐเฑเฐฏเฐเฐฒเฐฐเฐพ!
+ เฐ
เฐญเฑเฐฏเฐฐเฑเฐฅเฐจ เฐเฐกเฑเฐตเฑ เฐฎเฑเฐเฐฟเฐธเฐฟเฐเฐฆเฐฟ. เฐฆเฐฏเฐเฑเฐธเฐฟ เฐคเฐฐเฑเฐตเฐพเฐค เฐฎเฐณเฑเฐณเฑ เฐชเฑเฐฐเฐฏเฐคเฑเฐจเฐฟเฐเฐเฐเฐกเฐฟ.
+ เฐ
เฐฏเฑเฐฏเฑ!.. เฐเฐฆเฑ เฐคเฐชเฑเฐชเฑ เฐเฐฐเฐฟเฐเฐฟเฐเฐฆเฐฟ.
+ เฐฒเฑเฐเฑเฐทเฐจเฑ เฐธเฑเฐตเฐฒเฑ เฐเฐซเฑ เฐเฐจเฑเฐจเฐพเฐฏเฐฟ. เฐธเฑเฐฅเฐพเฐจเฐฟเฐ เฐตเฐพเฐคเฐพเฐตเฐฐเฐฃเฐ เฐชเฑเฐเฐฆเฐกเฐพเฐจเฐฟเฐเฐฟ GPS เฐเฐจเฑ เฐเฑเฐฏเฐเฐกเฐฟ.
+ GPS เฐเฐจเฑ เฐเฑเฐฏเฐเฐกเฐฟ
+ เฐฒเฑเฐเฑเฐทเฐจเฑ เฐเฑเฐเฐฐเฑเฐกเฐฟเฐจเฑเฐเฑเฐฒเฑ เฐเฐเฐเฐพ เฐ
เฐชเฑโเฐกเฑเฐเฑ เฐเฐพเฐฒเฑเฐฆเฑ.
+ เฐเฐชเฑเฐชเฑเฐกเฑ เฐ เฐจเฐเฐฐเฐพเฐฒเฑ เฐเฐจเฑเฐเฑเฐจเฐฌเฐกเฐฒเฑเฐฆเฑ. เฐคเฐฐเฑเฐตเฐพเฐค เฐคเฐจเฐฟเฐเฑ เฐเฑเฐฏเฐเฐกเฐฟ
+ เฐตเฐฟเฐตเฐฐเฐพเฐฒเฑ เฐเฐจเฑเฐเฑเฐจเฐฌเฐกเฐฒเฑเฐฆเฑ. เฐเฑเฐเฐค เฐธเฑเฐชเฑ เฐคเฐฐเฑเฐตเฐพเฐค เฐชเฑเฐฐเฐฏเฐคเฑเฐจเฐฟเฐเฐเฐเฐกเฐฟ.
+
+
+ เฐชเฑเฐฐเฑเฐซเฑเฐฒเฑ
+ เฐฒเฐพเฐเฑ เฐ
เฐตเฑเฐเฑ
+ เฐฎเฑเฐฐเฑ เฐเฐเฑเฐเฐฟเฐคเฐเฐเฐพ เฐฒเฐพเฐเฑ เฐ
เฐตเฑเฐเฑ เฐ
เฐตเฑเฐตเฐพเฐฒเฐจเฑเฐเฑเฐเฐเฑเฐจเฑเฐจเฐพเฐฐเฐพ?
+ เฐฎเฑ เฐเฐพเฐคเฐพเฐจเฑ เฐฏเฐพเฐเฑเฐธเฑเฐธเฑ เฐเฑเฐฏเฐกเฐพเฐจเฐฟเฐเฐฟ เฐฎเฐณเฑเฐณเฑ เฐฒเฐพเฐเฐฟเฐจเฑ เฐ
เฐตเฑเฐตเฐพเฐฒเฐฟ.
+ เฐจเฐฟเฐฐเฑเฐงเฐพเฐฐเฐฟเฐเฐเฐเฐกเฐฟ
+ เฐฐเฐฆเฑเฐฆเฑ เฐเฑเฐฏเฐเฐกเฐฟ
+ เฐจเฑเฐเฐฟเฐซเฐฟเฐเฑเฐทเฐจเฑเฐฒเฑ
+ เฐญเฐพเฐท
+ เฐเฑเฐชเฑเฐฏเฐคเฐพ เฐตเฐฟเฐงเฐพเฐจเฐ
+ เฐตเฐฟเฐจเฐฟเฐฏเฑเฐ เฐจเฐฟเฐฌเฐเฐงเฐจเฐฒเฑ
+ เฐฏเฐพเฐชเฑ เฐตเฑเฐฐเฑเฐทเฐจเฑ
+ เฐธเฑเฐเฑเฐเฐฟเฐเฐเฑเฐฒเฑ
+ เฐเฐเฑเฐเฐชเฐฐเฐฎเฑเฐจ
+
+
+ เฐตเฐพเฐคเฐพเฐตเฐฐเฐฃ เฐธเฑเฐฅเฐฟเฐคเฐฟ เฐเฐฟเฐนเฑเฐจเฐ
+ เฐญเฐพเฐท เฐเฐฟเฐนเฑเฐจเฐ
+ เฐฎเฑเฐจเฑ เฐเฐฟเฐนเฑเฐจเฐ
+ เฐเฐพเฐฒเฐฟ เฐเฐฟเฐนเฑเฐจเฐ
+ เฐคเฑเฐฎ เฐเฐฟเฐนเฑเฐจเฐ
+ เฐฒเฑเฐชเฐ เฐเฐฟเฐนเฑเฐจเฐ
+ เฐฎเฑเฐธเฐฟเฐตเฑเฐฏเฐฟ เฐเฐฟเฐนเฑเฐจเฐ
+ เฐตเฑเฐจเฑเฐเฐเฑ เฐฌเฐเฐจเฑ
+ เฐจเฑเฐเฐฟเฐซเฐฟเฐเฑเฐทเฐจเฑ เฐธเฑเฐเฑเฐเฐฟเฐเฐเฑเฐฒ เฐเฐฟเฐนเฑเฐจเฐ
+ เฐเฑเฐชเฑเฐฏเฐคเฐพ เฐตเฐฟเฐงเฐพเฐจเฐ เฐเฐฟเฐนเฑเฐจเฐ
+ เฐตเฐฟเฐจเฐฟเฐฏเฑเฐ เฐจเฐฟเฐฌเฐเฐงเฐจเฐฒ เฐเฐฟเฐนเฑเฐจเฐ
+ เฐธเฐฎเฐพเฐเฐพเฐฐเฐ เฐเฐฟเฐนเฑเฐจเฐ
+ เฐคเฐฆเฑเฐชเฐฐเฐฟ เฐธเฑเฐเฑเฐฐเฑเฐจเฑโเฐเฑ เฐจเฐพเฐตเฐฟเฐเฑเฐเฑ เฐเฑเฐฏเฐเฐกเฐฟ
+ เฐญเฐพเฐทเฐพ เฐเฐพเฐจเฑเฐซเฐฟเฐเฐฐเฑเฐทเฐจเฑ เฐฒเฑเฐกเฑ เฐเฑเฐฏเฐกเฐเฐฒเฑ เฐตเฐฟเฐซเฐฒเฐฎเฑเฐเฐฆเฐฟ. เฐกเฐฟเฐซเฐพเฐฒเฑเฐเฑ เฐญเฐพเฐท เฐเฐชเฐฏเฑเฐเฐฟเฐธเฑเฐคเฑเฐจเฑเฐจเฐพเฐฐเฑ.
+
+
+ เฐ
เฐชเฑโเฐกเฑเฐเฑโเฐเฐพ เฐเฐเฐกเฐเฐกเฐฟ
+ เฐตเฐพเฐคเฐพเฐตเฐฐเฐฃ เฐนเฑเฐเฑเฐเฐฐเฐฟเฐเฐฒ เฐเฑเฐธเฐ เฐจเฑเฐเฐฟเฐซเฐฟเฐเฑเฐทเฐจเฑเฐฒเฐจเฑ เฐชเฑเฐฐเฐพเฐฐเฐเฐญเฐฟเฐเฐเฐเฐกเฐฟ
+ เฐชเฑเฐฐเฐพเฐฐเฐเฐญเฐฟเฐเฐเฑ
+
+
+ เฐชเฑเฐฐเฑเฐฎเฐฟเฐฏเฐ เฐชเฑเฐเฐฆเฐเฐกเฐฟ
+ เฐชเฑเฐฐเฐเฑเฐฐเฐฟเฐฏเฐฒเฑโฆ
+ เฐฎเฑเฐฎเฑ เฐฎเฑ เฐชเฑเฐฐเฑเฐฎเฐฟเฐฏเฐ เฐธเฐฌเฑโเฐธเฑเฐเฑเฐฐเฐฟเฐชเฑเฐทเฐจเฑโเฐจเฑ เฐธเฐเฑเฐฐเฐฟเฐฏเฐ เฐเฑเฐธเฑเฐคเฑเฐจเฑเฐจเฐชเฑเฐชเฑเฐกเฑ เฐฆเฐฏเฐเฑเฐธเฐฟ เฐเฐฆเฑเฐฐเฑเฐเฑเฐกเฐเฐกเฐฟ.
+ เฐ
เฐจเฑเฐจเฐฟ เฐซเฑเฐเฐฐเฑโเฐฒเฐจเฑ เฐ
เฐจเฑโเฐฒเฐพเฐเฑ เฐเฑเฐฏเฐเฐกเฐฟ เฐฎเฐฐเฐฟเฐฏเฑ เฐชเฑเฐฐเฐเฐเฐจ เฐฒเฑเฐจเฐฟ เฐ
เฐจเฑเฐญเฐตเฐพเฐจเฑเฐจเฐฟ เฐเฐธเฑเฐตเฐพเฐฆเฐฟเฐเฐเฐเฐกเฐฟ.
+ เฐเฐชเฑเฐชเฑเฐกเฑ เฐ
เฐชเฑโเฐเฑเฐฐเฑเฐกเฑ เฐเฑเฐฏเฐเฐกเฐฟ
+ เฐฎเฑเฐฐเฑ เฐชเฑเฐฐเฑเฐฎเฐฟเฐฏเฐ เฐเฐชเฐฏเฑเฐเฐเฐฐเฑเฐค
+ %1$sเฐเฑ เฐฎเฑเฐเฑเฐธเฑเฐคเฑเฐเฐฆเฐฟ
+ เฐธเฐเฑเฐฐเฐฟเฐฏ
+ เฐชเฑเฐฐเฑเฐฎเฐฟเฐฏเฐ เฐธเฐเฑเฐฐเฐฟเฐฏเฐ เฐเฑเฐฏเฐฌเฐกเฐฟเฐเฐฆเฐฟ
+ เฐฎเฑ เฐชเฑเฐฐเฑเฐฎเฐฟเฐฏเฐ เฐธเฐฌเฑโเฐธเฑเฐเฑเฐฐเฐฟเฐชเฑเฐทเฐจเฑ เฐเฐชเฑเฐชเฑเฐกเฑ เฐธเฐเฑเฐฐเฐฟเฐฏเฐฎเฑเฐเฐฆเฐฟ!
+
+
+ เฐธเฑเฐตเฑ เฐเฑเฐธเฐฟเฐจ เฐชเฑเฐฐเฐฆเฑเฐถเฐพเฐฒเฑ
+ saved_locations_nested_nav
+ เฐธเฑเฐตเฑ เฐเฑเฐธเฐฟเฐจ เฐชเฑเฐฐเฐฆเฑเฐถเฐพเฐฒเฑ
+ เฐเฐเฐเฐพ เฐธเฑเฐตเฑ เฐเฑเฐธเฐฟเฐจ เฐชเฑเฐฐเฐฆเฑเฐถเฐพเฐฒเฑ เฐฒเฑเฐตเฑ. เฐเฑเฐกเฐฟเฐเฐเฐกเฐพเฐจเฐฟเฐเฐฟ + เฐจเฑเฐเฑเฐเฐเฐกเฐฟ.
+ เฐชเฑเฐฐเฐฆเฑเฐถเฐ เฐเฑเฐกเฐฟเฐเฐเฐเฐกเฐฟ
+ เฐคเฑเฐฒเฐเฐฟเฐเฐเฐเฐกเฐฟ
+ เฐธเฑเฐตเฑ เฐเฑเฐฏเฐเฐกเฐฟ
+ เฐชเฑเฐฐเฐฆเฑเฐถเฐ เฐชเฑเฐฐเฑ (เฐเฐฆเฐพ. เฐเฑเฐนเฐ)
+ เฐชเฑเฐฐเฑเฐฎเฐฟเฐฏเฐ เฐธเฐตเฐฐเฐฃ
+ เฐฎเฑ เฐเฐทเฑเฐเฐฎเฑเฐจ เฐชเฑเฐฐเฐฆเฑเฐถเฐพเฐฒเฐจเฑ เฐธเฑเฐตเฑ เฐเฑเฐฏเฐเฐกเฐฟ เฐฎเฐฐเฐฟเฐฏเฑ เฐคเฐเฑเฐทเฐฃเฐฎเฑ เฐชเฑเฐฐเฐพเฐชเฑเฐฏเฐค เฐเฑเฐฏเฐเฐกเฐฟ. เฐ เฐซเฑเฐเฐฐเฑโเฐจเฑ เฐ
เฐจเฑโเฐฒเฐพเฐเฑ เฐเฑเฐฏเฐกเฐพเฐจเฐฟเฐเฐฟ เฐชเฑเฐฐเฑเฐฎเฐฟเฐฏเฐโเฐเฑ เฐ
เฐชเฑโเฐเฑเฐฐเฑเฐกเฑ เฐเฑเฐฏเฐเฐกเฐฟ.
+ เฐธเฑเฐตเฑ เฐเฑเฐธเฐฟเฐจ เฐชเฑเฐฐเฐฆเฑเฐถเฐพเฐฒเฐจเฑ เฐฒเฑเฐกเฑ เฐเฑเฐฏเฐกเฐเฐฒเฑ เฐตเฐฟเฐซเฐฒเฐฎเฑเฐเฐฆเฐฟ.
+ เฐชเฑเฐฐเฐฆเฑเฐถเฐ เฐตเฐฟเฐเฐฏเฐตเฐเฐคเฐเฐเฐพ เฐธเฑเฐตเฑ เฐเฑเฐฏเฐฌเฐกเฐฟเฐเฐฆเฐฟ.
+ เฐชเฑเฐฐเฐฆเฑเฐถเฐ เฐคเฑเฐฒเฐเฐฟเฐเฐเฐฌเฐกเฐฟเฐเฐฆเฐฟ.
+ เฐธเฑเฐตเฑ เฐเฑเฐธเฐฟเฐจ เฐชเฑเฐฐเฐฆเฑเฐถเฐพเฐฒเฑ
+ เฐเฑเฐคเฑเฐค เฐชเฑเฐฐเฐฆเฑเฐถเฐ เฐเฑเฐกเฐฟเฐเฐเฐเฐกเฐฟ
+ เฐชเฑเฐฐเฐฆเฑเฐถเฐ เฐคเฑเฐฒเฐเฐฟเฐเฐเฐเฐกเฐฟ
+
+
+ เฐเฐ เฐชเฑเฐฐเฐฆเฑเฐถเฐ เฐเฑเฐธเฐ เฐตเฑเฐคเฐเฐเฐกเฐฟ
+ เฐจเฐเฐฐเฐ เฐฒเฑเฐฆเฐพ เฐเฐฟเฐฐเฑเฐจเฐพเฐฎเฐพ เฐเฑเฐชเฑ เฐเฑเฐฏเฐเฐกเฐฟโฆ
+ \'%1$s\'เฐเฑ เฐเฐเฑเฐตเฐเฐเฐฟ เฐชเฑเฐฐเฐฆเฑเฐถเฐพเฐฒเฑ เฐเฐจเฑเฐเฑเฐจเฐฌเฐกเฐฒเฑเฐฆเฑ
+ เฐตเฐพเฐคเฐพเฐตเฐฐเฐฃ เฐชเฑเฐฐเฐฆเฑเฐถเฐเฐเฐพ เฐเฐชเฐฏเฑเฐเฐฟเฐเฐเฐพเฐฒเฐพ?
+ เฐฎเฑ เฐชเฑเฐฐเฐธเฑเฐคเฑเฐค GPS เฐธเฑเฐฅเฐพเฐจเฐพเฐจเฐฟเฐเฐฟ เฐฌเฐฆเฑเฐฒเฑเฐเฐพ %1$s เฐตเฐพเฐคเฐพเฐตเฐฐเฐฃ เฐกเฑเฐเฐพ เฐเฑเฐชเฐฌเฐกเฑเฐคเฑเฐเฐฆเฐฟ.
+ เฐเฐฆเฐฟ เฐธเฐเฑเฐฐเฐฟเฐฏเฐเฐเฐพ เฐเฐจเฑเฐจเฐชเฑเฐชเฑเฐกเฑ เฐฎเฑ เฐจเฑเฐฐเฑเฐเฐพ GPS เฐธเฑเฐฅเฐพเฐจเฐ เฐจเฐตเฑเฐเฐฐเฐฟเฐเฐเฐฌเฐกเฐฆเฑ.
+ เฐกเฐฟเฐซเฐพเฐฒเฑเฐเฑโเฐเฐพ เฐธเฑเฐเฑ เฐเฑเฐฏเฐเฐกเฐฟ
+ %1$s เฐเฐชเฐฏเฑเฐเฐฟเฐธเฑเฐคเฑเฐเฐฆเฐฟ
+ GPS เฐเฐฟ เฐฐเฑเฐธเฑเฐเฑ เฐเฑเฐฏเฐเฐกเฐฟ
+ เฐชเฑเฐฐเฐธเฑเฐคเฑเฐคเฐ %1$s เฐตเฐพเฐคเฐพเฐตเฐฐเฐฃเฐ เฐเฑเฐชเฐฌเฐกเฑเฐคเฑเฐเฐฆเฐฟ. GPS เฐเฐฟ เฐฐเฑเฐธเฑเฐเฑ เฐเฑเฐฏเฐกเฐพเฐจเฐฟเฐเฐฟ เฐจเฑเฐเฑเฐเฐเฐกเฐฟ.
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index b14ff1b0..e82205af 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -12,4 +12,5 @@
#556799
#4A4A4A
#242534
+ #FF6200EE
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e493a926..34f22fd3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -8,6 +8,14 @@
Profile
Settings
+
+ weatherify_notifications
+ Weather Alerts
+ Weather alerts and updates
+ Weather Update
+ New weather alert
+
+
home_nested_nav
profile_nested_nav
@@ -35,11 +43,41 @@
City not found!
Ummโฆ Looks like server issue!
Ummโฆ Can you re-check internet connectivity!
+ Ummโฆ Can you re-check internet connectivity!
+ Request timed out. Please try again later.
Oops!..Something went wrong.
+ Location services are turned off. Enable GPS to get your local weather.
+ Enable GPS
Location coordinates not yet updated.
Oh no! No cities found at this moment. Check back later
No details found. Try after sometime.
+
+ Profile
+ Logout
+ Are you sure you want to log out?
+ You\'ll need to log in again to access your account.
+ Confirm
+ Cancel
+
+ Get Premium
+ Processingโฆ
+ Please wait while we activate your premium subscription.
+ Unlock all features and enjoy an ad-free experience.
+ Upgrade Now
+ You are a Premium User
+ Expires %1$s
+ Active
+ Premium Activated
+ Your premium subscription is now active!
+ Notifications
+ Language
+ Privacy Policy
+ Terms of Use
+ App Version
+ Settings
+ Legal
+
weather condition icon
Language icon
@@ -48,4 +86,46 @@
Humidity icon
Error icon
Close icon
+ Back button
+ Notification settings icon
+ Privacy policy icon
+ Terms of use icon
+ Information icon
+ Navigate to next screen
+ Failed to load language configuration. Using default language.
+
+
+ Stay Updated
+ Enable notifications for weather alerts
+ Enable
+
+
+ Saved Locations
+ saved_locations_nested_nav
+ Saved Locations
+ No saved locations yet. Tap + to add one.
+ Add Location
+ Delete
+ Save
+ Location name (e.g. Home)
+ Premium Feature
+ Save and access your favourite locations instantly. Upgrade to Premium to unlock this feature.
+ Failed to load saved locations.
+ Location saved successfully.
+ Location removed.
+ Saved locations
+ Add new location
+ Delete location
+ Use as weather location?
+ Weather data will show for %1$s instead of your current GPS position.
+ Your live GPS location won\'t update while this is active.
+ Set as Default
+ Using %1$s
+ Reset to GPS
+ Currently showing weather for %1$s. Tap to reset to GPS.
+
+
+ Search for a Place
+ Type a city or addressโฆ
+ No places found for \'%1$s\'
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
index c846ee45..90923015 100644
--- a/app/src/main/res/xml/locales_config.xml
+++ b/app/src/main/res/xml/locales_config.xml
@@ -3,4 +3,9 @@
+
+
+
+
+
diff --git a/app/src/test/java/bose/ankush/weatherify/MainCoroutineRule.kt b/app/src/test/java/bose/ankush/weatherify/MainCoroutineRule.kt
deleted file mode 100644
index 16e1127c..00000000
--- a/app/src/test/java/bose/ankush/weatherify/MainCoroutineRule.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package bose.ankush.weatherify
-
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.*
-import org.junit.rules.TestWatcher
-import org.junit.runner.Description
-
-/**
- * MainCoroutineRule installs a TestCoroutineDispatcher for Disptachers.Main.
- * It extends TestScope, we can launch coroutine directly with this.
- */
-@OptIn(ExperimentalCoroutinesApi::class)
-class MainCoroutineRule(
- private val dispatcher: CoroutineDispatcher = StandardTestDispatcher(),
- val testScope: TestScope = TestScope(dispatcher)
-) : TestWatcher() {
-
- override fun starting(description: Description) {
- super.starting(description)
- Dispatchers.setMain(dispatcher)
- }
-
- override fun finished(description: Description) {
- super.finished(description)
- Dispatchers.resetMain()
- }
-
-}
\ No newline at end of file
diff --git a/app/src/test/java/bose/ankush/weatherify/MockWebServerUtil.kt b/app/src/test/java/bose/ankush/weatherify/MockWebServerUtil.kt
deleted file mode 100644
index 88cc3b4e..00000000
--- a/app/src/test/java/bose/ankush/weatherify/MockWebServerUtil.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package bose.ankush.weatherify
-
-import okhttp3.mockwebserver.MockResponse
-import okhttp3.mockwebserver.MockWebServer
-import okio.buffer
-import okio.source
-import java.nio.charset.StandardCharsets
-object MockWebServerUtil {
-
- internal fun MockWebServer.enqueueResponse(fileName: String, code: Int) {
- val inputStream = javaClass.classLoader?.getResourceAsStream(fileName)
-
- val source = inputStream?.use { inputStream.source().buffer() }
- source?.let {
- enqueue(
- MockResponse()
- .setResponseCode(code)
- .setBody(source.readString(StandardCharsets.UTF_8))
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/test/java/bose/ankush/weatherify/base/DateTimeUtilsTest.kt b/app/src/test/java/bose/ankush/weatherify/base/DateTimeUtilsTest.kt
index a7e7263a..15721254 100644
--- a/app/src/test/java/bose/ankush/weatherify/base/DateTimeUtilsTest.kt
+++ b/app/src/test/java/bose/ankush/weatherify/base/DateTimeUtilsTest.kt
@@ -5,92 +5,63 @@ import com.google.common.truth.Truth.assertThat
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.mockkObject
-import io.mockk.mockkStatic
import io.mockk.unmockkAll
import org.junit.After
import org.junit.Before
import org.junit.Test
-import java.time.Clock
-import java.time.Instant
-import java.time.ZoneId
import java.util.Calendar
-
+import java.util.TimeZone
class DateTimeUtilsTest {
-
- private val now = 1669873946L // 1st December 2022
- private val fixedClock = Clock.fixed(Instant.ofEpochMilli(now), ZoneId.systemDefault())
+ private val now = 1669873946L // 1st December 2022 (UTC)
+ private lateinit var originalTimeZone: TimeZone
/**
- * this method is helps to initiate mockk and setup mocked objects before tests are run
+ * Initiate MockK and set a deterministic timezone before tests run
*/
@Before
fun setup() {
MockKAnnotations.init(this)
- mockkObject(DateTimeUtils::class)
- mockkStatic(Clock::class)
- every { Clock.systemUTC() } returns fixedClock
+ mockkObject(DateTimeUtils)
+ originalTimeZone = TimeZone.getDefault()
+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
}
/**
- * this method runs at the end of tests to unmockk all mocked objects
+ * Restore timezone and unmock all objects after tests
*/
@After
fun teardown() {
+ TimeZone.setDefault(originalTimeZone)
unmockkAll()
}
-/*
-
- */
-/**
- * this test verifies if clock has been fixed successfully
- *//*
-
- @Test
- fun `verify that clock is fixed to given time`() {
- assertThat(Instant.now().toEpochMilli().toString()).isEqualTo("1669873946")
- }
-
- */
-/**
- * this test verifies that getCurrentTimestamp returns expected time stamp
- *//*
-
- @Test
- fun `verify that getCurrentTimestamp returns time stamp successfully`() {
- val result = DateTimeUtils.getCurrentTimestamp()
- assertThat(result).isEqualTo(now.toString())
- }
-*/
/**
- * this test verifies that getDayWiseDifferenceFromToday method returns expected day difference
- * as integer
+ * Verify that getDayWiseDifferenceFromToday can be stubbed and returns expected difference
*/
@Test
fun `verify that getDayWiseDifferenceFromToday returns day difference successfully`() {
- mockkStatic(Calendar::class)
- every { Calendar.getInstance().time = any() } returns Unit
- every { DateTimeUtils.getDayWiseDifferenceFromToday(now.toInt()) } returns 0
- val numberOfDays = DateTimeUtils.getDayWiseDifferenceFromToday(now.toInt())
+ every { DateTimeUtils.getDayWiseDifferenceFromToday(now) } returns 0
+ val numberOfDays = DateTimeUtils.getDayWiseDifferenceFromToday(now)
assertThat(numberOfDays).isEqualTo(0)
}
/**
- * this test verifies that getTodayDateInCalenderFormat returns correct year as per given epoch
+ * Verify that getTodayDateInCalenderFormat returns the current year
*/
@Test
fun `verify that getTodayDateInCalenderFormat returns correct year number`() {
- val todaysDate = DateTimeUtils.getTodayDateInCalenderFormat().get(Calendar.YEAR)
- assertThat(todaysDate).isEqualTo(2023)
+ val todaysYear = DateTimeUtils.getTodayDateInCalenderFormat().get(Calendar.YEAR)
+ val expectedYear = Calendar.getInstance().get(Calendar.YEAR)
+ assertThat(todaysYear).isEqualTo(expectedYear)
}
/**
- * this test verifies getDayNameFromEpoch returns correct day name as per given epoch
+ * Verify getDayNameFromEpoch returns correct day name for the given epoch
*/
@Test
fun `verify that getDayNameFromEpoch returns correct day name`() {
val dayName = now.dayName()
assertThat(dayName).isEqualTo("Thursday")
}
-}
\ No newline at end of file
+}
diff --git a/app/src/test/java/bose/ankush/weatherify/common/ExtensionTest.kt b/app/src/test/java/bose/ankush/weatherify/common/ExtensionTest.kt
index 756e4fcf..3ba0f7d3 100644
--- a/app/src/test/java/bose/ankush/weatherify/common/ExtensionTest.kt
+++ b/app/src/test/java/bose/ankush/weatherify/common/ExtensionTest.kt
@@ -11,7 +11,6 @@ import org.junit.Before
import org.junit.Test
class ExtensionTest {
-
@Before
fun setup() {
MockKAnnotations.init(this)
@@ -29,4 +28,4 @@ class ExtensionTest {
val celsiusTemp = kelvinTemp.toCelsius()
assertThat(celsiusTemp).isEqualTo("16")
}
-}
\ No newline at end of file
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index d73f6982..64306719 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -4,6 +4,7 @@ buildscript {
classpath(BuildPlugins.buildGradle)
classpath(BuildPlugins.kotlinGradlePlugin)
classpath(BuildPlugins.googleServicePlugin)
+ classpath(BuildPlugins.composeMultiplatformPlugin)
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
@@ -15,11 +16,14 @@ plugins {
id("org.jetbrains.kotlin.multiplatform") version Versions.kotlin apply false
id("org.jetbrains.kotlin.plugin.serialization") version Versions.kotlin apply false
id("com.google.dagger.hilt.android") version Versions.hilt apply false
+ id("com.google.devtools.ksp") version Versions.ksp apply false
id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") version Versions.secretPlugin apply false
- id("org.jlleitschuh.gradle.ktlint") version Versions.ktLintVersion apply false
+ id("org.jlleitschuh.gradle.ktlint") version Versions.ktLintGradlePlugin apply false
id("com.diffplug.spotless") version Versions.spotlessVersion apply false
+ id("io.gitlab.arturbosch.detekt") version Versions.detekt apply false
id("com.github.ben-manes.versions") version Versions.benManes
id("org.jetbrains.kotlin.plugin.compose") version Versions.kotlin apply false
+ id("org.jetbrains.compose") version Versions.composeMultiplatform apply false
}
tasks.named("dependencyUpdates").configure {
@@ -30,3 +34,134 @@ tasks.named("
outputDir = "build/dependencyUpdates"
reportfileName = "dependency_update_report"
}
+
+// Deep clean task: runs all module clean tasks, then removes build artefacts and repo-local .gradle
+// Does NOT touch the user-level ~/.gradle cache.
+tasks.register("deepClean") {
+ description = "Cleans every module and removes all build artefacts in this repo."
+ group = "build setup"
+
+ // Run each subproject's own clean task first (honors plugin-specific clean hooks)
+ dependsOn(subprojects.map { "${it.path}:clean" })
+
+ doLast {
+ val dirsToDelete = mutableSetOf().apply {
+ allprojects.forEach { add(it.layout.buildDirectory.get().asFile) }
+ add(rootProject.layout.projectDirectory.dir(".gradle").asFile)
+ }
+ delete(dirsToDelete)
+ }
+}
+
+// Spotless + ktlint configuration for all subprojects
+subprojects {
+ apply(plugin = "com.diffplug.spotless")
+
+ configure {
+ kotlin {
+ target("**/*.kt")
+ targetExclude("**/build/**")
+ ktlint(Versions.ktLintCli).editorConfigOverride(
+ mapOf(
+ "ktlint_code_style" to "ktlint_official",
+ "indent_size" to "4",
+ "max_line_length" to "120",
+ // Allow common Android/KMP patterns without false positives
+ "ktlint_function_naming_ignore_when_annotated_with" to "Composable",
+ // Project uses snake_case package segments (use_case, remote_config) โ keep as-is
+ "ktlint_standard_package-name" to "disabled",
+ // Backing properties exposed via asStateFlow() functions rather than matching val โ valid pattern
+ "ktlint_standard_backing-property-naming" to "disabled"
+ )
+ )
+ trimTrailingWhitespace()
+ endWithNewline()
+ }
+ kotlinGradle {
+ target("**/*.gradle.kts")
+ ktlint(Versions.ktLintCli)
+ }
+ }
+}
+
+// Detekt configuration for all subprojects
+subprojects {
+ apply(plugin = "io.gitlab.arturbosch.detekt")
+
+ extensions.configure("detekt") {
+ buildUponDefaultConfig = true
+ allRules = false
+ ignoreFailures = true
+ autoCorrect = false
+ parallel = true
+ }
+
+ // Applies to both `detekt` and `detektAutoCorrect` tasks
+ tasks.withType().configureEach {
+ jvmTarget = "17"
+ reports {
+ xml.required.set(false)
+ txt.required.set(false)
+ sarif.required.set(false)
+ md.required.set(false)
+ html.required.set(true)
+ }
+ }
+
+ // Auto-correct variant โ fixes the subset of rules detekt can patch automatically
+ tasks.register("detektAutoCorrect", io.gitlab.arturbosch.detekt.Detekt::class.java) {
+ description = "Runs detekt with auto-correct enabled"
+ group = "verification"
+ autoCorrect = true
+ buildUponDefaultConfig = true
+ ignoreFailures = true
+ parallel = true
+ setSource(files("src"))
+ include("**/*.kt", "**/*.kts")
+ exclude("**/build/**")
+ }
+
+ // Ensure detekt auto-correct runs after spotless has already formatted the files
+ tasks.named("detektAutoCorrect") { mustRunAfter("spotlessApply") }
+}
+
+// Aggregator tasks
+tasks.register("spotlessCheckAll") {
+ group = "verification"
+ description = "Runs spotlessCheck in all subprojects"
+ dependsOn(subprojects.map { "${it.path}:spotlessCheck" })
+}
+
+tasks.register("spotlessApplyAll") {
+ group = "formatting"
+ description = "Runs spotlessApply in all subprojects"
+ dependsOn(subprojects.map { "${it.path}:spotlessApply" })
+}
+
+tasks.register("detektAll") {
+ group = "verification"
+ description = "Runs detekt in all subprojects"
+ dependsOn(subprojects.map { "${it.path}:detekt" })
+}
+
+tasks.register("detektAllAutoCorrect") {
+ group = "formatting"
+ description = "Runs detekt with auto-correct in all subprojects"
+ dependsOn(subprojects.map { "${it.path}:detektAutoCorrect" })
+}
+
+// Single command: audit all style and lint issues without modifying files
+tasks.register("codeCheck") {
+ group = "verification"
+ description = "Checks formatting (spotless) and runs detekt across all subprojects"
+ dependsOn("spotlessCheckAll", "detektAll")
+}
+
+// Single command: apply all auto-fixable formatting and lint corrections
+tasks.register("codeFormat") {
+ group = "formatting"
+ description = "Applies spotless formatting and detekt auto-corrections across all subprojects"
+ dependsOn("spotlessApplyAll", "detektAllAutoCorrect")
+}
+
+tasks.named("detektAllAutoCorrect") { mustRunAfter("spotlessApplyAll") }
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
index 20ea48c8..82ac6685 100644
--- a/buildSrc/build.gradle.kts
+++ b/buildSrc/build.gradle.kts
@@ -1,4 +1,6 @@
import org.gradle.kotlin.dsl.`kotlin-dsl`
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
`kotlin-dsl`
@@ -9,8 +11,8 @@ repositories {
mavenCentral()
}
-tasks.withType().configureEach {
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_17.toString()
+tasks.withType().configureEach {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
}
}
diff --git a/buildSrc/src/main/java/ConfigData.kt b/buildSrc/src/main/java/ConfigData.kt
index d7b6b423..9c40aa1c 100644
--- a/buildSrc/src/main/java/ConfigData.kt
+++ b/buildSrc/src/main/java/ConfigData.kt
@@ -1,9 +1,8 @@
object ConfigData {
- const val compileSdkVersion = 34
- const val buildToolsVersion = "30.0.3"
+ const val compileSdkVersion = 36
const val minSdkVersion = 26
- const val targetSdkVersion = 34
+ const val targetSdkVersion = 36
const val versionCode = 101
const val versionName = "1.1"
const val multiDexEnabled = true
diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt
index de2204c0..c7165f14 100644
--- a/buildSrc/src/main/java/Dependencies.kt
+++ b/buildSrc/src/main/java/Dependencies.kt
@@ -3,6 +3,7 @@ object BuildPlugins {
val buildGradle by lazy { "com.android.tools.build:gradle:${Versions.buildGradle}" }
val kotlinGradlePlugin by lazy { "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}" }
val googleServicePlugin by lazy { "com.google.gms:google-services:${Versions.googleServices}" }
+ val composeMultiplatformPlugin by lazy { "org.jetbrains.compose:compose-gradle-plugin:${Versions.composeMultiplatform}" }
}
// Dependencies
@@ -20,6 +21,7 @@ object Deps {
val systemUIController by lazy { "com.google.accompanist:accompanist-systemuicontroller:${Versions.accompanist}" }
val dataStore by lazy { "androidx.datastore:datastore-preferences:${Versions.dataStore}" }
val splashScreen by lazy { "androidx.core:core-splashscreen:${Versions.splashScreen}" }
+ val securityCrypto by lazy { "androidx.security:security-crypto:${Versions.securityCrypto}" }
// Compose
val composeBom by lazy { "androidx.compose:compose-bom:${Versions.composeBom}" }
@@ -28,6 +30,7 @@ object Deps {
val composeUi by lazy { "androidx.compose.ui:ui" }
val composeUiTooling by lazy { "androidx.compose.ui:ui-tooling" }
val composeUiToolingPreview by lazy { "androidx.compose.ui:ui-tooling-preview" }
+ val composeIconsExtended by lazy { "androidx.compose.material:material-icons-extended" }
// Unit Testing
val junit by lazy { "junit:junit:${Versions.junit}" }
@@ -55,28 +58,33 @@ object Deps {
// Firebase
val firebaseBom by lazy { "com.google.firebase:firebase-bom:${Versions.firebaseBom}" }
- val firebaseConfig by lazy { "com.google.firebase:firebase-config-ktx" }
- val firebaseAnalytics by lazy { "com.google.firebase:firebase-analytics-ktx" }
+ // Firebase dependencies will use versions from BOM - no explicit versions needed
+ val firebaseConfig by lazy { "com.google.firebase:firebase-config" }
+ val firebaseAnalytics by lazy { "com.google.firebase:firebase-analytics" }
val firebasePerformanceMonitoring by lazy { "com.google.firebase:firebase-perf" }
+ val firebaseMessaging by lazy { "com.google.firebase:firebase-messaging" }
// Coroutines
val coroutinesCore by lazy { "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutines}" }
val coroutinesAndroid by lazy { "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutines}" }
+ // Date/Time (KMP-compatible, replaces java.time)
+ val kotlinxDatetime by lazy { "org.jetbrains.kotlinx:kotlinx-datetime:${Versions.kotlinxDatetime}" }
+
// Dependency Injection
val hilt by lazy { "com.google.dagger:hilt-android:${Versions.hilt}" }
val hiltTesting by lazy { "com.google.dagger:hilt-android-testing:${Versions.hilt}" }
val hiltDaggerAndroidCompiler by lazy { "com.google.dagger:hilt-android-compiler:${Versions.hilt}" }
val hiltNavigationCompose by lazy { "androidx.hilt:hilt-navigation-compose:${Versions.hiltCompose}" }
+ val hiltAndroidXCompiler by lazy { "androidx.hilt:hilt-compiler:${Versions.hiltCompose}" }
// Miscellaneous
val timber by lazy { "com.jakewharton.timber:timber:${Versions.timber}" }
- val lottieCompose by lazy { "com.airbnb.android:lottie-compose:${Versions.lottie}" }
val coilCompose by lazy { "io.coil-kt:coil-compose:${Versions.coilCompose}" }
// Memory Leak
val leakCanary by lazy { "com.squareup.leakcanary:leakcanary-android:${Versions.leakCanary}" }
- /*For Payment module*/
+ // For Payment module
val razorPay by lazy { "com.razorpay:checkout:${Versions.razorPay}" }
}
diff --git a/buildSrc/src/main/java/Versions.kt b/buildSrc/src/main/java/Versions.kt
index d91a74ea..d1073841 100644
--- a/buildSrc/src/main/java/Versions.kt
+++ b/buildSrc/src/main/java/Versions.kt
@@ -1,70 +1,77 @@
-
object Versions {
// Kotlin
- const val kotlin = "2.0.20"
- const val kotlinCompiler = "1.9"
+ const val kotlin = "2.2.21"
// Compose
- const val composeBom = "2023.08.00"
+ const val composeBom = "2025.06.01"
+ const val composeMultiplatform = "1.8.0" // for Compose Multiplatform (iOS + Android)
// Plugins
- const val buildGradle = "8.11.1"
- const val navigation = "2.7.0"
+ const val buildGradle = "8.12.0"
+ const val navigation = "2.7.7"
const val secretPlugin = "2.0.1"
const val benManes = "0.52.0"
const val spotlessVersion = "6.25.0"
- const val ktLintVersion = "13.0.0"
- const val googleServices = "4.3.15"
+ const val ksp = "2.2.21-2.0.5"
+
+ // KtLint versions: separate plugin and CLI to avoid resolution confusion
+ const val ktLintGradlePlugin = "12.1.1"
+ const val ktLintCli = "1.7.1"
+ const val detekt = "1.23.6"
+ const val googleServices = "4.4.1"
// Testing
const val junit = "4.13.2"
- const val extJunit = "1.1.5"
- const val truth = "1.1.3"
- const val turbine = "0.13.0"
- const val coroutineTest = "1.7.1"
+ const val extJunit = "1.3.0"
+ const val truth = "1.4.5"
+ const val turbine = "1.2.1"
+ const val coroutineTest = "1.10.2"
const val coreTesting = "2.2.0"
- const val espresso = "3.5.1"
+ const val espresso = "3.7.0"
const val mockitoInline = "5.2.0"
const val mockitoNhaarman = "2.2.0"
- const val mockWebServer = "4.9.3"
- const val mockk = "1.13.5"
+ const val mockWebServer = "4.12.0"
+ const val mockk = "1.14.9"
// Core
- const val androidCore = "1.9.0"
- const val appCompat = "1.6.1"
- const val androidMaterial = "1.7.0"
- const val lifecycle = "2.6.1"
+ const val androidCore = "1.13.1"
+ const val appCompat = "1.7.0"
+ const val androidMaterial = "1.11.0"
+ const val lifecycle = "2.7.0"
const val googlePlayCore = "2.1.0"
- const val googlePlayLocation = "21.0.1"
- const val accompanist = "0.28.0"
- const val dataStore = "1.0.0"
- const val splashScreen = "1.0.1"
+ const val googlePlayLocation = "21.3.0"
+ const val accompanist = "0.36.0"
+ const val dataStore = "1.1.1"
+ const val splashScreen = "1.2.0"
+ const val securityCrypto = "1.1.0-alpha06"
// Room
- const val room = "2.5.2"
+ const val room = "2.8.4"
// Networking
- const val gson = "2.13.1"
+ const val gson = "2.13.2"
// Firebase
- const val firebaseBom = "32.2.0"
+ const val firebaseBom = "34.10.0"
// Coroutines
- const val coroutines = "1.6.4"
+ const val coroutines = "1.10.2"
+
+ // Kotlinx Date/Time (KMP-compatible, replaces java.time)
+ const val kotlinxDatetime = "0.6.2"
// Dependency Injection
- const val hilt = "2.52"
- const val hiltCompose = "1.0.0"
+ const val hilt = "2.58"
+ const val hiltCompose = "1.2.0"
// Miscellaneous
const val timber = "5.0.1"
- const val lottie = "6.0.0"
- const val coilCompose = "2.4.0"
+ const val coilCompose = "2.7.0"
// Memory leak
- const val leakCanary = "2.12"
+ const val leakCanary = "2.13"
/*For Payment module*/
- const val razorPay = "1.6.30"
+ const val razorPay = "1.6.41"
}
diff --git a/buildSrc/src/main/kotlin/KmmDeps.kt b/buildSrc/src/main/kotlin/KmmDeps.kt
index 08751443..514a0a12 100644
--- a/buildSrc/src/main/kotlin/KmmDeps.kt
+++ b/buildSrc/src/main/kotlin/KmmDeps.kt
@@ -1,11 +1,35 @@
import org.gradle.api.artifacts.dsl.DependencyHandler
+// Compose Multiplatform runtime dependencies for the common-ui KMP module.
+// The version MUST match your Kotlin version. Check the table at:
+// https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
+// Known mapping: Kotlin 2.1.x โ CMP 1.7.x | Update below for your exact Kotlin version.
+object CmpVersions {
+ const val composeMultiplatform = "1.7.3"
+}
+
+object CmpDeps {
+ const val runtime = "org.jetbrains.compose.runtime:runtime:${CmpVersions.composeMultiplatform}"
+ const val ui = "org.jetbrains.compose.ui:ui:${CmpVersions.composeMultiplatform}"
+ const val foundation =
+ "org.jetbrains.compose.foundation:foundation:${CmpVersions.composeMultiplatform}"
+ const val material3 =
+ "org.jetbrains.compose.material3:material3:${CmpVersions.composeMultiplatform}"
+ const val animation =
+ "org.jetbrains.compose.animation:animation:${CmpVersions.composeMultiplatform}"
+ const val components =
+ "org.jetbrains.compose.components:components-resources:${CmpVersions.composeMultiplatform}"
+ const val uiTooling = "org.jetbrains.compose.ui:ui-tooling:${CmpVersions.composeMultiplatform}"
+}
+
object KmmVersions {
const val ktor = "2.3.13"
- const val kotlinxSerialization = "1.6.0"
- const val kotlinxCoroutines = "1.7.3"
+ const val kotlinxSerialization = "1.7.3"
+ const val kotlinxCoroutines = "1.9.0"
const val koin = "3.5.6"
- const val kotlinxDateTime = "0.4.1"
+ const val koinAndroidCompose = "3.5.6"
+ const val kotlinxDateTime = "0.6.1"
+ const val kmpLifecycleViewModel = "2.8.4"
}
object KmmDeps {
@@ -15,24 +39,30 @@ object KmmDeps {
const val ktorContentNegotiation = "io.ktor:ktor-client-content-negotiation:${KmmVersions.ktor}"
const val ktorJson = "io.ktor:ktor-serialization-kotlinx-json:${KmmVersions.ktor}"
const val ktorLogging = "io.ktor:ktor-client-logging:${KmmVersions.ktor}"
-
+
// Platform-specific Ktor engines
const val ktorAndroid = "io.ktor:ktor-client-android:${KmmVersions.ktor}"
const val ktorIOS = "io.ktor:ktor-client-darwin:${KmmVersions.ktor}"
-
+
// Kotlinx Serialization
const val kotlinxSerialization = "org.jetbrains.kotlinx:kotlinx-serialization-json:${KmmVersions.kotlinxSerialization}"
-
+
// Kotlinx Coroutines
const val kotlinxCoroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${KmmVersions.kotlinxCoroutines}"
-
+
// Koin
const val koinCore = "io.insert-koin:koin-core:${KmmVersions.koin}"
-
+ const val koinAndroid = "io.insert-koin:koin-android:${KmmVersions.koin}"
+ const val koinAndroidCompose = "io.insert-koin:koin-androidx-compose:${KmmVersions.koinAndroidCompose}"
+
// DateTime
const val kotlinxDateTime = "org.jetbrains.kotlinx:kotlinx-datetime:${KmmVersions.kotlinxDateTime}"
+
+ // KMP-compatible ViewModel (JetBrains port of AndroidX lifecycle-viewmodel)
+ const val kmpLifecycleViewModel = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:${KmmVersions.kmpLifecycleViewModel}"
}
+@Suppress("unused")
fun DependencyHandler.addKmmCommonDependencies() {
implementation(KmmDeps.ktorCore)
implementation(KmmDeps.ktorSerialization)
@@ -45,10 +75,12 @@ fun DependencyHandler.addKmmCommonDependencies() {
implementation(KmmDeps.kotlinxDateTime)
}
+@Suppress("unused")
fun DependencyHandler.addKmmAndroidDependencies() {
implementation(KmmDeps.ktorAndroid)
}
+@Suppress("unused")
fun DependencyHandler.addKmmIOSDependencies() {
implementation(KmmDeps.ktorIOS)
}
diff --git a/common-ui/build.gradle.kts b/common-ui/build.gradle.kts
new file mode 100644
index 00000000..e23fca3b
--- /dev/null
+++ b/common-ui/build.gradle.kts
@@ -0,0 +1,86 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ id("com.android.library")
+ kotlin("multiplatform")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("org.jetbrains.compose")
+}
+
+kotlin {
+ androidTarget {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ }
+ }
+
+ listOf(
+ iosX64(),
+ iosArm64(),
+ iosSimulatorArm64(),
+ ).forEach {
+ it.binaries.framework {
+ baseName = "common_ui"
+ isStatic = true
+ }
+ }
+
+ sourceSets {
+ val commonMain by getting {
+ dependencies {
+ implementation("org.jetbrains.kotlin:kotlin-stdlib")
+ // Compose Multiplatform โ works on Android + iOS
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material3)
+ implementation(compose.ui)
+ implementation(compose.materialIconsExtended)
+ // Payment UI state types (PaymentUiState, PaymentStage) used in SettingsScreen
+ implementation(project(":feature-payment"))
+ // Location models (SavedLocation, PlaceSuggestion) and repositories for SavedLocationsScreen
+ implementation(project(":network"))
+ // Date/time utilities for KMP
+ implementation(KmmDeps.kotlinxDateTime)
+ }
+ }
+
+ @Suppress("UNUSED_VARIABLE")
+ val androidMain by getting {
+ dependencies {
+ implementation(KmmDeps.kotlinxCoroutinesCore)
+ // BackHandler support for InAppWebView
+ implementation("androidx.activity:activity-compose:1.13.0")
+ }
+ }
+
+ val iosX64Main by getting
+ val iosArm64Main by getting
+ val iosSimulatorArm64Main by getting
+
+ @Suppress("UNUSED_VARIABLE")
+ val iosMain by creating {
+ dependsOn(commonMain)
+ iosX64Main.dependsOn(this)
+ iosArm64Main.dependsOn(this)
+ iosSimulatorArm64Main.dependsOn(this)
+ }
+ }
+}
+
+android {
+ namespace = "bose.ankush.commonui"
+ compileSdk = ConfigData.compileSdkVersion
+
+ defaultConfig {
+ minSdk = ConfigData.minSdkVersion
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ buildFeatures {
+ compose = true
+ }
+}
diff --git a/common-ui/src/androidMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt b/common-ui/src/androidMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt
new file mode 100644
index 00000000..8ccec05c
--- /dev/null
+++ b/common-ui/src/androidMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt
@@ -0,0 +1,13 @@
+package bose.ankush.commonui.util
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+actual fun formatDate(
+ millis: Long,
+ pattern: String,
+): String {
+ val df = SimpleDateFormat(pattern, Locale.getDefault())
+ return df.format(Date(millis))
+}
diff --git a/common-ui/src/androidMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt b/common-ui/src/androidMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt
new file mode 100644
index 00000000..f2e59444
--- /dev/null
+++ b/common-ui/src/androidMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt
@@ -0,0 +1,461 @@
+package bose.ankush.commonui.web
+
+import android.annotation.SuppressLint
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.net.Uri
+import android.util.Log
+import android.view.ViewGroup
+import android.webkit.CookieManager
+import android.webkit.SslErrorHandler
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material.icons.outlined.ErrorOutline
+import androidx.compose.material.icons.outlined.OpenInBrowser
+import androidx.compose.material.icons.outlined.Refresh
+import androidx.compose.material.icons.outlined.Share
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProgressIndicatorDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.net.toUri
+
+/** Android actual: security-hardened WebView wrapped in AndroidView. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+actual fun InAppWebView(
+ url: String,
+ modifier: Modifier,
+ onClose: () -> Unit,
+) {
+ val context = LocalContext.current
+
+ val pageTitle = remember { mutableStateOf("") }
+ var progress by remember { mutableIntStateOf(0) }
+ var loadError by remember { mutableStateOf(false) }
+ var errorMessage by remember { mutableStateOf("") }
+ var isInitialLoad by remember { mutableStateOf(true) }
+ val currentUrl = remember { mutableStateOf(url) }
+
+ // Keep a single WebView instance across recompositions
+ val webView =
+ remember(context) {
+ WebView(context).apply {
+ layoutParams =
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ }
+ }
+
+ BackHandler(enabled = true) {
+ if (webView.canGoBack()) webView.goBack() else onClose()
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = pageTitle.value.ifBlank { "Weatherify" },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = {
+ if (webView.canGoBack()) webView.goBack() else onClose()
+ }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = { webView.reload() }) {
+ Icon(
+ imageVector = Icons.Outlined.Refresh,
+ contentDescription = "Refresh page",
+ )
+ }
+ IconButton(onClick = {
+ val shareIntent =
+ Intent().apply {
+ action = Intent.ACTION_SEND
+ putExtra(Intent.EXTRA_TEXT, currentUrl.value)
+ type = "text/plain"
+ }
+ val chooser = Intent.createChooser(shareIntent, "Share URL")
+ try {
+ context.startActivity(chooser)
+ } catch (_: ActivityNotFoundException) {
+ // No app available to handle share
+ }
+ }) {
+ Icon(
+ imageVector = Icons.Outlined.Share,
+ contentDescription = "Share page",
+ )
+ }
+ IconButton(onClick = {
+ try {
+ context.startActivity(
+ Intent(
+ Intent.ACTION_VIEW,
+ currentUrl.value.toUri()
+ )
+ )
+ } catch (_: ActivityNotFoundException) {
+ // No browser available
+ }
+ }) {
+ Icon(
+ imageVector = Icons.Outlined.OpenInBrowser,
+ contentDescription = "Open in browser",
+ )
+ }
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ titleContentColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ )
+ },
+ ) { paddingValues ->
+ Column(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .padding(paddingValues),
+ ) {
+ if (progress in 1..99) {
+ LinearProgressIndicator(
+ progress = { progress / 100f },
+ modifier =
+ Modifier
+ .height(2.dp)
+ .fillMaxWidth(),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = ProgressIndicatorDefaults.linearTrackColor,
+ strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
+ )
+ }
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ ) {
+ AndroidView(
+ factory = { ctx ->
+ webView.apply {
+ configureWebView(
+ view = this,
+ onTitle = { pageTitle.value = it },
+ onProgress = { progress = it },
+ onExternalIntent = { intent ->
+ try {
+ ctx.startActivity(intent)
+ } catch (_: ActivityNotFoundException) {
+ // No handler available
+ }
+ },
+ onError = { message ->
+ loadError = true
+ errorMessage = message
+ },
+ onPageFinished = {
+ isInitialLoad = false
+ },
+ )
+ }
+ },
+ update = { view ->
+ if (view.url != url) {
+ currentUrl.value = url
+ view.loadUrl(url)
+ }
+ },
+ )
+
+ // Loading overlay during initial page load
+ if (isInitialLoad && progress < 100) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background.copy(alpha = 0.8f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+
+ // Error overlay when page fails to load
+ if (loadError) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background.copy(alpha = 0.95f)),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.ErrorOutline,
+ contentDescription = "Error",
+ modifier =
+ Modifier
+ .size(64.dp)
+ .padding(bottom = 16.dp),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ Text(
+ text = "Failed to load page",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+ if (errorMessage.isNotBlank()) {
+ Text(
+ text = errorMessage,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
+ modifier = Modifier.padding(bottom = 24.dp),
+ )
+ }
+ Button(
+ onClick = {
+ loadError = false
+ errorMessage = ""
+ isInitialLoad = true
+ webView.reload()
+ },
+ ) {
+ Text("Retry")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Clean up WebView resources when composable leaves composition
+ DisposableEffect(Unit) {
+ onDispose {
+ try {
+ webView.stopLoading()
+ webView.clearHistory()
+ webView.removeAllViews()
+ webView.destroy()
+ } catch (_: Exception) {
+ }
+ }
+ }
+}
+
+@SuppressLint("SetJavaScriptEnabled")
+private fun configureWebView(
+ view: WebView,
+ onTitle: (String) -> Unit,
+ onProgress: (Int) -> Unit,
+ onExternalIntent: (Intent) -> Unit,
+ onError: (String) -> Unit = {},
+ onPageFinished: () -> Unit = {},
+) {
+ with(view.settings) {
+ // SECURITY: Disable JavaScript to prevent XSS attacks in legal documents
+ javaScriptEnabled = false
+ // SECURITY: Disable DOM storage to prevent credential/token theft
+ domStorageEnabled = false
+ @Suppress("DEPRECATION")
+ databaseEnabled = false
+ // SECURITY: Never allow mixed content (HTTP on HTTPS) to prevent MITM attacks
+ mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
+ cacheMode = WebSettings.LOAD_DEFAULT
+ builtInZoomControls = true
+ displayZoomControls = false
+ useWideViewPort = true
+ loadWithOverviewMode = true
+ // SECURITY: Disable multiple windows to prevent popup injection attacks
+ setSupportMultipleWindows(false)
+ // SECURITY: Require user gesture for media playback to prevent unwanted autoplay
+ mediaPlaybackRequiresUserGesture = true
+ }
+
+ // SECURITY: Only accept cookies from trusted legal content domains
+ CookieManager.getInstance().setAcceptCookie(false)
+
+ view.webViewClient =
+ object : WebViewClient() {
+ override fun shouldOverrideUrlLoading(
+ view: WebView?,
+ request: WebResourceRequest?,
+ ): Boolean {
+ val uri = request?.url ?: return false
+ val scheme = uri.scheme ?: ""
+ return handleUrl(view, uri, scheme, onExternalIntent)
+ }
+
+ override fun onPageFinished(
+ view: WebView?,
+ url: String?,
+ ) {
+ super.onPageFinished(view, url)
+ onPageFinished()
+ }
+
+ override fun onReceivedError(
+ view: WebView?,
+ request: WebResourceRequest?,
+ error: WebResourceError?,
+ ) {
+ super.onReceivedError(view, request, error)
+ // Only report errors for main-frame loads, ignore subresource failures
+ if (request?.isForMainFrame == true) {
+ val errorDesc = error?.description?.toString() ?: "Unknown error"
+ onError("Failed to load: $errorDesc")
+ }
+ }
+
+ override fun onReceivedHttpError(
+ view: WebView?,
+ request: WebResourceRequest?,
+ errorResponse: android.webkit.WebResourceResponse?,
+ ) {
+ super.onReceivedHttpError(view, request, errorResponse)
+ // Only report errors for main-frame loads, ignore subresource failures
+ if (request?.isForMainFrame == true) {
+ val statusCode = errorResponse?.statusCode ?: 0
+ val reason = errorResponse?.reasonPhrase ?: "Unknown error"
+ onError("HTTP Error $statusCode: $reason")
+ }
+ }
+
+ override fun onReceivedSslError(
+ view: WebView?,
+ handler: SslErrorHandler?,
+ error: android.net.http.SslError?,
+ ) {
+ super.onReceivedSslError(view, handler, error)
+ handler?.cancel()
+ val errorMsg =
+ when (error?.primaryError) {
+ android.net.http.SslError.SSL_EXPIRED -> "SSL certificate expired"
+ android.net.http.SslError.SSL_IDMISMATCH -> "SSL certificate hostname mismatch"
+ android.net.http.SslError.SSL_NOTYETVALID -> "SSL certificate not yet valid"
+ android.net.http.SslError.SSL_UNTRUSTED -> "SSL certificate not trusted"
+ else -> "SSL certificate error"
+ }
+ onError(errorMsg)
+ }
+ }
+
+ view.webChromeClient =
+ object : WebChromeClient() {
+ override fun onProgressChanged(
+ view: WebView?,
+ newProgress: Int,
+ ) {
+ super.onProgressChanged(view, newProgress)
+ onProgress(newProgress)
+ }
+
+ override fun onReceivedTitle(
+ view: WebView?,
+ title: String?,
+ ) {
+ super.onReceivedTitle(view, title)
+ if (!title.isNullOrBlank()) onTitle(title)
+ }
+ }
+}
+
+private fun handleUrl(
+ webView: WebView?,
+ uri: Uri,
+ scheme: String,
+ onExternalIntent: (Intent) -> Unit,
+): Boolean {
+ when (scheme.lowercase()) {
+ "http", "https" -> {
+ if (isWhitelistedUrl(uri)) {
+ webView?.loadUrl(uri.toString())
+ } else {
+ // Reject URLs from untrusted domains
+ Log.w("InAppWebView", "Blocked untrusted URL: $uri")
+ }
+ }
+ "tel", "mailto", "geo", "sms", "intent" -> {
+ onExternalIntent(Intent(Intent.ACTION_VIEW, uri))
+ }
+ else -> {
+ onExternalIntent(Intent(Intent.ACTION_VIEW, uri))
+ }
+ }
+ // Always return true to indicate we handled the URL loading
+ return true
+}
+
+/**
+ * Validate URL against whitelist of trusted domains.
+ * Only allows loading content from whitelisted legal document hosts.
+ */
+private fun isWhitelistedUrl(uri: Uri): Boolean {
+ val host = uri.host?.lowercase() ?: return false
+ val whitelistedDomains =
+ setOf(
+ "data.androidplay.in", // Terms, Privacy Policy
+ )
+ return whitelistedDomains.any { trustedDomain ->
+ host == trustedDomain || host.endsWith(".$trustedDomain")
+ }
+}
diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/auth/LoginScreen.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/auth/LoginScreen.kt
new file mode 100644
index 00000000..5137b27d
--- /dev/null
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/auth/LoginScreen.kt
@@ -0,0 +1,404 @@
+package bose.ankush.commonui.auth
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+
+// Multiplatform-safe email regex (replaces android.util.Patterns)
+private val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
+
+/**
+ * Login Screen composable that displays a login form with email and password fields,
+ * login/register toggle, and terms & conditions link.
+ *
+ * CMP-compatible: works on Android and iOS via Compose Multiplatform.
+ *
+ * @param onLoginClick Callback when the login button is clicked
+ * @param onRegisterClick Callback when the register button is clicked
+ * @param onWebUrlClick Callback when a web URL (terms/privacy) link is clicked
+ * @param isLoading Whether the screen is in loading state
+ */
+@Composable
+fun LoginScreen(
+ onLoginClick: (email: String, password: String) -> Unit,
+ onRegisterClick: (email: String, password: String) -> Unit,
+ onWebUrlClick: (url: String) -> Unit = {},
+ isLoading: Boolean = false,
+) {
+ var email by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var isPasswordVisible by remember { mutableStateOf(false) }
+ var isLoginMode by remember { mutableStateOf(true) }
+ var errorMessage by remember { mutableStateOf(null) }
+
+ var isTitleClicked by remember { mutableStateOf(false) }
+ var isSubtitleClicked by remember { mutableStateOf(false) }
+
+ val focusManager = LocalFocusManager.current
+
+ val isEmailValid = { input: String -> EMAIL_REGEX.matches(input) }
+ val isPasswordValid = { input: String -> input.length >= 6 }
+
+ val validateInputs = {
+ when {
+ email.isBlank() -> {
+ errorMessage = "Email cannot be empty"
+ false
+ }
+ !isEmailValid(email) -> {
+ errorMessage = "Please enter a valid email address"
+ false
+ }
+ password.isBlank() -> {
+ errorMessage = "Password cannot be empty"
+ false
+ }
+ !isPasswordValid(password) -> {
+ errorMessage = "Password must be at least 6 characters"
+ false
+ }
+ else -> {
+ errorMessage = null
+ true
+ }
+ }
+ }
+
+ val handleSubmit = {
+ if (!isLoading && validateInputs()) {
+ if (isLoginMode) onLoginClick(email, password) else onRegisterClick(email, password)
+ }
+ }
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .imePadding()
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ // Header
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 80.dp),
+ ) {
+ val titleScale by animateFloatAsState(
+ targetValue = if (isTitleClicked) 1.1f else 1.0f,
+ animationSpec = spring(dampingRatio = 0.4f, stiffness = 300f),
+ label = "titleScale",
+ )
+ val titleColor =
+ if (isTitleClicked) {
+ MaterialTheme.colorScheme.tertiary
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
+
+ Text(
+ text = if (isLoginMode) "Welcome Back" else "Create Account",
+ style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
+ color = titleColor,
+ textAlign = TextAlign.Start,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp)
+ .scale(titleScale)
+ .clickable { isTitleClicked = !isTitleClicked },
+ )
+
+ val subtitleScale by animateFloatAsState(
+ targetValue = if (isSubtitleClicked) 1.1f else 1.0f,
+ animationSpec =
+ tween(
+ durationMillis = 300,
+ easing = androidx.compose.animation.core.FastOutSlowInEasing,
+ ),
+ label = "subtitleScale",
+ )
+ val subtitleColor =
+ if (isSubtitleClicked) {
+ MaterialTheme.colorScheme.secondary
+ } else {
+ MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
+ }
+
+ Text(
+ text = if (isLoginMode) "Sign in to continue" else "Join our community",
+ style = MaterialTheme.typography.bodyLarge,
+ color = subtitleColor,
+ textAlign = TextAlign.Start,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .scale(subtitleScale)
+ .clickable { isSubtitleClicked = !isSubtitleClicked },
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Form
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ OutlinedTextField(
+ value = email,
+ onValueChange = {
+ email = it
+ errorMessage = null
+ },
+ label = { Text("Email address") },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ imeAction = ImeAction.Next,
+ ),
+ keyboardActions =
+ KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Down) },
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading,
+ shape = RoundedCornerShape(12.dp),
+ )
+
+ OutlinedTextField(
+ value = password,
+ onValueChange = {
+ password = it
+ errorMessage = null
+ },
+ label = { Text("Password") },
+ singleLine = true,
+ visualTransformation =
+ if (isPasswordVisible) {
+ VisualTransformation.None
+ } else {
+ PasswordVisualTransformation()
+ },
+ keyboardOptions =
+ KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions =
+ KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ handleSubmit()
+ },
+ ),
+ trailingIcon = {
+ TextButton(
+ onClick = { isPasswordVisible = !isPasswordVisible },
+ enabled = !isLoading,
+ contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
+ ) {
+ Text(
+ text = if (isPasswordVisible) "Hide" else "Show",
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading,
+ shape = RoundedCornerShape(12.dp),
+ )
+
+ if (errorMessage != null) {
+ Text(
+ text = errorMessage!!,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Button(
+ onClick = { handleSubmit() },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ enabled = !isLoading,
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ strokeWidth = 2.dp,
+ )
+ } else {
+ Text(
+ text = if (isLoginMode) "Sign In" else "Create Account",
+ style =
+ MaterialTheme.typography.titleMedium.copy(
+ fontWeight = FontWeight.Bold,
+ ),
+ )
+ }
+ }
+ }
+
+ // Footer
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ TextButton(
+ onClick = { isLoginMode = !isLoginMode },
+ enabled = !isLoading,
+ ) {
+ Text(
+ text =
+ if (isLoginMode) {
+ "Don't have an account? Register"
+ } else {
+ "Already registered? Login"
+ },
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+
+ val termsText =
+ buildAnnotatedString {
+ append("By continuing, you agree to our ")
+ pushStringAnnotation(tag = "terms", annotation = "terms")
+ withStyle(
+ style =
+ SpanStyle(
+ color = MaterialTheme.colorScheme.primary,
+ textDecoration = TextDecoration.Underline,
+ ),
+ ) { append("Terms & Conditions") }
+ pop()
+ append(" & ")
+ pushStringAnnotation(tag = "privacy", annotation = "privacy")
+ withStyle(
+ style =
+ SpanStyle(
+ color = MaterialTheme.colorScheme.primary,
+ textDecoration = TextDecoration.Underline,
+ ),
+ ) { append("Privacy Policy") }
+ pop()
+ }
+
+ var textLayoutResult by remember { mutableStateOf(null) }
+
+ BasicText(
+ text = termsText,
+ style =
+ MaterialTheme.typography.bodySmall.copy(
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
+ ),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp)
+ .pointerInput(isLoading) {
+ if (!isLoading) {
+ detectTapGestures { offsetPosition ->
+ textLayoutResult?.let { layoutResult ->
+ val offset =
+ layoutResult.getOffsetForPosition(offsetPosition)
+ termsText
+ .getStringAnnotations(
+ start = offset,
+ end = offset,
+ ).firstOrNull()
+ ?.let { annotation ->
+ when (annotation.tag) {
+ "terms" ->
+ onWebUrlClick(
+ "https://data.androidplay.in/wfy/terms-and-conditions",
+ )
+
+ "privacy" ->
+ onWebUrlClick(
+ "https://data.androidplay.in/wfy/privacy-policy",
+ )
+ }
+ }
+ }
+ }
+ }
+ },
+ onTextLayout = { textLayoutResult = it },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/NotificationToast.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/NotificationToast.kt
new file mode 100644
index 00000000..2689601e
--- /dev/null
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/NotificationToast.kt
@@ -0,0 +1,182 @@
+package bose.ankush.commonui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+
+enum class ToastType { SUCCESS, WARNING, ERROR }
+
+/**
+ * Holds the measured height of an anchor component (e.g. bottom nav bar) so
+ * [NotificationToast] can automatically position itself above it.
+ */
+@Stable
+class ToastAnchorState internal constructor(
+ private val density: Float,
+) {
+ var anchorHeight: Dp by mutableStateOf(0.dp)
+ internal set
+
+ internal fun updateHeight(heightPx: Int) {
+ anchorHeight = (heightPx / density).dp
+ }
+}
+
+@Composable
+fun rememberToastAnchorState(): ToastAnchorState {
+ val density = LocalDensity.current
+ return remember { ToastAnchorState(density.density) }
+}
+
+/**
+ * Attach to the component above which the toast should appear (e.g. bottom nav bar).
+ * Measures the component's height and reports it to [ToastAnchorState].
+ */
+fun Modifier.toastAnchor(state: ToastAnchorState): Modifier =
+ this.onSizeChanged { size -> state.updateHeight(size.height) }
+
+/**
+ * Multiplatform toast notification overlay.
+ *
+ * Displays an animated toast message at the bottom of the screen, auto-dismissing after
+ * [durationMillis]. Position can be adjusted via [anchorState] (measures a bottom component's
+ * height) or a manual [bottomOffset].
+ *
+ * Supports Android and iOS via Compose Multiplatform.
+ */
+@Composable
+fun NotificationToast(
+ modifier: Modifier = Modifier,
+ message: String,
+ title: String,
+ type: ToastType,
+ isVisible: Boolean,
+ onDismiss: () -> Unit,
+ durationMillis: Long = 3000,
+ bottomOffset: Dp = 0.dp,
+ anchorState: ToastAnchorState? = null,
+) {
+ LaunchedEffect(isVisible) {
+ if (isVisible) {
+ delay(durationMillis)
+ onDismiss()
+ }
+ }
+
+ val (backgroundColor, icon, iconColor) =
+ when (type) {
+ ToastType.SUCCESS ->
+ Triple(
+ MaterialTheme.colorScheme.primaryContainer,
+ Icons.Filled.CheckCircle,
+ MaterialTheme.colorScheme.primary,
+ )
+
+ ToastType.WARNING ->
+ Triple(
+ MaterialTheme.colorScheme.tertiaryContainer,
+ Icons.Filled.Warning,
+ MaterialTheme.colorScheme.tertiary,
+ )
+
+ ToastType.ERROR ->
+ Triple(
+ MaterialTheme.colorScheme.errorContainer,
+ Icons.Filled.Close,
+ MaterialTheme.colorScheme.error,
+ )
+ }
+
+ Box(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(bottom = 16.dp + (anchorState?.anchorHeight ?: bottomOffset)),
+ contentAlignment = Alignment.BottomCenter,
+ ) {
+ AnimatedVisibility(
+ visible = isVisible,
+ enter =
+ fadeIn(animationSpec = tween(300, easing = FastOutSlowInEasing)) +
+ slideInVertically(
+ animationSpec = tween(300, easing = FastOutSlowInEasing),
+ initialOffsetY = { it },
+ ),
+ exit =
+ fadeOut(animationSpec = tween(300, easing = FastOutSlowInEasing)) +
+ slideOutVertically(
+ animationSpec = tween(300, easing = FastOutSlowInEasing),
+ targetOffsetY = { it },
+ ),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(backgroundColor)
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = iconColor,
+ modifier = Modifier.size(24.dp),
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/ServiceSubscriptionBottomSheet.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/ServiceSubscriptionBottomSheet.kt
new file mode 100644
index 00000000..19ed7e94
--- /dev/null
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/ServiceSubscriptionBottomSheet.kt
@@ -0,0 +1,510 @@
+package bose.ankush.commonui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import bose.ankush.commonui.viewmodel.ServiceSubscriptionUiState
+import bose.ankush.network.model.Feature
+import bose.ankush.network.model.PricingTier
+import bose.ankush.network.model.Service
+
+@Composable
+fun ServiceSubscriptionBottomSheet(
+ uiState: ServiceSubscriptionUiState,
+ loadService: () -> Unit,
+ onServiceSelected: (Service) -> Unit,
+ onTierSelected: (PricingTier) -> Unit,
+ onDismiss: () -> Unit,
+ onSubscribe: (service: Service, tier: PricingTier) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LaunchedEffect(Unit) {
+ loadService()
+ }
+
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.7f)
+ .background(MaterialTheme.colorScheme.surface)
+ .navigationBarsPadding()
+ .imePadding()
+ .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)),
+ ) {
+ AnimatedVisibility(
+ visible = uiState.isLoading,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ ShimmerBottomSheetSkeleton(
+ modifier = Modifier.padding(bottom = 20.dp),
+ )
+ }
+
+ AnimatedVisibility(
+ visible = !uiState.isLoading && uiState.error != null,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ ErrorContent(
+ error = uiState.error ?: "Unknown error",
+ onDismiss = onDismiss,
+ onRetry = loadService,
+ )
+ }
+
+ AnimatedVisibility(
+ visible = !uiState.isLoading && uiState.services.isNotEmpty() && uiState.error == null,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .fillMaxHeight()
+ .verticalScroll(rememberScrollState()),
+ ) {
+ CloseButton(onClose = onDismiss)
+
+ if (uiState.selectedService != null && uiState.selectedTier != null) {
+ PlanHeader(
+ service = uiState.selectedService,
+ tier = uiState.selectedTier,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ ServiceSelector(
+ services = uiState.services,
+ selectedService = uiState.selectedService,
+ onServiceSelected = onServiceSelected,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ FeaturesSection(
+ features = uiState.selectedService.features,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ TierSelector(
+ service = uiState.selectedService,
+ selectedTier = uiState.selectedTier,
+ onTierSelected = onTierSelected,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Button(
+ onClick = {
+ onSubscribe(uiState.selectedService, uiState.selectedTier)
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .padding(horizontal = 24.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ ),
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ Text(
+ text = "Subscribe Now",
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = Color.White,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CloseButton(onClose: () -> Unit) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ IconButton(onClick = onClose, modifier = Modifier.size(40.dp)) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Close",
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+}
+
+@Composable
+private fun PlanHeader(
+ service: Service,
+ tier: PricingTier,
+) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = service.displayName,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = tier.getDisplayPrice(),
+ fontSize = 32.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+
+ Text(
+ text = "for ${tier.getDisplayDuration()}",
+ fontSize = 14.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+@Composable
+private fun ServiceSelector(
+ services: List,
+ selectedService: Service,
+ onServiceSelected: (Service) -> Unit,
+) {
+ if (services.size <= 1) return
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp),
+ ) {
+ Text(
+ text = "Select Plan",
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(bottom = 12.dp),
+ )
+
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ services.forEach { service ->
+ ServiceOptionCard(
+ service = service,
+ isSelected = service.id == selectedService.id,
+ onClick = { onServiceSelected(service) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ServiceOptionCard(
+ service: Service,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+) {
+ Box(
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(
+ if (isSelected) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ },
+ ).clickable(onClick = onClick)
+ .padding(12.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = service.displayName.take(10),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+
+ if (isSelected) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun FeaturesSection(features: List) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp),
+ ) {
+ Text(
+ text = "Features",
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+
+ features.take(8).forEach { feature ->
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.Top,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ tint = Color(0xFF4CAF50),
+ modifier =
+ Modifier
+ .size(16.dp)
+ .padding(top = 1.dp),
+ )
+
+ Text(
+ text = feature.description,
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f),
+ maxLines = 1,
+ )
+ }
+ }
+
+ if (features.size > 8) {
+ Text(
+ text = "+ ${features.size - 8} more features",
+ fontSize = 11.sp,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun TierSelector(
+ service: Service,
+ selectedTier: PricingTier,
+ onTierSelected: (PricingTier) -> Unit,
+) {
+ if (service.pricingTiers.size <= 1) return
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp),
+ ) {
+ Text(
+ text = "Select Duration",
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(bottom = 12.dp),
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ service.pricingTiers.forEach { tier ->
+ TierOption(
+ tier = tier,
+ isSelected = tier.id == selectedTier.id,
+ onClick = { onTierSelected(tier) },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TierOption(
+ tier: PricingTier,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier =
+ modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(
+ if (isSelected) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ },
+ ).clickable(onClick = onClick)
+ .padding(12.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = tier.getDisplayPrice(),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = tier.getDisplayDuration(),
+ fontSize = 11.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ErrorContent(
+ error: String,
+ onDismiss: () -> Unit,
+ onRetry: () -> Unit,
+) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .fillMaxHeight()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = "Oops!",
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.error,
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Text(
+ text = error,
+ fontSize = 14.sp,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = onRetry,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(48.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ ),
+ shape = RoundedCornerShape(8.dp),
+ ) {
+ Text("Retry")
+ }
+
+ Text(
+ text = "Cancel",
+ fontSize = 14.sp,
+ color = MaterialTheme.colorScheme.primary,
+ modifier =
+ Modifier
+ .clickable { onDismiss() }
+ .padding(vertical = 8.dp),
+ )
+ }
+}
diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/ShimmerEffect.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/ShimmerEffect.kt
new file mode 100644
index 00000000..0c0fe4f0
--- /dev/null
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/ShimmerEffect.kt
@@ -0,0 +1,182 @@
+package bose.ankush.commonui.components
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * KMP-compatible shimmer effect for loading states.
+ * Works seamlessly on Android and iOS.
+ */
+@Composable
+fun ShimmerEffect(
+ modifier: Modifier = Modifier,
+ height: Dp = 12.dp,
+ cornerRadius: Dp = 4.dp,
+ baseColor: Color = Color.LightGray.copy(alpha = 0.3f),
+ highlightColor: Color = Color.White.copy(alpha = 0.8f),
+) {
+ val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
+ val shimmerX =
+ infiniteTransition.animateFloat(
+ initialValue = -1000f,
+ targetValue = 1000f,
+ animationSpec =
+ infiniteRepeatable(
+ animation =
+ androidx.compose.animation.core.tween(
+ durationMillis = 1200,
+ easing = LinearEasing,
+ ),
+ ),
+ label = "shimmer_x",
+ )
+
+ val shimmerBrush =
+ Brush.linearGradient(
+ colors =
+ listOf(
+ baseColor,
+ highlightColor,
+ baseColor,
+ ),
+ start = Offset(shimmerX.value - 200f, 0f),
+ end = Offset(shimmerX.value + 200f, 0f),
+ )
+
+ Box(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .height(height)
+ .background(
+ brush = shimmerBrush,
+ shape = RoundedCornerShape(cornerRadius),
+ ),
+ )
+}
+
+/**
+ * Shimmer loading skeleton for bottom sheet content
+ */
+@Composable
+fun ShimmerBottomSheetSkeleton(
+ modifier: Modifier = Modifier,
+ baseColor: Color = Color.LightGray.copy(alpha = 0.3f),
+ highlightColor: Color = Color.White.copy(alpha = 0.8f),
+) {
+ androidx.compose.foundation.layout.Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 16.dp),
+ ) {
+ // Header
+ ShimmerEffect(
+ height = 28.dp,
+ cornerRadius = 6.dp,
+ baseColor = baseColor,
+ highlightColor = highlightColor,
+ modifier =
+ Modifier
+ .fillMaxWidth(0.6f)
+ .padding(bottom = 16.dp),
+ )
+
+ // Description lines
+ repeat(2) {
+ ShimmerEffect(
+ height = 14.dp,
+ cornerRadius = 4.dp,
+ baseColor = baseColor,
+ highlightColor = highlightColor,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp),
+ )
+ }
+
+ ShimmerEffect(
+ height = 14.dp,
+ cornerRadius = 4.dp,
+ baseColor = baseColor,
+ highlightColor = highlightColor,
+ modifier =
+ Modifier
+ .fillMaxWidth(0.7f)
+ .padding(bottom = 16.dp),
+ )
+
+ // Features section
+ ShimmerEffect(
+ height = 18.dp,
+ cornerRadius = 4.dp,
+ baseColor = baseColor,
+ highlightColor = highlightColor,
+ modifier =
+ Modifier
+ .fillMaxWidth(0.3f)
+ .padding(bottom = 12.dp),
+ )
+
+ repeat(3) {
+ ShimmerEffect(
+ height = 14.dp,
+ cornerRadius = 4.dp,
+ baseColor = baseColor,
+ highlightColor = highlightColor,
+ modifier =
+ Modifier
+ .fillMaxWidth(0.8f)
+ .padding(bottom = 10.dp),
+ )
+ }
+
+ // Pricing section
+ ShimmerEffect(
+ height = 20.dp,
+ cornerRadius = 6.dp,
+ baseColor = baseColor,
+ highlightColor = highlightColor,
+ modifier =
+ Modifier
+ .fillMaxWidth(0.4f)
+ .padding(top = 16.dp, bottom = 12.dp),
+ )
+
+ ShimmerEffect(
+ height = 14.dp,
+ cornerRadius = 4.dp,
+ baseColor = baseColor,
+ highlightColor = highlightColor,
+ modifier =
+ Modifier
+ .fillMaxWidth(0.5f)
+ .padding(bottom = 20.dp),
+ )
+
+ // Button
+ ShimmerEffect(
+ height = 48.dp,
+ cornerRadius = 8.dp,
+ baseColor = baseColor,
+ highlightColor = highlightColor,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/components/SunriseSunsetAnimation.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/SunriseSunsetAnimation.kt
similarity index 59%
rename from sunriseui/src/main/java/bose/ankush/sunriseui/components/SunriseSunsetAnimation.kt
rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/SunriseSunsetAnimation.kt
index dfef20a0..3b560792 100644
--- a/sunriseui/src/main/java/bose/ankush/sunriseui/components/SunriseSunsetAnimation.kt
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/SunriseSunsetAnimation.kt
@@ -1,4 +1,6 @@
-package bose.ankush.sunriseui.components
+@file:Suppress("ktlint:standard:max-line-length")
+
+package bose.ankush.commonui.components
/**
* Dynamic sunrise/sunset landscape animation that responds to real-time data.
@@ -31,10 +33,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
-import bose.ankush.sunriseui.constants.SunriseConstants
+import bose.ankush.commonui.constants.SunriseConstants
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@@ -48,25 +51,28 @@ fun SunriseSunsetCombinedAnimation(
sunriseTimestamp: Long?,
sunsetTimestamp: Long?,
currentTimestamp: Long,
- windDirection: Float = 225f
+ windDirection: Float = 225f,
) {
Box(
modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center
+ contentAlignment = Alignment.Center,
) {
if (sunriseTimestamp == null || sunsetTimestamp == null) {
Box(
- modifier = Modifier
- .fillMaxSize()
- .background(
- brush = Brush.verticalGradient(
- colors = SunriseConstants.Colors.DEFAULT_GRADIENT
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(
+ brush =
+ Brush.verticalGradient(
+ colors = SunriseConstants.Colors.DEFAULT_GRADIENT,
+ ),
+ shape =
+ RoundedCornerShape(
+ topStart = SunriseConstants.Dimensions.CORNER_RADIUS,
+ topEnd = SunriseConstants.Dimensions.CORNER_RADIUS,
+ ),
),
- shape = RoundedCornerShape(
- topStart = SunriseConstants.Dimensions.CORNER_RADIUS,
- topEnd = SunriseConstants.Dimensions.CORNER_RADIUS
- )
- )
)
return@Box
}
@@ -74,11 +80,12 @@ fun SunriseSunsetCombinedAnimation(
// Calculate the normalized position (0 to 1) based on current time
val dayDuration = sunsetTimestamp - sunriseTimestamp
val timeElapsed = currentTimestamp - sunriseTimestamp
- val normalizedTimePosition = if (dayDuration == 0L) {
- 0f // Safe default value if duration is zero
- } else {
- (timeElapsed.toFloat() / dayDuration).coerceIn(0f, 1f)
- }
+ val normalizedTimePosition =
+ if (dayDuration == 0L) {
+ 0f // Safe default value if duration is zero
+ } else {
+ (timeElapsed.toFloat() / dayDuration).coerceIn(0f, 1f)
+ }
val isBeforeSunrise = currentTimestamp < sunriseTimestamp
val isAfterSunset = currentTimestamp > sunsetTimestamp
@@ -91,18 +98,20 @@ fun SunriseSunsetCombinedAnimation(
val cloudDrift = remember { Animatable(0f) }
LaunchedEffect(Unit) {
if (!initialAnimationPlayed) {
- val targetProgress = when {
- isBeforeSunrise -> 0f
- isAfterSunset -> 1f
- else -> normalizedTimePosition
- }
+ val targetProgress =
+ when {
+ isBeforeSunrise -> 0f
+ isAfterSunset -> 1f
+ else -> normalizedTimePosition
+ }
animatedProgress.animateTo(
targetValue = targetProgress,
- animationSpec = tween(
- durationMillis = SunriseConstants.Durations.INITIAL_ANIMATION,
- easing = FastOutSlowInEasing
- )
+ animationSpec =
+ tween(
+ durationMillis = SunriseConstants.Durations.INITIAL_ANIMATION,
+ easing = FastOutSlowInEasing,
+ ),
)
initialAnimationPlayed = true
}
@@ -111,39 +120,45 @@ fun SunriseSunsetCombinedAnimation(
LaunchedEffect(Unit) {
starTwinkle.animateTo(
targetValue = 1f,
- animationSpec = infiniteRepeatable(
- animation = tween(
- durationMillis = SunriseConstants.Durations.STAR_TWINKLE,
- easing = EaseInOutCubic
+ animationSpec =
+ infiniteRepeatable(
+ animation =
+ tween(
+ durationMillis = SunriseConstants.Durations.STAR_TWINKLE,
+ easing = EaseInOutCubic,
+ ),
+ repeatMode = RepeatMode.Reverse,
),
- repeatMode = RepeatMode.Reverse
- )
)
}
LaunchedEffect(Unit) {
atmosphericGlow.animateTo(
targetValue = 1f,
- animationSpec = infiniteRepeatable(
- animation = tween(
- durationMillis = SunriseConstants.Durations.ATMOSPHERIC_GLOW,
- easing = EaseInOutCubic
+ animationSpec =
+ infiniteRepeatable(
+ animation =
+ tween(
+ durationMillis = SunriseConstants.Durations.ATMOSPHERIC_GLOW,
+ easing = EaseInOutCubic,
+ ),
+ repeatMode = RepeatMode.Reverse,
),
- repeatMode = RepeatMode.Reverse
- )
)
}
LaunchedEffect(Unit) {
cloudDrift.animateTo(
targetValue = 1f,
- animationSpec = infiniteRepeatable(
- animation = tween(
- durationMillis = SunriseConstants.Durations.CLOUD_DRIFT,
- easing = EaseInOutCubic
+ animationSpec =
+ infiniteRepeatable(
+ animation =
+ tween(
+ durationMillis = SunriseConstants.Durations.CLOUD_DRIFT,
+ easing = EaseInOutCubic,
+ ),
+ repeatMode = RepeatMode.Restart,
),
- repeatMode = RepeatMode.Restart
- )
)
}
@@ -151,27 +166,37 @@ fun SunriseSunsetCombinedAnimation(
val skyGradient = createSoothingSkyGradient(progress, isBeforeSunrise, isAfterSunset)
Box(
- modifier = Modifier
- .fillMaxSize()
- .background(
- brush = if (isNight) Brush.verticalGradient(colors = SunriseConstants.Colors.NIGHT_GRADIENT) else skyGradient,
- shape = RoundedCornerShape(
- topStart = SunriseConstants.Dimensions.CORNER_RADIUS,
- topEnd = SunriseConstants.Dimensions.CORNER_RADIUS
- )
- )
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(
+ brush =
+ if (isNight) {
+ Brush.verticalGradient(
+ colors = SunriseConstants.Colors.NIGHT_GRADIENT,
+ )
+ } else {
+ skyGradient
+ },
+ shape =
+ RoundedCornerShape(
+ topStart = SunriseConstants.Dimensions.CORNER_RADIUS,
+ topEnd = SunriseConstants.Dimensions.CORNER_RADIUS,
+ ),
+ ),
)
Canvas(
- modifier = Modifier
- .fillMaxSize()
+ modifier =
+ Modifier
+ .fillMaxSize(),
) {
val isDaytime = !isBeforeSunrise && !isAfterSunset
if (isNight) {
drawStarField(
twinkleIntensity = starTwinkle.value,
- isBeforeSunrise = isBeforeSunrise
+ isBeforeSunrise = isBeforeSunrise,
)
drawMoon(
@@ -179,7 +204,7 @@ fun SunriseSunsetCombinedAnimation(
atmosphericIntensity = atmosphericGlow.value,
currentTimestamp = currentTimestamp,
sunriseTimestamp = sunriseTimestamp,
- sunsetTimestamp = sunsetTimestamp
+ sunsetTimestamp = sunsetTimestamp,
)
}
@@ -189,7 +214,7 @@ fun SunriseSunsetCombinedAnimation(
atmosphericIntensity = atmosphericGlow.value,
currentTimestamp = currentTimestamp,
sunriseTimestamp = sunriseTimestamp,
- sunsetTimestamp = sunsetTimestamp
+ sunsetTimestamp = sunsetTimestamp,
)
}
@@ -211,13 +236,17 @@ fun SunriseSunsetCombinedAnimation(
* @param color2 Ending color (fraction = 1.0)
* @param fraction Interpolation factor, clamped to [0.0, 1.0]
*/
-private fun lerpColor(color1: Color, color2: Color, fraction: Float): Color {
+private fun lerpColor(
+ color1: Color,
+ color2: Color,
+ fraction: Float,
+): Color {
val clampedFraction = fraction.coerceIn(0f, 1f)
return Color(
red = color1.red + (color2.red - color1.red) * clampedFraction,
green = color1.green + (color2.green - color1.green) * clampedFraction,
blue = color1.blue + (color2.blue - color1.blue) * clampedFraction,
- alpha = color1.alpha + (color2.alpha - color1.alpha) * clampedFraction
+ alpha = color1.alpha + (color2.alpha - color1.alpha) * clampedFraction,
)
}
@@ -230,7 +259,7 @@ private fun lerpColor(color1: Color, color2: Color, fraction: Float): Color {
private fun createSoothingSkyGradient(
progress: Float,
isBeforeSunrise: Boolean,
- isAfterSunset: Boolean
+ isAfterSunset: Boolean,
): Brush {
val nightColors = SunriseConstants.Colors.NIGHT_GRADIENT
@@ -244,39 +273,42 @@ private fun createSoothingSkyGradient(
if (isAfterSunset) {
return Brush.verticalGradient(colors = nightColors)
}
- val interpolatedColors = when {
- progress <= SunriseConstants.TimeThresholds.DAWN_END -> {
- val transitionFactor =
- (progress / SunriseConstants.TimeThresholds.DAWN_END).coerceIn(0f, 1f)
- listOf(
- lerpColor(dawnColors[0], dayColors[0], transitionFactor),
- lerpColor(dawnColors[1], dayColors[1], transitionFactor),
- lerpColor(dawnColors[2], dayColors[2], transitionFactor),
- lerpColor(dawnColors[3], dayColors[3], transitionFactor)
- )
- }
+ val interpolatedColors =
+ when {
+ progress <= SunriseConstants.TimeThresholds.DAWN_END -> {
+ val transitionFactor =
+ (progress / SunriseConstants.TimeThresholds.DAWN_END).coerceIn(0f, 1f)
+ listOf(
+ lerpColor(dawnColors[0], dayColors[0], transitionFactor),
+ lerpColor(dawnColors[1], dayColors[1], transitionFactor),
+ lerpColor(dawnColors[2], dayColors[2], transitionFactor),
+ lerpColor(dawnColors[3], dayColors[3], transitionFactor),
+ )
+ }
- progress >= SunriseConstants.TimeThresholds.DUSK_START -> {
- val transitionFactor =
- ((progress - SunriseConstants.TimeThresholds.DUSK_START) / (1f - SunriseConstants.TimeThresholds.DUSK_START)).coerceIn(
- 0f,
- 1f
+ progress >= SunriseConstants.TimeThresholds.DUSK_START -> {
+ val transitionFactor =
+ (
+ (progress - SunriseConstants.TimeThresholds.DUSK_START) /
+ (1f - SunriseConstants.TimeThresholds.DUSK_START)
+ ).coerceIn(
+ 0f,
+ 1f,
+ )
+ listOf(
+ lerpColor(dayColors[0], duskColors[0], transitionFactor),
+ lerpColor(dayColors[1], duskColors[1], transitionFactor),
+ lerpColor(dayColors[2], duskColors[2], transitionFactor),
+ lerpColor(dayColors[3], duskColors[3], transitionFactor),
)
- listOf(
- lerpColor(dayColors[0], duskColors[0], transitionFactor),
- lerpColor(dayColors[1], duskColors[1], transitionFactor),
- lerpColor(dayColors[2], duskColors[2], transitionFactor),
- lerpColor(dayColors[3], duskColors[3], transitionFactor)
- )
- }
+ }
- else -> dayColors
- }
+ else -> dayColors
+ }
return Brush.verticalGradient(colors = interpolatedColors)
}
-
/**
* Renders twinkling stars across the night sky with varying opacity and size.
* @param twinkleIntensity Animation value (0.0-1.0) controlling twinkle effect
@@ -284,7 +316,7 @@ private fun createSoothingSkyGradient(
*/
private fun DrawScope.drawStarField(
twinkleIntensity: Float,
- isBeforeSunrise: Boolean
+ isBeforeSunrise: Boolean,
) {
val baseOpacity =
if (isBeforeSunrise) SunriseConstants.Opacity.STAR_BASE_BEFORE_SUNRISE else SunriseConstants.Opacity.STAR_BASE_AFTER_SUNSET
@@ -296,7 +328,8 @@ private fun DrawScope.drawStarField(
// Create twinkling effect
val twinkle =
- sin((twinkleIntensity * 2 * PI + index * 0.5).toFloat()) * SunriseConstants.Opacity.TWINKLE_VARIATION + SunriseConstants.Opacity.TWINKLE_BASE
+ sin((twinkleIntensity * 2 * PI + index * 0.5).toFloat()) * SunriseConstants.Opacity.TWINKLE_VARIATION +
+ SunriseConstants.Opacity.TWINKLE_BASE
val starOpacity = baseOpacity * twinkle
val starSize =
@@ -305,7 +338,7 @@ private fun DrawScope.drawStarField(
drawCircle(
color = SunriseConstants.Colors.STAR_COLOR.copy(alpha = starOpacity),
radius = starSize,
- center = androidx.compose.ui.geometry.Offset(x, y)
+ center = Offset(x, y),
)
}
}
@@ -323,7 +356,7 @@ private fun DrawScope.drawMoon(
atmosphericIntensity: Float,
currentTimestamp: Long,
sunriseTimestamp: Long?,
- sunsetTimestamp: Long?
+ sunsetTimestamp: Long?,
) {
val moonX: Float
val moonY: Float
@@ -334,50 +367,68 @@ private fun DrawScope.drawMoon(
val timeElapsed = currentTimestamp - (sunsetTimestamp - 24 * 3600)
val nightProgress = (timeElapsed.toFloat() / nightDuration).coerceIn(0f, 1f)
moonX =
- size.width * (SunriseConstants.Positioning.MOON_START_X - nightProgress * SunriseConstants.Positioning.MOON_TRAVEL_DISTANCE)
+ size.width *
+ (
+ SunriseConstants.Positioning.MOON_START_X -
+ nightProgress * SunriseConstants.Positioning.MOON_TRAVEL_DISTANCE
+ )
moonY =
- size.height * (SunriseConstants.Positioning.MOON_Y_VARIATION - (sin(nightProgress * PI).toFloat() * SunriseConstants.Positioning.MOON_Y_AMPLITUDE))
+ size.height *
+ (
+ SunriseConstants.Positioning.MOON_Y_VARIATION -
+ (sin(nightProgress * PI).toFloat() * SunriseConstants.Positioning.MOON_Y_AMPLITUDE)
+ )
} else {
val nextSunrise = sunriseTimestamp + 24 * 3600
val nightDuration = nextSunrise - sunsetTimestamp
val timeElapsed = currentTimestamp - sunsetTimestamp
val nightProgress = (timeElapsed.toFloat() / nightDuration).coerceIn(0f, 1f)
moonX =
- size.width * (SunriseConstants.Positioning.MOON_END_X + nightProgress * SunriseConstants.Positioning.MOON_TRAVEL_DISTANCE)
+ size.width *
+ (
+ SunriseConstants.Positioning.MOON_END_X +
+ nightProgress * SunriseConstants.Positioning.MOON_TRAVEL_DISTANCE
+ )
moonY =
- size.height * (SunriseConstants.Positioning.MOON_Y_VARIATION - (sin(nightProgress * PI).toFloat() * SunriseConstants.Positioning.MOON_Y_AMPLITUDE))
+ size.height *
+ (
+ SunriseConstants.Positioning.MOON_Y_VARIATION -
+ (sin(nightProgress * PI).toFloat() * SunriseConstants.Positioning.MOON_Y_AMPLITUDE)
+ )
}
} else {
- moonX = if (isBeforeSunrise) {
- size.width * SunriseConstants.Positioning.MOON_START_X
- } else {
- size.width * SunriseConstants.Positioning.MOON_END_X
- }
+ moonX =
+ if (isBeforeSunrise) {
+ size.width * SunriseConstants.Positioning.MOON_START_X
+ } else {
+ size.width * SunriseConstants.Positioning.MOON_END_X
+ }
moonY = size.height * SunriseConstants.Positioning.MOON_BASE_Y
}
val moonRadius =
- SunriseConstants.Dimensions.MOON_BASE_RADIUS + (SunriseConstants.Dimensions.MOON_RADIUS_VARIATION * atmosphericIntensity)
+ SunriseConstants.Dimensions.MOON_BASE_RADIUS +
+ (SunriseConstants.Dimensions.MOON_RADIUS_VARIATION * atmosphericIntensity)
val moonOpacity =
SunriseConstants.Opacity.MOON_BASE + (SunriseConstants.Opacity.MOON_VARIATION * atmosphericIntensity)
drawCircle(
color = SunriseConstants.Colors.MOON_COLOR.copy(alpha = moonOpacity * 0.3f),
radius = moonRadius * 1.5f,
- center = androidx.compose.ui.geometry.Offset(moonX, moonY)
+ center = Offset(moonX, moonY),
)
drawCircle(
color = SunriseConstants.Colors.MOON_COLOR.copy(alpha = moonOpacity),
radius = moonRadius,
- center = androidx.compose.ui.geometry.Offset(moonX, moonY)
+ center = Offset(moonX, moonY),
)
val phaseOffset = moonRadius * 0.3f
drawCircle(
color = SunriseConstants.Colors.MOON_PHASE_COLOR.copy(alpha = 0.2f),
radius = moonRadius * 0.8f,
- center = androidx.compose.ui.geometry.Offset(moonX + phaseOffset, moonY)
+ center = Offset(moonX + phaseOffset, moonY),
)
}
@@ -394,39 +445,46 @@ private fun DrawScope.drawSun(
atmosphericIntensity: Float,
currentTimestamp: Long,
sunriseTimestamp: Long,
- sunsetTimestamp: Long
+ sunsetTimestamp: Long,
) {
val dayDuration = sunsetTimestamp - sunriseTimestamp
val timeElapsed = currentTimestamp - sunriseTimestamp
val timeProgress = (timeElapsed.toFloat() / dayDuration).coerceIn(0f, 1f)
val sunX =
- size.width * (SunriseConstants.Positioning.SUN_START_X + timeProgress * SunriseConstants.Positioning.SUN_TRAVEL_DISTANCE)
+ size.width *
+ (SunriseConstants.Positioning.SUN_START_X + timeProgress * SunriseConstants.Positioning.SUN_TRAVEL_DISTANCE)
val sunY =
- size.height * (SunriseConstants.Positioning.SUN_BASE_Y - (sin(timeProgress * PI).toFloat() * SunriseConstants.Positioning.SUN_Y_AMPLITUDE))
+ size.height *
+ (
+ SunriseConstants.Positioning.SUN_BASE_Y -
+ (sin(timeProgress * PI).toFloat() * SunriseConstants.Positioning.SUN_Y_AMPLITUDE)
+ )
val sunRadius =
- SunriseConstants.Dimensions.SUN_BASE_RADIUS + (SunriseConstants.Dimensions.SUN_RADIUS_VARIATION * atmosphericIntensity)
+ SunriseConstants.Dimensions.SUN_BASE_RADIUS +
+ (SunriseConstants.Dimensions.SUN_RADIUS_VARIATION * atmosphericIntensity)
val sunOpacity =
SunriseConstants.Opacity.SUN_BASE + (SunriseConstants.Opacity.SUN_VARIATION * atmosphericIntensity)
- val sunColor = when {
- timeProgress < SunriseConstants.TimeThresholds.SUN_MORNING_END -> SunriseConstants.Colors.SUN_EARLY_MORNING
- timeProgress < SunriseConstants.TimeThresholds.SUN_MIDMORNING_END -> SunriseConstants.Colors.SUN_MORNING
- timeProgress < SunriseConstants.TimeThresholds.SUN_EVENING_START -> SunriseConstants.Colors.SUN_MIDDAY
- timeProgress < SunriseConstants.TimeThresholds.SUN_LATE_EVENING_START -> SunriseConstants.Colors.SUN_EVENING
- else -> SunriseConstants.Colors.SUN_LATE_EVENING
- }
+ val sunColor =
+ when {
+ timeProgress < SunriseConstants.TimeThresholds.SUN_MORNING_END -> SunriseConstants.Colors.SUN_EARLY_MORNING
+ timeProgress < SunriseConstants.TimeThresholds.SUN_MIDMORNING_END -> SunriseConstants.Colors.SUN_MORNING
+ timeProgress < SunriseConstants.TimeThresholds.SUN_EVENING_START -> SunriseConstants.Colors.SUN_MIDDAY
+ timeProgress < SunriseConstants.TimeThresholds.SUN_LATE_EVENING_START -> SunriseConstants.Colors.SUN_EVENING
+ else -> SunriseConstants.Colors.SUN_LATE_EVENING
+ }
drawCircle(
color = sunColor.copy(alpha = sunOpacity * 0.3f),
radius = sunRadius * 1.8f,
- center = androidx.compose.ui.geometry.Offset(sunX, sunY)
+ center = Offset(sunX, sunY),
)
drawCircle(
color = sunColor.copy(alpha = sunOpacity),
radius = sunRadius,
- center = androidx.compose.ui.geometry.Offset(sunX, sunY)
+ center = Offset(sunX, sunY),
)
val rayCount = SunriseConstants.Counts.SUN_RAY_COUNT
@@ -445,9 +503,9 @@ private fun DrawScope.drawSun(
drawLine(
color = sunColor.copy(alpha = sunOpacity * 0.6f),
- start = androidx.compose.ui.geometry.Offset(startX, startY),
- end = androidx.compose.ui.geometry.Offset(endX, endY),
- strokeWidth = rayWidth
+ start = Offset(startX, startY),
+ end = Offset(endX, endY),
+ strokeWidth = rayWidth,
)
}
}
@@ -464,16 +522,20 @@ private fun DrawScope.drawClouds(
windDirection: Float,
) {
val cloudCount = SunriseConstants.Counts.CLOUD_COUNT
- val cloudColor = when {
- progress <= SunriseConstants.TimeThresholds.DAWN_END -> SunriseConstants.Colors.CLOUD_DAWN_COLOR
- progress >= SunriseConstants.TimeThresholds.DUSK_START -> SunriseConstants.Colors.CLOUD_DUSK_COLOR
- else -> SunriseConstants.Colors.CLOUD_DAY_COLOR
- }
+ val cloudColor =
+ when {
+ progress <= SunriseConstants.TimeThresholds.DAWN_END -> SunriseConstants.Colors.CLOUD_DAWN_COLOR
+ progress >= SunriseConstants.TimeThresholds.DUSK_START -> SunriseConstants.Colors.CLOUD_DUSK_COLOR
+ else -> SunriseConstants.Colors.CLOUD_DAY_COLOR
+ }
val baseOpacity =
- SunriseConstants.Opacity.CLOUD_BASE + (SunriseConstants.Opacity.CLOUD_VARIATION * sin(
- cloudDriftProgress * PI
- ).toFloat())
+ SunriseConstants.Opacity.CLOUD_BASE + (
+ SunriseConstants.Opacity.CLOUD_VARIATION *
+ sin(
+ cloudDriftProgress * PI,
+ ).toFloat()
+ )
// Calculate wind influence on cloud movement
val windInfluenceX =
@@ -485,7 +547,8 @@ private fun DrawScope.drawClouds(
val baseX =
(i * SunriseConstants.Positioning.CLOUD_SPACING_X + cloudDriftProgress * windInfluenceX) % 1.2f - 0.1f
val baseY =
- SunriseConstants.Positioning.CLOUD_BASE_Y + (i % 2) * SunriseConstants.Positioning.CLOUD_Y_VARIATION + windInfluenceY
+ SunriseConstants.Positioning.CLOUD_BASE_Y + (i % 2) * SunriseConstants.Positioning.CLOUD_Y_VARIATION +
+ windInfluenceY
val cloudX = size.width * baseX
val cloudY = size.height * baseY
@@ -503,7 +566,7 @@ private fun DrawScope.drawClouds(
drawCircle(
color = cloudColor.copy(alpha = baseOpacity * 0.8f),
radius = puffSize,
- center = androidx.compose.ui.geometry.Offset(puffX, puffY)
+ center = Offset(puffX, puffY),
)
}
}
diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherAlertCard.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherAlertCard.kt
new file mode 100644
index 00000000..24dd98f2
--- /dev/null
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherAlertCard.kt
@@ -0,0 +1,352 @@
+package bose.ankush.commonui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import kotlinx.datetime.Instant
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+
+/**
+ * A composable that displays weather alert information.
+ * Shows a summarized alert by default and can be expanded to show more details.
+ */
+@Composable
+fun WeatherAlertCard(
+ title: String?,
+ description: String?,
+ startTime: Long?,
+ endTime: Long?,
+ source: String?,
+ onReadMoreClick: (() -> Unit)? = null,
+ initiallyExpanded: Boolean = false,
+) {
+ // Skip rendering if essential data is missing
+ if (title.isNullOrEmpty() || description.isNullOrEmpty()) return
+
+ // State and calculated values
+ var isExpanded by remember { mutableStateOf(initiallyExpanded) }
+
+ // Define colors
+ val primaryColor = MaterialTheme.colorScheme.error
+ val textColor = MaterialTheme.colorScheme.onErrorContainer
+ val accentColor = primaryColor.copy(alpha = 0.8f)
+ val surfaceColor = textColor.copy(alpha = 0.07f)
+ val subtleTextColor = textColor.copy(alpha = 0.7f)
+
+ // Create colors object
+ val colors =
+ AlertCardColors(
+ primaryColor = primaryColor,
+ textColor = textColor,
+ accentColor = accentColor,
+ surfaceColor = surfaceColor,
+ subtleTextColor = subtleTextColor,
+ )
+
+ // Format timestamps
+ val formattedStartTime =
+ remember(startTime) {
+ startTime?.let { formatTimestamp(it) } ?: "Unknown"
+ }
+ val formattedEndTime =
+ remember(endTime) {
+ endTime?.let { formatTimestamp(it) } ?: "Unknown"
+ }
+
+ // Create short description
+ val shortDescription =
+ remember(description) {
+ if (description.length > 100) description.take(100) + "..." else description
+ }
+
+ // Animation spec for content size changes
+ val contentSizeAnimSpec =
+ spring(
+ dampingRatio = Spring.DampingRatioLowBouncy,
+ stiffness = Spring.StiffnessMediumLow,
+ )
+
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ .animateContentSize(animationSpec = contentSizeAnimSpec),
+ shape = RoundedCornerShape(20.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
+ horizontalAlignment = Alignment.Start,
+ ) {
+ // Header section with icon, title and timestamp
+ AlertHeader(
+ title = title,
+ timestamp = formattedStartTime,
+ colors = colors,
+ )
+
+ // Report section with expandable description
+ AlertReportSection(
+ description = description,
+ shortDescription = shortDescription,
+ isExpanded = isExpanded,
+ colors = colors,
+ onToggleExpanded = {
+ isExpanded = !isExpanded
+ if (isExpanded && onReadMoreClick != null) {
+ onReadMoreClick()
+ }
+ },
+ )
+
+ // Expanded content with source and validity
+ AnimatedVisibility(
+ visible = isExpanded,
+ enter =
+ fadeIn(tween(300, easing = FastOutSlowInEasing)) +
+ expandVertically(tween(350, easing = FastOutSlowInEasing)),
+ exit =
+ fadeOut(tween(200)) +
+ shrinkVertically(tween(250)),
+ ) {
+ Column(modifier = Modifier.padding(top = 16.dp)) {
+ // Source information
+ AlertInfoSection(
+ title = "Source",
+ content = source ?: "Unknown",
+ colors = colors,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Validity information
+ AlertInfoSection(
+ title = "Valid Until",
+ content = formattedEndTime,
+ colors = colors,
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Header section of the alert card with icon, title and timestamp
+ */
+@Composable
+private fun AlertHeader(
+ title: String?,
+ timestamp: String,
+ colors: AlertCardColors,
+) {
+ // Alert Icon
+ Icon(
+ imageVector = Icons.Filled.Warning,
+ contentDescription = "Weather Alert Icon",
+ tint = colors.primaryColor,
+ modifier =
+ Modifier
+ .size(32.dp)
+ .padding(bottom = 12.dp),
+ )
+
+ // Title
+ Text(
+ text = title ?: "Weather Alert",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = colors.textColor,
+ modifier = Modifier.padding(bottom = 4.dp),
+ )
+
+ // Timestamp
+ Text(
+ text = "Issued: $timestamp",
+ style = MaterialTheme.typography.bodySmall,
+ color = colors.subtleTextColor,
+ modifier = Modifier.padding(bottom = 16.dp),
+ )
+}
+
+/**
+ * Report section with expandable description and read more/less button
+ */
+@Composable
+private fun AlertReportSection(
+ description: String,
+ shortDescription: String,
+ isExpanded: Boolean,
+ colors: AlertCardColors,
+ onToggleExpanded: () -> Unit,
+) {
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = colors.surfaceColor,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ // Report label
+ Text(
+ text = "Report",
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = colors.accentColor,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+
+ // Description text
+ Text(
+ text = if (isExpanded) description else shortDescription,
+ style = MaterialTheme.typography.bodyMedium,
+ color = colors.textColor,
+ lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2f,
+ overflow = if (isExpanded) TextOverflow.Visible else TextOverflow.Ellipsis,
+ maxLines = if (isExpanded) Int.MAX_VALUE else 3,
+ modifier =
+ Modifier.semantics {
+ contentDescription = "Alert description: $description"
+ },
+ )
+
+ // Read more/less button
+ val readMoreText = if (isExpanded) "Read less" else "Read more"
+ val buttonAlpha by animateFloatAsState(
+ targetValue = 1f,
+ animationSpec = tween(300, easing = FastOutSlowInEasing),
+ label = "Button Alpha",
+ )
+
+ TextButton(
+ onClick = onToggleExpanded,
+ modifier =
+ Modifier
+ .align(Alignment.End)
+ .padding(top = 4.dp)
+ .alpha(buttonAlpha)
+ .semantics {
+ contentDescription =
+ if (isExpanded) {
+ "Read less about this alert"
+ } else {
+ "Read more about this alert"
+ }
+ },
+ ) {
+ Text(
+ text = readMoreText,
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Medium,
+ color = colors.primaryColor,
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Reusable section for displaying information with a title and content
+ */
+@Composable
+private fun AlertInfoSection(
+ title: String,
+ content: String,
+ colors: AlertCardColors,
+) {
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = colors.surfaceColor,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = colors.accentColor,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+
+ Text(
+ text = content,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = colors.textColor,
+ )
+ }
+ }
+}
+
+/**
+ * Data class to hold color values for the alert card
+ */
+private data class AlertCardColors(
+ val primaryColor: Color,
+ val textColor: Color,
+ val accentColor: Color,
+ val surfaceColor: Color,
+ val subtleTextColor: Color,
+)
+
+/**
+ * Formats a timestamp into a readable date and time string.
+ */
+private fun formatTimestamp(timestamp: Long): String {
+ val instant = Instant.fromEpochSeconds(timestamp)
+ val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
+ val month = localDateTime.month.name.take(3)
+ val day = localDateTime.dayOfMonth
+ val hour12 =
+ when {
+ localDateTime.hour == 0 -> 12
+ localDateTime.hour > 12 -> localDateTime.hour - 12
+ else -> localDateTime.hour
+ }
+ val minute = localDateTime.minute.toString().padStart(2, '0')
+ val amPm = if (localDateTime.hour < 12) "AM" else "PM"
+ return "$month $day, $hour12:$minute $amPm"
+}
diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherCondition.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherCondition.kt
similarity index 93%
rename from sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherCondition.kt
rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherCondition.kt
index 7fcb8507..2ce5968f 100644
--- a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherCondition.kt
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherCondition.kt
@@ -1,6 +1,8 @@
-package bose.ankush.sunriseui.components
+package bose.ankush.commonui.components
-enum class WeatherCondition(val description: String) {
+enum class WeatherCondition(
+ val description: String,
+) {
// Group 2xx: Thunderstorm
THUNDERSTORM_WITH_LIGHT_RAIN("thunderstorm with light rain"),
THUNDERSTORM_WITH_RAIN("thunderstorm with rain"),
@@ -68,7 +70,8 @@ enum class WeatherCondition(val description: String) {
FEW_CLOUDS("few clouds: 11-25%"),
SCATTERED_CLOUDS("scattered clouds: 25-50%"),
BROKEN_CLOUDS("broken clouds: 51-84%"),
- OVERCAST_CLOUDS("overcast clouds: 85-100%");
+ OVERCAST_CLOUDS("overcast clouds: 85-100%"),
+ ;
override fun toString(): String = description
-}
\ No newline at end of file
+}
diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherDayCard.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherDayCard.kt
similarity index 84%
rename from sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherDayCard.kt
rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherDayCard.kt
index cf63bee6..bc4732f5 100644
--- a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherDayCard.kt
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherDayCard.kt
@@ -1,4 +1,4 @@
-package bose.ankush.sunriseui.components
+package bose.ankush.commonui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -38,26 +38,29 @@ fun WeatherDayCard(
minTemperature: String,
maxTemperature: String,
weatherDescription: String? = null,
- iconContent: @Composable () -> Unit
+ iconContent: @Composable () -> Unit,
) {
Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
- )
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp),
+ ),
) {
Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(20.dp)
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier.fillMaxWidth(),
) {
// Day name
Text(
@@ -66,16 +69,16 @@ fun WeatherDayCard(
fontWeight = FontWeight.Medium,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.weight(1f)
+ modifier = Modifier.weight(1f),
)
// Temperature range
Column(
horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier.weight(1f)
+ modifier = Modifier.weight(1f),
) {
Row(
- verticalAlignment = Alignment.CenterVertically
+ verticalAlignment = Alignment.CenterVertically,
) {
// Min temperature
Text(
@@ -103,7 +106,7 @@ fun WeatherDayCard(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
maxLines = 1,
- overflow = TextOverflow.Ellipsis
+ overflow = TextOverflow.Ellipsis,
)
}
}
@@ -112,11 +115,11 @@ fun WeatherDayCard(
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
- modifier = Modifier.size(48.dp)
+ modifier = Modifier.size(48.dp),
) {
iconContent()
}
}
}
}
-}
\ No newline at end of file
+}
diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherHourCard.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherHourCard.kt
similarity index 83%
rename from sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherHourCard.kt
rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherHourCard.kt
index 85955b78..97f94455 100644
--- a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherHourCard.kt
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherHourCard.kt
@@ -1,4 +1,4 @@
-package bose.ankush.sunriseui.components
+package bose.ankush.commonui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -39,24 +39,25 @@ fun WeatherHourCard(
weatherDescription: String? = null,
isSelected: Boolean = false,
onClick: () -> Unit = {},
- iconContent: @Composable () -> Unit
+ iconContent: @Composable () -> Unit,
) {
// Pre-calculate background colors
val selectedBackground = MaterialTheme.colorScheme.primaryContainer
val unselectedBackground = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
-
+
Box(
- modifier = Modifier
- .padding(horizontal = 8.dp)
- .clip(RoundedCornerShape(16.dp))
- .clickable(onClick = onClick)
- .background(if (isSelected) selectedBackground else unselectedBackground)
- .padding(horizontal = 10.dp, vertical = 20.dp)
+ modifier =
+ Modifier
+ .padding(horizontal = 8.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .clickable(onClick = onClick)
+ .background(if (isSelected) selectedBackground else unselectedBackground)
+ .padding(horizontal = 10.dp, vertical = 20.dp),
) {
Column(
modifier = Modifier.width(IntrinsicSize.Max),
verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
+ horizontalAlignment = Alignment.CenterHorizontally,
) {
// Time
Text(
@@ -76,7 +77,7 @@ fun WeatherHourCard(
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.padding(top = 16.dp)
+ modifier = Modifier.padding(top = 16.dp),
)
// Weather description (if available)
@@ -87,9 +88,9 @@ fun WeatherHourCard(
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.alpha(0.6f),
- textAlign = TextAlign.Center
+ textAlign = TextAlign.Center,
)
}
}
}
-}
\ No newline at end of file
+}
diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherIcon.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherIcon.kt
new file mode 100644
index 00000000..8888d3b9
--- /dev/null
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherIcon.kt
@@ -0,0 +1,449 @@
+package bose.ankush.commonui.components
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.EaseInOutCubic
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import bose.ankush.commonui.constants.WeatherIconConstants
+
+/**
+ * Holds color values for weather icons that adapt to light/dark theme.
+ */
+class WeatherIconColors(
+ val sunColor: Color,
+ val sunGlowColor: Color,
+ val cloudColor: Color,
+ val rainColor: Color,
+ val snowColor: Color,
+ val thunderColor: Color,
+ val fogColor: Color,
+) {
+ companion object {
+ /**
+ * Creates theme-aware colors for weather icons.
+ */
+ @Composable
+ fun default(isDarkTheme: Boolean = isSystemInDarkTheme()): WeatherIconColors {
+ // Use a try-catch to handle cases where MaterialTheme is not available
+ val sunColor =
+ try {
+ if (isDarkTheme) Color(0xFFFFD700) else Color(0xFFFF9800)
+ } catch (_: Exception) {
+ Color(0xFFFF9800) // Default fallback
+ }
+
+ val sunGlowColor =
+ try {
+ if (isDarkTheme) {
+ Color(0xFFFFD700).copy(alpha = WeatherIconConstants.SUN_GLOW_ALPHA)
+ } else {
+ Color(0xFFFF9800).copy(alpha = WeatherIconConstants.SUN_GLOW_ALPHA)
+ }
+ } catch (_: Exception) {
+ Color(0xFFFF9800).copy(alpha = WeatherIconConstants.SUN_GLOW_ALPHA) // Default fallback
+ }
+
+ return WeatherIconColors(
+ sunColor = sunColor,
+ sunGlowColor = sunGlowColor,
+ cloudColor = if (isDarkTheme) Color.White.copy(alpha = 0.9f) else Color.White,
+ rainColor = if (isDarkTheme) Color(0xFF64B5F6) else Color(0xFF2196F3),
+ snowColor = if (isDarkTheme) Color.White else Color.White.copy(alpha = 0.9f),
+ thunderColor = if (isDarkTheme) Color(0xFFFFEB3B) else Color(0xFFFFC107),
+ fogColor =
+ if (isDarkTheme) {
+ Color.LightGray.copy(alpha = 0.7f)
+ } else {
+ Color.Gray.copy(
+ alpha = 0.5f,
+ )
+ },
+ )
+ }
+ }
+}
+
+/**
+ * A composable that displays an animated weather icon based on the weather description.
+ * Maps the description to the appropriate WeatherCondition and renders the corresponding animation.
+ * Optimized for performance and supports dark mode.
+ *
+ * @param weatherDescription The description of the weather condition
+ * @param modifier Modifier to be applied to the icon
+ * @param colors Theme-aware colors for the weather icons
+ */
+@Composable
+fun AnimatedWeatherIcon(
+ weatherDescription: String?,
+ modifier: Modifier = Modifier.size(48.dp),
+ colors: WeatherIconColors = WeatherIconColors.default(),
+) {
+ // Map the weather description to a WeatherCondition
+ val weatherCondition =
+ remember(weatherDescription) {
+ mapToWeatherCondition(weatherDescription)
+ }
+
+ // Create a content description for accessibility
+ val contentDesc =
+ remember(weatherCondition) {
+ "Weather icon: ${weatherCondition.description}"
+ }
+
+ // Determine which animations are needed based on weather condition
+ val needsSunAnimation =
+ remember(weatherCondition) {
+ weatherCondition == WeatherCondition.CLEAR_SKY ||
+ weatherCondition == WeatherCondition.FEW_CLOUDS
+ }
+
+ val needsCloudAnimation =
+ remember(weatherCondition) {
+ weatherCondition in
+ listOf(
+ WeatherCondition.FEW_CLOUDS,
+ WeatherCondition.SCATTERED_CLOUDS,
+ WeatherCondition.BROKEN_CLOUDS,
+ WeatherCondition.OVERCAST_CLOUDS,
+ ) || weatherCondition.description.contains("rain") ||
+ weatherCondition.description.contains("drizzle") ||
+ weatherCondition.description.contains("snow") ||
+ weatherCondition.description.contains("thunderstorm")
+ }
+
+ val needsRainAnimation =
+ remember(weatherCondition) {
+ weatherCondition.description.contains("rain") ||
+ weatherCondition.description.contains("drizzle") ||
+ weatherCondition.description.contains("thunderstorm")
+ }
+
+ val needsSnowAnimation =
+ remember(weatherCondition) {
+ weatherCondition.description.contains("snow") ||
+ weatherCondition.description.contains("sleet")
+ }
+
+ val needsThunderAnimation =
+ remember(weatherCondition) {
+ weatherCondition.description.contains("thunderstorm")
+ }
+
+ val needsFogAnimation =
+ remember(weatherCondition) {
+ weatherCondition in
+ listOf(
+ WeatherCondition.MIST,
+ WeatherCondition.SMOKE,
+ WeatherCondition.HAZE,
+ WeatherCondition.SAND_DUST_WHIRLS,
+ WeatherCondition.FOG,
+ WeatherCondition.SAND,
+ WeatherCondition.DUST,
+ WeatherCondition.VOLCANIC_ASH,
+ WeatherCondition.SQUALLS,
+ WeatherCondition.TORNADO,
+ )
+ }
+
+ // Animation specs - define once to use as keys in LaunchedEffect
+ val sunAnimSpec =
+ remember {
+ infiniteRepeatable(
+ animation =
+ tween(
+ durationMillis = WeatherIconConstants.SUN_ANIMATION_DURATION,
+ easing = EaseInOutCubic,
+ ),
+ repeatMode = RepeatMode.Reverse,
+ )
+ }
+
+ val cloudAnimSpec =
+ remember {
+ infiniteRepeatable(
+ animation =
+ tween(
+ durationMillis = WeatherIconConstants.CLOUD_ANIMATION_DURATION,
+ easing = EaseInOutCubic,
+ ),
+ repeatMode = RepeatMode.Restart,
+ )
+ }
+
+ val rainAnimSpec =
+ remember {
+ infiniteRepeatable(
+ animation =
+ tween(
+ durationMillis = WeatherIconConstants.RAIN_ANIMATION_DURATION,
+ easing = LinearEasing,
+ ),
+ repeatMode = RepeatMode.Restart,
+ )
+ }
+
+ val snowAnimSpec =
+ remember {
+ infiniteRepeatable(
+ animation =
+ tween(
+ durationMillis = WeatherIconConstants.SNOW_ANIMATION_DURATION,
+ easing = LinearEasing,
+ ),
+ repeatMode = RepeatMode.Restart,
+ )
+ }
+
+ val thunderAnimSpec =
+ remember {
+ infiniteRepeatable(
+ animation =
+ tween(
+ durationMillis = WeatherIconConstants.THUNDER_ANIMATION_DURATION,
+ easing = FastOutSlowInEasing,
+ ),
+ repeatMode = RepeatMode.Restart,
+ )
+ }
+
+ // Animation states - only initialize what's needed
+ val sunGlow = remember { Animatable(0f) }
+ val cloudDrift = remember { Animatable(0f) }
+ val rainDrop = remember { Animatable(0f) }
+ val snowFall = remember { Animatable(0f) }
+ val thunderFlash = remember { Animatable(0f) }
+
+ // Start animations only if needed, with proper keys to restart when specs change
+ if (needsSunAnimation) {
+ LaunchedEffect(weatherCondition, sunAnimSpec) {
+ sunGlow.animateTo(
+ targetValue = 1f,
+ animationSpec = sunAnimSpec,
+ )
+ }
+ }
+
+ if (needsCloudAnimation || needsFogAnimation) {
+ LaunchedEffect(weatherCondition, cloudAnimSpec) {
+ cloudDrift.animateTo(
+ targetValue = 1f,
+ animationSpec = cloudAnimSpec,
+ )
+ }
+ }
+
+ if (needsRainAnimation) {
+ LaunchedEffect(weatherCondition, rainAnimSpec) {
+ rainDrop.animateTo(
+ targetValue = 1f,
+ animationSpec = rainAnimSpec,
+ )
+ }
+ }
+
+ if (needsSnowAnimation) {
+ LaunchedEffect(weatherCondition, snowAnimSpec) {
+ snowFall.animateTo(
+ targetValue = 1f,
+ animationSpec = snowAnimSpec,
+ )
+ }
+ }
+
+ if (needsThunderAnimation) {
+ LaunchedEffect(weatherCondition, thunderAnimSpec) {
+ thunderFlash.animateTo(
+ targetValue = 1f,
+ animationSpec = thunderAnimSpec,
+ )
+ }
+ }
+
+ Box(
+ modifier =
+ modifier.semantics {
+ contentDescription = contentDesc
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Canvas(modifier = Modifier.matchParentSize()) {
+ when {
+ // Clear sky
+ weatherCondition == WeatherCondition.CLEAR_SKY -> {
+ drawSun(
+ animationProgress = sunGlow.value,
+ sunColor = colors.sunColor,
+ sunGlowColor = colors.sunGlowColor,
+ )
+ }
+
+ // Clouds
+ weatherCondition in
+ listOf(
+ WeatherCondition.FEW_CLOUDS,
+ WeatherCondition.SCATTERED_CLOUDS,
+ WeatherCondition.BROKEN_CLOUDS,
+ WeatherCondition.OVERCAST_CLOUDS,
+ )
+ -> {
+ val cloudiness =
+ when (weatherCondition) {
+ WeatherCondition.FEW_CLOUDS -> 0.2f
+ WeatherCondition.SCATTERED_CLOUDS -> 0.4f
+ WeatherCondition.BROKEN_CLOUDS -> 0.7f
+ WeatherCondition.OVERCAST_CLOUDS -> 1.0f
+ else -> 0.5f
+ }
+
+ if (weatherCondition == WeatherCondition.FEW_CLOUDS) {
+ drawSun(
+ animationProgress = sunGlow.value,
+ scale = 0.7f,
+ offsetX = -size.width * 0.15f,
+ sunColor = colors.sunColor,
+ sunGlowColor = colors.sunGlowColor,
+ )
+ }
+
+ drawClouds(
+ animationProgress = cloudDrift.value,
+ cloudiness = cloudiness,
+ cloudColor = colors.cloudColor,
+ )
+ }
+
+ // Rain
+ weatherCondition.description.contains("rain") &&
+ !weatherCondition.description.contains(
+ "thunderstorm",
+ )
+ -> {
+ val intensity =
+ when {
+ weatherCondition.description.contains("light") -> 0.3f
+ weatherCondition.description.contains("heavy") ||
+ weatherCondition.description.contains("intense") ||
+ weatherCondition.description.contains("extreme") -> 0.9f
+
+ else -> 0.6f
+ }
+
+ drawClouds(
+ animationProgress = cloudDrift.value,
+ cloudiness = 0.8f,
+ cloudColor = colors.cloudColor,
+ )
+ drawRain(
+ animationProgress = rainDrop.value,
+ intensity = intensity,
+ rainColor = colors.rainColor,
+ )
+ }
+
+ // Snow
+ weatherCondition.description.contains("snow") ||
+ weatherCondition.description.contains(
+ "sleet",
+ )
+ -> {
+ val intensity =
+ when {
+ weatherCondition.description.contains("light") -> 0.3f
+ weatherCondition.description.contains("heavy") -> 0.9f
+ else -> 0.6f
+ }
+
+ drawClouds(
+ animationProgress = cloudDrift.value,
+ cloudiness = 0.7f,
+ cloudColor = colors.cloudColor,
+ )
+ drawSnow(
+ animationProgress = snowFall.value,
+ intensity = intensity,
+ snowColor = colors.snowColor,
+ )
+ }
+
+ // Thunderstorm
+ weatherCondition.description.contains("thunderstorm") -> {
+ drawClouds(
+ animationProgress = cloudDrift.value,
+ cloudiness = 0.9f,
+ cloudColor = colors.cloudColor,
+ )
+ drawRain(
+ animationProgress = rainDrop.value,
+ intensity = 0.7f,
+ rainColor = colors.rainColor,
+ )
+ drawThunder(
+ animationProgress = thunderFlash.value,
+ thunderColor = colors.thunderColor,
+ )
+ }
+
+ // Drizzle
+ weatherCondition.description.contains("drizzle") -> {
+ drawClouds(
+ animationProgress = cloudDrift.value,
+ cloudiness = 0.7f,
+ cloudColor = colors.cloudColor,
+ )
+ drawRain(
+ animationProgress = rainDrop.value,
+ intensity = 0.3f,
+ rainColor = colors.rainColor,
+ )
+ }
+
+ // Atmosphere (mist, fog, etc.)
+ weatherCondition in
+ listOf(
+ WeatherCondition.MIST,
+ WeatherCondition.SMOKE,
+ WeatherCondition.HAZE,
+ WeatherCondition.SAND_DUST_WHIRLS,
+ WeatherCondition.FOG,
+ WeatherCondition.SAND,
+ WeatherCondition.DUST,
+ WeatherCondition.VOLCANIC_ASH,
+ WeatherCondition.SQUALLS,
+ WeatherCondition.TORNADO,
+ )
+ -> {
+ drawFog(
+ animationProgress = cloudDrift.value,
+ fogColor = colors.fogColor,
+ )
+ }
+
+ // Default fallback
+ else -> {
+ drawSun(
+ animationProgress = sunGlow.value,
+ sunColor = colors.sunColor,
+ sunGlowColor = colors.sunGlowColor,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherIconDrawing.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherIconDrawing.kt
similarity index 78%
rename from sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherIconDrawing.kt
rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherIconDrawing.kt
index d84f8539..1d2e4f90 100644
--- a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherIconDrawing.kt
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/components/WeatherIconDrawing.kt
@@ -1,8 +1,12 @@
-package bose.ankush.sunriseui.components
+@file:Suppress("ktlint:standard:max-line-length")
+package bose.ankush.commonui.components
+
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
-import bose.ankush.sunriseui.constants.WeatherIconConstants
+import bose.ankush.commonui.constants.WeatherIconConstants
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@@ -12,29 +16,30 @@ import kotlin.math.sin
* Uses theme-aware colors that adapt to light/dark mode.
*/
fun DrawScope.drawSun(
- animationProgress: Float,
- scale: Float = 1.0f,
+ animationProgress: Float,
+ scale: Float = 1.0f,
offsetX: Float = 0f,
sunColor: Color,
- sunGlowColor: Color
+ sunGlowColor: Color,
) {
val centerX = size.width / 2 + offsetX
val centerY = size.height / 2
val radius = size.width.coerceAtMost(size.height) * 0.25f * scale
// Enhanced glow effect with smoother pulsing
- val glowRadius = radius * (1.0f + WeatherIconConstants.SUN_PULSE_SCALE * sin(animationProgress * PI).toFloat())
+ val glowRadius =
+ radius * (1.0f + WeatherIconConstants.SUN_PULSE_SCALE * sin(animationProgress * PI).toFloat())
drawCircle(
color = sunGlowColor,
radius = glowRadius * WeatherIconConstants.SUN_GLOW_SCALE,
- center = androidx.compose.ui.geometry.Offset(centerX, centerY)
+ center = Offset(centerX, centerY),
)
// Sun body with slight variation for more natural appearance
drawCircle(
color = sunColor,
radius = radius * (1.0f + WeatherIconConstants.SUN_BODY_VARIATION * sin(animationProgress * PI * 2).toFloat()),
- center = androidx.compose.ui.geometry.Offset(centerX, centerY)
+ center = Offset(centerX, centerY),
)
// More dynamic sun rays with varying lengths
@@ -59,9 +64,9 @@ fun DrawScope.drawSun(
drawLine(
color = sunColor.copy(alpha = 0.7f),
- start = androidx.compose.ui.geometry.Offset(startX, startY),
- end = androidx.compose.ui.geometry.Offset(endX, endY),
- strokeWidth = strokeWidth
+ start = Offset(startX, startY),
+ end = Offset(endX, endY),
+ strokeWidth = strokeWidth,
)
}
}
@@ -71,17 +76,21 @@ fun DrawScope.drawSun(
* Uses theme-aware colors that adapt to light/dark mode.
*/
fun DrawScope.drawClouds(
- animationProgress: Float,
+ animationProgress: Float,
cloudiness: Float,
- cloudColor: Color
+ cloudColor: Color,
) {
val cloudCount = (2 + (cloudiness * 2).toInt()).coerceAtMost(4)
for (i in 0 until cloudCount) {
// Smoother cloud movement with varying speeds
val speedFactor = 0.8f + (i % 3) * 0.1f
- val baseX = size.width * (0.3f + (i * 0.15f) + animationProgress * WeatherIconConstants.CLOUD_MOVEMENT_SCALE * speedFactor) % size.width
- val baseY = size.height * (0.4f + (i % 2) * 0.1f + sin(animationProgress * PI * speedFactor) * 0.02f)
+ val baseX =
+ size.width *
+ (0.3f + (i * 0.15f) + animationProgress * WeatherIconConstants.CLOUD_MOVEMENT_SCALE * speedFactor) %
+ size.width
+ val baseY =
+ size.height * (0.4f + (i % 2) * 0.1f + sin(animationProgress * PI * speedFactor) * 0.02f)
// Draw cloud as multiple overlapping circles with varying sizes
val puffCount = 3
@@ -90,15 +99,17 @@ fun DrawScope.drawClouds(
for (j in 0 until puffCount) {
val puffX = baseX + (j - 1) * (puffRadius * 1.2f)
val puffY = baseY + sin((j + animationProgress * 1.5f) * PI).toFloat() * 2f
- val puffSize = puffRadius * (0.8f + (j % 2) * 0.4f + sin(animationProgress * PI + j) * 0.05f)
+ val puffSize =
+ puffRadius * (0.8f + (j % 2) * 0.4f + sin(animationProgress * PI + j) * 0.05f)
// Vary opacity slightly for more natural appearance
- val alpha = WeatherIconConstants.CLOUD_BASE_ALPHA + 0.2f * sin((animationProgress * PI + j * 0.5f).toFloat())
+ val alpha =
+ WeatherIconConstants.CLOUD_BASE_ALPHA + 0.2f * sin((animationProgress * PI + j * 0.5f).toFloat())
drawCircle(
color = cloudColor.copy(alpha = alpha),
radius = puffSize.toFloat(),
- center = androidx.compose.ui.geometry.Offset(puffX, puffY.toFloat())
+ center = Offset(puffX, puffY.toFloat()),
)
}
}
@@ -110,9 +121,9 @@ fun DrawScope.drawClouds(
* Uses theme-aware colors that adapt to light/dark mode.
*/
fun DrawScope.drawRain(
- animationProgress: Float,
+ animationProgress: Float,
intensity: Float,
- rainColor: Color
+ rainColor: Color,
) {
// Increase drop count for heavier rain, with a higher maximum
val baseDropCount = 8 + (intensity * 25).toInt()
@@ -163,17 +174,18 @@ fun DrawScope.drawRain(
// Vary opacity based on thickness and random factors
// Thinner drops are more transparent
- val baseAlpha = (WeatherIconConstants.RAIN_BASE_ALPHA - 0.2f + 0.4f * (dropThickness / 3.0f))
- .coerceIn(0.3f, 0.9f)
+ val baseAlpha =
+ (WeatherIconConstants.RAIN_BASE_ALPHA - 0.2f + 0.4f * (dropThickness / 3.0f))
+ .coerceIn(0.3f, 0.9f)
val alphaVariation = 0.15f * sin((animationProgress * PI * 0.7f + seed).toFloat())
val dropAlpha = (baseAlpha + alphaVariation).coerceIn(0.2f, 0.95f)
// Draw the raindrop with slant from wind
drawLine(
color = rainColor.copy(alpha = dropAlpha),
- start = androidx.compose.ui.geometry.Offset(dropX.toFloat(), dropY),
- end = androidx.compose.ui.geometry.Offset(endX.toFloat(), endY),
- strokeWidth = dropThickness
+ start = Offset(dropX.toFloat(), dropY),
+ end = Offset(endX.toFloat(), endY),
+ strokeWidth = dropThickness,
)
// Add splash effect when drops hit the bottom
@@ -190,7 +202,11 @@ fun DrawScope.drawRain(
drawCircle(
color = rainColor.copy(alpha = splashAlpha),
radius = splashSize,
- center = androidx.compose.ui.geometry.Offset(endX.toFloat(), size.height * 0.98f)
+ center =
+ Offset(
+ endX.toFloat(),
+ size.height * 0.98f,
+ ),
)
// For heavier rain, add a second splash ripple
@@ -203,7 +219,11 @@ fun DrawScope.drawRain(
drawCircle(
color = rainColor.copy(alpha = rippleAlpha),
radius = rippleSize,
- center = androidx.compose.ui.geometry.Offset(endX.toFloat(), size.height * 0.98f)
+ center =
+ Offset(
+ endX.toFloat(),
+ size.height * 0.98f,
+ ),
)
}
}
@@ -217,16 +237,17 @@ fun DrawScope.drawRain(
* Uses theme-aware colors that adapt to light/dark mode.
*/
fun DrawScope.drawSnow(
- animationProgress: Float,
+ animationProgress: Float,
intensity: Float,
- snowColor: Color
+ snowColor: Color,
) {
val flakeCount = (5 + (intensity * 15).toInt()).coerceAtMost(20)
for (i in 0 until flakeCount) {
// Vary flake speeds and paths for more realistic snow
val speedFactor = 0.6f + (i % 5) * 0.1f
- val horizontalMovement = sin((animationProgress + i * 0.1f) * PI * 2) * size.width * WeatherIconConstants.SNOW_HORIZONTAL_MOVEMENT
+ val horizontalMovement =
+ sin((animationProgress + i * 0.1f) * PI * 2) * size.width * WeatherIconConstants.SNOW_HORIZONTAL_MOVEMENT
val flakeX = size.width * ((i * 0.1f) % 1.0f) + horizontalMovement
val flakeProgress = (animationProgress * speedFactor + (i * 0.1f)) % 1.0f
val flakeY = size.height * (0.5f + flakeProgress * 0.5f)
@@ -236,9 +257,13 @@ fun DrawScope.drawSnow(
// Draw snowflake (simple circle for now, could be enhanced to actual snowflake shape)
drawCircle(
- color = snowColor.copy(alpha = WeatherIconConstants.SNOW_BASE_ALPHA + 0.2f * sin((animationProgress * PI + i).toFloat())),
+ color =
+ snowColor.copy(
+ alpha =
+ WeatherIconConstants.SNOW_BASE_ALPHA + 0.2f * sin((animationProgress * PI + i).toFloat()),
+ ),
radius = flakeSize,
- center = androidx.compose.ui.geometry.Offset(flakeX.toFloat(), flakeY)
+ center = Offset(flakeX.toFloat(), flakeY),
)
}
}
@@ -249,7 +274,7 @@ fun DrawScope.drawSnow(
*/
fun DrawScope.drawThunder(
animationProgress: Float,
- thunderColor: Color
+ thunderColor: Color,
) {
// Make thunder appear more gradually instead of abruptly
val flashIntensity = sin(animationProgress * PI * 2).toFloat().coerceIn(0f, 1f)
@@ -259,26 +284,27 @@ fun DrawScope.drawThunder(
val startY = size.height * 0.4f
// Draw lightning bolt with varying intensity
- val path = androidx.compose.ui.graphics.Path().apply {
- moveTo(centerX, startY)
- lineTo(centerX - size.width * 0.1f, startY + size.height * 0.15f)
- lineTo(centerX, startY + size.height * 0.2f)
- lineTo(centerX - size.width * 0.05f, startY + size.height * 0.4f)
- lineTo(centerX + size.width * 0.1f, startY + size.height * 0.15f)
- lineTo(centerX, startY + size.height * 0.1f)
- close()
- }
+ val path =
+ Path().apply {
+ moveTo(centerX, startY)
+ lineTo(centerX - size.width * 0.1f, startY + size.height * 0.15f)
+ lineTo(centerX, startY + size.height * 0.2f)
+ lineTo(centerX - size.width * 0.05f, startY + size.height * 0.4f)
+ lineTo(centerX + size.width * 0.1f, startY + size.height * 0.15f)
+ lineTo(centerX, startY + size.height * 0.1f)
+ close()
+ }
drawPath(
path = path,
- color = thunderColor.copy(alpha = flashIntensity * WeatherIconConstants.THUNDER_FLASH_ALPHA)
+ color = thunderColor.copy(alpha = flashIntensity * WeatherIconConstants.THUNDER_FLASH_ALPHA),
)
// Add a glow effect around the lightning
drawCircle(
color = thunderColor.copy(alpha = flashIntensity * 0.3f),
radius = size.width * 0.2f,
- center = androidx.compose.ui.geometry.Offset(centerX, startY + size.height * 0.2f)
+ center = Offset(centerX, startY + size.height * 0.2f),
)
}
}
@@ -289,7 +315,7 @@ fun DrawScope.drawThunder(
*/
fun DrawScope.drawFog(
animationProgress: Float,
- fogColor: Color
+ fogColor: Color,
) {
val layerCount = 6
@@ -298,17 +324,19 @@ fun DrawScope.drawFog(
val layerY = size.height * (0.3f + i * 0.1f)
val layerWidth = size.width * (0.6f + (i % 3) * 0.1f)
val speedFactor = 0.8f + (i % 3) * 0.1f
- val layerOffset = size.width * 0.15f + sin((animationProgress * speedFactor + i * 0.2f) * PI).toFloat() * size.width * 0.08f
+ val layerOffset =
+ size.width * 0.15f + sin((animationProgress * speedFactor + i * 0.2f) * PI).toFloat() * size.width * 0.08f
// Vary opacity for more natural appearance
- val alpha = WeatherIconConstants.FOG_BASE_ALPHA + 0.2f * sin((animationProgress * PI + i * 0.5f)).toFloat()
+ val alpha =
+ WeatherIconConstants.FOG_BASE_ALPHA + 0.2f * sin((animationProgress * PI + i * 0.5f)).toFloat()
// Draw fog layer with rounded ends for more natural appearance
drawLine(
color = fogColor.copy(alpha = alpha),
- start = androidx.compose.ui.geometry.Offset(layerOffset, layerY),
- end = androidx.compose.ui.geometry.Offset(layerOffset + layerWidth, layerY),
- strokeWidth = size.height * (0.02f + 0.01f * (i % 3) / 3f)
+ start = Offset(layerOffset, layerY),
+ end = Offset(layerOffset + layerWidth, layerY),
+ strokeWidth = size.height * (0.02f + 0.01f * (i % 3) / 3f),
)
}
}
@@ -340,6 +368,7 @@ fun mapToWeatherCondition(description: String?): WeatherCondition {
else -> WeatherCondition.THUNDERSTORM
}
}
+
"drizzle" in lowerDesc -> {
when {
"light" in lowerDesc || "slight" in lowerDesc -> WeatherCondition.LIGHT_INTENSITY_DRIZZLE
@@ -347,6 +376,7 @@ fun mapToWeatherCondition(description: String?): WeatherCondition {
else -> WeatherCondition.DRIZZLE
}
}
+
"rain" in lowerDesc -> {
when {
"light" in lowerDesc || "slight" in lowerDesc -> WeatherCondition.LIGHT_RAIN
@@ -355,6 +385,7 @@ fun mapToWeatherCondition(description: String?): WeatherCondition {
else -> WeatherCondition.MODERATE_RAIN
}
}
+
"snow" in lowerDesc -> {
when {
"light" in lowerDesc || "slight" in lowerDesc || "flurries" in lowerDesc -> WeatherCondition.LIGHT_SNOW
@@ -362,6 +393,7 @@ fun mapToWeatherCondition(description: String?): WeatherCondition {
else -> WeatherCondition.SNOW
}
}
+
"sleet" in lowerDesc -> WeatherCondition.SLEET
"clear" in lowerDesc || "sunny" in lowerDesc || "fair" in lowerDesc -> WeatherCondition.CLEAR_SKY
"cloud" in lowerDesc -> {
@@ -373,6 +405,7 @@ fun mapToWeatherCondition(description: String?): WeatherCondition {
else -> WeatherCondition.SCATTERED_CLOUDS
}
}
+
"mist" in lowerDesc -> WeatherCondition.MIST
"fog" in lowerDesc -> WeatherCondition.FOG
"haze" in lowerDesc -> WeatherCondition.HAZE
diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/constants/SunriseConstants.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/SunriseConstants.kt
similarity index 55%
rename from sunriseui/src/main/java/bose/ankush/sunriseui/constants/SunriseConstants.kt
rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/SunriseConstants.kt
index e1f172b5..a770a6a4 100644
--- a/sunriseui/src/main/java/bose/ankush/sunriseui/constants/SunriseConstants.kt
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/SunriseConstants.kt
@@ -1,4 +1,4 @@
-package bose.ankush.sunriseui.constants
+package bose.ankush.commonui.constants
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@@ -8,7 +8,6 @@ import androidx.compose.ui.unit.dp
* Contains timing, visual, and behavioral parameters organized into logical groups.
*/
object SunriseConstants {
-
/** Animation timing constants in milliseconds. */
object Durations {
const val INITIAL_ANIMATION = 3000
@@ -56,61 +55,66 @@ object SunriseConstants {
/** Color schemes for different time periods and visual elements. */
object Colors {
// Night colors - Deep blue to dark blue-black
- val NIGHT_GRADIENT = listOf(
- Color(0xFF000011).copy(alpha = 0.9f), // Almost black with slight blue tint
- Color(0xFF0A1035).copy(alpha = 0.8f), // Very dark blue
- Color(0xFF0F1A4A).copy(alpha = 0.7f), // Dark blue
- Color(0xFF162554).copy(alpha = 0.6f) // Medium-dark blue
- )
+ val NIGHT_GRADIENT =
+ listOf(
+ Color(0xFF000011).copy(alpha = 0.9f), // Almost black with slight blue tint
+ Color(0xFF0A1035).copy(alpha = 0.8f), // Very dark blue
+ Color(0xFF0F1A4A).copy(alpha = 0.7f), // Dark blue
+ Color(0xFF162554).copy(alpha = 0.6f), // Medium-dark blue
+ )
// Dawn colors - Dark blue to purple, pink, orange, yellow
- val DAWN_GRADIENT = listOf(
- Color(0xFF0A1035).copy(alpha = 0.8f), // Very dark blue
- Color(0xFF341C5D).copy(alpha = 0.7f), // Deep purple
- Color(0xFF9A3A6A).copy(alpha = 0.6f), // Pink-purple
- Color(0xFFE67E45).copy(alpha = 0.5f) // Orange
- )
+ val DAWN_GRADIENT =
+ listOf(
+ Color(0xFF0A1035).copy(alpha = 0.8f), // Very dark blue
+ Color(0xFF341C5D).copy(alpha = 0.7f), // Deep purple
+ Color(0xFF9A3A6A).copy(alpha = 0.6f), // Pink-purple
+ Color(0xFFE67E45).copy(alpha = 0.5f), // Orange
+ )
// Day colors - Deep blue to lighter blue
- val DAY_GRADIENT = listOf(
- Color(0xFF0E4C92).copy(alpha = 0.7f), // Deep blue
- Color(0xFF1A75FF).copy(alpha = 0.6f), // Medium blue
- Color(0xFF5D9EFF).copy(alpha = 0.5f), // Light blue
- Color(0xFF87CEEB).copy(alpha = 0.4f) // Sky blue
- )
+ val DAY_GRADIENT =
+ listOf(
+ Color(0xFF0E4C92).copy(alpha = 0.7f), // Deep blue
+ Color(0xFF1A75FF).copy(alpha = 0.6f), // Medium blue
+ Color(0xFF5D9EFF).copy(alpha = 0.5f), // Light blue
+ Color(0xFF87CEEB).copy(alpha = 0.4f), // Sky blue
+ )
// Dusk colors - Dark blue to purple, pink, orange, red
- val DUSK_GRADIENT = listOf(
- Color(0xFF0A1035).copy(alpha = 0.8f), // Very dark blue
- Color(0xFF341C5D).copy(alpha = 0.7f), // Deep purple
- Color(0xFF9A3A6A).copy(alpha = 0.6f), // Pink-purple
- Color(0xFFE05038).copy(alpha = 0.5f) // Orange-red
- )
+ val DUSK_GRADIENT =
+ listOf(
+ Color(0xFF0A1035).copy(alpha = 0.8f), // Very dark blue
+ Color(0xFF341C5D).copy(alpha = 0.7f), // Deep purple
+ Color(0xFF9A3A6A).copy(alpha = 0.6f), // Pink-purple
+ Color(0xFFE05038).copy(alpha = 0.5f), // Orange-red
+ )
// Default fallback colors - Realistic daytime sky
- val DEFAULT_GRADIENT = listOf(
- Color(0xFF0E4C92).copy(alpha = 0.7f), // Deep blue
- Color(0xFF1A75FF).copy(alpha = 0.6f), // Medium blue
- Color(0xFF5D9EFF).copy(alpha = 0.5f), // Light blue
- Color(0xFF87CEEB).copy(alpha = 0.4f) // Sky blue
- )
+ val DEFAULT_GRADIENT =
+ listOf(
+ Color(0xFF0E4C92).copy(alpha = 0.7f), // Deep blue
+ Color(0xFF1A75FF).copy(alpha = 0.6f), // Medium blue
+ Color(0xFF5D9EFF).copy(alpha = 0.5f), // Light blue
+ Color(0xFF87CEEB).copy(alpha = 0.4f), // Sky blue
+ )
// Celestial body colors
val MOON_COLOR = Color(0xFFF5F5DC)
val MOON_PHASE_COLOR = Color(0xFF0F0F23)
- val STAR_COLOR = Color.Companion.White
+ val STAR_COLOR = Color.White
// Cloud colors - Adjusted to match realistic sky gradients
- val CLOUD_DAY_COLOR = Color(0xFFFFFFFF) // Pure white for daytime
- val CLOUD_DAWN_COLOR = Color(0xFFFAE3C6) // Warm cream/peach for sunrise
- val CLOUD_DUSK_COLOR = Color(0xFFFFB8A0) // Soft orange-pink for sunset
+ val CLOUD_DAY_COLOR = Color(0xFFFFFFFF) // Pure white for daytime
+ val CLOUD_DAWN_COLOR = Color(0xFFFAE3C6) // Warm cream/peach for sunrise
+ val CLOUD_DUSK_COLOR = Color(0xFFFFB8A0) // Soft orange-pink for sunset
// Sun colors by time - Enhanced for realistic appearance
- val SUN_EARLY_MORNING = Color(0xFFFF7E45) // Warm orange-red for early morning
- val SUN_MORNING = Color(0xFFFFAA33) // Golden orange for morning
- val SUN_MIDDAY = Color(0xFFFFD700) // Bright gold for midday
- val SUN_EVENING = Color(0xFFFFAA33) // Golden orange for evening
- val SUN_LATE_EVENING = Color(0xFFFF7E45) // Warm orange-red for late evening
+ val SUN_EARLY_MORNING = Color(0xFFFF7E45) // Warm orange-red for early morning
+ val SUN_MORNING = Color(0xFFFFAA33) // Golden orange for morning
+ val SUN_MIDDAY = Color(0xFFFFD700) // Bright gold for midday
+ val SUN_EVENING = Color(0xFFFFAA33) // Golden orange for evening
+ val SUN_LATE_EVENING = Color(0xFFFF7E45) // Warm orange-red for late evening
}
/** Spatial positioning and movement parameters (normalized 0.0-1.0). */
@@ -144,10 +148,19 @@ object SunriseConstants {
}
/** Predefined star positions as normalized (x, y) coordinates. */
- val STAR_POSITIONS = listOf(
- Pair(0.15f, 0.2f), Pair(0.3f, 0.15f), Pair(0.45f, 0.25f),
- Pair(0.6f, 0.1f), Pair(0.75f, 0.3f), Pair(0.85f, 0.18f),
- Pair(0.2f, 0.4f), Pair(0.4f, 0.45f), Pair(0.65f, 0.35f),
- Pair(0.8f, 0.5f), Pair(0.1f, 0.6f), Pair(0.9f, 0.65f)
- )
-}
\ No newline at end of file
+ val STAR_POSITIONS =
+ listOf(
+ Pair(0.15f, 0.2f),
+ Pair(0.3f, 0.15f),
+ Pair(0.45f, 0.25f),
+ Pair(0.6f, 0.1f),
+ Pair(0.75f, 0.3f),
+ Pair(0.85f, 0.18f),
+ Pair(0.2f, 0.4f),
+ Pair(0.4f, 0.45f),
+ Pair(0.65f, 0.35f),
+ Pair(0.8f, 0.5f),
+ Pair(0.1f, 0.6f),
+ Pair(0.9f, 0.65f),
+ )
+}
diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/constants/WeatherIconConstants.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/WeatherIconConstants.kt
similarity index 85%
rename from sunriseui/src/main/java/bose/ankush/sunriseui/constants/WeatherIconConstants.kt
rename to common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/WeatherIconConstants.kt
index cd13614b..c11bd6fc 100644
--- a/sunriseui/src/main/java/bose/ankush/sunriseui/constants/WeatherIconConstants.kt
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/constants/WeatherIconConstants.kt
@@ -1,4 +1,4 @@
-package bose.ankush.sunriseui.constants
+package bose.ankush.commonui.constants
/**
* Constants for weather icon drawing
@@ -7,7 +7,7 @@ object WeatherIconConstants {
// Animation constants
const val SUN_ANIMATION_DURATION = 2500
const val CLOUD_ANIMATION_DURATION = 4000
- const val RAIN_ANIMATION_DURATION = 3500 // Increased for more natural rain movement
+ const val RAIN_ANIMATION_DURATION = 3500 // Increased for more natural rain movement
const val SNOW_ANIMATION_DURATION = 3000
const val THUNDER_ANIMATION_DURATION = 3000
@@ -25,4 +25,4 @@ object WeatherIconConstants {
const val SUN_BODY_VARIATION = 0.05f
const val CLOUD_MOVEMENT_SCALE = 0.1f
const val SNOW_HORIZONTAL_MOVEMENT = 0.05f
-}
\ No newline at end of file
+}
diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/locations/SavedLocationsScreen.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/locations/SavedLocationsScreen.kt
new file mode 100644
index 00000000..6f3689a0
--- /dev/null
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/locations/SavedLocationsScreen.kt
@@ -0,0 +1,594 @@
+package bose.ankush.commonui.locations
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.outlined.LocationOn
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import bose.ankush.network.model.PlaceSuggestion
+import bose.ankush.network.model.SavedLocation
+import kotlin.math.round
+
+// ============ UI State Classes ============
+
+@Immutable
+data class SavedLocationsUiState(
+ val isPremium: Boolean = false,
+ val isLoading: Boolean = false,
+ val locations: List = emptyList(),
+ val error: String? = null,
+ val successMessage: String? = null,
+)
+
+@Immutable
+data class PlaceSearchUiState(
+ val searchQuery: String = "",
+ val results: List = emptyList(),
+ val isLoading: Boolean = false,
+ val error: String? = null,
+)
+
+// ============ Strings ============
+
+data class SavedLocationsStrings(
+ val title: String = "Saved Locations",
+ val premiumTitle: String = "Premium Feature",
+ val premiumDesc: String =
+ "Save your favorite locations to access them quickly. " +
+ "Upgrade to premium to unlock this feature.",
+ val emptyText: String = "No saved locations yet. Add one to get started!",
+ val searchHint: String = "Search for a place",
+ val searchDialogTitle: String = "Add Location",
+ val noResults: (String) -> String = { "No results found for \"$it\"" },
+ val deleteContentDesc: String = "Delete location",
+ val addContentDesc: String = "Add location",
+ val cancelBtn: String = "Cancel",
+ val saveSuccessMsg: String = "Location saved successfully",
+ val deleteSuccessMsg: String = "Location deleted successfully",
+ val setAsDefaultDialogTitle: String = "Use as weather location?",
+ val setAsDefaultDialogBody: (String) -> String = { "Weather data will show for $it instead of your current GPS position." },
+ val setAsDefaultDialogWarning: String = "Your live GPS location won't update while this is active.",
+ val setAsDefaultConfirmBtn: String = "Set as Default",
+)
+
+// ============ Main Screen ============
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SavedLocationsScreen(
+ locationsState: SavedLocationsUiState,
+ searchState: PlaceSearchUiState,
+ onQueryChanged: (String) -> Unit,
+ onClearSearch: () -> Unit,
+ onSaveLocation: (name: String, lat: Double, lon: Double) -> Unit,
+ onDeleteLocation: (String) -> Unit,
+ onLocationSelected: (SavedLocation) -> Unit,
+ onMessageShown: () -> Unit,
+ strings: SavedLocationsStrings = SavedLocationsStrings(),
+ bottomBar: @Composable () -> Unit = {},
+) {
+ val snackbarHostState = remember { SnackbarHostState() }
+ val pendingLocation = remember { mutableStateOf(null) }
+
+ pendingLocation.value?.let { location ->
+ SetAsDefaultLocationDialog(
+ locationName = location.name,
+ strings = strings,
+ onConfirm = {
+ onLocationSelected(location)
+ pendingLocation.value = null
+ },
+ onDismiss = { pendingLocation.value = null },
+ )
+ }
+
+ LaunchedEffect(locationsState.successMessage, locationsState.error) {
+ val message = locationsState.successMessage ?: locationsState.error
+ if (message != null) {
+ snackbarHostState.showSnackbar(message)
+ onMessageShown()
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = strings.title,
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ },
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ floatingActionButton = {
+ if (locationsState.isPremium) {
+ AddLocationFab(
+ onPlaceSelected = { place ->
+ onSaveLocation(
+ place.name,
+ place.latitude.toDouble(),
+ place.longitude.toDouble(),
+ )
+ },
+ searchState = searchState,
+ onQueryChanged = onQueryChanged,
+ onClearSearch = onClearSearch,
+ strings = strings,
+ )
+ }
+ },
+ bottomBar = bottomBar,
+ ) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ ) {
+ when {
+ !locationsState.isPremium -> PremiumGate(strings)
+ locationsState.isLoading && locationsState.locations.isEmpty() -> ShowLoading()
+ locationsState.locations.isEmpty() -> EmptyLocations(strings)
+ else -> LocationList(
+ locations = locationsState.locations,
+ onDelete = onDeleteLocation,
+ onLocationClick = { pendingLocation.value = it },
+ strings = strings,
+ )
+ }
+ }
+ }
+}
+
+// ============ Composable Components ============
+
+@Composable
+private fun PremiumGate(strings: SavedLocationsStrings) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = strings.premiumTitle,
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = strings.premiumDesc,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+@Composable
+private fun ShowLoading(modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+}
+
+@Composable
+private fun EmptyLocations(strings: SavedLocationsStrings) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = strings.emptyText,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(32.dp),
+ )
+ }
+}
+
+@Composable
+private fun LocationList(
+ locations: List,
+ onDelete: (String) -> Unit,
+ onLocationClick: (SavedLocation) -> Unit,
+ strings: SavedLocationsStrings,
+) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(
+ locations.distinctBy { it.id.ifEmpty { "${it.lat}_${it.lon}_${it.name}" } },
+ key = { it.id.ifEmpty { "${it.lat}_${it.lon}_${it.name}" } },
+ ) { location ->
+ LocationCard(
+ location = location,
+ onClick = { onLocationClick(location) },
+ onDelete = { onDelete(location.id) },
+ strings = strings,
+ )
+ }
+ }
+}
+
+@Composable
+private fun LocationCard(
+ location: SavedLocation,
+ onClick: () -> Unit,
+ onDelete: () -> Unit,
+ strings: SavedLocationsStrings,
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onClick,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = location.name,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = formatCoordinates(location.lat, location.lon),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ IconButton(onClick = onDelete) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = strings.deleteContentDesc,
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ }
+}
+
+private fun formatCoordinates(lat: Double, lon: Double): String {
+ val latRounded = round(lat * 10000) / 10000.0
+ val lonRounded = round(lon * 10000) / 10000.0
+ return "$latRounded, $lonRounded"
+}
+
+@Composable
+private fun AddLocationFab(
+ onPlaceSelected: (PlaceSuggestion) -> Unit,
+ searchState: PlaceSearchUiState,
+ onQueryChanged: (String) -> Unit,
+ onClearSearch: () -> Unit,
+ strings: SavedLocationsStrings,
+) {
+ val showDialog = remember { mutableStateOf(false) }
+
+ FloatingActionButton(onClick = { showDialog.value = true }) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = strings.addContentDesc,
+ )
+ }
+
+ if (showDialog.value) {
+ PlaceSearchDialog(
+ onDismiss = { showDialog.value = false },
+ onPlaceSelected = { place ->
+ showDialog.value = false
+ onPlaceSelected(place)
+ },
+ searchState = searchState,
+ onQueryChanged = onQueryChanged,
+ onClearSearch = onClearSearch,
+ strings = strings,
+ )
+ }
+}
+
+@Composable
+private fun PlaceSearchDialog(
+ onDismiss: () -> Unit,
+ onPlaceSelected: (PlaceSuggestion) -> Unit,
+ searchState: PlaceSearchUiState,
+ onQueryChanged: (String) -> Unit,
+ onClearSearch: () -> Unit,
+ strings: SavedLocationsStrings,
+) {
+ val focusRequester = remember { FocusRequester() }
+
+ AlertDialog(
+ onDismissRequest = {
+ onClearSearch()
+ onDismiss()
+ },
+ title = { Text(strings.searchDialogTitle) },
+ text = {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ OutlinedTextField(
+ value = searchState.searchQuery,
+ onValueChange = onQueryChanged,
+ placeholder = { Text(strings.searchHint) },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ )
+
+ AnimatedVisibility(
+ visible = searchState.isLoading,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ }
+ }
+
+ AnimatedVisibility(
+ visible = searchState.error != null,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ if (searchState.error != null) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ MaterialTheme.colorScheme.errorContainer,
+ shape = MaterialTheme.shapes.small,
+ )
+ .padding(12.dp),
+ ) {
+ Text(
+ text = searchState.error,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ }
+
+ AnimatedVisibility(
+ visible = searchState.searchQuery.length >= 2 &&
+ searchState.results.isEmpty() &&
+ !searchState.isLoading &&
+ searchState.error == null,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = strings.noResults(searchState.searchQuery),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+
+ AnimatedVisibility(
+ visible = searchState.results.isNotEmpty(),
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ LazyColumn(
+ modifier = Modifier.heightIn(max = 280.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ items(
+ searchState.results,
+ key = { "${it.name}_${it.latitude}_${it.longitude}" },
+ ) { place ->
+ PlaceSuggestionItem(
+ place = place,
+ onClick = {
+ onClearSearch()
+ onPlaceSelected(place)
+ },
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {},
+ dismissButton = {
+ TextButton(onClick = {
+ onClearSearch()
+ onDismiss()
+ }) {
+ Text(strings.cancelBtn)
+ }
+ },
+ )
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+}
+
+@Composable
+private fun PlaceSuggestionItem(
+ place: PlaceSuggestion,
+ onClick: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .background(
+ color = MaterialTheme.colorScheme.surface,
+ shape = MaterialTheme.shapes.small,
+ )
+ .padding(vertical = 12.dp, horizontal = 12.dp),
+ ) {
+ Text(
+ text = place.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = listOfNotNull(place.city, place.state, place.country)
+ .filter { it.isNotEmpty() }
+ .joinToString(", "),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+@Composable
+private fun SetAsDefaultLocationDialog(
+ locationName: String,
+ strings: SavedLocationsStrings,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ icon = {
+ Icon(
+ imageVector = Icons.Outlined.LocationOn,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ title = {
+ Text(
+ text = strings.setAsDefaultDialogTitle,
+ style = MaterialTheme.typography.titleLarge,
+ )
+ },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(
+ text = strings.setAsDefaultDialogBody(locationName),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ HorizontalDivider()
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.LocationOn,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.tertiary,
+ )
+ Text(
+ text = strings.setAsDefaultDialogWarning,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.tertiary,
+ )
+ }
+ }
+ },
+ confirmButton = {
+ Button(onClick = onConfirm) {
+ Text(strings.setAsDefaultConfirmBtn)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(strings.cancelBtn)
+ }
+ },
+ )
+}
diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/permissions/PermissionAlertDialog.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/permissions/PermissionAlertDialog.kt
new file mode 100644
index 00000000..ca25ccb7
--- /dev/null
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/permissions/PermissionAlertDialog.kt
@@ -0,0 +1,51 @@
+package bose.ankush.commonui.permissions
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+
+/**
+ * A CMP-compatible permission alert dialog.
+ *
+ * The caller is responsible for:
+ * - Providing localised [descriptionText] based on permission state
+ * - Providing meaningful [positiveButtonLabel] / [negativeButtonLabel]
+ * - Handling back-press behaviour (e.g. via BackHandler in the host composable)
+ *
+ * @param descriptionText Body text shown inside the dialog.
+ * @param isPermanentlyDeclined When true the negative (Exit/Cancel) button is shown and
+ * tapping outside the dialog triggers [onNegativeAction].
+ * @param onPositiveAction Called when the confirm button is tapped.
+ * @param onNegativeAction Called when the dismiss button is tapped or the dialog is
+ * dismissed by an outside tap (only when [isPermanentlyDeclined]).
+ * @param positiveButtonLabel Label for the confirm button. Defaults to "OK".
+ * @param negativeButtonLabel Label for the dismiss button. Defaults to "Cancel".
+ */
+@Composable
+fun PermissionAlertDialog(
+ descriptionText: String,
+ isPermanentlyDeclined: Boolean,
+ onPositiveAction: () -> Unit,
+ onNegativeAction: () -> Unit,
+ positiveButtonLabel: String = "OK",
+ negativeButtonLabel: String = "Cancel",
+) {
+ AlertDialog(
+ onDismissRequest = if (isPermanentlyDeclined) onNegativeAction else onPositiveAction,
+ title = { Text(text = "Permissions required") },
+ text = { Text(text = descriptionText) },
+ confirmButton = {
+ TextButton(onClick = onPositiveAction) {
+ Text(text = positiveButtonLabel)
+ }
+ },
+ dismissButton = {
+ if (isPermanentlyDeclined) {
+ TextButton(onClick = onNegativeAction) {
+ Text(text = negativeButtonLabel)
+ }
+ }
+ },
+ )
+}
diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/settings/SettingsScreen.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/settings/SettingsScreen.kt
new file mode 100644
index 00000000..efb3d44e
--- /dev/null
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/settings/SettingsScreen.kt
@@ -0,0 +1,659 @@
+package bose.ankush.commonui.settings
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material.icons.outlined.Gavel
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material.icons.outlined.Language
+import androidx.compose.material.icons.outlined.Notifications
+import androidx.compose.material.icons.outlined.PrivacyTip
+import androidx.compose.material.icons.outlined.WorkspacePremium
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import bose.ankush.commonui.components.NotificationToast
+import bose.ankush.commonui.components.ServiceSubscriptionBottomSheet
+import bose.ankush.commonui.components.ToastAnchorState
+import bose.ankush.commonui.components.ToastType
+import bose.ankush.commonui.util.formatDate
+import bose.ankush.commonui.viewmodel.ServiceSubscriptionUiState
+import bose.ankush.commonui.web.InAppWebView
+import bose.ankush.network.model.PricingTier
+import bose.ankush.network.model.Service
+import bose.ankush.payment.presentation.PaymentStage
+import bose.ankush.payment.presentation.PaymentUiState
+import kotlinx.coroutines.delay
+
+/**
+ * Holds localized strings for SettingsScreen.
+ * Allows the KMP composable to accept platform-specific localized resources.
+ */
+data class SettingsScreenStrings(
+ val profileTitle: String,
+ val logout: String,
+ val logoutConfirmation: String,
+ val confirm: String,
+ val cancel: String,
+ val getPremium: String,
+ val processing: String,
+ val processingDescription: String,
+ val unlockDescription: String,
+ val upgradeNow: String,
+ val premiumActive: String,
+ val premiumExpires: String,
+ val premiumActiveStatus: String,
+ val notificationsTitle: String,
+ val languageTitle: String,
+ val privacyPolicy: String,
+ val termsOfUse: String,
+ val appVersion: String,
+ val backButtonDesc: String,
+ val arrowRightDesc: String,
+ val premiumActivatedTitle: String,
+ val premiumActivatedMessage: String,
+)
+
+/**
+ * Holds UI state for SettingsScreen.
+ * Managed by parent ViewModel, not created within the screen.
+ */
+data class SettingsScreenState(
+ val showPremiumBottomSheet: Boolean = false,
+ val showLogoutDialog: Boolean = false,
+ val showPremiumActivationToast: Boolean = false,
+ val currentWebUrl: String? = null,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+ paymentUiState: PaymentUiState,
+ isLoggingOut: Boolean,
+ isLoggedOut: Boolean,
+ versionName: String,
+ shouldShowNotificationItem: Boolean,
+ languageList: Array,
+ uiState: SettingsScreenState,
+ strings: SettingsScreenStrings,
+ serviceSubscriptionBottomSheetUiState: ServiceSubscriptionUiState,
+ onLogout: () -> Unit,
+ onLoggedOutHandled: () -> Unit,
+ onStartPayment: (amountPaise: Long) -> Unit,
+ onLoadServices: () -> Unit,
+ onServiceSelected: (Service) -> Unit,
+ onTierSelected: (PricingTier) -> Unit,
+ onBackNavAction: () -> Unit,
+ onLanguageNavAction: (Array) -> Unit,
+ onNotificationNavAction: () -> Unit,
+ onStateChange: (SettingsScreenState) -> Unit,
+ onBottomBarVisibilityChange: (Boolean) -> Unit = {},
+ toastAnchorState: ToastAnchorState? = null,
+ bottomBar: @Composable () -> Unit = {},
+) {
+ val previousPaymentStage = remember { mutableStateOf(paymentUiState.stage) }
+
+ LaunchedEffect(uiState.showPremiumBottomSheet) {
+ onBottomBarVisibilityChange(!uiState.showPremiumBottomSheet)
+ }
+
+ LaunchedEffect(paymentUiState.stage) {
+ when {
+ paymentUiState.stage == PaymentStage.CreatingOrder ||
+ paymentUiState.stage == PaymentStage.AwaitingPayment ->
+ onStateChange(uiState.copy(showPremiumBottomSheet = false))
+
+ paymentUiState.stage == PaymentStage.Success &&
+ previousPaymentStage.value != PaymentStage.Success -> {
+ onStateChange(
+ uiState.copy(
+ showPremiumActivationToast = true,
+ showPremiumBottomSheet = false,
+ ),
+ )
+ }
+
+ paymentUiState.stage == PaymentStage.Failure ->
+ onStateChange(uiState.copy(showPremiumBottomSheet = false))
+ }
+ previousPaymentStage.value = paymentUiState.stage
+ }
+
+ LaunchedEffect(isLoggedOut) {
+ if (isLoggedOut) {
+ onStateChange(uiState.copy(showLogoutDialog = false))
+ onLoggedOutHandled()
+ }
+ }
+
+ val settingsSectionState = remember { MutableTransitionState(false) }
+ val legalSectionState = remember { MutableTransitionState(false) }
+ val logoutButtonState = remember { MutableTransitionState(false) }
+
+ LaunchedEffect(Unit) {
+ settingsSectionState.targetState = false
+ legalSectionState.targetState = false
+ logoutButtonState.targetState = false
+
+ delay(100)
+ settingsSectionState.targetState = true
+ delay(150)
+ legalSectionState.targetState = true
+ delay(150)
+ logoutButtonState.targetState = true
+ }
+
+ if (uiState.currentWebUrl != null) {
+ InAppWebView(
+ url = uiState.currentWebUrl,
+ onClose = { onStateChange(uiState.copy(currentWebUrl = null)) },
+ )
+ } else {
+ Box(modifier = Modifier.fillMaxSize()) {
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = { Text(strings.profileTitle, fontWeight = FontWeight.SemiBold) },
+ navigationIcon = {
+ IconButton(onClick = onBackNavAction) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
+ contentDescription = strings.backButtonDesc,
+ )
+ }
+ },
+ colors =
+ TopAppBarDefaults.centerAlignedTopAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ titleContentColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ )
+ },
+ content = { innerPadding ->
+ LazyColumn(
+ modifier =
+ Modifier
+ .padding(innerPadding)
+ .padding(horizontal = 16.dp),
+ ) {
+ // Future enhancement: Add user profile section here
+
+ item { Spacer(modifier = Modifier.height(24.dp)) }
+
+ item {
+ PremiumCard(
+ paymentUiState = paymentUiState,
+ onClick = { onStateChange(uiState.copy(showPremiumBottomSheet = true)) },
+ strings = strings,
+ )
+ }
+
+ item { Spacer(modifier = Modifier.height(24.dp)) }
+
+ item {
+ AnimatedVisibility(
+ visibleState = settingsSectionState,
+ enter =
+ fadeIn(animationSpec = tween(durationMillis = 500)) +
+ slideInVertically(
+ animationSpec = tween(durationMillis = 500),
+ initialOffsetY = { it / 3 },
+ ),
+ exit = fadeOut(),
+ ) {
+ SettingsSection(
+ shouldShowNotificationItem = shouldShowNotificationItem,
+ onNotificationNavAction = onNotificationNavAction,
+ onLanguageNavAction = { onLanguageNavAction(languageList) },
+ strings = strings,
+ )
+ }
+ }
+
+ item { Spacer(modifier = Modifier.height(24.dp)) }
+
+ item {
+ AnimatedVisibility(
+ visibleState = legalSectionState,
+ enter =
+ fadeIn(animationSpec = tween(durationMillis = 500)) +
+ slideInVertically(
+ animationSpec = tween(durationMillis = 500),
+ initialOffsetY = { it / 3 },
+ ),
+ exit = fadeOut(),
+ ) {
+ LegalSection(
+ versionName = versionName,
+ onUrlClick = { url -> onStateChange(uiState.copy(currentWebUrl = url)) },
+ strings = strings,
+ )
+ }
+ }
+
+ item { Spacer(modifier = Modifier.height(24.dp)) }
+
+ item {
+ AnimatedVisibility(
+ visibleState = logoutButtonState,
+ enter =
+ fadeIn(animationSpec = tween(durationMillis = 500)) +
+ slideInVertically(
+ animationSpec = tween(durationMillis = 500),
+ initialOffsetY = { it / 3 },
+ ),
+ exit = fadeOut(),
+ ) {
+ TextButton(
+ onClick = { onStateChange(uiState.copy(showLogoutDialog = true)) },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = strings.logout,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ }
+
+ item { Spacer(modifier = Modifier.height(24.dp)) }
+ }
+
+ if (uiState.showLogoutDialog) {
+ AlertDialog(
+ onDismissRequest = {
+ if (!isLoggingOut) onStateChange(uiState.copy(showLogoutDialog = false))
+ },
+ title = { Text(text = strings.logout) },
+ text = { Text(text = strings.logoutConfirmation) },
+ confirmButton = {
+ TextButton(
+ onClick = onLogout,
+ enabled = !isLoggingOut,
+ ) {
+ Text(strings.confirm)
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = { onStateChange(uiState.copy(showLogoutDialog = false)) },
+ enabled = !isLoggingOut,
+ ) {
+ Text(strings.cancel)
+ }
+ },
+ )
+ }
+
+ if (uiState.showPremiumBottomSheet) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.5f)),
+ ) {
+ Spacer(
+ modifier =
+ Modifier
+ .fillMaxSize(0.2f)
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ ) { onStateChange(uiState.copy(showPremiumBottomSheet = false)) },
+ )
+
+ ServiceSubscriptionBottomSheet(
+ uiState = serviceSubscriptionBottomSheetUiState,
+ loadService = onLoadServices,
+ onServiceSelected = onServiceSelected,
+ onTierSelected = onTierSelected,
+ onDismiss = { onStateChange(uiState.copy(showPremiumBottomSheet = false)) },
+ onSubscribe = { _, tier ->
+ onStartPayment(tier.getAmountInPaise().toLong())
+ onStateChange(uiState.copy(showPremiumBottomSheet = false))
+ },
+ modifier = Modifier.align(Alignment.BottomCenter),
+ )
+ }
+ }
+ },
+ bottomBar = bottomBar,
+ )
+
+ NotificationToast(
+ modifier = Modifier.align(Alignment.BottomCenter),
+ message = strings.premiumActivatedMessage,
+ title = strings.premiumActivatedTitle,
+ type = ToastType.SUCCESS,
+ isVisible = uiState.showPremiumActivationToast,
+ onDismiss = { onStateChange(uiState.copy(showPremiumActivationToast = false)) },
+ anchorState = toastAnchorState,
+ )
+ } // end Box
+ }
+}
+
+@Composable
+fun PremiumCard(
+ paymentUiState: PaymentUiState,
+ onClick: () -> Unit,
+ strings: SettingsScreenStrings,
+) {
+ val isPremiumActive =
+ paymentUiState.isPremiumActivated || paymentUiState.stage == PaymentStage.Success
+ val cardColors =
+ if (isPremiumActive) {
+ CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
+ } else {
+ CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)
+ }
+
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .then(if (!isPremiumActive) Modifier.clickable(onClick = onClick) else Modifier),
+ shape = RoundedCornerShape(16.dp),
+ colors = cardColors,
+ ) {
+ if (isPremiumActive) {
+ SubscribedPremiumCard(paymentUiState, strings)
+ } else {
+ UnsubscribedPremiumCard(paymentUiState, onClick, strings)
+ }
+ }
+}
+
+@Composable
+fun UnsubscribedPremiumCard(
+ paymentUiState: PaymentUiState,
+ onClick: () -> Unit,
+ strings: SettingsScreenStrings,
+) {
+ val loadingStages =
+ remember {
+ listOf(
+ PaymentStage.CreatingOrder,
+ PaymentStage.AwaitingPayment,
+ PaymentStage.Verifying,
+ )
+ }
+ val isLoading = paymentUiState.loading || paymentUiState.stage in loadingStages
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (isLoading) {
+ LinearProgressIndicator(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(2.dp),
+ color = MaterialTheme.colorScheme.tertiary,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+ Icon(
+ imageVector = Icons.Outlined.WorkspacePremium,
+ contentDescription = "Premium subscription icon",
+ modifier = Modifier.size(56.dp),
+ tint = MaterialTheme.colorScheme.onTertiaryContainer,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = if (isLoading) strings.processing else strings.getPremium,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onTertiaryContainer,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text =
+ if (isLoading) {
+ strings.processingDescription
+ } else {
+ strings.unlockDescription
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f),
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(
+ onClick = onClick,
+ enabled = !isLoading,
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.tertiary,
+ contentColor = MaterialTheme.colorScheme.onTertiary,
+ ),
+ ) {
+ Text(if (isLoading) strings.processing else strings.upgradeNow)
+ }
+ }
+}
+
+@Composable
+fun SubscribedPremiumCard(
+ paymentUiState: PaymentUiState,
+ strings: SettingsScreenStrings,
+) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.WorkspacePremium,
+ contentDescription = "Premium subscription icon",
+ modifier = Modifier.size(56.dp),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = strings.premiumActive,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ val expiryTop = paymentUiState.expiryMillis
+ if (expiryTop != null) {
+ val dateStr = remember(expiryTop) { formatDate(expiryTop) }
+ Text(
+ text = strings.premiumExpires.replace("%s", dateStr),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f),
+ )
+ } else {
+ Text(
+ text = strings.premiumActiveStatus,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+}
+
+@Composable
+fun SettingsSection(
+ shouldShowNotificationItem: Boolean,
+ onNotificationNavAction: () -> Unit,
+ onLanguageNavAction: () -> Unit,
+ strings: SettingsScreenStrings,
+) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
+ .padding(vertical = 8.dp),
+ ) {
+ if (shouldShowNotificationItem) {
+ SettingsItem(
+ icon = Icons.Outlined.Notifications,
+ title = strings.notificationsTitle,
+ onClick = onNotificationNavAction,
+ arrowRightDesc = strings.arrowRightDesc,
+ )
+ }
+ SettingsItem(
+ icon = Icons.Outlined.Language,
+ title = strings.languageTitle,
+ onClick = onLanguageNavAction,
+ arrowRightDesc = strings.arrowRightDesc,
+ )
+ }
+}
+
+@Composable
+fun LegalSection(
+ versionName: String,
+ onUrlClick: (String) -> Unit,
+ strings: SettingsScreenStrings,
+) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
+ .padding(vertical = 8.dp),
+ ) {
+ SettingsItem(
+ icon = Icons.Outlined.PrivacyTip,
+ title = strings.privacyPolicy,
+ onClick = { onUrlClick("https://data.androidplay.in/wfy/privacy-policy") },
+ arrowRightDesc = strings.arrowRightDesc,
+ )
+ SettingsItem(
+ icon = Icons.Outlined.Gavel,
+ title = strings.termsOfUse,
+ onClick = {
+ onUrlClick("https://data.androidplay.in/wfy/terms-and-conditions")
+ },
+ arrowRightDesc = strings.arrowRightDesc,
+ )
+ SettingsItem(
+ icon = Icons.Outlined.Info,
+ title = strings.appVersion,
+ trailingContent = {
+ Text(
+ text = versionName,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+ )
+ },
+ arrowRightDesc = strings.arrowRightDesc,
+ )
+ }
+}
+
+@Composable
+fun SettingsItem(
+ icon: ImageVector,
+ title: String,
+ onClick: (() -> Unit)? = null,
+ trailingContent: @Composable (() -> Unit)? = null,
+ arrowRightDesc: String = "",
+) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.weight(1f),
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = title,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (trailingContent != null) {
+ Spacer(modifier = Modifier.width(12.dp))
+ trailingContent()
+ } else if (onClick != null) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = arrowRightDesc,
+ tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+ )
+ }
+ }
+}
diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt
new file mode 100644
index 00000000..a93b4265
--- /dev/null
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt
@@ -0,0 +1,6 @@
+package bose.ankush.commonui.util
+
+expect fun formatDate(
+ millis: Long,
+ pattern: String = "MMM d, yyyy",
+): String
diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/viewmodel/ServiceSubscriptionViewModel.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/viewmodel/ServiceSubscriptionViewModel.kt
new file mode 100644
index 00000000..3abbdc33
--- /dev/null
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/viewmodel/ServiceSubscriptionViewModel.kt
@@ -0,0 +1,95 @@
+package bose.ankush.commonui.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import bose.ankush.network.model.PricingTier
+import bose.ankush.network.model.Service
+import bose.ankush.network.repository.ServiceRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+data class ServiceSubscriptionUiState(
+ val isLoading: Boolean = true,
+ val services: List = emptyList(),
+ val selectedService: Service? = null,
+ val selectedTier: PricingTier? = null,
+ val error: String? = null,
+ val message: String? = null,
+)
+
+class ServiceSubscriptionViewModel(
+ private val repository: ServiceRepository,
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(ServiceSubscriptionUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun loadServices() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+
+ repository.getServices().fold(
+ onSuccess = { services ->
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ services = services.filter { service -> service.isAvailable },
+ selectedService = services.firstOrNull { service -> service.isAvailable },
+ selectedTier =
+ services
+ .firstOrNull { service -> service.isAvailable }
+ ?.getRecommendedTier(),
+ )
+ }
+ },
+ onFailure = { error ->
+ val userMessage = getUserFriendlyErrorMessage(error)
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = userMessage,
+ )
+ }
+ },
+ )
+ }
+ }
+
+ private fun getUserFriendlyErrorMessage(error: Throwable): String =
+ when {
+ error.message?.contains("Illegal input", ignoreCase = true) == true ->
+ "Unable to load subscription plans. Please try again."
+
+ error.message?.contains("Network", ignoreCase = true) == true ->
+ "Network connection issue. Please check your internet."
+
+ error.message?.contains("404", ignoreCase = true) == true ->
+ "Service not found. Please try again later."
+
+ error.message?.contains("500", ignoreCase = true) == true ->
+ "Server error. Please try again later."
+
+ else -> "Unable to load subscription plans. Please try again."
+ }
+
+ fun selectService(service: Service) {
+ _uiState.update {
+ it.copy(
+ selectedService = service,
+ selectedTier = service.getRecommendedTier(),
+ )
+ }
+ }
+
+ fun selectPricingTier(tier: PricingTier) {
+ _uiState.update { it.copy(selectedTier = tier) }
+ }
+
+ fun resetState() {
+ _uiState.update {
+ ServiceSubscriptionUiState(isLoading = true)
+ }
+ }
+}
diff --git a/common-ui/src/commonMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt
new file mode 100644
index 00000000..0c745680
--- /dev/null
+++ b/common-ui/src/commonMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt
@@ -0,0 +1,26 @@
+package bose.ankush.commonui.web
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+/**
+ * Cross-platform in-app web view composable (CMP/KMP).
+ *
+ * Android: Uses Android android.webkit.WebView wrapped in androidx.compose.ui.viewinterop.AndroidView.
+ * - JavaScript disabled, cookies disabled, mixed-content blocked (security hardened).
+ * - URL whitelist enforced via android.webkit.WebViewClient.
+ *
+ * iOS: Uses platform.WebKit.WKWebView wrapped in androidx.compose.ui.viewinterop.UIKitView.
+ * - Non-persistent website data store (no cookie/cache persistence).
+ * - Navigation delegate tracks load state and errors.
+ *
+ * @param url The URL to load on first display.
+ * @param modifier Optional modifier for the root layout.
+ * @param onClose Called when the user navigates back past the first page or taps the back icon.
+ */
+@Composable
+expect fun InAppWebView(
+ url: String,
+ modifier: Modifier = Modifier,
+ onClose: () -> Unit,
+)
diff --git a/common-ui/src/iosMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt b/common-ui/src/iosMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt
new file mode 100644
index 00000000..13136b44
--- /dev/null
+++ b/common-ui/src/iosMain/kotlin/bose/ankush/commonui/util/DateFormatter.kt
@@ -0,0 +1,18 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
+package bose.ankush.commonui.util
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import platform.Foundation.NSDate
+import platform.Foundation.NSDateFormatter
+import platform.Foundation.dateWithTimeIntervalSince1970
+
+actual fun formatDate(
+ millis: Long,
+ pattern: String,
+): String {
+ val date = NSDate.dateWithTimeIntervalSince1970(millis / 1000.0)
+ val formatter = NSDateFormatter()
+ formatter.dateFormat = pattern
+ return formatter.stringFromDate(date)
+}
diff --git a/common-ui/src/iosMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt b/common-ui/src/iosMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt
new file mode 100644
index 00000000..edf51d3d
--- /dev/null
+++ b/common-ui/src/iosMain/kotlin/bose/ankush/commonui/web/InAppWebView.kt
@@ -0,0 +1,378 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
+package bose.ankush.commonui.web
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material.icons.outlined.ErrorOutline
+import androidx.compose.material.icons.outlined.OpenInBrowser
+import androidx.compose.material.icons.outlined.Refresh
+import androidx.compose.material.icons.outlined.Share
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.UIKitView
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.ObjCSignatureOverride
+import kotlinx.cinterop.readValue
+import platform.CoreGraphics.CGRectZero
+import platform.Foundation.NSError
+import platform.Foundation.NSURL
+import platform.Foundation.NSURLRequest
+import platform.UIKit.UIActivityViewController
+import platform.UIKit.UIApplication
+import platform.WebKit.WKNavigation
+import platform.WebKit.WKNavigationAction
+import platform.WebKit.WKNavigationActionPolicy
+import platform.WebKit.WKNavigationDelegateProtocol
+import platform.WebKit.WKWebView
+import platform.WebKit.WKWebViewConfiguration
+import platform.WebKit.WKWebsiteDataStore
+import platform.darwin.NSObject
+
+/**
+ * iOS actual: WKWebView wrapped in UIKitView.
+ * Non-persistent website data store (no cookie/cache persistence).
+ * Back navigation via top-bar icon (iOS swipe-back gesture also supported natively).
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+actual fun InAppWebView(
+ url: String,
+ modifier: Modifier,
+ onClose: () -> Unit,
+) {
+ var pageTitle by remember { mutableStateOf("") }
+ var isLoading by remember { mutableStateOf(true) }
+ var loadError by remember { mutableStateOf(false) }
+ var errorMessage by remember { mutableStateOf("") }
+ var canGoBack by remember { mutableStateOf(false) }
+ var currentUrl by remember { mutableStateOf(url) }
+
+ // Hold a strong reference to the delegate โ WKWebView.navigationDelegate is weak in ObjC
+ val delegate = remember { InAppWebViewDelegate() }
+
+ val webView =
+ remember {
+ val config =
+ WKWebViewConfiguration().apply {
+ // Non-persistent storage: equivalent to CookieManager.setAcceptCookie(false) on Android
+ websiteDataStore = WKWebsiteDataStore.nonPersistentDataStore()
+ }
+ WKWebView(frame = CGRectZero.readValue(), configuration = config).apply {
+ navigationDelegate = delegate
+ }
+ }
+
+ // Wire delegate callbacks to the latest captured state setters on each recomposition
+ delegate.initialUrl = url
+ delegate.onLoadStart = {
+ isLoading = true
+ loadError = false
+ }
+ delegate.onLoadFinish = { wv ->
+ isLoading = false
+ canGoBack = wv.canGoBack
+ pageTitle = wv.title ?: ""
+ currentUrl = wv.URL?.absoluteString ?: url
+ }
+ delegate.onLoadError = { msg ->
+ isLoading = false
+ loadError = true
+ errorMessage = msg
+ }
+ delegate.onNavigationBlocked = {
+ onClose()
+ }
+
+ // Load (or reload) the URL whenever it changes
+ LaunchedEffect(url) {
+ NSURL.URLWithString(url)?.let { nsUrl ->
+ webView.loadRequest(NSURLRequest.requestWithURL(nsUrl))
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = pageTitle.ifBlank { "Weatherify" },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = {
+ if (canGoBack) webView.goBack() else onClose()
+ }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = { webView.reload() }) {
+ Icon(
+ imageVector = Icons.Outlined.Refresh,
+ contentDescription = "Refresh page",
+ )
+ }
+ IconButton(onClick = {
+ val activityVC =
+ UIActivityViewController(
+ activityItems = listOf(currentUrl),
+ applicationActivities = null,
+ )
+ @Suppress("DEPRECATION")
+ UIApplication.sharedApplication.keyWindow
+ ?.rootViewController
+ ?.presentViewController(activityVC, animated = true, completion = null)
+ }) {
+ Icon(
+ imageVector = Icons.Outlined.Share,
+ contentDescription = "Share page",
+ )
+ }
+ IconButton(onClick = {
+ NSURL.URLWithString(currentUrl)?.let { nsUrl ->
+ @Suppress("DEPRECATION")
+ UIApplication.sharedApplication.openURL(nsUrl)
+ }
+ }) {
+ Icon(
+ imageVector = Icons.Outlined.OpenInBrowser,
+ contentDescription = "Open in browser",
+ )
+ }
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ titleContentColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ )
+ },
+ ) { paddingValues ->
+ Column(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .padding(paddingValues),
+ ) {
+ if (isLoading) {
+ LinearProgressIndicator(
+ modifier =
+ Modifier
+ .height(2.dp)
+ .fillMaxWidth(),
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ ) {
+ UIKitView(
+ factory = { webView },
+ modifier = Modifier.fillMaxSize(),
+ update = {},
+ )
+
+ // Loading overlay
+ if (isLoading) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background.copy(alpha = 0.8f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+
+ // Error overlay
+ if (loadError) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background.copy(alpha = 0.95f)),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.ErrorOutline,
+ contentDescription = "Error",
+ modifier =
+ Modifier
+ .size(64.dp)
+ .padding(bottom = 16.dp),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ Text(
+ text = "Failed to load page",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+ if (errorMessage.isNotBlank()) {
+ Text(
+ text = errorMessage,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
+ modifier = Modifier.padding(bottom = 24.dp),
+ )
+ }
+ Button(
+ onClick = {
+ loadError = false
+ errorMessage = ""
+ isLoading = true
+ webView.reload()
+ },
+ ) {
+ Text("Retry")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Release WKWebView resources when the composable leaves composition
+ DisposableEffect(Unit) {
+ onDispose {
+ webView.stopLoading()
+ webView.navigationDelegate = null
+ }
+ }
+}
+
+/**
+ * WKNavigationDelegate implementation that forwards load events to Compose state setters.
+ * Kept as a named class so that a strong Kotlin reference can be held (WKWebView.navigationDelegate
+ * is a weak ObjC reference and would otherwise be immediately deallocated).
+ *
+ * URL whitelist enforcement: decidePolicyForNavigationAction validates navigation URLs
+ * against the same trusted domain list used on Android (e.g., data.androidplay.in).
+ */
+private class InAppWebViewDelegate :
+ NSObject(),
+ WKNavigationDelegateProtocol {
+ var onLoadStart: () -> Unit = {}
+ var onLoadFinish: (WKWebView) -> Unit = {}
+ var onLoadError: (String) -> Unit = {}
+ var onNavigationBlocked: () -> Unit = {}
+ var initialUrl: String = ""
+
+ @ObjCSignatureOverride
+ override fun webView(
+ webView: WKWebView,
+ didStartProvisionalNavigation: WKNavigation?,
+ ) {
+ onLoadStart()
+ }
+
+ @ObjCSignatureOverride
+ override fun webView(
+ webView: WKWebView,
+ didFinishNavigation: WKNavigation?,
+ ) {
+ onLoadFinish(webView)
+ }
+
+ @ObjCSignatureOverride
+ override fun webView(
+ webView: WKWebView,
+ didFailProvisionalNavigation: WKNavigation?,
+ withError: NSError,
+ ) {
+ onLoadError(withError.localizedDescription)
+ }
+
+ @ObjCSignatureOverride
+ override fun webView(
+ webView: WKWebView,
+ didFailNavigation: WKNavigation?,
+ withError: NSError,
+ ) {
+ onLoadError(withError.localizedDescription)
+ }
+
+ @ObjCSignatureOverride
+ override fun webView(
+ webView: WKWebView,
+ decidePolicyForNavigationAction: WKNavigationAction,
+ decisionHandler: (WKNavigationActionPolicy) -> Unit,
+ ) {
+ val requestUrl = decidePolicyForNavigationAction.request.URL?.absoluteString ?: ""
+
+ // Allow initial URL load
+ if (requestUrl == initialUrl) {
+ decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyAllow)
+ return
+ }
+
+ // Validate subsequent navigation against whitelist
+ if (isWhitelistedUrl(requestUrl)) {
+ decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyAllow)
+ } else {
+ // Block navigation from untrusted domains
+ decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyCancel)
+ onNavigationBlocked()
+ }
+ }
+
+ private fun isWhitelistedUrl(urlString: String): Boolean {
+ val url = NSURL.URLWithString(urlString) ?: return false
+ val host = url.host?.lowercase() ?: return false
+ val whitelistedDomains =
+ setOf(
+ "data.androidplay.in", // Terms, Privacy Policy
+ )
+ return whitelistedDomains.any { trustedDomain ->
+ host == trustedDomain || host.endsWith(".$trustedDomain")
+ }
+ }
+}
diff --git a/feature-payment/build.gradle.kts b/feature-payment/build.gradle.kts
new file mode 100644
index 00000000..157b6deb
--- /dev/null
+++ b/feature-payment/build.gradle.kts
@@ -0,0 +1,65 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ kotlin("multiplatform")
+ id("com.android.library")
+}
+
+kotlin {
+ androidTarget {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ }
+ }
+
+ listOf(
+ iosX64(),
+ iosArm64(),
+ iosSimulatorArm64(),
+ ).forEach {
+ it.binaries.framework {
+ baseName = "feature_payment"
+ isStatic = true
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(project(":network"))
+ implementation(KmmDeps.koinCore)
+ implementation(KmmDeps.kotlinxCoroutinesCore)
+ implementation(KmmDeps.kotlinxDateTime)
+ implementation(KmmDeps.kmpLifecycleViewModel)
+ }
+
+ androidMain.dependencies {
+ implementation(KmmDeps.koinAndroid)
+ }
+
+ val iosX64Main by getting
+ val iosArm64Main by getting
+ val iosSimulatorArm64Main by getting
+
+ @Suppress("UNUSED_VARIABLE")
+ val iosMain by creating {
+ dependsOn(commonMain.get())
+ iosX64Main.dependsOn(this)
+ iosArm64Main.dependsOn(this)
+ iosSimulatorArm64Main.dependsOn(this)
+ }
+ }
+}
+
+android {
+ namespace = "bose.ankush.payment"
+ compileSdk = ConfigData.compileSdkVersion
+
+ defaultConfig {
+ minSdk = ConfigData.minSdkVersion
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
diff --git a/feature-payment/src/androidMain/kotlin/bose/ankush/payment/di/PaymentAndroidModule.kt b/feature-payment/src/androidMain/kotlin/bose/ankush/payment/di/PaymentAndroidModule.kt
new file mode 100644
index 00000000..5b3a8322
--- /dev/null
+++ b/feature-payment/src/androidMain/kotlin/bose/ankush/payment/di/PaymentAndroidModule.kt
@@ -0,0 +1,20 @@
+package bose.ankush.payment.di
+
+import bose.ankush.payment.presentation.PaymentViewModel
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.core.module.Module
+import org.koin.dsl.module
+
+val paymentViewModelModule: Module =
+ module {
+ viewModel { PaymentViewModel(get(), get(), get(), get()) }
+ }
+
+/**
+ * All Koin modules required by the feature-payment module on Android.
+ * Load these in the host application's [org.koin.core.context.startKoin] call,
+ * alongside the app-level module that provides the platform-specific bindings:
+ * [bose.ankush.network.api.PaymentApiService], [bose.ankush.network.common.NetworkConnectivity],
+ * [bose.ankush.payment.domain.store.PremiumStore], and [bose.ankush.payment.domain.config.PaymentConfig].
+ */
+val featurePaymentModules: List = listOf(paymentDomainModule, paymentViewModelModule)
diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/data/PaymentRepositoryImpl.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/data/PaymentRepositoryImpl.kt
new file mode 100644
index 00000000..a6e42401
--- /dev/null
+++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/data/PaymentRepositoryImpl.kt
@@ -0,0 +1,28 @@
+package bose.ankush.payment.data
+
+import bose.ankush.network.api.PaymentApiService
+import bose.ankush.network.common.NetworkConnectivity
+import bose.ankush.network.model.CreateOrderRequest
+import bose.ankush.network.model.CreateOrderResponse
+import bose.ankush.network.model.VerifyPaymentRequest
+import bose.ankush.network.model.VerifyPaymentResponse
+import bose.ankush.payment.domain.repository.PaymentRepository
+
+internal class PaymentRepositoryImpl(
+ private val apiService: PaymentApiService,
+ private val networkConnectivity: NetworkConnectivity,
+) : PaymentRepository {
+ override suspend fun createOrder(request: CreateOrderRequest): Result {
+ if (!networkConnectivity.isNetworkAvailable()) {
+ return Result.failure(IllegalStateException("No internet connection"))
+ }
+ return runCatching { apiService.createOrder(request) }
+ }
+
+ override suspend fun verifyPayment(request: VerifyPaymentRequest): Result {
+ if (!networkConnectivity.isNetworkAvailable()) {
+ return Result.failure(IllegalStateException("No internet connection"))
+ }
+ return runCatching { apiService.verifyPayment(request) }
+ }
+}
diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/di/PaymentModule.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/di/PaymentModule.kt
new file mode 100644
index 00000000..de9fc305
--- /dev/null
+++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/di/PaymentModule.kt
@@ -0,0 +1,22 @@
+package bose.ankush.payment.di
+
+import bose.ankush.payment.data.PaymentRepositoryImpl
+import bose.ankush.payment.domain.repository.PaymentRepository
+import bose.ankush.payment.domain.usecase.CreateOrderUseCase
+import bose.ankush.payment.domain.usecase.VerifyPaymentUseCase
+import org.koin.core.module.Module
+import org.koin.dsl.module
+
+/**
+ * Koin module for the payment feature โ domain and data layer bindings.
+ * Platform-specific bindings (NetworkConnectivity, PaymentApiService, PremiumStore,
+ * PaymentConfig) and the ViewModel must be provided by the host application.
+ *
+ * @see bose.ankush.payment.di โ androidMain for Android ViewModel module.
+ */
+val paymentDomainModule: Module =
+ module {
+ single { PaymentRepositoryImpl(get(), get()) }
+ factory { CreateOrderUseCase(get()) }
+ factory { VerifyPaymentUseCase(get()) }
+ }
diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/config/PaymentConfig.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/config/PaymentConfig.kt
new file mode 100644
index 00000000..f358181f
--- /dev/null
+++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/config/PaymentConfig.kt
@@ -0,0 +1,5 @@
+package bose.ankush.payment.domain.config
+
+interface PaymentConfig {
+ val razorpayKey: String
+}
diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/repository/PaymentRepository.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/repository/PaymentRepository.kt
new file mode 100644
index 00000000..fd18b3e2
--- /dev/null
+++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/repository/PaymentRepository.kt
@@ -0,0 +1,12 @@
+package bose.ankush.payment.domain.repository
+
+import bose.ankush.network.model.CreateOrderRequest
+import bose.ankush.network.model.CreateOrderResponse
+import bose.ankush.network.model.VerifyPaymentRequest
+import bose.ankush.network.model.VerifyPaymentResponse
+
+interface PaymentRepository {
+ suspend fun createOrder(request: CreateOrderRequest): Result
+
+ suspend fun verifyPayment(request: VerifyPaymentRequest): Result
+}
diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/store/PremiumStore.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/store/PremiumStore.kt
new file mode 100644
index 00000000..0b056693
--- /dev/null
+++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/store/PremiumStore.kt
@@ -0,0 +1,38 @@
+package bose.ankush.payment.domain.store
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Interface for managing and observing the premium subscription status of the user.
+ */
+interface PremiumStore {
+ /**
+ * Returns a [Flow] that emits the current [PremiumStatus].
+ *
+ * @return A flow of premium status updates.
+ */
+ fun observePremiumStatus(): Flow
+
+ /**
+ * Persists the user's premium subscription status.
+ *
+ * @param isPremium True if the user has an active premium subscription, false otherwise.
+ * @param expiryMillis The expiration time of the premium subscription in milliseconds, or null if not applicable.
+ */
+ suspend fun savePremiumStatus(
+ isPremium: Boolean,
+ expiryMillis: Long?,
+ )
+}
+
+/**
+ * Data class representing the premium subscription state.
+ *
+ * @property isPremium Indicates whether the user currently has a premium subscription.
+ * @property expiryMillis The timestamp in milliseconds when the premium subscription expires,
+ * or null if not applicable.
+ */
+data class PremiumStatus(
+ val isPremium: Boolean,
+ val expiryMillis: Long?,
+)
diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/usecase/CreateOrderUseCase.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/usecase/CreateOrderUseCase.kt
new file mode 100644
index 00000000..31fff513
--- /dev/null
+++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/usecase/CreateOrderUseCase.kt
@@ -0,0 +1,12 @@
+package bose.ankush.payment.domain.usecase
+
+import bose.ankush.network.model.CreateOrderRequest
+import bose.ankush.network.model.CreateOrderResponse
+import bose.ankush.payment.domain.repository.PaymentRepository
+
+class CreateOrderUseCase(
+ private val repository: PaymentRepository,
+) {
+ suspend operator fun invoke(request: CreateOrderRequest): Result =
+ repository.createOrder(request)
+}
diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/usecase/VerifyPaymentUseCase.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/usecase/VerifyPaymentUseCase.kt
new file mode 100644
index 00000000..114927a2
--- /dev/null
+++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/domain/usecase/VerifyPaymentUseCase.kt
@@ -0,0 +1,12 @@
+package bose.ankush.payment.domain.usecase
+
+import bose.ankush.network.model.VerifyPaymentRequest
+import bose.ankush.network.model.VerifyPaymentResponse
+import bose.ankush.payment.domain.repository.PaymentRepository
+
+class VerifyPaymentUseCase(
+ private val repository: PaymentRepository,
+) {
+ suspend operator fun invoke(request: VerifyPaymentRequest): Result =
+ repository.verifyPayment(request)
+}
diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/presentation/PaymentUiState.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/presentation/PaymentUiState.kt
new file mode 100644
index 00000000..ce0865c7
--- /dev/null
+++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/presentation/PaymentUiState.kt
@@ -0,0 +1,26 @@
+package bose.ankush.payment.presentation
+
+enum class PaymentStage { Idle, CreatingOrder, AwaitingPayment, Verifying, Success, Failure }
+
+data class PaymentUiState(
+ val loading: Boolean = false,
+ val message: String? = null,
+ val stage: PaymentStage = PaymentStage.Idle,
+ val isPremiumActivated: Boolean = false,
+ val expiryMillis: Long? = null,
+)
+
+/**
+ * Parameters for launching the platform-specific payment checkout (e.g. Razorpay on Android).
+ * Emitted by [PaymentViewModel] after a successful order creation; consumed by the UI layer.
+ */
+data class CheckoutParams(
+ val keyId: String,
+ val orderId: String,
+ val amount: Long,
+ val currency: String,
+ val name: String,
+ val description: String,
+ val email: String? = null,
+ val contact: String? = null,
+)
diff --git a/feature-payment/src/commonMain/kotlin/bose/ankush/payment/presentation/PaymentViewModel.kt b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/presentation/PaymentViewModel.kt
new file mode 100644
index 00000000..87cf0d81
--- /dev/null
+++ b/feature-payment/src/commonMain/kotlin/bose/ankush/payment/presentation/PaymentViewModel.kt
@@ -0,0 +1,210 @@
+package bose.ankush.payment.presentation
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import bose.ankush.network.model.CreateOrderRequest
+import bose.ankush.network.model.VerifyPaymentRequest
+import bose.ankush.payment.domain.config.PaymentConfig
+import bose.ankush.payment.domain.store.PremiumStore
+import bose.ankush.payment.domain.usecase.CreateOrderUseCase
+import bose.ankush.payment.domain.usecase.VerifyPaymentUseCase
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.datetime.Clock
+import kotlin.time.Duration.Companion.days
+
+class PaymentViewModel(
+ private val createOrderUseCase: CreateOrderUseCase,
+ private val verifyPaymentUseCase: VerifyPaymentUseCase,
+ private val premiumStore: PremiumStore,
+ private val paymentConfig: PaymentConfig,
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(PaymentUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ /**
+ * One-shot events to trigger the platform-specific checkout UI (e.g. Razorpay on Android).
+ * Consumed by the Activity/UI layer; never observed from the ViewModel itself.
+ */
+ private val _checkoutParams = Channel(Channel.BUFFERED)
+ val checkoutParams: Flow = _checkoutParams.receiveAsFlow()
+
+ init {
+ observePremiumStatus()
+ }
+
+ private fun observePremiumStatus() {
+ viewModelScope.launch {
+ premiumStore.observePremiumStatus().collect { status ->
+ val now = Clock.System.now().toEpochMilliseconds()
+ val isActive = status.expiryMillis != null && status.expiryMillis > now
+ _uiState.update {
+ it.copy(
+ isPremiumActivated = isActive,
+ expiryMillis = status.expiryMillis,
+ stage = if (isActive) PaymentStage.Success else it.stage,
+ )
+ }
+ }
+ }
+ }
+
+ fun startPayment(
+ amountPaise: Long = 10_000L,
+ currency: String = "INR",
+ ) {
+ viewModelScope.launch {
+ _uiState.update {
+ it.copy(loading = true, message = "Creating order...", stage = PaymentStage.CreatingOrder)
+ }
+
+ val receipt = "receipt_${Clock.System.now().toEpochMilliseconds()}"
+
+ createOrderUseCase(
+ CreateOrderRequest(
+ amount = amountPaise,
+ currency = currency,
+ receipt = receipt,
+ partialPayment = true,
+ firstPaymentMinAmount = 500L,
+ ),
+ ).fold(
+ onSuccess = { response ->
+ val data = response.extractData()
+ val key = paymentConfig.razorpayKey
+ when {
+ data == null ->
+ _uiState.update {
+ it.copy(
+ loading = false,
+ message = friendlyServerMessage(response.message),
+ stage = PaymentStage.Failure,
+ )
+ }
+
+ key.isBlank() ->
+ _uiState.update {
+ it.copy(
+ loading = false,
+ message = "Payment is temporarily unavailable. Please try again later.",
+ stage = PaymentStage.Failure,
+ )
+ }
+
+ data.orderId.isBlank() || data.amount <= 0L || data.currency.isBlank() ->
+ _uiState.update {
+ it.copy(
+ loading = false,
+ message = "We couldn't start the payment. Please try again.",
+ stage = PaymentStage.Failure,
+ )
+ }
+
+ else -> {
+ _checkoutParams.trySend(
+ CheckoutParams(
+ keyId = key,
+ orderId = data.orderId,
+ amount = data.amount,
+ currency = data.currency,
+ name = "Weatherify Subscription",
+ description = "Premium Plan",
+ ),
+ )
+ _uiState.update {
+ it.copy(
+ loading = false,
+ message = response.message ?: "Order created",
+ stage = PaymentStage.AwaitingPayment,
+ )
+ }
+ }
+ }
+ },
+ onFailure = { e ->
+ _uiState.update {
+ it.copy(loading = false, message = friendlyErrorMessage(e), stage = PaymentStage.Failure)
+ }
+ },
+ )
+ }
+ }
+
+ fun verifyPayment(
+ orderId: String,
+ paymentId: String,
+ signature: String,
+ ) {
+ viewModelScope.launch {
+ _uiState.update {
+ it.copy(loading = true, message = "Verifying payment...", stage = PaymentStage.Verifying)
+ }
+
+ verifyPaymentUseCase(
+ VerifyPaymentRequest(
+ razorpayOrderId = orderId,
+ razorpayPaymentId = paymentId,
+ razorpaySignature = signature,
+ ),
+ ).fold(
+ onSuccess = { resp ->
+ if (!resp.success) {
+ _uiState.update {
+ it.copy(
+ loading = false,
+ message = friendlyServerMessage(resp.message),
+ stage = PaymentStage.Failure,
+ )
+ }
+ return@fold
+ }
+ val expiryMillis =
+ Clock.System
+ .now()
+ .plus(30.days)
+ .toEpochMilliseconds()
+ premiumStore.savePremiumStatus(isPremium = true, expiryMillis = expiryMillis)
+ // _uiState auto-updates via observePremiumStatus() collecting the new value
+ _uiState.update {
+ it.copy(loading = false, message = "Payment verified", stage = PaymentStage.Success)
+ }
+ },
+ onFailure = { e ->
+ _uiState.update {
+ it.copy(loading = false, message = friendlyErrorMessage(e), stage = PaymentStage.Failure)
+ }
+ },
+ )
+ }
+ }
+
+ fun onPaymentFailed(message: String) {
+ _uiState.update {
+ it.copy(loading = false, message = friendlyServerMessage(message), stage = PaymentStage.Failure)
+ }
+ }
+
+ private fun friendlyServerMessage(message: String?): String {
+ if (message.isNullOrBlank()) return "Something went wrong. Please try again."
+ val lower = message.lowercase()
+ return when {
+ "timeout" in lower -> "The server took too long to respond. Please try again."
+ "cancel" in lower -> "Payment was cancelled."
+ "network" in lower || "unable to resolve host" in lower ->
+ "Please check your internet connection and try again."
+ else -> "Something went wrong. Please try again."
+ }
+ }
+
+ private fun friendlyErrorMessage(t: Throwable?): String {
+ if (t is CancellationException) throw t
+ return "Something went wrong. Please try again."
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index 0208a1ee..dd47eafc 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -17,4 +17,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
-org.gradle.unsafe.configuration-cache=false
\ No newline at end of file
+org.gradle.unsafe.configuration-cache=false
+# Kotlin Multiplatform: Disable default hierarchy template since network/storage use explicit dependsOn() calls
+kotlin.mpp.applyDefaultHierarchyTemplate=false
\ No newline at end of file
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 00000000..fd83dd97
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,13 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/0b98aec810298c2c1d7fdac5dac37910/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9c55677aff3966382f3d853c0959bfb2/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/23adb857f3cb3cbe28750bc7faa7abc0/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ac151d55def6b6a9a159dc4cb4642851/redirect
+toolchainVendor=JETBRAINS
+toolchainVersion=21
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index e2847c82..d4081da4 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/language/build.gradle.kts b/language/build.gradle.kts
index 315b8678..ee920589 100644
--- a/language/build.gradle.kts
+++ b/language/build.gradle.kts
@@ -20,7 +20,7 @@ android {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
+ "proguard-rules.pro",
)
}
}
@@ -39,14 +39,6 @@ android {
jvmTarget = JavaVersion.VERSION_17.toString()
}
- kotlin {
- sourceSets.all {
- languageSettings {
- languageVersion = Versions.kotlinCompiler
- }
- }
- }
-
lint {
abortOnError = false
}
@@ -75,4 +67,4 @@ dependencies {
implementation(Deps.composeMaterial1)
implementation(Deps.composeMaterial3)
implementation(Deps.navigationCompose)
-}
\ No newline at end of file
+}
diff --git a/language/src/androidTest/java/bose/ankush/language/ExampleInstrumentedTest.kt b/language/src/androidTest/java/bose/ankush/language/ExampleInstrumentedTest.kt
index dc686b74..f1b0bb15 100644
--- a/language/src/androidTest/java/bose/ankush/language/ExampleInstrumentedTest.kt
+++ b/language/src/androidTest/java/bose/ankush/language/ExampleInstrumentedTest.kt
@@ -1,13 +1,11 @@
package bose.ankush.language
-import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
-
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
-import org.junit.Assert.*
-
/**
* Instrumented test, which will execute on an Android device.
*
@@ -21,4 +19,4 @@ class ExampleInstrumentedTest {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("bose.ankush.language.test", appContext.packageName)
}
-}
\ No newline at end of file
+}
diff --git a/language/src/main/java/bose/ankush/language/presentation/LanguageScreen.kt b/language/src/main/java/bose/ankush/language/presentation/LanguageScreen.kt
index c17c9a3c..455a2bab 100644
--- a/language/src/main/java/bose/ankush/language/presentation/LanguageScreen.kt
+++ b/language/src/main/java/bose/ankush/language/presentation/LanguageScreen.kt
@@ -82,19 +82,20 @@ fun LanguageScreen(
}
Box(
- modifier = Modifier.fillMaxSize()
+ modifier = Modifier.fillMaxSize(),
) {
Scaffold(
topBar = { ScreenHeader(rememberedNavAction) },
content = { innerPadding ->
AnimatedVisibility(
visibleState = screenTransitionState,
- enter = fadeIn(animationSpec = tween(durationMillis = 400)) +
+ enter =
+ fadeIn(animationSpec = tween(durationMillis = 400)) +
slideInVertically(
animationSpec = tween(durationMillis = 500),
- initialOffsetY = { it / 3 }
+ initialOffsetY = { it / 3 },
),
- exit = fadeOut()
+ exit = fadeOut(),
) {
Column(modifier = Modifier.padding(innerPadding)) {
// Header text with animation
@@ -104,11 +105,11 @@ fun LanguageScreen(
ShowUI(
languages = languages,
- changedLanguage = changedLanguage
+ changedLanguage = changedLanguage,
)
}
}
- }
+ },
)
}
}
@@ -116,15 +117,16 @@ fun LanguageScreen(
@Composable
private fun LanguageScreenHeader() {
Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp)
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = stringResource(R.string.language_screen_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onBackground
+ color = MaterialTheme.colorScheme.onBackground,
)
Spacer(modifier = Modifier.height(4.dp))
@@ -132,7 +134,7 @@ private fun LanguageScreenHeader() {
Text(
text = stringResource(R.string.language_screen_subtitle),
style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
)
}
}
@@ -150,12 +152,13 @@ private fun ScreenHeader(navAction: () -> Unit) {
AnimatedVisibility(
visibleState = headerTransitionState,
- enter = fadeIn(animationSpec = tween(durationMillis = 300)) +
+ enter =
+ fadeIn(animationSpec = tween(durationMillis = 300)) +
slideInVertically(
animationSpec = tween(durationMillis = 300),
- initialOffsetY = { -it / 2 }
+ initialOffsetY = { -it / 2 },
),
- exit = fadeOut()
+ exit = fadeOut(),
) {
TopAppBar(
title = { /* Empty title, we'll use our custom title below */ },
@@ -163,55 +166,58 @@ private fun ScreenHeader(navAction: () -> Unit) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp),
- modifier = Modifier
- .padding(start = 8.dp)
- .size(40.dp)
- .clip(CircleShape)
- .clickable { navAction.invoke() }
+ modifier =
+ Modifier
+ .padding(start = 8.dp)
+ .size(40.dp)
+ .clip(CircleShape)
+ .clickable { navAction.invoke() },
) {
Icon(
painter = painterResource(id = R.drawable.ic_back),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = stringResource(R.string.navigate_back),
- modifier = Modifier.padding(8.dp)
+ modifier = Modifier.padding(8.dp),
)
}
},
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = MaterialTheme.colorScheme.background,
- titleContentColor = MaterialTheme.colorScheme.onBackground
- )
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ ),
)
}
}
-
@Composable
private fun ShowUI(
languages: Array,
- changedLanguage: androidx.compose.runtime.MutableState
+ changedLanguage: androidx.compose.runtime.MutableState,
) {
val listState = rememberLazyListState()
LazyColumn(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- state = listState
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ state = listState,
) {
itemsIndexed(
items = languages,
- key = { _, item -> item }
+ key = { _, item -> item },
) { index, language ->
LanguageItem(
language = language,
index = index,
isSelected = changedLanguage.value == language,
- onLanguageSelected = remember(language) {
- {
- changedLanguage.value = changeLanguageTo(language)
- }
- }
+ onLanguageSelected =
+ remember(language) {
+ {
+ changedLanguage.value = changeLanguageTo(language)
+ }
+ },
)
}
}
@@ -222,17 +228,18 @@ private fun LanguageItem(
language: String,
index: Int,
isSelected: Boolean,
- onLanguageSelected: () -> Unit
+ onLanguageSelected: () -> Unit,
) {
// Create animation for selection
val scale by animateFloatAsState(
targetValue = if (isSelected) 1.02f else 1f,
- animationSpec = spring(
- dampingRatio = Spring.DampingRatioNoBouncy,
- stiffness = Spring.StiffnessHigh,
- visibilityThreshold = 0.005f
- ),
- label = "selection_scale"
+ animationSpec =
+ spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessHigh,
+ visibilityThreshold = 0.005f,
+ ),
+ label = "selection_scale",
)
// Create a staggered animation for items
@@ -245,40 +252,47 @@ private fun LanguageItem(
AnimatedVisibility(
visibleState = itemTransitionState,
- enter = fadeIn(animationSpec = tween(durationMillis = 300)) +
+ enter =
+ fadeIn(animationSpec = tween(durationMillis = 300)) +
slideInVertically(
animationSpec = tween(durationMillis = 400),
- initialOffsetY = { it / 3 }
+ initialOffsetY = { it / 3 },
),
- exit = fadeOut()
+ exit = fadeOut(),
) {
Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp)
- .clickable(onClick = onLanguageSelected)
- .scale(scale),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp)
+ .clickable(onClick = onLanguageSelected)
+ .scale(scale),
shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(
- containerColor = if (isSelected)
- MaterialTheme.colorScheme.primaryContainer
- else
- MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
- ),
- elevation = CardDefaults.cardElevation(
- defaultElevation = 0.dp
- )
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ if (isSelected) {
+ MaterialTheme.colorScheme.primaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
+ },
+ ),
+ elevation =
+ CardDefaults.cardElevation(
+ defaultElevation = 0.dp,
+ ),
) {
Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.weight(1f)
+ modifier = Modifier.weight(1f),
) {
LanguageFlag(language)
@@ -289,10 +303,12 @@ private fun LanguageItem(
text = language.getDisplayName(),
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
- color = if (isSelected)
- MaterialTheme.colorScheme.onPrimaryContainer
- else
- MaterialTheme.colorScheme.onSurface
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
)
}
@@ -310,18 +326,20 @@ private fun LanguageFlag(language: String) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.background,
- modifier = Modifier.size(40.dp)
+ modifier = Modifier.size(40.dp),
) {
Text(
text = language.getCountryFlag(),
fontFamily = FontFamily.Default,
- style = TextStyle(
- platformStyle = PlatformTextStyle(
- emojiSupportMatch = EmojiSupportMatch.None
- )
- ),
+ style =
+ TextStyle(
+ platformStyle =
+ PlatformTextStyle(
+ emojiSupportMatch = EmojiSupportMatch.None,
+ ),
+ ),
textAlign = TextAlign.Center,
- modifier = Modifier.padding(8.dp)
+ modifier = Modifier.padding(8.dp),
)
}
}
@@ -331,13 +349,13 @@ private fun SelectionCheckmark(language: String) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(32.dp)
+ modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.Check,
tint = MaterialTheme.colorScheme.onPrimary,
contentDescription = stringResource(R.string.language_selected, language),
- modifier = Modifier.padding(6.dp)
+ modifier = Modifier.padding(6.dp),
)
}
}
diff --git a/language/src/main/java/bose/ankush/language/util/LocaleHelper.kt b/language/src/main/java/bose/ankush/language/util/LocaleHelper.kt
index 56bf04cc..689e9383 100644
--- a/language/src/main/java/bose/ankush/language/util/LocaleHelper.kt
+++ b/language/src/main/java/bose/ankush/language/util/LocaleHelper.kt
@@ -5,7 +5,6 @@ import androidx.core.os.LocaleListCompat
import java.util.Locale
internal object LocaleHelper {
-
fun String.getCountryFlag(): String {
val countryCode = this.split("-").lastOrNull()?.uppercase(Locale.getDefault()) ?: return ""
@@ -19,16 +18,22 @@ internal object LocaleHelper {
}
fun String.getDisplayName(): String {
- val languageCode = this.split("-").firstOrNull() ?: this
- val locale = Locale(languageCode)
+ val locale =
+ if (this.isBlank()) {
+ Locale.getDefault()
+ } else {
+ Locale.forLanguageTag(this)
+ }
return locale.getDisplayName(locale)
}
fun changeLanguageTo(languageCode: String): String {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(languageCode))
- return AppCompatDelegate.getApplicationLocales().toLanguageTags()
+ return AppCompatDelegate
+ .getApplicationLocales()
+ .toLanguageTags()
.ifEmpty { getDefaultLanguage() }
}
fun getDefaultLanguage(): String = Locale.getDefault().toLanguageTag()
-}
\ No newline at end of file
+}
diff --git a/language/src/main/res/values-bn/strings.xml b/language/src/main/res/values-bn/strings.xml
new file mode 100644
index 00000000..ff6a5d2d
--- /dev/null
+++ b/language/src/main/res/values-bn/strings.xml
@@ -0,0 +1,9 @@
+
+
+ เฆญเฆพเฆทเฆพ เฆฌเงเฆเง เฆจเฆฟเฆจ
+ เฆซเฆฟเฆฐเง เฆฏเฆพเฆจ
+ เฆเฆชเฆจเฆพเฆฐ เฆญเฆพเฆทเฆพ เฆฌเงเฆเง เฆจเฆฟเฆจ
+ เฆฌเงเฆฏเฆเงเฆคเฆฟเฆเฆคเฆเงเฆค เฆ
เฆญเฆฟเฆเงเฆเฆคเฆพเฆฐ เฆเฆจเงเฆฏ เฆเฆชเฆจเฆพเฆฐ เฆชเฆเฆจเงเฆฆเงเฆฐ เฆญเฆพเฆทเฆพ เฆฌเงเฆเง เฆจเฆฟเฆจ
+ %s เฆจเฆฟเฆฐเงเฆฌเฆพเฆเฆฟเฆค
+ เฆชเฆฟเฆเฆจเง เฆจเงเฆญเฆฟเฆเงเฆ เฆเฆฐเงเฆจ
+
diff --git a/language/src/main/res/values-iw/strings.xml b/language/src/main/res/values-iw/strings.xml
index 7c9783d9..246a67bc 100644
--- a/language/src/main/res/values-iw/strings.xml
+++ b/language/src/main/res/values-iw/strings.xml
@@ -2,4 +2,8 @@
ืืืจ ืฉืคื
ืชืืืืจ
+ ืืืจ ืืช ืืฉืคื ืฉืื
+ ืืืจ ืืช ืืฉืคื ืืืืขืืคืช ืขืืื ืืืืืื ืืืชืืืช ืืืฉืืช
+ %s ื ืืืจ
+ ื ืืื ืืืืืจ
\ No newline at end of file
diff --git a/language/src/main/res/values-kn/strings.xml b/language/src/main/res/values-kn/strings.xml
new file mode 100644
index 00000000..21579362
--- /dev/null
+++ b/language/src/main/res/values-kn/strings.xml
@@ -0,0 +1,9 @@
+
+
+ เฒญเฒพเฒทเณ เฒเฒฏเณเฒเณเฒฎเฒพเฒกเฒฟ
+ เฒนเฒฟเฒเฒฆเณ เฒนเณเฒเฒฟ
+ เฒจเฒฟเฒฎเณเฒฎ เฒญเฒพเฒทเณ เฒเฒฏเณเฒเณเฒฎเฒพเฒกเฒฟ
+ เฒตเณเฒฏเฒเณเฒคเฒฟเฒ เฒ
เฒจเณเฒญเฒตเฒเณเฒเฒพเฒเฒฟ เฒจเฒฟเฒฎเณเฒฎ เฒเฒฆเณเฒฏเฒคเณเฒฏ เฒญเฒพเฒทเณ เฒเฒฏเณเฒเณเฒฎเฒพเฒกเฒฟ
+ %s เฒเฒฏเณเฒเณ เฒฎเฒพเฒกเฒฒเฒพเฒเฒฟเฒฆเณ
+ เฒนเฒฟเฒเฒฆเณ เฒจเณเฒฏเฒพเฒตเฒฟเฒเณเฒเณ เฒฎเฒพเฒกเฒฟ
+
diff --git a/language/src/main/res/values-ml/strings.xml b/language/src/main/res/values-ml/strings.xml
new file mode 100644
index 00000000..a7243a32
--- /dev/null
+++ b/language/src/main/res/values-ml/strings.xml
@@ -0,0 +1,9 @@
+
+
+ เดญเดพเดท เดคเดฟเดฐเดเตเดเตเดเตเดเตเดเตเด
+ เดคเดฟเดฐเดฟเดเต เดชเตเดเตเด
+ เดจเดฟเดเตเดเดณเตเดเต เดญเดพเดท เดคเดฟเดฐเดเตเดเตเดเตเดเตเดเตเด
+ เดตเตเดฏเดเตเดคเดฟเดเดค เด
เดจเตเดญเดตเดคเตเดคเดฟเดจเดพเดฏเดฟ เดจเดฟเดเตเดเตพ เดเดทเตเดเดชเตเดชเตเดเตเดจเตเดจ เดญเดพเดท เดคเดฟเดฐเดเตเดเตเดเตเดเตเดเตเด
+ %s เดคเดฟเดฐเดเตเดเตเดเตเดคเตเดคเต
+ เดชเดฟเดฑเดเตเดเตเดเต เดจเดพเดตเดฟเดเตเดฑเตเดฑเต เดเตเดฏเตเดฏเตเด
+
diff --git a/language/src/main/res/values-ta/strings.xml b/language/src/main/res/values-ta/strings.xml
new file mode 100644
index 00000000..7d8a23b1
--- /dev/null
+++ b/language/src/main/res/values-ta/strings.xml
@@ -0,0 +1,9 @@
+
+
+ เฎฎเฏเฎดเฎฟเฎฏเฏ เฎคเฏเฎฐเฏเฎจเฏเฎคเฏเฎเฏเฎเฏเฎเฎตเฏเฎฎเฏ
+ เฎคเฎฟเฎฐเฏเฎฎเฏเฎชเฎฟ เฎเฏเฎฒเฏเฎฒเฎตเฏเฎฎเฏ
+ เฎเฎเฏเฎเฎณเฏ เฎฎเฏเฎดเฎฟเฎฏเฏ เฎคเฏเฎฐเฏเฎจเฏเฎคเฏเฎเฏเฎเฏเฎเฎตเฏเฎฎเฏ
+ เฎคเฎฉเฎฟเฎชเฏเฎชเฎฏเฎฉเฎพเฎเฏเฎเฎชเฏเฎชเฎเฏเฎ เฎ
เฎฉเฏเฎชเฎตเฎคเฏเฎคเฎฟเฎฑเฏเฎเฏ เฎเฎเฏเฎเฎณเฏเฎเฏเฎเฏ เฎตเฎฟเฎฐเฏเฎชเฏเฎชเฎฎเฎพเฎฉ เฎฎเฏเฎดเฎฟเฎฏเฏ เฎคเฏเฎฐเฏเฎจเฏเฎคเฏเฎเฏเฎเฏเฎเฎตเฏเฎฎเฏ
+ %s เฎคเฏเฎฐเฏเฎจเฏเฎคเฏเฎเฏเฎเฏเฎเฎชเฏเฎชเฎเฏเฎเฎคเฏ
+ เฎชเฎฟเฎฉเฏเฎฉเฎพเฎฒเฏ เฎเฏเฎฒเฏเฎฒเฎตเฏเฎฎเฏ
+
diff --git a/language/src/main/res/values-te/strings.xml b/language/src/main/res/values-te/strings.xml
new file mode 100644
index 00000000..7fd89b31
--- /dev/null
+++ b/language/src/main/res/values-te/strings.xml
@@ -0,0 +1,9 @@
+
+
+ เฐญเฐพเฐท เฐเฐเฐเฑเฐเฑเฐเฐกเฐฟ
+ เฐตเฑเฐจเฑเฐเฐเฑ เฐตเฑเฐณเฑเฐณเฑ
+ เฐฎเฑ เฐญเฐพเฐท เฐเฐเฐเฑเฐเฑเฐเฐกเฐฟ
+ เฐตเฑเฐฏเฐเฑเฐคเฐฟเฐเฐค เฐ
เฐจเฑเฐญเฐตเฐ เฐเฑเฐธเฐ เฐฎเฑเฐเฑ เฐเฐทเฑเฐเฐฎเฑเฐจ เฐญเฐพเฐทเฐจเฑ เฐเฐเฐเฑเฐเฑเฐเฐกเฐฟ
+ %s เฐเฐเฐเฑเฐเฑเฐฌเฐกเฐฟเฐเฐฆเฐฟ
+ เฐตเฑเฐจเฑเฐเฐเฑ เฐจเฐพเฐตเฐฟเฐเฑเฐเฑ เฐเฑเฐฏเฐเฐกเฐฟ
+
diff --git a/language/src/test/java/bose/ankush/language/ExampleUnitTest.kt b/language/src/test/java/bose/ankush/language/ExampleUnitTest.kt
index 10f7f92f..365e820f 100644
--- a/language/src/test/java/bose/ankush/language/ExampleUnitTest.kt
+++ b/language/src/test/java/bose/ankush/language/ExampleUnitTest.kt
@@ -1,9 +1,8 @@
package bose.ankush.language
+import org.junit.Assert.assertEquals
import org.junit.Test
-import org.junit.Assert.*
-
/**
* Example local unit test, which will execute on the development machine (host).
*
@@ -14,4 +13,4 @@ class ExampleUnitTest {
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
-}
\ No newline at end of file
+}
diff --git a/local.properties b/local.properties
index 8136714b..7da022eb 100644
--- a/local.properties
+++ b/local.properties
@@ -4,6 +4,6 @@
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
-#Mon Aug 19 11:29:33 IST 2024
+#Thu Apr 16 01:21:59 IST 2026
sdk.dir=/Users/t0304iw/Library/Android/sdk
-OPEN_WEATHER_API=eb1842dacd16299875b9b1eb9299108d
\ No newline at end of file
+RAZORPAY_KEY=rzp_test_SfEkbjOeXT0ilF
\ No newline at end of file
diff --git a/network/build.gradle.kts b/network/build.gradle.kts
index 9b5a4943..c7b8db14 100644
--- a/network/build.gradle.kts
+++ b/network/build.gradle.kts
@@ -1,3 +1,5 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
plugins {
kotlin("multiplatform")
id("com.android.library")
@@ -6,17 +8,15 @@ plugins {
kotlin {
androidTarget {
- compilations.all {
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_17.toString()
- }
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
}
}
listOf(
iosX64(),
iosArm64(),
- iosSimulatorArm64()
+ iosSimulatorArm64(),
).forEach {
it.binaries.framework {
baseName = "network"
@@ -26,6 +26,7 @@ kotlin {
sourceSets {
val commonMain by getting {
dependencies {
+ implementation(project(":storage"))
implementation(KmmDeps.ktorCore)
implementation(KmmDeps.ktorSerialization)
implementation(KmmDeps.ktorContentNegotiation)
@@ -42,15 +43,21 @@ kotlin {
implementation(kotlin("test"))
}
}
+
+ @Suppress("UNUSED_VARIABLE")
val androidMain by getting {
dependencies {
implementation(KmmDeps.ktorAndroid)
}
}
+
+ @Suppress("UNUSED_VARIABLE")
val androidUnitTest by getting
val iosX64Main by getting
val iosArm64Main by getting
val iosSimulatorArm64Main by getting
+
+ @Suppress("UNUSED_VARIABLE")
val iosMain by creating {
dependsOn(commonMain)
iosX64Main.dependsOn(this)
@@ -63,6 +70,8 @@ kotlin {
val iosX64Test by getting
val iosArm64Test by getting
val iosSimulatorArm64Test by getting
+
+ @Suppress("UNUSED_VARIABLE")
val iosTest by creating {
dependsOn(commonTest)
iosX64Test.dependsOn(this)
diff --git a/network/src/androidMain/kotlin/bose/ankush/network/common/AndroidNetworkConnectivity.kt b/network/src/androidMain/kotlin/bose/ankush/network/common/AndroidNetworkConnectivity.kt
index 75a72f8c..525e26b9 100644
--- a/network/src/androidMain/kotlin/bose/ankush/network/common/AndroidNetworkConnectivity.kt
+++ b/network/src/androidMain/kotlin/bose/ankush/network/common/AndroidNetworkConnectivity.kt
@@ -9,14 +9,13 @@ import android.net.NetworkCapabilities
*
*/
class AndroidNetworkConnectivity(
- private val context: Context
+ private val context: Context,
) : NetworkConnectivity {
-
override fun isNetworkAvailable(): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
-
+
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) ||
diff --git a/network/src/androidMain/kotlin/bose/ankush/network/di/AndroidHttpClient.kt b/network/src/androidMain/kotlin/bose/ankush/network/di/AndroidHttpClient.kt
index 5b7a8187..74adaf46 100644
--- a/network/src/androidMain/kotlin/bose/ankush/network/di/AndroidHttpClient.kt
+++ b/network/src/androidMain/kotlin/bose/ankush/network/di/AndroidHttpClient.kt
@@ -1,36 +1,36 @@
package bose.ankush.network.di
+import android.util.Log
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
-import io.ktor.http.ContentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* Android implementation of createPlatformHttpClient
*/
-actual fun createPlatformHttpClient(json: Json): HttpClient {
- return HttpClient(Android) {
+actual fun createPlatformHttpClient(json: Json): HttpClient =
+ HttpClient(Android) {
engine {
connectTimeout = 60_000
socketTimeout = 60_000
}
install(ContentNegotiation) {
+ // Register standard JSON handling once; other content types should be handled
+ // explicitly per request if needed.
json(json)
- // Register for mixed content type (application/json, text/html)
- json(json, contentType = ContentType.parse("application/json, text/html; charset=UTF-8"))
}
install(Logging) {
- logger = object : Logger {
- override fun log(message: String) {
- println("Ktor Android: $message")
+ logger =
+ object : Logger {
+ override fun log(message: String) {
+ Log.d("Ktor Android:", message)
+ }
}
- }
level = LogLevel.INFO
}
}
-}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/FeedbackApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/FeedbackApiService.kt
new file mode 100644
index 00000000..241dc1b5
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/api/FeedbackApiService.kt
@@ -0,0 +1,14 @@
+package bose.ankush.network.api
+
+import bose.ankush.network.model.FeedbackRequest
+import bose.ankush.network.model.FeedbackResponse
+
+/**
+ * API service interface for feedback operations
+ */
+interface FeedbackApiService {
+ /**
+ * Submit user feedback
+ */
+ suspend fun submitFeedback(request: FeedbackRequest): FeedbackResponse
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorFeedbackApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorFeedbackApiService.kt
new file mode 100644
index 00000000..77248b9a
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorFeedbackApiService.kt
@@ -0,0 +1,28 @@
+package bose.ankush.network.api
+
+import bose.ankush.network.model.FeedbackRequest
+import bose.ankush.network.model.FeedbackResponse
+import bose.ankush.network.utils.NetworkUtils
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+
+/**
+ * Ktor implementation of FeedbackApiService
+ */
+class KtorFeedbackApiService(
+ private val httpClient: HttpClient,
+ private val baseUrl: String,
+) : FeedbackApiService {
+ override suspend fun submitFeedback(request: FeedbackRequest): FeedbackResponse =
+ NetworkUtils.retryWithExponentialBackoff {
+ httpClient
+ .post("$baseUrl/feedback") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }.body()
+ }
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorLocationApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorLocationApiService.kt
new file mode 100644
index 00000000..a2cb6cd5
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorLocationApiService.kt
@@ -0,0 +1,42 @@
+package bose.ankush.network.api
+
+import bose.ankush.network.model.ApiResponse
+import bose.ankush.network.model.PlaceSuggestion
+import bose.ankush.network.model.SaveLocationRequest
+import bose.ankush.network.model.SavedLocation
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.request.delete
+import io.ktor.client.request.get
+import io.ktor.client.request.parameter
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+
+/**
+ * Ktor implementation of LocationApiService.
+ */
+class KtorLocationApiService(
+ private val httpClient: HttpClient,
+ private val baseUrl: String,
+) : LocationApiService {
+ override suspend fun saveLocation(request: SaveLocationRequest): ApiResponse =
+ httpClient
+ .post("$baseUrl/save-location") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }.body()
+
+ override suspend fun getSavedLocations(): ApiResponse> =
+ httpClient.get("$baseUrl/saved-places").body()
+
+ override suspend fun deleteLocation(id: String): ApiResponse =
+ httpClient.delete("$baseUrl/saved-places/$id").body()
+
+ override suspend fun searchPlaces(query: String): ApiResponse> =
+ httpClient
+ .get("$baseUrl/search-place") {
+ parameter("q", query)
+ }.body()
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorPaymentApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorPaymentApiService.kt
new file mode 100644
index 00000000..9c35690c
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorPaymentApiService.kt
@@ -0,0 +1,39 @@
+package bose.ankush.network.api
+
+import bose.ankush.network.model.CreateOrderRequest
+import bose.ankush.network.model.CreateOrderResponse
+import bose.ankush.network.model.VerifyPaymentRequest
+import bose.ankush.network.model.VerifyPaymentResponse
+import bose.ankush.network.utils.NetworkUtils
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+
+/**
+ * Ktor implementation of PaymentApiService
+ */
+class KtorPaymentApiService(
+ private val httpClient: HttpClient,
+ private val baseUrl: String,
+) : PaymentApiService {
+ override suspend fun createOrder(request: CreateOrderRequest): CreateOrderResponse =
+ NetworkUtils.retryWithExponentialBackoff {
+ httpClient
+ .post("$baseUrl/create-order") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }.body()
+ }
+
+ override suspend fun verifyPayment(request: VerifyPaymentRequest): VerifyPaymentResponse =
+ NetworkUtils.retryWithExponentialBackoff {
+ httpClient
+ .post("$baseUrl/store-payment") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }.body()
+ }
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorServiceApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorServiceApiService.kt
new file mode 100644
index 00000000..d30ac2a4
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorServiceApiService.kt
@@ -0,0 +1,37 @@
+package bose.ankush.network.api
+
+import bose.ankush.network.model.ServiceListResponse
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.parameter
+
+class KtorServiceApiService(
+ private val httpClient: HttpClient,
+ private val baseUrl: String,
+) : ServiceApiService {
+ override suspend fun getServices(
+ page: Int,
+ pageSize: Int,
+ search: String?,
+ ): Result =
+ try {
+ val response =
+ httpClient
+ .get("$baseUrl/services/public") {
+ parameter("page", page)
+ parameter("pageSize", pageSize)
+ if (!search.isNullOrBlank()) {
+ parameter("search", search)
+ }
+ }.body()
+
+ if (response.success) {
+ Result.success(response)
+ } else {
+ Result.failure(Exception(response.message))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorWeatherApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorWeatherApiService.kt
index 806a3289..4c8f5a51 100644
--- a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorWeatherApiService.kt
+++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorWeatherApiService.kt
@@ -1,6 +1,5 @@
package bose.ankush.network.api
-import bose.ankush.network.model.AirQuality
import bose.ankush.network.model.WeatherForecast
import io.ktor.client.HttpClient
import io.ktor.client.call.body
@@ -12,26 +11,15 @@ import io.ktor.client.request.parameter
*/
class KtorWeatherApiService(
private val httpClient: HttpClient,
- private val baseUrl: String
+ private val baseUrl: String,
) : WeatherApiService {
-
- override suspend fun getCurrentAirQuality(
- latitude: String,
- longitude: String
- ): AirQuality {
- return httpClient.get("$baseUrl/get-air-pollution") {
- parameter("lat", latitude)
- parameter("lon", longitude)
- }.body()
- }
-
override suspend fun getOneCallWeather(
latitude: String,
- longitude: String
- ): WeatherForecast {
- return httpClient.get("$baseUrl/get-weather") {
- parameter("lat", latitude)
- parameter("lon", longitude)
- }.body()
- }
-}
\ No newline at end of file
+ longitude: String,
+ ): WeatherForecast =
+ httpClient
+ .get("$baseUrl/weather") {
+ parameter("lat", latitude)
+ parameter("lon", longitude)
+ }.body()
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/LocationApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/LocationApiService.kt
new file mode 100644
index 00000000..e35c4c81
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/api/LocationApiService.kt
@@ -0,0 +1,23 @@
+package bose.ankush.network.api
+
+import bose.ankush.network.model.ApiResponse
+import bose.ankush.network.model.PlaceSuggestion
+import bose.ankush.network.model.SaveLocationRequest
+import bose.ankush.network.model.SavedLocation
+
+/**
+ * API service for saved locations. All endpoints require a valid JWT (handled by auth interceptor).
+ */
+interface LocationApiService {
+ /** POST /save-location โ save a favourite location. */
+ suspend fun saveLocation(request: SaveLocationRequest): ApiResponse
+
+ /** GET /saved-places โ retrieve all saved locations for the current user. */
+ suspend fun getSavedLocations(): ApiResponse>
+
+ /** DELETE /saved-places/{id} โ remove a saved location by its id. */
+ suspend fun deleteLocation(id: String): ApiResponse
+
+ /** POST /api/v1/places/search โ search for place suggestions by query string. */
+ suspend fun searchPlaces(query: String): ApiResponse>
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/PaymentApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/PaymentApiService.kt
new file mode 100644
index 00000000..a4e30ba7
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/api/PaymentApiService.kt
@@ -0,0 +1,21 @@
+package bose.ankush.network.api
+
+import bose.ankush.network.model.CreateOrderRequest
+import bose.ankush.network.model.CreateOrderResponse
+import bose.ankush.network.model.VerifyPaymentRequest
+import bose.ankush.network.model.VerifyPaymentResponse
+
+/**
+ * API service interface for payment operations
+ */
+interface PaymentApiService {
+ /**
+ * Create an order on backend which in turn calls Razorpay Orders API
+ */
+ suspend fun createOrder(request: CreateOrderRequest): CreateOrderResponse
+
+ /**
+ * Verify payment signature on backend
+ */
+ suspend fun verifyPayment(request: VerifyPaymentRequest): VerifyPaymentResponse
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/ServiceApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/ServiceApiService.kt
new file mode 100644
index 00000000..50b73383
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/api/ServiceApiService.kt
@@ -0,0 +1,11 @@
+package bose.ankush.network.api
+
+import bose.ankush.network.model.ServiceListResponse
+
+interface ServiceApiService {
+ suspend fun getServices(
+ page: Int = 1,
+ pageSize: Int = 20,
+ search: String? = null,
+ ): Result
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/api/WeatherApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/api/WeatherApiService.kt
index 0df7a8af..0fa04887 100644
--- a/network/src/commonMain/kotlin/bose/ankush/network/api/WeatherApiService.kt
+++ b/network/src/commonMain/kotlin/bose/ankush/network/api/WeatherApiService.kt
@@ -1,6 +1,5 @@
package bose.ankush.network.api
-import bose.ankush.network.model.AirQuality
import bose.ankush.network.model.WeatherForecast
/**
@@ -8,24 +7,14 @@ import bose.ankush.network.model.WeatherForecast
*/
interface WeatherApiService {
/**
- * Get current air quality for a location
+ * Get unified weather data for a location (current, hourly, daily, alerts, air quality).
+ * Air quality and premium-only fields are null for free tier users.
* @param latitude Latitude of the location
* @param longitude Longitude of the location
- * @return AirQuality
- */
- suspend fun getCurrentAirQuality(
- latitude: String,
- longitude: String
- ): AirQuality
-
- /**
- * Get weather forecast for a location
- * @param latitude Latitude of the location
- * @param longitude Longitude of the location
- * @return WeatherForecast
+ * @return WeatherForecast containing all available data for the user's subscription tier
*/
suspend fun getOneCallWeather(
latitude: String,
- longitude: String
+ longitude: String,
): WeatherForecast
-}
\ No newline at end of file
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/api/AuthApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/api/AuthApiService.kt
new file mode 100644
index 00000000..7def4270
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/api/AuthApiService.kt
@@ -0,0 +1,39 @@
+package bose.ankush.network.auth.api
+
+import bose.ankush.network.auth.model.AuthResponse
+import bose.ankush.network.auth.model.LoginRequest
+import bose.ankush.network.auth.model.LogoutResponse
+import bose.ankush.network.auth.model.RefreshTokenRequest
+import bose.ankush.network.auth.model.RegisterRequest
+
+/**
+ * API service interface for authentication operations
+ */
+interface AuthApiService {
+ /**
+ * Login with email and password
+ * @param request LoginRequest containing email and password
+ * @return AuthResponse with JWT token
+ */
+ suspend fun login(request: LoginRequest): AuthResponse
+
+ /**
+ * Register with email and password
+ * @param request RegisterRequest containing email and password
+ * @return AuthResponse with JWT token
+ */
+ suspend fun register(request: RegisterRequest): AuthResponse
+
+ /**
+ * Refresh JWT token
+ * @param request RefreshTokenRequest containing the expired token
+ * @return AuthResponse with new JWT token
+ */
+ suspend fun refreshToken(request: RefreshTokenRequest): AuthResponse
+
+ /**
+ * Logout the current user
+ * @return LogoutResponse indicating success or failure
+ */
+ suspend fun logout(): LogoutResponse
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/api/KtorAuthApiService.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/api/KtorAuthApiService.kt
new file mode 100644
index 00000000..0925cd86
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/api/KtorAuthApiService.kt
@@ -0,0 +1,54 @@
+package bose.ankush.network.auth.api
+
+import bose.ankush.network.auth.model.AuthResponse
+import bose.ankush.network.auth.model.LoginRequest
+import bose.ankush.network.auth.model.LogoutResponse
+import bose.ankush.network.auth.model.RefreshTokenRequest
+import bose.ankush.network.auth.model.RegisterRequest
+import bose.ankush.network.utils.NetworkUtils
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+
+/**
+ * Ktor implementation of AuthApiService
+ */
+class KtorAuthApiService(
+ private val httpClient: HttpClient,
+ private val baseUrl: String,
+) : AuthApiService {
+ override suspend fun login(request: LoginRequest): AuthResponse =
+ NetworkUtils.retryWithExponentialBackoff {
+ httpClient
+ .post("$baseUrl/login") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }.body()
+ }
+
+ override suspend fun register(request: RegisterRequest): AuthResponse =
+ NetworkUtils.retryWithExponentialBackoff {
+ httpClient
+ .post("$baseUrl/register") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }.body()
+ }
+
+ override suspend fun refreshToken(request: RefreshTokenRequest): AuthResponse =
+ NetworkUtils.retryWithExponentialBackoff {
+ httpClient
+ .post("$baseUrl/refresh-token") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }.body()
+ }
+
+ override suspend fun logout(): LogoutResponse =
+ NetworkUtils.retryWithExponentialBackoff {
+ httpClient.post("$baseUrl/logout").body()
+ }
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/events/AuthEvents.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/events/AuthEvents.kt
new file mode 100644
index 00000000..7f348645
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/events/AuthEvents.kt
@@ -0,0 +1,31 @@
+package bose.ankush.network.auth.events
+
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+
+/**
+ * Global authentication-related events emitted from the network layer.
+ * The app layer can observe these to react (e.g., navigate to Login on 401).
+ */
+sealed class AuthEvent {
+ data class Unauthorized(
+ val message: String,
+ ) : AuthEvent()
+}
+
+object AuthEventBus {
+ private val _events =
+ MutableSharedFlow(
+ replay = 1,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+ val events: SharedFlow = _events
+
+ /** Suspends only if buffer == capacity after dropping oldest. */
+ suspend fun emit(event: AuthEvent) = _events.emit(event)
+
+ /** Never suspends; drops oldest if full. */
+ fun tryEmit(event: AuthEvent): Boolean = _events.tryEmit(event)
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/interceptor/AuthInterceptor.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/interceptor/AuthInterceptor.kt
new file mode 100644
index 00000000..f1f0571a
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/interceptor/AuthInterceptor.kt
@@ -0,0 +1,88 @@
+@file:Suppress("ktlint:standard:max-line-length")
+
+package bose.ankush.network.auth.interceptor
+
+import bose.ankush.network.auth.events.AuthEvent
+import bose.ankush.network.auth.events.AuthEventBus
+import bose.ankush.network.auth.token.TokenManager
+import bose.ankush.network.auth.token.TokenResult
+import bose.ankush.storage.api.TokenStorage
+import io.ktor.client.HttpClientConfig
+import io.ktor.client.plugins.api.Send
+import io.ktor.client.plugins.api.createClientPlugin
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.request.header
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpStatusCode
+import kotlinx.coroutines.runBlocking
+
+/**
+ * Helper function to configure a HttpClient with authentication
+ *
+ * @param tokenManager The manager for JWT tokens
+ * @return A configured HttpClient with authentication headers and token refresh
+ */
+fun HttpClientConfig<*>.configureAuth(tokenManager: TokenManager) {
+ install(
+ createClientPlugin("AuthTokenPlugin") {
+ on(Send) { request ->
+ // Attach stored token before sending โ no proactive refresh here
+ tokenManager.getStoredToken()?.takeIf { it.isNotBlank() }?.let { token ->
+ request.headers.append(HttpHeaders.Authorization, "Bearer $token")
+ }
+
+ val originalCall = proceed(request)
+
+ // On 401, refresh token and retry once
+ if (originalCall.response.status == HttpStatusCode.Unauthorized) {
+ when (val refreshResult = tokenManager.handleUnauthorized()) {
+ is TokenResult.Valid -> {
+ request.headers.remove(HttpHeaders.Authorization)
+ request.headers.append(
+ HttpHeaders.Authorization,
+ "Bearer ${refreshResult.token}",
+ )
+ proceed(request)
+ }
+
+ is TokenResult.Error -> {
+ val event =
+ AuthEvent.Unauthorized(
+ message = "Network error during re-authentication: ${refreshResult.exception.message}",
+ )
+ AuthEventBus.tryEmit(event)
+ originalCall
+ }
+
+ is TokenResult.InvalidToken, is TokenResult.NoToken -> {
+ tokenManager.forceLogout()
+ val event =
+ AuthEvent.Unauthorized(
+ message = "For security, please log in again to continue using the app.",
+ )
+ AuthEventBus.tryEmit(event)
+ originalCall
+ }
+ }
+ } else {
+ originalCall
+ }
+ }
+ },
+ )
+}
+
+/**
+ * Legacy helper function to maintain backward compatibility
+ * @param tokenStorage The storage for authentication tokens
+ */
+fun HttpClientConfig<*>.configureAuth(tokenStorage: TokenStorage) {
+ defaultRequest {
+ runBlocking {
+ val token = tokenStorage.getToken()
+ if (!token.isNullOrBlank()) {
+ header("Authorization", "Bearer $token")
+ }
+ }
+ }
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/model/AuthModels.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/model/AuthModels.kt
new file mode 100644
index 00000000..42a6d8a1
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/model/AuthModels.kt
@@ -0,0 +1,78 @@
+package bose.ankush.network.auth.model
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Request model for login operation
+ */
+@Serializable
+data class LoginRequest(
+ val email: String,
+ val password: String,
+)
+
+/**
+ * Request model for register operation
+ */
+@Serializable
+data class RegisterRequest(
+ val email: String,
+ val password: String,
+ val timestampOfRegistration: String? = null,
+ val deviceModel: String? = null,
+ val operatingSystem: String? = null,
+ val osVersion: String? = null,
+ val appVersion: String? = null,
+ val registrationSource: String? = null,
+ val firebaseToken: String? = null,
+)
+
+/**
+ * Request model for token refresh operation
+ */
+@Serializable
+data class RefreshTokenRequest(
+ val token: String,
+)
+
+/**
+ * Data class for authentication response data.
+ * Defaults allow this to be used for both success and error shapes
+ * (e.g. TOKEN_NOT_EXPIRED only has errorCode, no token/email).
+ */
+@Serializable
+data class AuthData(
+ val token: String = "",
+ val email: String = "",
+ val isActive: Boolean = false,
+ val isPremium: Boolean = false,
+ val premiumExpiresAt: String? = null,
+ val errorCode: String? = null,
+)
+
+/**
+ * Response model for authentication operations
+ */
+@Serializable
+data class AuthResponse(
+ val success: Boolean? = null,
+ val status: Boolean = false,
+ val message: String? = null,
+ val data: AuthData? = null,
+) {
+ fun isSuccess(): Boolean = success ?: status
+}
+
+@Serializable
+data class LogoutErrorData(
+ val errorType: String? = null,
+ val errorMessage: String? = null,
+ val errorClass: String? = null,
+ val endpoint: String? = null,
+)
+
+@Serializable
+data class LogoutResponse(
+ val message: String? = null,
+ val data: LogoutErrorData? = null,
+)
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepository.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepository.kt
new file mode 100644
index 00000000..25a2ecd0
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepository.kt
@@ -0,0 +1,67 @@
+package bose.ankush.network.auth.repository
+
+import bose.ankush.network.auth.model.AuthResponse
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Repository interface for authentication operations
+ */
+interface AuthRepository {
+ /**
+ * Login with email and password
+ * @param email User's email
+ * @param password User's password
+ * @return Flow of AuthResponse
+ */
+ suspend fun login(
+ email: String,
+ password: String,
+ ): AuthResponse
+
+ /**
+ * Register with email and password and additional device information
+ * @param email User's email
+ * @param password User's password
+ * @param timestampOfRegistration UTC timestamp of registration
+ * @param deviceModel Device model (e.g., "Pixel 7 Pro")
+ * @param operatingSystem Operating system (e.g., "Android")
+ * @param osVersion Operating system version (e.g., "14")
+ * @param appVersion App version
+ * @param registrationSource Registration source (e.g., "Android App")
+ * @return AuthResponse
+ */
+ suspend fun register(
+ email: String,
+ password: String,
+ timestampOfRegistration: String? = null,
+ deviceModel: String? = null,
+ operatingSystem: String? = null,
+ osVersion: String? = null,
+ appVersion: String? = null,
+ registrationSource: String? = null,
+ firebaseToken: String? = null,
+ ): AuthResponse
+
+ /**
+ * Check if user is logged in
+ * @return Flow of Boolean indicating login status
+ */
+ fun isLoggedIn(): Flow
+
+ /**
+ * Get the current JWT token
+ * @return The JWT token or null if not logged in
+ */
+ suspend fun getToken(): String?
+
+ /**
+ * Refresh the JWT token
+ * @return AuthResponse with new token
+ */
+ suspend fun refreshToken(): AuthResponse?
+
+ /**
+ * Logout the user
+ */
+ suspend fun logout(): Result
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepositoryImpl.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepositoryImpl.kt
new file mode 100644
index 00000000..b74dd10c
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepositoryImpl.kt
@@ -0,0 +1,107 @@
+package bose.ankush.network.auth.repository
+
+import bose.ankush.network.auth.api.AuthApiService
+import bose.ankush.network.auth.model.AuthResponse
+import bose.ankush.network.auth.model.LoginRequest
+import bose.ankush.network.auth.model.RefreshTokenRequest
+import bose.ankush.network.auth.model.RegisterRequest
+import bose.ankush.storage.api.TokenStorage
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Implementation of AuthRepository
+ */
+class AuthRepositoryImpl(
+ private val apiService: AuthApiService,
+ private val tokenStorage: TokenStorage,
+) : AuthRepository {
+ override suspend fun login(
+ email: String,
+ password: String,
+ ): AuthResponse {
+ val request = LoginRequest(email = email, password = password)
+ val response = apiService.login(request)
+
+ // Save token on successful login
+ val token = response.data?.token
+ if (response.isSuccess() && !token.isNullOrBlank()) {
+ tokenStorage.saveToken(token)
+ }
+
+ return response
+ }
+
+ override suspend fun register(
+ email: String,
+ password: String,
+ timestampOfRegistration: String?,
+ deviceModel: String?,
+ operatingSystem: String?,
+ osVersion: String?,
+ appVersion: String?,
+ registrationSource: String?,
+ firebaseToken: String?,
+ ): AuthResponse {
+ val request =
+ RegisterRequest(
+ email = email,
+ password = password,
+ timestampOfRegistration = timestampOfRegistration,
+ deviceModel = deviceModel,
+ operatingSystem = operatingSystem,
+ osVersion = osVersion,
+ appVersion = appVersion,
+ registrationSource = registrationSource,
+ firebaseToken = firebaseToken,
+ )
+ val response = apiService.register(request)
+
+ // Save token on successful registration
+ val token = response.data?.token
+ if (response.isSuccess() && !token.isNullOrBlank()) {
+ tokenStorage.saveToken(token)
+ }
+
+ return response
+ }
+
+ override fun isLoggedIn(): Flow = tokenStorage.hasToken()
+
+ override suspend fun getToken(): String? = tokenStorage.getToken()
+
+ override suspend fun refreshToken(): AuthResponse? {
+ val currentToken = tokenStorage.getToken() ?: return null
+
+ val request = RefreshTokenRequest(token = currentToken)
+ val response = apiService.refreshToken(request)
+
+ // Save new token on successful refresh
+ val token = response.data?.token
+ if (response.isSuccess() && !token.isNullOrBlank()) {
+ tokenStorage.saveToken(token)
+ }
+
+ return response
+ }
+
+ override suspend fun logout(): Result =
+ try {
+ val response = apiService.logout()
+ val isSuccess = response.data == null
+ if (isSuccess) {
+ tokenStorage.clearToken()
+ Result.success(Unit)
+ } else {
+ val errorMsg = response.data.errorMessage
+ val message =
+ if (!errorMsg.isNullOrBlank()) {
+ errorMsg
+ } else {
+ response.message ?: "Logout failed"
+ }
+ Result.failure(Exception(message))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenManager.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenManager.kt
new file mode 100644
index 00000000..2443da78
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenManager.kt
@@ -0,0 +1,71 @@
+package bose.ankush.network.auth.token
+
+import bose.ankush.network.auth.repository.AuthRepository
+import bose.ankush.storage.api.TokenStorage
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.datetime.Clock
+
+/**
+ * Handles JWT token lifecycle including validation and refresh.
+ */
+class TokenManager(
+ private val tokenStorage: TokenStorage,
+ private val authRepository: AuthRepository,
+) {
+ private val refreshMutex = Mutex()
+ private var lastRefreshTime: Long = 0
+
+ /**
+ * Refreshes the token if possible.
+ */
+ suspend fun refreshToken(currentTime: Long = Clock.System.now().epochSeconds): TokenResult =
+ refreshMutex.withLock {
+ lastRefreshTime = currentTime
+ try {
+ val response =
+ authRepository.refreshToken()
+ ?: return TokenResult.NoToken
+ val newToken = response.data?.token
+ if (response.isSuccess() && !newToken.isNullOrBlank()) {
+ tokenStorage.saveToken(newToken)
+ return TokenResult.Valid(newToken)
+ }
+ // If token is missing in a successful response, treat as no token
+ if (response.isSuccess() && newToken.isNullOrBlank()) {
+ return TokenResult.NoToken
+ }
+ return TokenResult.InvalidToken(response.data?.errorCode)
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ return TokenResult.Error(e)
+ }
+ }
+
+ /**
+ * Returns the currently stored token without attempting a refresh.
+ * Used by the auth interceptor to attach a token to every outgoing request.
+ */
+ suspend fun getStoredToken(): String? = tokenStorage.getToken()
+
+ /**
+ * Handles 401 Unauthorized by forcing a token refresh.
+ */
+ suspend fun handleUnauthorized(): TokenResult {
+ lastRefreshTime = 0
+ return refreshToken()
+ }
+
+ /**
+ * Forces logout by clearing any stored token.
+ */
+ suspend fun forceLogout(): TokenResult =
+ try {
+ tokenStorage.clearToken()
+ TokenResult.NoToken
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ TokenResult.Error(e)
+ }
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenResult.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenResult.kt
new file mode 100644
index 00000000..55751cbe
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenResult.kt
@@ -0,0 +1,17 @@
+package bose.ankush.network.auth.token
+
+sealed class TokenResult {
+ data class Valid(
+ val token: String,
+ ) : TokenResult()
+
+ data object NoToken : TokenResult()
+
+ data class InvalidToken(
+ val errorCode: String?,
+ ) : TokenResult()
+
+ data class Error(
+ val exception: Exception,
+ ) : TokenResult()
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/utils/PremiumUtils.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/utils/PremiumUtils.kt
new file mode 100644
index 00000000..317bb053
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/utils/PremiumUtils.kt
@@ -0,0 +1,19 @@
+package bose.ankush.network.auth.utils
+
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+
+/**
+ * Returns true if the user's premium subscription is currently active.
+ *
+ * Derives premium status from [premiumExpiresAt] (ISO-8601 UTC string) at the point of
+ * use rather than relying on the stored boolean flag, which may be stale between requests.
+ */
+fun isPremiumActive(premiumExpiresAt: String?): Boolean {
+ if (premiumExpiresAt == null) return false
+ return try {
+ Clock.System.now() < Instant.parse(premiumExpiresAt)
+ } catch (_: Exception) {
+ false
+ }
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkConnectivity.kt b/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkConnectivity.kt
index 7048413d..167441ec 100644
--- a/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkConnectivity.kt
+++ b/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkConnectivity.kt
@@ -10,4 +10,4 @@ interface NetworkConnectivity {
* @return true if network is available, false otherwise
*/
fun isNetworkAvailable(): Boolean
-}
\ No newline at end of file
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkException.kt b/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkException.kt
new file mode 100644
index 00000000..6bdc69ad
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/common/NetworkException.kt
@@ -0,0 +1,44 @@
+package bose.ankush.network.common
+
+/**
+ * Exception class for network-related errors
+ * Encapsulates error codes and messages for better error handling
+ */
+class NetworkException(
+ val errorCode: Int,
+ override val message: String,
+ override val cause: Throwable? = null,
+) : Exception(message, cause) {
+ companion object {
+ // Common HTTP error codes
+ const val BAD_REQUEST = 400
+ const val UNAUTHORIZED = 401
+ const val FORBIDDEN = 403
+ const val NOT_FOUND = 404
+ const val SERVER_ERROR = 500
+ const val SERVICE_UNAVAILABLE = 503
+
+ // Network-specific error codes
+ const val NETWORK_UNAVAILABLE = 1000
+ const val TIMEOUT = 1001
+ const val UNKNOWN_HOST = 1002
+ const val UNKNOWN_ERROR = 1999
+
+ /**
+ * Create a NetworkException from a generic exception
+ * Attempts to extract error code if possible, otherwise uses a default code
+ */
+ fun fromException(e: Exception): NetworkException {
+ // Extract error code from exception message if possible
+ val errorCodeRegex = Regex("(\\d{3})")
+ val errorCodeMatch = errorCodeRegex.find(e.message ?: "")
+ val errorCode = errorCodeMatch?.value?.toIntOrNull() ?: UNKNOWN_ERROR
+
+ return NetworkException(
+ errorCode = errorCode,
+ message = e.message ?: "Unknown error",
+ cause = e,
+ )
+ }
+ }
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/di/NetworkModule.kt b/network/src/commonMain/kotlin/bose/ankush/network/di/NetworkModule.kt
index ac8d8a2b..1b055427 100644
--- a/network/src/commonMain/kotlin/bose/ankush/network/di/NetworkModule.kt
+++ b/network/src/commonMain/kotlin/bose/ankush/network/di/NetworkModule.kt
@@ -1,11 +1,36 @@
package bose.ankush.network.di
+import bose.ankush.network.api.FeedbackApiService
+import bose.ankush.network.api.KtorFeedbackApiService
+import bose.ankush.network.api.KtorLocationApiService
+import bose.ankush.network.api.KtorPaymentApiService
+import bose.ankush.network.api.KtorServiceApiService
import bose.ankush.network.api.KtorWeatherApiService
-import bose.ankush.network.utils.NetworkConstants
+import bose.ankush.network.api.PaymentApiService
+import bose.ankush.network.api.ServiceApiService
+import bose.ankush.network.auth.api.KtorAuthApiService
+import bose.ankush.network.auth.interceptor.configureAuth
+import bose.ankush.network.auth.repository.AuthRepository
+import bose.ankush.network.auth.repository.AuthRepositoryImpl
+import bose.ankush.network.auth.token.TokenManager
import bose.ankush.network.common.NetworkConnectivity
+import bose.ankush.network.repository.FeedbackRepository
+import bose.ankush.network.repository.FeedbackRepositoryImpl
+import bose.ankush.network.repository.LocationRepository
+import bose.ankush.network.repository.LocationRepositoryImpl
+import bose.ankush.network.repository.ServiceRepository
+import bose.ankush.network.repository.ServiceRepositoryImpl
import bose.ankush.network.repository.WeatherRepository
import bose.ankush.network.repository.WeatherRepositoryImpl
+import bose.ankush.network.utils.NetworkConstants
+import bose.ankush.storage.api.TokenStorage
import io.ktor.client.HttpClient
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.logging.LogLevel
+import io.ktor.client.plugins.logging.Logger
+import io.ktor.client.plugins.logging.Logging
+import io.ktor.client.plugins.logging.SIMPLE
+import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
@@ -14,21 +39,199 @@ import kotlinx.serialization.json.Json
*/
expect fun createPlatformHttpClient(json: Json): HttpClient
+/**
+ * Creates a basic HttpClient without auth.
+ */
+@Suppress("unused")
+fun createBasicHttpClient(): HttpClient {
+ val json =
+ Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ prettyPrint = false
+ encodeDefaults = true
+ coerceInputValues = true
+ }
+ val client = createPlatformHttpClient(json)
+ return client.config {
+ install(ContentNegotiation) {
+ json(json)
+ }
+ }
+}
+
+/**
+ * Creates a TokenManager instance
+ * @param tokenStorage The storage for authentication tokens
+ * @param authRepository The repository for authentication operations
+ * @return A TokenManager instance
+ */
+fun createTokenManager(
+ tokenStorage: TokenStorage,
+ authRepository: AuthRepository,
+): TokenManager = TokenManager(tokenStorage, authRepository)
+
/**
* Factory function to create a WeatherRepository instance
* This is useful for non-Koin consumers of the network module
+ *
+ * @param networkConnectivity The network connectivity checker
+ * @param tokenStorage The storage for authentication tokens (required for JWT authentication)
+ * @param baseUrl The base URL for API requests
+ * @return A WeatherRepository instance with authentication
*/
fun createWeatherRepository(
networkConnectivity: NetworkConnectivity,
- baseUrl: String = NetworkConstants.WEATHER_BASE_URL
+ tokenStorage: TokenStorage,
+ baseUrl: String = NetworkConstants.WEATHER_BASE_URL,
): WeatherRepository {
- val json = Json {
- ignoreUnknownKeys = true
- isLenient = true
- prettyPrint = false
- encodeDefaults = true
- }
- val httpClient = createPlatformHttpClient(json)
+ // Create AuthRepository first (needed for TokenManager)
+ val authRepository = createAuthRepository(tokenStorage, baseUrl)
+
+ // Create TokenManager
+ val tokenManager = createTokenManager(tokenStorage, authRepository)
+
+ // Create an authenticated HttpClient that will include the JWT token in requests
+ val httpClient = createAuthenticatedHttpClient(tokenManager)
val apiService = KtorWeatherApiService(httpClient, baseUrl)
return WeatherRepositoryImpl(apiService, networkConnectivity)
}
+
+/**
+ * Factory function to create a [PaymentApiService] with an authenticated HTTP client.
+ * Consumed by the feature-payment module's Koin DI setup in the host application.
+ */
+fun createPaymentApiService(
+ tokenStorage: TokenStorage,
+ baseUrl: String = NetworkConstants.WEATHER_BASE_URL,
+): PaymentApiService {
+ val authRepository = createAuthRepository(tokenStorage, baseUrl)
+ val tokenManager = createTokenManager(tokenStorage, authRepository)
+ val httpClient = createAuthenticatedHttpClient(tokenManager)
+ return KtorPaymentApiService(httpClient, baseUrl)
+}
+
+/**
+ * Creates an HttpClient with authentication configuration using TokenManager
+ * @param tokenManager The manager for JWT tokens
+ * @return An HttpClient configured with authentication and token refresh
+ */
+fun createAuthenticatedHttpClient(tokenManager: TokenManager): HttpClient {
+ val json =
+ Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ prettyPrint = false
+ encodeDefaults = true
+ coerceInputValues = true
+ }
+
+ // Create a platform-specific HttpClient with authentication configuration
+ val client = createPlatformHttpClient(json)
+ return client.config {
+ // Install ContentNegotiation plugin
+ install(ContentNegotiation) {
+ json(json)
+ }
+
+ // Add authentication configuration with token refresh
+ configureAuth(tokenManager)
+
+ // Install Logging plugin - SECURITY: Use LogLevel.NONE in production to prevent JWT token exposure in logs
+ // Debug mode can be enabled per-platform in androidMain/iosMain if needed
+ install(Logging) {
+ logger = Logger.SIMPLE
+ level = LogLevel.NONE
+ }
+ }
+}
+
+/**
+ * Legacy function for backward compatibility
+ * Creates an HttpClient with basic authentication configuration
+ * @param tokenStorage The storage for authentication tokens
+ * @return An HttpClient configured with authentication (no token refresh)
+ */
+fun createAuthenticatedHttpClient(tokenStorage: TokenStorage): HttpClient {
+ val json =
+ Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ prettyPrint = false
+ encodeDefaults = true
+ coerceInputValues = true
+ }
+
+ // Create a platform-specific HttpClient with authentication configuration
+ val client = createPlatformHttpClient(json)
+ return client.config {
+ // Install ContentNegotiation plugin
+ install(ContentNegotiation) {
+ json(json)
+ }
+
+ // Add authentication configuration
+ configureAuth(tokenStorage)
+
+ // Install Logging plugin - SECURITY: Use LogLevel.NONE in production to prevent JWT token exposure in logs
+ // Debug mode can be enabled per-platform in androidMain/iosMain if needed
+ install(Logging) {
+ logger = Logger.SIMPLE
+ level = LogLevel.NONE
+ }
+ }
+}
+
+/**
+ * Factory function to create an AuthRepository instance
+ * This is useful for non-Koin consumers of the network module
+ */
+fun createAuthRepository(
+ tokenStorage: TokenStorage,
+ baseUrl: String = NetworkConstants.WEATHER_BASE_URL,
+): AuthRepository {
+ // Use the legacy HttpClient for AuthRepository to avoid circular dependency
+ val httpClient = createAuthenticatedHttpClient(tokenStorage)
+ val apiService = KtorAuthApiService(httpClient, baseUrl)
+ return AuthRepositoryImpl(apiService, tokenStorage)
+}
+
+/**
+ * Factory function to create a LocationRepository instance.
+ * Uses an authenticated HTTP client so all requests carry a valid JWT.
+ */
+fun createLocationRepository(
+ tokenStorage: TokenStorage,
+ baseUrl: String = NetworkConstants.WEATHER_BASE_URL,
+): LocationRepository {
+ val authRepository = createAuthRepository(tokenStorage, baseUrl)
+ val tokenManager = createTokenManager(tokenStorage, authRepository)
+ val httpClient = createAuthenticatedHttpClient(tokenManager)
+ val apiService = KtorLocationApiService(httpClient, baseUrl)
+ return LocationRepositoryImpl(apiService)
+}
+
+/**
+ * Factory function to create a FeedbackRepository instance
+ */
+fun createFeedbackRepository(
+ networkConnectivity: NetworkConnectivity,
+ tokenStorage: TokenStorage,
+ baseUrl: String = NetworkConstants.WEATHER_BASE_URL,
+): FeedbackRepository {
+ val authRepository = createAuthRepository(tokenStorage, baseUrl)
+ val tokenManager = createTokenManager(tokenStorage, authRepository)
+ val httpClient = createAuthenticatedHttpClient(tokenManager)
+ val apiService: FeedbackApiService = KtorFeedbackApiService(httpClient, baseUrl)
+ return FeedbackRepositoryImpl(apiService, networkConnectivity)
+}
+
+/**
+ * Factory function to create a ServiceRepository instance.
+ * Uses a basic HTTP client; the /services/public endpoint requires NO authentication.
+ */
+fun createServiceRepository(baseUrl: String = NetworkConstants.WEATHER_BASE_URL): ServiceRepository {
+ val httpClient = createBasicHttpClient()
+ val apiService: ServiceApiService = KtorServiceApiService(httpClient, baseUrl)
+ return ServiceRepositoryImpl(apiService)
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/domain/SavedLocationsUseCase.kt b/network/src/commonMain/kotlin/bose/ankush/network/domain/SavedLocationsUseCase.kt
new file mode 100644
index 00000000..024e061e
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/domain/SavedLocationsUseCase.kt
@@ -0,0 +1,22 @@
+package bose.ankush.network.domain
+
+import bose.ankush.network.model.SavedLocation
+import bose.ankush.network.repository.LocationRepository
+
+/**
+ * Use case for managing saved locations.
+ * Wraps LocationRepository methods with KMP-compatible interface.
+ */
+class SavedLocationsUseCase(
+ private val repository: LocationRepository,
+) {
+ suspend fun getSavedLocations(): Result> = repository.getSavedLocations()
+
+ suspend fun saveLocation(
+ name: String,
+ lat: Double,
+ lon: Double,
+ ): Result = repository.saveLocation(name, lat, lon)
+
+ suspend fun deleteLocation(id: String): Result = repository.deleteLocation(id)
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/domain/SearchPlacesUseCase.kt b/network/src/commonMain/kotlin/bose/ankush/network/domain/SearchPlacesUseCase.kt
new file mode 100644
index 00000000..2718e961
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/domain/SearchPlacesUseCase.kt
@@ -0,0 +1,15 @@
+package bose.ankush.network.domain
+
+import bose.ankush.network.model.PlaceSuggestion
+import bose.ankush.network.repository.LocationRepository
+
+/**
+ * Use case for searching places by query string.
+ * Wraps LocationRepository.searchPlaces with KMP-compatible interface.
+ */
+class SearchPlacesUseCase(
+ private val repository: LocationRepository,
+) {
+ suspend operator fun invoke(query: String): Result> =
+ repository.searchPlaces(query)
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/AirQuality.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/AirQuality.kt
index 9bbb5d23..f01f98db 100644
--- a/network/src/commonMain/kotlin/bose/ankush/network/model/AirQuality.kt
+++ b/network/src/commonMain/kotlin/bose/ankush/network/model/AirQuality.kt
@@ -1,18 +1,57 @@
package bose.ankush.network.model
+import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
-/**
- * Domain model for air quality data
- */
@Serializable
data class AirQuality(
- val id: Long? = null,
- val aqi: Int = 0,
- val co: Double = 0.0,
- val no2: Double = 0.0,
- val o3: Double = 0.0,
- val so2: Double = 0.0,
- val pm10: Double = 0.0,
- val pm25: Double = 0.0,
-)
\ No newline at end of file
+ @SerialName("data")
+ val `data`: Data?,
+ @SerialName("message")
+ val message: String?,
+ @SerialName("status")
+ val status: Boolean?,
+) {
+ @Serializable
+ data class Data(
+ @SerialName("list")
+ val list: List?,
+ ) {
+ @Serializable
+ data class Item9(
+ @SerialName("components")
+ val components: Components?,
+ @SerialName("dt")
+ val dt: Int?,
+ @SerialName("main")
+ val main: Main?,
+ ) {
+ @Serializable
+ data class Components(
+ @SerialName("co")
+ val co: Double?,
+ @SerialName("nh3")
+ val nh3: Double?,
+ @SerialName("no")
+ val no: Double?,
+ @SerialName("no2")
+ val no2: Double?,
+ @SerialName("o3")
+ val o3: Double?,
+ @SerialName("pm10")
+ val pm10: Double?,
+ @SerialName("pm2_5")
+ val pm25: Double?,
+ @SerialName("so2")
+ val so2: Double?,
+ )
+
+ @Serializable
+ data class Main(
+ @SerialName("aqi")
+ val aqi: Int?,
+ )
+ }
+ }
+
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/FeedbackModels.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/FeedbackModels.kt
new file mode 100644
index 00000000..9a39c0d3
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/model/FeedbackModels.kt
@@ -0,0 +1,19 @@
+package bose.ankush.network.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class FeedbackRequest(
+ @SerialName("deviceId") val deviceId: String,
+ @SerialName("deviceOs") val deviceOs: String,
+ @SerialName("feedbackTitle") val feedbackTitle: String,
+ @SerialName("feedbackDescription") val feedbackDescription: String,
+)
+
+@Serializable
+data class FeedbackResponse(
+ val success: Boolean = false,
+ val data: String? = null, // feedback id
+ val message: String? = null,
+)
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/LocationModels.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/LocationModels.kt
new file mode 100644
index 00000000..f50ed907
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/model/LocationModels.kt
@@ -0,0 +1,80 @@
+package bose.ankush.network.model
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.jsonPrimitive
+
+/**
+ * Handles MongoDB Extended JSON ObjectId format {"$oid": "..."} and plain strings.
+ */
+private object ObjectIdAsStringSerializer : KSerializer {
+ override val descriptor = PrimitiveSerialDescriptor("ObjectId", PrimitiveKind.STRING)
+
+ override fun serialize(encoder: Encoder, value: String) = encoder.encodeString(value)
+
+ override fun deserialize(decoder: Decoder): String {
+ val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString()
+ return when (val element = jsonDecoder.decodeJsonElement()) {
+ is JsonObject -> element["\$oid"]?.jsonPrimitive?.content ?: element.toString()
+ is JsonPrimitive -> element.content
+ else -> element.toString()
+ }
+ }
+}
+
+/**
+ * A saved favourite location returned by GET /saved-places.
+ */
+@Serializable
+data class SavedLocation(
+ @Serializable(with = ObjectIdAsStringSerializer::class)
+ val id: String = "",
+ val userEmail: String = "",
+ val name: String = "",
+ val lat: Double = 0.0,
+ val lon: Double = 0.0,
+ val createdAt: String = "",
+)
+
+/**
+ * Request body for POST /save-location.
+ */
+@Serializable
+data class SaveLocationRequest(
+ val name: String,
+ val lat: Double,
+ val lon: Double,
+)
+
+/**
+ * Generic API envelope shared across location endpoints.
+ */
+@Serializable
+data class ApiResponse(
+ val status: Boolean,
+ val message: String,
+ val data: T? = null,
+)
+
+/**
+ * A place suggestion returned by GET /search-place.
+ */
+@Serializable
+data class PlaceSuggestion(
+ val name: String,
+ val city: String,
+ val state: String,
+ val country: String,
+ @SerialName("lat")
+ val latitude: String,
+ @SerialName("lon")
+ val longitude: String,
+)
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/PaymentModels.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/PaymentModels.kt
new file mode 100644
index 00000000..93fb4b91
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/model/PaymentModels.kt
@@ -0,0 +1,85 @@
+package bose.ankush.network.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonPrimitive
+
+@Serializable
+data class CreateOrderRequest(
+ val amount: Long,
+ val currency: String,
+ val receipt: String? = null,
+ @SerialName("partial_payment") val partialPayment: Boolean? = null,
+ @SerialName("first_payment_min_amount") val firstPaymentMinAmount: Long? = null,
+ val notes: Map? = null,
+)
+
+@Serializable
+data class CreateOrderData(
+ val orderId: String,
+ val amount: Long,
+ val currency: String,
+ val receipt: String? = null,
+ val status: String? = null,
+ val createdAt: Long? = null,
+)
+
+@Serializable
+data class CreateOrderResponse(
+ val message: String? = null,
+ val data: JsonElement? = null,
+ val status: JsonElement? = null,
+) {
+ /**
+ * Safely extract CreateOrderData when the backend returns the expected object in `data`.
+ * Returns null if fields are missing or types are invalid.
+ */
+ fun extractData(): CreateOrderData? {
+ val obj = data as? JsonObject ?: return null
+ val orderId =
+ obj["orderId"]?.jsonPrimitive?.contentOrNull
+ ?: obj["order_id"]?.jsonPrimitive?.contentOrNull ?: ""
+ val amountStr = obj["amount"]?.jsonPrimitive?.content
+ val amount = amountStr?.toLongOrNull() ?: 0L
+ val currency = obj["currency"]?.jsonPrimitive?.contentOrNull ?: ""
+ val receipt = obj["receipt"]?.jsonPrimitive?.contentOrNull
+ val statusStr = obj["status"]?.jsonPrimitive?.contentOrNull
+ val createdAt =
+ obj["createdAt"]?.jsonPrimitive?.content?.toLongOrNull()
+ ?: obj["created_at"]?.jsonPrimitive?.content?.toLongOrNull()
+ return if (orderId.isNotBlank() && amount > 0 && currency.isNotBlank()) {
+ CreateOrderData(
+ orderId = orderId,
+ amount = amount,
+ currency = currency,
+ receipt = receipt,
+ status = statusStr,
+ createdAt = createdAt,
+ )
+ } else {
+ null
+ }
+ }
+}
+
+@Serializable
+data class VerifyPaymentRequest(
+ @SerialName("razorpay_order_id") val razorpayOrderId: String,
+ @SerialName("razorpay_payment_id") val razorpayPaymentId: String,
+ @SerialName("razorpay_signature") val razorpaySignature: String,
+)
+
+@Serializable
+data class VerifyPaymentData(
+ val verified: Boolean = false,
+)
+
+@Serializable
+data class VerifyPaymentResponse(
+ @SerialName("status") val success: Boolean = false,
+ val message: String? = null,
+ val data: VerifyPaymentData? = null,
+)
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/ServiceModels.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/ServiceModels.kt
new file mode 100644
index 00000000..1e82257f
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/model/ServiceModels.kt
@@ -0,0 +1,234 @@
+package bose.ankush.network.model
+
+import kotlinx.datetime.Clock
+import kotlinx.serialization.Serializable
+
+// ============ DTO Models (Serializable) ============
+
+@Serializable
+data class ServiceListResponse(
+ val success: Boolean = true,
+ val message: String = "",
+ val data: ServiceListData,
+)
+
+@Serializable
+data class ServiceListData(
+ val services: List = emptyList(),
+ val totalCount: Long = 0,
+ val page: Int = 1,
+ val pageSize: Int = 20,
+)
+
+@Serializable
+data class ServiceDto(
+ val id: String,
+ val serviceCode: String,
+ val displayName: String,
+ val description: String,
+ val pricingTiers: List,
+ val features: List,
+ val status: String,
+ val limits: Map = emptyMap(),
+ val availabilityStart: String? = null,
+ val availabilityEnd: String? = null,
+ val totalPurchases: Long = 0,
+ val lowestPrice: Int = 0,
+ val currency: String = "INR",
+ val createdAt: String = "",
+ val updatedAt: String = "",
+)
+
+@Serializable
+data class PricingTierDto(
+ val id: String,
+ val amount: Int,
+ val currency: String,
+ val duration: Int,
+ val durationType: String,
+ val isDefault: Boolean = false,
+ val isFeatured: Boolean = false,
+ val displayOrder: Int = 0,
+)
+
+@Serializable
+data class FeatureDto(
+ val id: String,
+ val description: String,
+ val isHighlighted: Boolean = false,
+ val displayOrder: Int = 0,
+)
+
+@Serializable
+data class ServiceLimitDto(
+ val value: Long,
+ val type: String,
+ val unit: String,
+)
+
+// ============ Domain Models ============
+
+data class Service(
+ val id: String,
+ val serviceCode: String,
+ val displayName: String,
+ val description: String,
+ val pricingTiers: List,
+ val features: List,
+ val status: ServiceStatus,
+ val limits: Map,
+ val availabilityStart: String? = null,
+ val availabilityEnd: String? = null,
+ val totalPurchases: Long = 0,
+ val lowestPrice: Int = 0,
+ val currency: String = "INR",
+ val createdAt: String,
+ val updatedAt: String,
+) {
+ val isAvailable: Boolean
+ get() = status == ServiceStatus.ACTIVE && isWithinAvailabilityWindow()
+
+ private fun isWithinAvailabilityWindow(): Boolean {
+ if (availabilityStart == null && availabilityEnd == null) return true
+
+ val now = Clock.System.now().toEpochMilliseconds()
+ val start = availabilityStart?.let { parseIsoDate(it) }
+ val end = availabilityEnd?.let { parseIsoDate(it) }
+
+ if (start != null && now < start) return false
+ if (end != null && now > end) return false
+ return true
+ }
+
+ fun getRecommendedTier(): PricingTier? =
+ pricingTiers.firstOrNull { it.isFeatured }
+ ?: pricingTiers.firstOrNull { it.isDefault }
+ ?: pricingTiers.firstOrNull()
+}
+
+data class PricingTier(
+ val id: String,
+ val amount: Int,
+ val currency: String,
+ val duration: Int,
+ val durationType: DurationType,
+ val isDefault: Boolean = false,
+ val isFeatured: Boolean = false,
+ val displayOrder: Int = 0,
+) {
+ fun getDisplayPrice(): String = "โน$amount"
+
+ fun getAmountInPaise(): Int = amount * 100
+
+ fun getDisplayDuration(): String =
+ when (durationType) {
+ DurationType.DAYS -> if (duration == 1) "1 day" else "$duration days"
+ DurationType.MONTHS -> if (duration == 1) "1 month" else "$duration months"
+ DurationType.YEARS -> if (duration == 1) "1 year" else "$duration years"
+ }
+}
+
+data class Feature(
+ val id: String,
+ val description: String,
+ val isHighlighted: Boolean = false,
+ val displayOrder: Int = 0,
+)
+
+data class ServiceLimit(
+ val value: Long,
+ val type: LimitType,
+ val unit: String,
+)
+
+enum class ServiceStatus {
+ ACTIVE,
+ INACTIVE,
+ ARCHIVED,
+}
+
+enum class DurationType {
+ DAYS,
+ MONTHS,
+ YEARS,
+}
+
+enum class LimitType {
+ HARD,
+ SOFT,
+}
+
+// ============ Extension Functions ============
+
+fun ServiceDto.toDomain(): Service =
+ Service(
+ id = id,
+ serviceCode = serviceCode,
+ displayName = displayName,
+ description = description,
+ pricingTiers = pricingTiers.map { it.toDomain() },
+ features = features.map { it.toDomain() }.sortedBy { it.displayOrder },
+ status =
+ try {
+ ServiceStatus.valueOf(status.uppercase())
+ } catch (_: Exception) {
+ ServiceStatus.ACTIVE
+ },
+ limits = limits.mapValues { it.value.toDomain() },
+ availabilityStart = availabilityStart,
+ availabilityEnd = availabilityEnd,
+ totalPurchases = totalPurchases,
+ lowestPrice = lowestPrice,
+ currency = currency,
+ createdAt = createdAt,
+ updatedAt = updatedAt,
+ )
+
+fun PricingTierDto.toDomain(): PricingTier =
+ PricingTier(
+ id = id,
+ amount = amount,
+ currency = currency,
+ duration = duration,
+ durationType =
+ try {
+ DurationType.valueOf(durationType.uppercase())
+ } catch (_: Exception) {
+ DurationType.MONTHS
+ },
+ isDefault = isDefault,
+ isFeatured = isFeatured,
+ displayOrder = displayOrder,
+ )
+
+fun FeatureDto.toDomain(): Feature =
+ Feature(
+ id = id,
+ description = description,
+ isHighlighted = isHighlighted,
+ displayOrder = displayOrder,
+ )
+
+fun ServiceLimitDto.toDomain(): ServiceLimit =
+ ServiceLimit(
+ value = value,
+ type =
+ try {
+ LimitType.valueOf(type.uppercase())
+ } catch (_: Exception) {
+ LimitType.HARD
+ },
+ unit = unit,
+ )
+
+fun parseIsoDate(dateString: String): Long? =
+ try {
+ // Simple ISO 8601 parser for basic format (YYYY-MM-DDTHH:mm:ssZ)
+ dateString.replace("Z", "+00:00").let { _ ->
+ // This is a simplified parser. For production, use proper date library
+ // For now, return current time as fallback
+ Clock.System.now().toEpochMilliseconds()
+ }
+ } catch (_: Exception) {
+ null
+ }
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherCondition.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherCondition.kt
deleted file mode 100644
index 7516e32c..00000000
--- a/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherCondition.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package bose.ankush.network.model
-
-import kotlinx.serialization.Serializable
-
-/**
- * Domain model for weather condition
- */
-@Serializable
-data class WeatherCondition(
- val description: String,
- val icon: String,
- val id: Int,
- val main: String
-)
\ No newline at end of file
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherForecast.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherForecast.kt
index 1e967ee9..5a967ba9 100644
--- a/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherForecast.kt
+++ b/network/src/commonMain/kotlin/bose/ankush/network/model/WeatherForecast.kt
@@ -1,79 +1,152 @@
package bose.ankush.network.model
+import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
-/**
- * Domain model for weather forecast data
- */
@Serializable
data class WeatherForecast(
- val id: Long = 0, // Default value to handle missing id in API response
- val alerts: List? = listOf(),
- val current: Current? = null,
- val daily: List? = listOf(),
- val hourly: List? = listOf(),
- val lastUpdated: Long = 0,
+ @SerialName("data")
+ val `data`: Data?,
+ @SerialName("message")
+ val message: String?,
+ @SerialName("status")
+ val status: Boolean?,
) {
@Serializable
- data class Alert(
- val description: String? = null,
- val end: Int? = null,
- val event: String? = null,
- val sender_name: String? = null,
- val start: Int? = null,
- )
+ data class Data(
+ @SerialName("alerts")
+ val alerts: List? = null,
+ @SerialName("current")
+ val current: Current?,
+ @SerialName("daily")
+ val daily: List?,
+ @SerialName("hourly")
+ val hourly: List?,
+ @SerialName("airQuality")
+ val airQuality: AirQuality.Data? = null,
+ @SerialName("entitlements")
+ val entitlements: Entitlements? = null,
+ ) {
+ @Serializable
+ data class WeatherInfo(
+ @SerialName("description")
+ val description: String = "",
+ @SerialName("icon")
+ val icon: String = "",
+ @SerialName("id")
+ val id: Int = 0,
+ @SerialName("main")
+ val main: String = "",
+ )
- @Serializable
- data class Current(
- val clouds: Int? = null,
- val dt: Long? = null,
- val feels_like: Double? = null,
- val humidity: Int? = null,
- val pressure: Int? = null,
- val sunrise: Int? = null,
- val sunset: Int? = null,
- val temp: Double? = null,
- val uvi: Double? = null,
- val weather: List? = listOf(),
- val wind_gust: Double? = null,
- val wind_speed: Double? = null
- )
+ @Serializable
+ data class Alert(
+ val description: String?,
+ val end: Long?,
+ val event: String?,
+ @SerialName("senderName") val senderName: String?,
+ val start: Long?,
+ )
- @Serializable
- data class Daily(
- val clouds: Int? = null,
- val dew_point: Double? = null,
- val dt: Long? = null,
- val humidity: Int? = null,
- val pressure: Int? = null,
- val rain: Double? = null,
- val summary: String? = null,
- val sunrise: Int? = null,
- val sunset: Int? = null,
- val temp: Temp? = null,
- val uvi: Double? = null,
- val weather: List? = listOf(),
- val wind_gust: Double? = null,
- val wind_speed: Double? = null
- ) {
@Serializable
- data class Temp(
- val day: Double? = null,
- val eve: Double? = null,
- val max: Double? = null,
- val min: Double? = null,
- val morn: Double? = null,
- val night: Double? = null
+ data class Current(
+ @SerialName("clouds")
+ val clouds: Int? = null,
+ @SerialName("dt")
+ val dt: Long? = null,
+ @SerialName("feelsLike")
+ val feelsLike: Double? = null,
+ @SerialName("humidity")
+ val humidity: Int? = null,
+ @SerialName("pressure")
+ val pressure: Int? = null,
+ @SerialName("sunrise")
+ val sunrise: Long? = null,
+ @SerialName("sunset")
+ val sunset: Long? = null,
+ @SerialName("temp")
+ val temp: Double? = null,
+ @SerialName("uvi")
+ val uvi: Double? = null,
+ @SerialName("weather")
+ val weather: List? = null,
+ @SerialName("windGust")
+ val windGust: Double? = null,
+ @SerialName("windSpeed")
+ val windSpeed: Double? = null,
)
- }
- @Serializable
- data class Hourly(
- val clouds: Int? = null,
- val dt: Long? = null,
- val feels_like: Double? = null,
- val humidity: Int? = null,
- val temp: Double? = null,
- val weather: List? = listOf(),
- )
+ @Serializable
+ data class Daily(
+ @SerialName("clouds")
+ val clouds: Int? = null,
+ @SerialName("dewPoint")
+ val dewPoint: Double? = null,
+ @SerialName("dt")
+ val dt: Long? = null,
+ @SerialName("humidity")
+ val humidity: Int? = null,
+ @SerialName("pressure")
+ val pressure: Int? = null,
+ @SerialName("rain")
+ val rain: Double? = null,
+ @SerialName("summary")
+ val summary: String? = null,
+ @SerialName("sunrise")
+ val sunrise: Long? = null,
+ @SerialName("sunset")
+ val sunset: Long? = null,
+ @SerialName("temp")
+ val temp: Temp? = null,
+ @SerialName("uvi")
+ val uvi: Double? = null,
+ @SerialName("weather")
+ val weather: List? = null,
+ @SerialName("windGust")
+ val windGust: Double? = null,
+ @SerialName("windSpeed")
+ val windSpeed: Double? = null,
+ ) {
+ @Serializable
+ data class Temp(
+ @SerialName("day")
+ val day: Double?,
+ @SerialName("eve")
+ val eve: Double?,
+ @SerialName("max")
+ val max: Double?,
+ @SerialName("min")
+ val min: Double?,
+ @SerialName("morn")
+ val morn: Double?,
+ @SerialName("night")
+ val night: Double?,
+ )
+ }
+
+ @Serializable
+ data class Hourly(
+ @SerialName("clouds")
+ val clouds: Int? = null,
+ @SerialName("dt")
+ val dt: Long? = null,
+ @SerialName("feelsLike")
+ val feelsLike: Double? = null,
+ @SerialName("humidity")
+ val humidity: Int? = null,
+ @SerialName("temp")
+ val temp: Double? = null,
+ @SerialName("weather")
+ val weather: List? = null,
+ )
+
+ @Serializable
+ data class Entitlements(
+ val hourlyIncluded: Boolean = false,
+ val dailyIncluded: Boolean = false,
+ val alertsIncluded: Boolean = false,
+ val airQualityIncluded: Boolean = false,
+ val upgradeRequired: Boolean = true,
+ )
+ }
}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepository.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepository.kt
new file mode 100644
index 00000000..600098aa
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepository.kt
@@ -0,0 +1,15 @@
+package bose.ankush.network.repository
+
+import bose.ankush.network.model.FeedbackRequest
+import bose.ankush.network.model.FeedbackResponse
+
+/**
+ * Repository interface for feedback operations
+ */
+interface FeedbackRepository {
+ /**
+ * Submit user feedback
+ * Returns a Result wrapping either the response or an error
+ */
+ suspend fun submitFeedback(request: FeedbackRequest): Result
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepositoryImpl.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepositoryImpl.kt
new file mode 100644
index 00000000..7153b58f
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepositoryImpl.kt
@@ -0,0 +1,21 @@
+package bose.ankush.network.repository
+
+import bose.ankush.network.api.FeedbackApiService
+import bose.ankush.network.common.NetworkConnectivity
+import bose.ankush.network.model.FeedbackRequest
+import bose.ankush.network.model.FeedbackResponse
+
+/**
+ * Implementation of FeedbackRepository
+ */
+class FeedbackRepositoryImpl(
+ private val apiService: FeedbackApiService,
+ private val networkConnectivity: NetworkConnectivity,
+) : FeedbackRepository {
+ override suspend fun submitFeedback(request: FeedbackRequest): Result {
+ if (!networkConnectivity.isNetworkAvailable()) {
+ return Result.failure(IllegalStateException("No internet connection"))
+ }
+ return runCatching { apiService.submitFeedback(request) }
+ }
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepository.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepository.kt
new file mode 100644
index 00000000..2241a62c
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepository.kt
@@ -0,0 +1,26 @@
+package bose.ankush.network.repository
+
+import bose.ankush.network.model.PlaceSuggestion
+import bose.ankush.network.model.SavedLocation
+
+/**
+ * Repository interface for saved favourite locations.
+ * All operations require the user to be authenticated (JWT is attached by the interceptor).
+ */
+interface LocationRepository {
+ /** Save a new favourite location. */
+ suspend fun saveLocation(
+ name: String,
+ lat: Double,
+ lon: Double,
+ ): Result
+
+ /** Retrieve all saved locations for the current user. */
+ suspend fun getSavedLocations(): Result>
+
+ /** Delete a saved location by its server-assigned id. */
+ suspend fun deleteLocation(id: String): Result
+
+ /** Search for place suggestions by query string. */
+ suspend fun searchPlaces(query: String): Result>
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepositoryImpl.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepositoryImpl.kt
new file mode 100644
index 00000000..c8defbfc
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/LocationRepositoryImpl.kt
@@ -0,0 +1,66 @@
+package bose.ankush.network.repository
+
+import bose.ankush.network.api.LocationApiService
+import bose.ankush.network.model.PlaceSuggestion
+import bose.ankush.network.model.SaveLocationRequest
+import bose.ankush.network.model.SavedLocation
+
+/**
+ * Implementation of LocationRepository.
+ */
+class LocationRepositoryImpl(
+ private val apiService: LocationApiService,
+) : LocationRepository {
+ override suspend fun saveLocation(
+ name: String,
+ lat: Double,
+ lon: Double,
+ ): Result =
+ try {
+ val response =
+ apiService.saveLocation(SaveLocationRequest(name = name, lat = lat, lon = lon))
+ if (response.status) {
+ Result.success(Unit)
+ } else {
+ Result.failure(Exception(response.message))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+
+ override suspend fun getSavedLocations(): Result> =
+ try {
+ val response = apiService.getSavedLocations()
+ if (response.status) {
+ Result.success(response.data ?: emptyList())
+ } else {
+ Result.failure(Exception(response.message))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+
+ override suspend fun deleteLocation(id: String): Result =
+ try {
+ val response = apiService.deleteLocation(id)
+ if (response.status) {
+ Result.success(Unit)
+ } else {
+ Result.failure(Exception(response.message))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+
+ override suspend fun searchPlaces(query: String): Result> =
+ try {
+ val response = apiService.searchPlaces(query)
+ if (response.status) {
+ Result.success(response.data ?: emptyList())
+ } else {
+ Result.failure(Exception(response.message))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/ServiceRepository.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/ServiceRepository.kt
new file mode 100644
index 00000000..4429df2f
--- /dev/null
+++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/ServiceRepository.kt
@@ -0,0 +1,47 @@
+package bose.ankush.network.repository
+
+import bose.ankush.network.model.Service
+import bose.ankush.network.model.toDomain
+
+interface ServiceRepository {
+ suspend fun getServices(
+ page: Int = 1,
+ pageSize: Int = 20,
+ search: String? = null,
+ ): Result>
+}
+
+class ServiceRepositoryImpl(
+ private val api: bose.ankush.network.api.ServiceApiService,
+) : ServiceRepository {
+ override suspend fun getServices(
+ page: Int,
+ pageSize: Int,
+ search: String?,
+ ): Result> =
+ try {
+ val response = api.getServices(page, pageSize, search)
+ response.fold(
+ onSuccess = { data ->
+ val services = data.data.services.map { it.toDomain() }
+ Result.success(services)
+ },
+ onFailure = { error ->
+ logError("Service API Error", error)
+ Result.failure(error)
+ },
+ )
+ } catch (e: Exception) {
+ logError("Service Repository Error", e)
+ Result.failure(e)
+ }
+
+ private fun logError(
+ tag: String,
+ error: Throwable,
+ ) {
+ // Log to Firebase or your analytics service
+ // FirebaseCrashlytics.getInstance().recordException(error)
+ println("$tag: ${error.message}")
+ }
+}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepository.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepository.kt
index f0d5c383..3e4a7ff9 100644
--- a/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepository.kt
+++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepository.kt
@@ -1,6 +1,5 @@
package bose.ankush.network.repository
-import bose.ankush.network.model.AirQuality
import bose.ankush.network.model.WeatherForecast
import kotlinx.coroutines.flow.Flow
@@ -9,21 +8,16 @@ import kotlinx.coroutines.flow.Flow
*/
interface WeatherRepository {
/**
- * Get air quality report for a location
- * @param coordinates Pair of latitude and longitude
- * @return Flow of AirQuality
- */
- fun getAirQualityReport(coordinates: Pair): Flow
-
- /**
- * Get weather report for a location
+ * Get unified weather report for a location.
+ * The response includes current, hourly, daily, alerts, and air quality.
+ * Premium-only fields are null for free tier users โ check entitlements.upgradeRequired.
* @param coordinates Pair of latitude and longitude
* @return Flow of WeatherForecast
*/
fun getWeatherReport(coordinates: Pair): Flow
/**
- * Refresh weather data for a location
+ * Force a fresh fetch from the network for a location
* @param coordinates Pair of latitude and longitude
*/
suspend fun refreshWeatherData(coordinates: Pair)
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepositoryImpl.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepositoryImpl.kt
index 668bbaff..40db05f6 100644
--- a/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepositoryImpl.kt
+++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepositoryImpl.kt
@@ -1,138 +1,68 @@
package bose.ankush.network.repository
import bose.ankush.network.api.WeatherApiService
-import bose.ankush.network.utils.NetworkConstants
import bose.ankush.network.common.NetworkConnectivity
-import bose.ankush.network.utils.NetworkUtils
-import bose.ankush.network.model.AirQuality
import bose.ankush.network.model.WeatherForecast
+import bose.ankush.network.utils.NetworkConstants
+import bose.ankush.network.utils.NetworkUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
-import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
/**
- * Implementation of WeatherRepository
+ * Implementation of WeatherRepository. Provides an in-memory cache for the unified weather
+ * response, which now includes air quality and entitlements from a single /weather call.
*/
class WeatherRepositoryImpl(
private val apiService: WeatherApiService,
- private val networkConnectivity: NetworkConnectivity
+ private val networkConnectivity: NetworkConnectivity,
) : WeatherRepository {
-
- // In-memory cache for weather data
- private val _weatherData = MutableStateFlow(null)
- private val _airQualityData = MutableStateFlow(null)
-
- // Last update timestamps
+ private val weatherCache = MutableStateFlow(null)
private var lastWeatherUpdateTime: Long = 0
- private var lastAirQualityUpdateTime: Long = 0
-
- override fun getAirQualityReport(coordinates: Pair): Flow {
- val currentTime = Clock.System.now().toEpochMilliseconds()
-
- // Check if we need to refresh the data
- if (_airQualityData.value == null ||
- (currentTime - lastAirQualityUpdateTime > NetworkConstants.CACHE_EXPIRATION_TIME)) {
- // Launch a coroutine to refresh the data
- CoroutineScope(Dispatchers.Default).launch {
- try {
- // Only refresh if network is available
- if (networkConnectivity.isNetworkAvailable()) {
- val airQualityData = NetworkUtils.retryWithExponentialBackoff {
- apiService.getCurrentAirQuality(
- latitude = coordinates.first.toString(),
- longitude = coordinates.second.toString()
- )
- }
- _airQualityData.value = airQualityData
- lastAirQualityUpdateTime = currentTime
- }
- } catch (e: Exception) {
- // Log the error but don't throw it to avoid crashing the UI
- println("Failed to refresh air quality data: ${e.message}")
- }
- }
- }
-
- // Initialize with a default AirQuality if null
- if (_airQualityData.value == null) {
- _airQualityData.value = AirQuality()
- }
-
- // Map the nullable flow to a non-nullable flow
- return _airQualityData.map { it ?: AirQuality() }
- }
override fun getWeatherReport(coordinates: Pair): Flow {
val currentTime = Clock.System.now().toEpochMilliseconds()
- // Check if we need to refresh the data
- if (_weatherData.value == null ||
- (currentTime - lastWeatherUpdateTime > NetworkConstants.CACHE_EXPIRATION_TIME)) {
- // Launch a coroutine to refresh the data
+ if (weatherCache.value == null ||
+ (currentTime - lastWeatherUpdateTime > NetworkConstants.CACHE_EXPIRATION_TIME)
+ ) {
CoroutineScope(Dispatchers.Default).launch {
try {
refreshWeatherData(coordinates)
} catch (e: Exception) {
- // Log the error but don't throw it to avoid crashing the UI
println("Failed to refresh weather data: ${e.message}")
}
}
}
- return _weatherData.asStateFlow()
+ return weatherCache.asStateFlow()
}
override suspend fun refreshWeatherData(coordinates: Pair) {
val currentTime = Clock.System.now().toEpochMilliseconds()
val isNetworkAvailable = networkConnectivity.isNetworkAvailable()
- // Only fetch new data if:
- // 1. Network is available AND
- // 2. Either data is null OR data is stale (older than cache expiration time)
- if (isNetworkAvailable &&
- (_weatherData.value == null || (currentTime - lastWeatherUpdateTime > NetworkConstants.CACHE_EXPIRATION_TIME))) {
+ if (isNetworkAvailable &&
+ (
+ weatherCache.value == null ||
+ (currentTime - lastWeatherUpdateTime > NetworkConstants.CACHE_EXPIRATION_TIME)
+ )
+ ) {
try {
- coroutineScope {
- // Use async to parallelize the API calls
- val weatherDeferred = async {
- NetworkUtils.retryWithExponentialBackoff {
- apiService.getOneCallWeather(
- coordinates.first.toString(),
- coordinates.second.toString()
- )
- }
+ val weatherData =
+ NetworkUtils.retryWithExponentialBackoff {
+ apiService.getOneCallWeather(
+ coordinates.first.toString(),
+ coordinates.second.toString(),
+ )
}
-
- val airQualityDeferred = async {
- NetworkUtils.retryWithExponentialBackoff {
- apiService.getCurrentAirQuality(
- latitude = coordinates.first.toString(),
- longitude = coordinates.second.toString()
- )
- }
- }
-
- // Wait for both API calls to complete
- val weatherData = weatherDeferred.await()
- val airQualityData = airQualityDeferred.await()
-
- // Update the weather data flow
- _weatherData.value = weatherData.copy(lastUpdated = currentTime)
- lastWeatherUpdateTime = currentTime
-
- // Update the air quality data flow
- _airQualityData.value = airQualityData
- lastAirQualityUpdateTime = currentTime
- }
+ weatherCache.value = weatherData
+ lastWeatherUpdateTime = currentTime
} catch (e: Exception) {
- // If there's an error, throw a more descriptive exception
throw Exception("Failed to refresh weather data: ${e.message}", e)
}
}
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/utils/Constants.kt b/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkConstants.kt
similarity index 86%
rename from network/src/commonMain/kotlin/bose/ankush/network/utils/Constants.kt
rename to network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkConstants.kt
index 096bca08..6f57b431 100644
--- a/network/src/commonMain/kotlin/bose/ankush/network/utils/Constants.kt
+++ b/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkConstants.kt
@@ -7,7 +7,7 @@ object NetworkConstants {
/**
* Base URL for the weather API
*/
- const val WEATHER_BASE_URL = "https://data.androidplay.in/"
+ const val WEATHER_BASE_URL = "https://data.androidplay.in"
/**
* Cache expiration time in milliseconds (30 minutes)
@@ -17,7 +17,7 @@ object NetworkConstants {
/**
* Maximum number of retries for network requests
*/
- const val MAX_RETRIES = 3
+ const val MAX_RETRIES = 1
/**
* Initial backoff delay in milliseconds for retry mechanism
diff --git a/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkUtils.kt b/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkUtils.kt
index 8a5bc08e..6dc9e5a5 100644
--- a/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkUtils.kt
+++ b/network/src/commonMain/kotlin/bose/ankush/network/utils/NetworkUtils.kt
@@ -1,5 +1,6 @@
package bose.ankush.network.utils
+import bose.ankush.network.common.NetworkException
import kotlinx.coroutines.delay
/**
@@ -13,21 +14,31 @@ object NetworkUtils {
* @param maxDelayMillis Maximum delay in milliseconds
* @param block The suspend function to retry
* @return The result of the suspend function
- * @throws Exception if all retries fail
+ * @throws NetworkException if all retries fail
*/
suspend fun retryWithExponentialBackoff(
maxRetries: Int = NetworkConstants.MAX_RETRIES,
initialDelayMillis: Long = NetworkConstants.INITIAL_BACKOFF_DELAY,
maxDelayMillis: Long = NetworkConstants.MAX_BACKOFF_DELAY,
- block: suspend () -> T
+ block: suspend () -> T,
): T {
var currentDelay = initialDelayMillis
+ var lastException: Exception? = null
+
repeat(maxRetries) { attempt ->
try {
return block()
} catch (e: Exception) {
- // If this is the last attempt, throw the exception
- if (attempt == maxRetries - 1) throw e
+ lastException = e
+
+ // If this is the last attempt, convert to NetworkException and throw
+ if (attempt == maxRetries - 1) {
+ if (e is NetworkException) {
+ throw e
+ } else {
+ throw NetworkException.fromException(e)
+ }
+ }
// Otherwise, delay and retry
delay(currentDelay)
@@ -36,6 +47,10 @@ object NetworkUtils {
}
}
// This should never be reached, but is needed for compilation
- throw IllegalStateException("Retry failed after $maxRetries attempts")
+ throw NetworkException(
+ NetworkException.UNKNOWN_ERROR,
+ "Retry failed after $maxRetries attempts",
+ lastException,
+ )
}
-}
\ No newline at end of file
+}
diff --git a/network/src/iosMain/kotlin/bose/ankush/network/common/IOSNetworkConnectivity.kt b/network/src/iosMain/kotlin/bose/ankush/network/common/IOSNetworkConnectivity.kt
index 624abd74..bb02d486 100644
--- a/network/src/iosMain/kotlin/bose/ankush/network/common/IOSNetworkConnectivity.kt
+++ b/network/src/iosMain/kotlin/bose/ankush/network/common/IOSNetworkConnectivity.kt
@@ -1,28 +1,34 @@
package bose.ankush.network.common
-import platform.Foundation.NSFileManager
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.alloc
+import kotlinx.cinterop.memScoped
+import kotlinx.cinterop.ptr
+import kotlinx.cinterop.value
import platform.SystemConfiguration.SCNetworkReachabilityCreateWithName
-import platform.SystemConfiguration.SCNetworkReachabilityFlags
+import platform.SystemConfiguration.SCNetworkReachabilityFlagsVar
import platform.SystemConfiguration.SCNetworkReachabilityGetFlags
+import platform.SystemConfiguration.kSCNetworkReachabilityFlagsConnectionRequired
import platform.SystemConfiguration.kSCNetworkReachabilityFlagsReachable
-import platform.darwin.NULL
-/**
- * iOS implementation of NetworkConnectivity
- */
+@Suppress("unused")
class IOSNetworkConnectivity : NetworkConnectivity {
-
+ @OptIn(ExperimentalForeignApi::class)
override fun isNetworkAvailable(): Boolean {
- val reachability = SCNetworkReachabilityCreateWithName(
- NULL,
- "www.apple.com"
- ) ?: return false
-
- val flags = ULongArray(1)
- if (SCNetworkReachabilityGetFlags(reachability, flags)) {
- return (flags[0].toInt() and kSCNetworkReachabilityFlagsReachable.toInt()) != 0
+ val reachability =
+ SCNetworkReachabilityCreateWithName(
+ null,
+ "www.apple.com",
+ ) ?: return false
+
+ return memScoped {
+ val flags = alloc()
+ if (SCNetworkReachabilityGetFlags(reachability, flags.ptr)) {
+ (flags.value and kSCNetworkReachabilityFlagsReachable) != 0u &&
+ (flags.value and kSCNetworkReachabilityFlagsConnectionRequired) == 0u
+ } else {
+ false
+ }
}
-
- return false
}
-}
\ No newline at end of file
+}
diff --git a/network/src/iosMain/kotlin/bose/ankush/network/di/IOSHttpClient.kt b/network/src/iosMain/kotlin/bose/ankush/network/di/IOSHttpClient.kt
index 40f030d9..b8debc25 100644
--- a/network/src/iosMain/kotlin/bose/ankush/network/di/IOSHttpClient.kt
+++ b/network/src/iosMain/kotlin/bose/ankush/network/di/IOSHttpClient.kt
@@ -6,15 +6,14 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
-import io.ktor.http.ContentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* iOS implementation of createPlatformHttpClient
*/
-actual fun createPlatformHttpClient(json: Json): HttpClient {
- return HttpClient(Darwin) {
+actual fun createPlatformHttpClient(json: Json): HttpClient =
+ HttpClient(Darwin) {
engine {
configureRequest {
setAllowsCellularAccess(true)
@@ -22,16 +21,16 @@ actual fun createPlatformHttpClient(json: Json): HttpClient {
}
}
install(ContentNegotiation) {
- // Register for mixed content type (application/json, text/html)
- json(json, contentType = ContentType.parse("application/json, text/html; charset=UTF-8"))
+ // Register standard JSON handling; handle other content types per request if required.
+ json(json)
}
install(Logging) {
- logger = object : Logger {
- override fun log(message: String) {
- println("Ktor iOS: $message")
+ logger =
+ object : Logger {
+ override fun log(message: String) {
+ println("Ktor iOS: $message")
+ }
}
- }
level = LogLevel.INFO
}
}
-}
diff --git a/network/src/iosMain/kotlin/bose/ankush/network/di/IOSNetworkModule.kt b/network/src/iosMain/kotlin/bose/ankush/network/di/IOSNetworkModule.kt
deleted file mode 100644
index ea5bc697..00000000
--- a/network/src/iosMain/kotlin/bose/ankush/network/di/IOSNetworkModule.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package bose.ankush.network.di
-
-import bose.ankush.network.common.IOSNetworkConnectivity
-import bose.ankush.network.common.NetworkConnectivity
-import org.koin.core.module.Module
-import org.koin.dsl.module
-
-/**
- * iOS-specific network module
- */
-fun iosNetworkModule(): Module = module {
- // iOS-specific NetworkConnectivity implementation
- single {
- IOSNetworkConnectivity()
- }
-}
\ No newline at end of file
diff --git a/payment/build.gradle.kts b/payment/build.gradle.kts
deleted file mode 100644
index b7537270..00000000
--- a/payment/build.gradle.kts
+++ /dev/null
@@ -1,82 +0,0 @@
-plugins {
- id("com.android.library")
- id("org.jetbrains.kotlin.android")
- id("org.jetbrains.kotlin.plugin.compose")
-}
-
-android {
- namespace = "bose.ankush.payment"
- compileSdk = ConfigData.compileSdkVersion
-
- defaultConfig {
- minSdk = ConfigData.minSdkVersion
-
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- consumerProguardFiles("consumer-rules.pro")
- }
-
- buildTypes {
- release {
- isMinifyEnabled = false
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- }
- }
-
- buildFeatures {
- compose = true
- buildConfig = true
- }
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
-
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_17.toString()
- }
-
- kotlin {
- sourceSets.all {
- languageSettings {
- languageVersion = Versions.kotlinCompiler
- }
- }
- }
-
- lint {
- abortOnError = false
- }
-}
-
-composeCompiler {
- enableStrongSkippingMode = true
-}
-
-dependencies {
-
- // Testing
- testImplementation(Deps.junit)
-
- // UI Testing
- androidTestImplementation(Deps.extJunit)
-
- // Core
- implementation(Deps.androidCore)
- implementation(Deps.appCompat)
-
- // Compose
- implementation(platform(Deps.composeBom))
- implementation(Deps.composeUiTooling)
- implementation(Deps.composeUiToolingPreview)
- implementation(Deps.composeUi)
- implementation(Deps.composeMaterial1)
- implementation(Deps.composeMaterial3)
- implementation(Deps.navigationCompose)
-
- // payment sdk
- implementation(Deps.razorPay)
-}
\ No newline at end of file
diff --git a/payment/consumer-rules.pro b/payment/consumer-rules.pro
deleted file mode 100644
index e69de29b..00000000
diff --git a/payment/proguard-rules.pro b/payment/proguard-rules.pro
deleted file mode 100644
index aec67fe4..00000000
--- a/payment/proguard-rules.pro
+++ /dev/null
@@ -1,37 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
-
--keepclassmembers class * {
- @android.webkit.JavascriptInterface ;
-}
-
--keepattributes JavascriptInterface
--keepattributes *Annotation*
-
--dontwarn com.razorpay.**
--keep class com.razorpay.** {*;}
-
--optimizations !method/inlining/*
-
--keepclasseswithmembers class * {
- public void onPayment*(...);
-}
\ No newline at end of file
diff --git a/payment/src/androidTest/java/bose/ankush/payment/ExampleInstrumentedTest.kt b/payment/src/androidTest/java/bose/ankush/payment/ExampleInstrumentedTest.kt
deleted file mode 100644
index 84a387f9..00000000
--- a/payment/src/androidTest/java/bose/ankush/payment/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package bose.ankush.payment
-
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.ext.junit.runners.AndroidJUnit4
-
-import org.junit.Test
-import org.junit.runner.RunWith
-
-import org.junit.Assert.*
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("bose.ankush.payment.test", appContext.packageName)
- }
-}
\ No newline at end of file
diff --git a/payment/src/main/AndroidManifest.xml b/payment/src/main/AndroidManifest.xml
deleted file mode 100644
index a921cff1..00000000
--- a/payment/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/payment/src/main/java/bose/ankush/payment/PaymentScreen.kt b/payment/src/main/java/bose/ankush/payment/PaymentScreen.kt
deleted file mode 100644
index 62157761..00000000
--- a/payment/src/main/java/bose/ankush/payment/PaymentScreen.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-package bose.ankush.payment
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.BottomSheetScaffold
-import androidx.compose.material3.Button
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Text
-import androidx.compose.material3.rememberBottomSheetScaffoldState
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import kotlinx.coroutines.launch
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Preview(showBackground = true)
-@Composable
-fun PaymentScreen() {
- val scope = rememberCoroutineScope()
- val scaffoldState = rememberBottomSheetScaffoldState()
- BottomSheetScaffold(
- scaffoldState = scaffoldState,
- sheetPeekHeight = 128.dp,
- sheetContent = {
- Box(
- Modifier
- .fillMaxWidth()
- .height(128.dp),
- contentAlignment = Alignment.Center
- ) {
- Text("Swipe up to expand sheet")
- }
- Column(
- Modifier
- .fillMaxWidth()
- .padding(64.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text("Sheet content")
- Spacer(Modifier.height(20.dp))
- Button(
- onClick = {
- scope.launch { scaffoldState.bottomSheetState.partialExpand() }
- }
- ) {
- Text("Click to collapse sheet")
- }
- }
- }) { innerPadding ->
- Box(Modifier.padding(innerPadding)) {
- Text("Scaffold Content")
- }
- }
-}
-
-/*@Composable
-private fun BottomSheetUI() {
-
-}*/
diff --git a/payment/src/main/res/values/strings.xml b/payment/src/main/res/values/strings.xml
deleted file mode 100644
index 73862c41..00000000
--- a/payment/src/main/res/values/strings.xml
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/payment/src/test/java/bose/ankush/payment/ExampleUnitTest.kt b/payment/src/test/java/bose/ankush/payment/ExampleUnitTest.kt
deleted file mode 100644
index d6e41b93..00000000
--- a/payment/src/test/java/bose/ankush/payment/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package bose.ankush.payment
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 2f833a76..2d7a356a 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -8,6 +8,9 @@ pluginManagement {
maven { url = uri("https://jitpack.io") }
}
}
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
@@ -21,9 +24,9 @@ rootProject.name = "Weatherify"
include(
":app",
+ ":common-ui",
+ ":feature-payment",
":language",
":network",
- ":storage",
- ":sunriseui",
- ":payment",
+ ":storage"
)
diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts
index 21f40702..ce4d5ec6 100644
--- a/storage/build.gradle.kts
+++ b/storage/build.gradle.kts
@@ -1,23 +1,27 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
plugins {
kotlin("multiplatform")
id("com.android.library")
kotlin("plugin.serialization")
- id("kotlin-kapt")
+ id("com.google.devtools.ksp")
}
kotlin {
androidTarget {
- compilations.all {
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_17.toString()
- }
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
}
}
+ compilerOptions {
+ freeCompilerArgs.add("-Xexpect-actual-classes")
+ }
+
listOf(
iosX64(),
iosArm64(),
- iosSimulatorArm64()
+ iosSimulatorArm64(),
).forEach {
it.binaries.framework {
baseName = "storage"
@@ -38,24 +42,29 @@ kotlin {
implementation(kotlin("test"))
}
}
+
+ @Suppress("UNUSED_VARIABLE")
val androidMain by getting {
dependencies {
// Room dependencies
implementation(Deps.room)
implementation(Deps.roomKtx)
+ // Security: Encrypted token storage
+ implementation(Deps.securityCrypto)
// Gson for JSON serialization
- implementation("com.google.code.gson:gson:2.10.1")
- // Network module dependency
- implementation(project(":network"))
- // Dagger/Hilt dependencies
- implementation(Deps.hilt)
- // We can't use kapt here directly, it will be applied in the android block
+ implementation(Deps.gson)
+ // Note: Network dependency removed to avoid circular dependency
+ // WeatherDataFetcher is injected via DI from app module
}
}
+
+ @Suppress("UNUSED_VARIABLE")
val androidUnitTest by getting
val iosX64Main by getting
val iosArm64Main by getting
val iosSimulatorArm64Main by getting
+
+ @Suppress("UNUSED_VARIABLE")
val iosMain by creating {
dependsOn(commonMain)
iosX64Main.dependsOn(this)
@@ -65,6 +74,8 @@ kotlin {
val iosX64Test by getting
val iosArm64Test by getting
val iosSimulatorArm64Test by getting
+
+ @Suppress("UNUSED_VARIABLE")
val iosTest by creating {
dependsOn(commonTest)
iosX64Test.dependsOn(this)
@@ -87,19 +98,14 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
+}
- // Room schema location
- kapt {
- arguments {
- arg("room.schemaLocation", "$projectDir/schemas")
- }
- }
+ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
}
-// Apply kapt plugin for Room and Hilt annotation processing
+// KSP configuration for Room annotation processing (Hilt moved to app module)
dependencies {
// Room annotation processor
- "kapt"(Deps.roomCompiler)
- // Hilt annotation processor
- "kapt"(Deps.hiltDaggerAndroidCompiler)
+ add("kspAndroid", Deps.roomCompiler)
}
diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt
new file mode 100644
index 00000000..eeccf72c
--- /dev/null
+++ b/storage/src/androidMain/kotlin/bose/ankush/storage/impl/EncryptedTokenStorageImpl.kt
@@ -0,0 +1,91 @@
+package bose.ankush.storage.impl
+
+import android.content.Context
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import bose.ankush.storage.api.TokenStorage
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.withContext
+
+/**
+ * SECURITY: Encrypted token storage using EncryptedSharedPreferences with Android Keystore
+ *
+ * This implementation uses androidx.security:security-crypto to encrypt tokens at rest
+ * using the Android Keystore system. Tokens are encrypted/decrypted transparently.
+ *
+ * Benefits:
+ * - Tokens encrypted with AES-256-GCM
+ * - Keys managed by Android Keystore (hardware-backed on supported devices)
+ * - Protection against database extraction attacks
+ * - Complies with OWASP guidelines for credential storage
+ */
+actual class EncryptedTokenStorageImpl : TokenStorage {
+ private val context: Context by lazy {
+ getApplicationContext()
+ }
+
+ private val masterKey: MasterKey by lazy {
+ MasterKey
+ .Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ }
+
+ private val encryptedSharedPreferences by lazy {
+ EncryptedSharedPreferences.create(
+ context,
+ PREFS_NAME,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+ }
+
+ private val _hasToken by lazy {
+ MutableStateFlow(encryptedSharedPreferences.contains(TOKEN_KEY))
+ }
+
+ actual override suspend fun saveToken(token: String) {
+ withContext(Dispatchers.IO) {
+ val success = encryptedSharedPreferences.edit().putString(TOKEN_KEY, token).commit()
+ if (success) {
+ _hasToken.value = true
+ }
+ }
+ }
+
+ actual override suspend fun getToken(): String? =
+ withContext(Dispatchers.IO) {
+ encryptedSharedPreferences.getString(TOKEN_KEY, null)
+ }
+
+ actual override fun hasToken(): Flow = _hasToken.asStateFlow()
+
+ actual override suspend fun clearToken() {
+ withContext(Dispatchers.IO) {
+ val success = encryptedSharedPreferences.edit().remove(TOKEN_KEY).commit()
+ if (success) {
+ _hasToken.value = false
+ }
+ }
+ }
+
+ companion object {
+ private const val PREFS_NAME = "encrypted_auth_prefs"
+ private const val TOKEN_KEY = "auth_token"
+ }
+}
+
+private var appContext: Context? = null
+
+fun setApplicationContext(context: Context) {
+ appContext = context
+}
+
+private fun getApplicationContext(): Context =
+ appContext ?: throw IllegalStateException(
+ "Application context not initialized. Call setApplicationContext() in your Application.onCreate()",
+ )
diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/impl/WeatherStorageImpl.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/impl/WeatherStorageImpl.kt
index 878d0424..6e3c99a4 100644
--- a/storage/src/androidMain/kotlin/bose/ankush/storage/impl/WeatherStorageImpl.kt
+++ b/storage/src/androidMain/kotlin/bose/ankush/storage/impl/WeatherStorageImpl.kt
@@ -1,212 +1,65 @@
package bose.ankush.storage.impl
-import bose.ankush.network.model.AirQuality as NetworkAirQuality
-import bose.ankush.network.model.WeatherForecast as NetworkWeatherForecast
-import bose.ankush.network.repository.WeatherRepository as NetworkWeatherRepository
import bose.ankush.storage.api.WeatherStorage
import bose.ankush.storage.room.AirQualityEntity
-import bose.ankush.storage.room.Weather
import bose.ankush.storage.room.WeatherDatabase
import bose.ankush.storage.room.WeatherEntity
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.firstOrNull
-import java.io.IOException
-import javax.inject.Inject
-import javax.inject.Singleton
+import kotlinx.coroutines.withContext
/**
- * Implementation of WeatherStorage that uses Room database for storage
- * and the network module for fetching data.
- *
- * This class is responsible for:
- * - Retrieving weather and air quality data from the local database
- * - Refreshing data from the network when needed
- * - Mapping between network models and database entities
- * - Tracking the last update time for weather data
+ * Implementation of WeatherStorage that uses Room database for persistence.
+ *
+ * This class is responsible ONLY for:
+ * - Reading weather data from the local database
+ * - Reading air quality data from the local database
+ * - Saving weather data to the database (called by orchestration layer)
+ *
+ * Data synchronization (fetch from network, map, save to DB) is handled
+ * by the orchestration layer (WeatherRepository in app module).
*/
-@Singleton
-class WeatherStorageImpl @Inject constructor(
- private val networkRepository: NetworkWeatherRepository,
- private val weatherDatabase: WeatherDatabase
+class WeatherStorageImpl(
+ private val weatherDatabase: WeatherDatabase,
) : WeatherStorage {
+ // In-memory per-location timestamp map. Keyed by "lat_lon" string.
+ // Reset on process restart intentionally โ fresh data should be fetched after a cold start.
+ private val locationTimestamps = mutableMapOf()
- /**
- * Gets the latest weather report from the local database
- * @param coordinates Pair of latitude and longitude (not used in current implementation)
- * @return Flow of WeatherEntity as Any?
- */
- override fun getWeatherReport(coordinates: Pair): Flow {
- return weatherDatabase.weatherDao().getWeather()
- }
+ private fun locationKey(coordinates: Pair) =
+ "${coordinates.first}_${coordinates.second}"
- /**
- * Gets the latest air quality report from the local database
- * @param coordinates Pair of latitude and longitude (not used in current implementation)
- * @return Flow of AirQualityEntity as Any?
- */
- override fun getAirQualityReport(coordinates: Pair): Flow {
- return weatherDatabase.weatherDao().getAirQuality()
- }
+ override fun getWeatherReport(coordinates: Pair): Flow =
+ weatherDatabase.weatherDao().getWeather()
- /**
- * Refreshes weather and air quality data from the network and stores it in the local database
- * @param coordinates Pair of latitude and longitude
- * @throws IOException if there's an error refreshing the data
- */
- override suspend fun refreshWeatherData(coordinates: Pair) {
- try {
- // Delegate to the network module's repository to refresh data
- networkRepository.refreshWeatherData(coordinates)
+ override fun getAirQualityReport(coordinates: Pair): Flow =
+ weatherDatabase.weatherDao().getAirQuality()
- // Get the latest data from the network repository
- val weatherData = networkRepository.getWeatherReport(coordinates).firstOrNull()
- val airQualityData = networkRepository.getAirQualityReport(coordinates).firstOrNull()
+ override suspend fun getLastWeatherUpdateTime(coordinates: Pair): Long =
+ locationTimestamps[locationKey(coordinates)] ?: 0L
- if (weatherData != null && airQualityData != null) {
- // Convert network models to storage entities
- val weatherEntity = mapNetworkWeatherToEntity(weatherData)
- val airQualityEntity = mapNetworkAirQualityToEntity(airQualityData)
+ override suspend fun saveLastWeatherUpdateTime(
+ coordinates: Pair,
+ time: Long,
+ ) {
+ locationTimestamps[locationKey(coordinates)] = time
+ }
- // Store the data in room db
+ override suspend fun saveWeatherData(
+ weatherEntity: Any,
+ airQualityEntity: Any,
+ ) {
+ withContext(Dispatchers.IO) {
+ if (weatherEntity is WeatherEntity && airQualityEntity is AirQualityEntity) {
weatherDatabase.weatherDao().refreshWeather(weatherEntity, airQualityEntity)
}
- } catch (e: Exception) {
- // If there's an error, throw a more specific IOException with detailed information
- throw IOException("Failed to refresh weather data for coordinates (${coordinates.first}, ${coordinates.second}): ${e.message}", e)
}
}
- /**
- * Gets the timestamp of the last weather data update
- * @return Timestamp in milliseconds, or 0 if no data is available
- */
- override suspend fun getLastWeatherUpdateTime(): Long {
- val weatherEntity = weatherDatabase.weatherDao().getWeather().firstOrNull()
- return weatherEntity?.lastUpdated ?: 0L
- }
-
- /**
- * Maps a NetworkWeatherForecast to a WeatherEntity for storage in the database
- * @param weatherData The network model to map
- * @return A WeatherEntity with all fields mapped from the network model
- */
- private fun mapNetworkWeatherToEntity(weatherData: NetworkWeatherForecast): WeatherEntity {
- return WeatherEntity(
- id = 0, // Room will auto-generate this
- lastUpdated = System.currentTimeMillis(),
- alerts = weatherData.alerts?.map { alert ->
- alert?.let {
- WeatherEntity.Alert(
- description = it.description,
- end = it.end,
- event = it.event,
- sender_name = it.sender_name,
- start = it.start
- )
- }
- },
- current = weatherData.current?.let { current ->
- WeatherEntity.Current(
- clouds = current.clouds,
- dt = current.dt,
- feels_like = current.feels_like,
- humidity = current.humidity,
- pressure = current.pressure,
- sunrise = current.sunrise,
- sunset = current.sunset,
- temp = current.temp,
- uvi = current.uvi,
- weather = current.weather?.map { weatherCondition ->
- weatherCondition?.let {
- Weather(
- description = it.description,
- icon = it.icon,
- id = it.id,
- main = it.main
- )
- }
- },
- wind_gust = current.wind_gust,
- wind_speed = current.wind_speed
- )
- },
- daily = weatherData.daily?.map { daily ->
- daily?.let {
- WeatherEntity.Daily(
- clouds = it.clouds,
- dew_point = it.dew_point,
- dt = it.dt,
- humidity = it.humidity,
- pressure = it.pressure,
- rain = it.rain,
- summary = it.summary,
- sunrise = it.sunrise,
- sunset = it.sunset,
- temp = it.temp?.let { temp ->
- WeatherEntity.Daily.Temp(
- day = temp.day,
- eve = temp.eve,
- max = temp.max,
- min = temp.min,
- morn = temp.morn,
- night = temp.night
- )
- },
- uvi = it.uvi,
- weather = it.weather?.map { weatherCondition ->
- weatherCondition?.let {
- Weather(
- description = it.description,
- icon = it.icon,
- id = it.id,
- main = it.main
- )
- }
- },
- wind_gust = it.wind_gust,
- wind_speed = it.wind_speed
- )
- }
- },
- hourly = weatherData.hourly?.map { hourly ->
- hourly?.let {
- WeatherEntity.Hourly(
- clouds = it.clouds,
- dt = it.dt,
- feels_like = it.feels_like,
- humidity = it.humidity,
- temp = it.temp,
- weather = it.weather?.map { weatherCondition ->
- weatherCondition?.let {
- Weather(
- description = it.description,
- icon = it.icon,
- id = it.id,
- main = it.main
- )
- }
- }
- )
- }
- }
- )
- }
-
- /**
- * Maps a NetworkAirQuality to an AirQualityEntity for storage in the database
- * @param airQualityData The network model to map
- * @return An AirQualityEntity with all fields mapped from the network model
- */
- private fun mapNetworkAirQualityToEntity(airQualityData: NetworkAirQuality): AirQualityEntity {
- return AirQualityEntity(
- id = null, // Room will auto-generate this
- aqi = airQualityData.aqi,
- co = airQualityData.co,
- no2 = airQualityData.no2,
- o3 = airQualityData.o3,
- so2 = airQualityData.so2,
- pm10 = airQualityData.pm10,
- pm25 = airQualityData.pm25
- )
+ override suspend fun clearAllData() {
+ withContext(Dispatchers.IO) {
+ weatherDatabase.weatherDao().clearAll()
+ }
+ locationTimestamps.clear()
}
}
diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/AirQualityEntity.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AirQualityEntity.kt
index a73a29a7..ed3f2ab6 100644
--- a/storage/src/androidMain/kotlin/bose/ankush/storage/room/AirQualityEntity.kt
+++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AirQualityEntity.kt
@@ -14,4 +14,4 @@ data class AirQualityEntity(
var so2: Double? = 0.0,
var pm10: Double? = 0.0,
var pm25: Double? = 0.0,
-)
\ No newline at end of file
+)
diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthToken.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthToken.kt
new file mode 100644
index 00000000..d5230dde
--- /dev/null
+++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthToken.kt
@@ -0,0 +1,15 @@
+package bose.ankush.storage.room
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * Room entity for storing authentication token
+ */
+@Entity(tableName = "auth_tokens")
+data class AuthToken(
+ @PrimaryKey
+ val id: Int = 1, // We only need one token, so use a fixed ID
+ val token: String,
+ val createdAt: Long = System.currentTimeMillis(),
+)
diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthTokenDao.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthTokenDao.kt
new file mode 100644
index 00000000..731cf07c
--- /dev/null
+++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/AuthTokenDao.kt
@@ -0,0 +1,42 @@
+package bose.ankush.storage.room
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Data Access Object for AuthToken entity
+ */
+@Dao
+interface AuthTokenDao {
+ /**
+ * Insert or replace a token
+ * @param token The token to save
+ * @return The row ID of the inserted token
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun saveToken(token: AuthToken): Long
+
+ /**
+ * Get the stored token
+ * @return The token or null if not found
+ */
+ @Query("SELECT * FROM auth_tokens WHERE id = 1 LIMIT 1")
+ fun getToken(): AuthToken?
+
+ /**
+ * Observe if a token exists
+ * @return Flow of Boolean indicating if a token exists
+ */
+ @Query("SELECT EXISTS(SELECT 1 FROM auth_tokens WHERE id = 1 LIMIT 1)")
+ fun hasToken(): Flow
+
+ /**
+ * Delete all tokens
+ * @return The number of tokens deleted
+ */
+ @Query("DELETE FROM auth_tokens")
+ fun clearTokens(): Int
+}
diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/JsonParser.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/JsonParser.kt
index 8d0d4109..57249414 100644
--- a/storage/src/androidMain/kotlin/bose/ankush/storage/room/JsonParser.kt
+++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/JsonParser.kt
@@ -3,17 +3,28 @@ package bose.ankush.storage.room
import com.google.gson.Gson
import java.lang.reflect.Type
-class JsonParser(private val gson: Gson) : Parser {
- override fun fromJson(json: String, type: Type): T? {
- return gson.fromJson(json, type)
- }
+class JsonParser(
+ private val gson: Gson,
+) : Parser {
+ override fun fromJson(
+ json: String,
+ type: Type,
+ ): T? = gson.fromJson(json, type)
- override fun toJson(obj: T, type: Type): String? {
- return gson.toJson(obj, type)
- }
+ override fun toJson(
+ obj: T,
+ type: Type,
+ ): String? = gson.toJson(obj, type)
}
interface Parser {
- fun fromJson(json: String, type: Type): T?
- fun toJson(obj: T, type: Type): String?
-}
\ No newline at end of file
+ fun fromJson(
+ json: String,
+ type: Type,
+ ): T?
+
+ fun toJson(
+ obj: T,
+ type: Type,
+ ): String?
+}
diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDao.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDao.kt
index 5148cd76..43848e3d 100644
--- a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDao.kt
+++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDao.kt
@@ -11,9 +11,11 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface WeatherDao {
-
@Transaction
- fun refreshWeather(weather: WeatherEntity, airQuality: AirQualityEntity) {
+ fun refreshWeather(
+ weather: WeatherEntity,
+ airQuality: AirQualityEntity,
+ ) {
deleteAllWeatherDetails()
deleteAllAirQualityDetails()
insertWeather(weather)
@@ -27,14 +29,20 @@ interface WeatherDao {
fun insertAirQuality(airQuality: AirQualityEntity)
@Query("SELECT * from $WEATHER_DATABASE_NAME")
- fun getWeather(): Flow
+ fun getWeather(): Flow
@Query("SELECT * from $AQ_DATABASE_NAME")
- fun getAirQuality(): Flow
+ fun getAirQuality(): Flow
@Query("DELETE from $WEATHER_DATABASE_NAME")
fun deleteAllWeatherDetails()
@Query("DELETE from $AQ_DATABASE_NAME")
fun deleteAllAirQualityDetails()
-}
\ No newline at end of file
+
+ @Transaction
+ fun clearAll() {
+ deleteAllWeatherDetails()
+ deleteAllAirQualityDetails()
+ }
+}
diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDataModelConverters.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDataModelConverters.kt
index 7f3d7768..fb7f3e3c 100644
--- a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDataModelConverters.kt
+++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDataModelConverters.kt
@@ -5,58 +5,62 @@ import androidx.room.TypeConverter
import com.google.gson.reflect.TypeToken
@ProvidedTypeConverter
-class WeatherDataModelConverters(private val parser: Parser) {
-
+class WeatherDataModelConverters(
+ private val parser: Parser,
+) {
@TypeConverter
- fun toAlertJson(alerts: List?): String = parser.toJson(
- alerts,
- object : TypeToken?>() {}.type
- ) ?: "[]"
+ fun toAlertJson(alerts: List?): String? =
+ parser.toJson(
+ alerts,
+ object : TypeToken?>() {}.type,
+ )
@TypeConverter
fun fromAlertJson(alertString: String): List =
parser.fromJson(
alertString,
- object : TypeToken?>() {}.type
+ object : TypeToken?>() {}.type,
) ?: emptyList()
@TypeConverter
- fun toDailyWeatherJson(dailyWeatherReports: List?): String = parser.toJson(
- dailyWeatherReports,
- object : TypeToken?>() {}.type
- ) ?: "[]"
+ fun toDailyWeatherJson(dailyWeatherReports: List?): String? =
+ parser.toJson(
+ dailyWeatherReports,
+ object : TypeToken