diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..222f83f2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + pull_request: + branches: [ "**" ] + push: + branches: [ "main" ] + +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: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Deep clean (project-local only) + run: ./gradlew --no-daemon deepClean + + - name: Build + run: ./gradlew --no-daemon build + + - name: Spotless check (soft enforcement for initial adoption) + run: ./gradlew --no-daemon spotlessCheckAll + continue-on-error: true + + - name: Detekt (soft enforcement for initial adoption) + run: ./gradlew --no-daemon detektAll + continue-on-error: true + +# Notes: +# - Spotless and Detekt steps are set to soft-fail initially to avoid breaking CI before the first formatting commit. +# After running `./gradlew applyCodeCleanup` and committing the formatting changes, set `continue-on-error: false` +# (or remove those lines) to enforce style and static analysis strictly on PRs. diff --git a/README.md b/README.md index eacf58ba..a91b8e75 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ A modern weather application built with Jetpack Compose that provides current weather conditions, forecasts, and air quality information. -+[![Download APK](https://img.shields.io/badge/download-APK-22272E.svg?style=for-the-badge&logo=android&logoColor=47954A)](https://github.com/bosankus/Compose-Weatherify/releases/latest) +[![Download APK](https://img.shields.io/badge/download-APK-22272E.svg?style=for-the-badge&logo=android&logoColor=47954A)](https://github.com/bosankus/Compose-Weatherify/releases/latest) ## 📱 Features - **Current Weather**: View today's temperature and weather conditions -- **5-Day Forecast**: See weather predictions for the next 4 days +- **5-Day Forecast**: See weather predictions for the next 5 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dc7be8df..aef761c6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -23,7 +23,10 @@ android { versionName = ConfigData.versionName multiDexEnabled = ConfigData.multiDexEnabled testInstrumentationRunner = "bose.ankush.weatherify.helper.HiltTestRunner" - resourceConfigurations.addAll(listOf("en", "hi", "iw")) + @Suppress("UnstableApiUsage") + androidResources { + localeFilters.addAll(listOf("en", "hi", "iw")) + } } kapt { @@ -58,17 +61,6 @@ 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 @@ -77,10 +69,9 @@ android { namespace = "bose.ankush.weatherify" } -composeCompiler { - featureFlags = setOf( - ComposeFeatureFlag.StrongSkipping.disabled() - ) + +kapt { + correctErrorTypes = true } dependencies { @@ -111,6 +102,7 @@ dependencies { debugImplementation(Deps.composeUiTooling) implementation(Deps.composeUiToolingPreview) implementation(Deps.composeMaterial3) + implementation(Deps.composeIconsExtended) // Unit Testing testImplementation(Deps.junit) @@ -133,11 +125,16 @@ dependencies { // Networking implementation(Deps.gson) + // Room runtime for providing WeatherDatabase from app DI + implementation(Deps.room) + implementation(Deps.roomKtx) + // Firebase implementation(platform(Deps.firebaseBom)) implementation(Deps.firebaseConfig) implementation(Deps.firebaseAnalytics) implementation(Deps.firebasePerformanceMonitoring) + implementation(Deps.firebaseMessaging) // Coroutines implementation(Deps.coroutinesCore) @@ -147,12 +144,27 @@ dependencies { implementation(Deps.hilt) implementation(Deps.hiltNavigationCompose) kapt(Deps.hiltDaggerAndroidCompiler) + kapt(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 moved to app module + implementation(Deps.razorPay) +} + + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.addAll( + "-Xopt-in=kotlin.RequiresOptIn", + "-Xopt-in=androidx.compose.animation.ExperimentalAnimationApi" + ) + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8127e848..3e57664f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,7 +11,7 @@ - + = Build.VERSION_CODES.TIRAMISU fun Context.openAppSystemSettings() = startActivity( @@ -32,12 +50,61 @@ object Extension { } ) - @RequiresApi(Build.VERSION_CODES.TIRAMISU) - fun Context.openAppLocaleSettings() = startActivity( - Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { + @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 + } + + // 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 + } + + // 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 + } + } fun Context.hasLocationPermission(): Boolean = listOf( android.Manifest.permission.ACCESS_COARSE_LOCATION, @@ -46,13 +113,6 @@ object Extension { ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED } - private fun Context.hasPhoneCallPermission(): Boolean { - return ContextCompat.checkSelfPermission( - this, - ACCESS_PHONE_CALL - ) == PackageManager.PERMISSION_GRANTED - } - fun Context.hasNotificationPermission(): Boolean { return ContextCompat.checkSelfPermission( this, @@ -69,11 +129,96 @@ 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 { + return Build.MODEL + } + + /** + * Gets the operating system name (e.g., "Android") + * @return The operating system name + */ + fun getOperatingSystem(): String { + return "Android" + } + + /** + * Gets the operating system version (e.g., "14", "13.1") + * @return The operating system version + */ + fun getOsVersion(): String { + return Build.VERSION.RELEASE + } + + /** + * Gets the app version from BuildConfig + * @return The app version + */ + fun getAppVersion(): String { + return 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 { + return "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/UiText.kt b/app/src/main/java/bose/ankush/weatherify/base/common/UiText.kt index f8eae807..bbc8a104 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,6 +2,7 @@ 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 { @@ -16,11 +17,39 @@ sealed class UiText { } } +/** + * 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 { 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) + // 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 { + return 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/location/DeviceLocationClient.kt b/app/src/main/java/bose/ankush/weatherify/base/location/DeviceLocationClient.kt index ea9677ee..2bd95e7c 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 @@ -17,9 +17,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow 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( @@ -90,7 +90,7 @@ class DeviceLocationClient @Inject constructor( return@suspendCancellableCoroutine } - client.lastLocation + client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) .addOnSuccessListener { location -> if (location != null) { continuation.resume(Result.success(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..0d0facc9 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,7 +14,7 @@ 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 @@ -30,10 +29,6 @@ class LocationService : Service() { return null } - override fun onCreate() { - super.onCreate() - } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_START -> start() @@ -53,11 +48,11 @@ class LocationService : Service() { .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) 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..168401f0 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,9 +1,9 @@ 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.weatherify.domain.model.WeatherCondition import bose.ankush.weatherify.domain.model.WeatherForecast +import bose.ankush.storage.room.Weather as StorageWeather +import bose.ankush.storage.room.WeatherEntity as StorageWeatherEntity /** * Mapper class to convert between WeatherEntity (data layer) and WeatherForecast (domain layer) @@ -15,10 +15,10 @@ object WeatherMapper { */ private fun mapStorageWeatherToDomain(weather: StorageWeather): WeatherCondition { return WeatherCondition( - description = weather.description, - icon = weather.icon, + description = weather.description ?: "", + icon = weather.icon ?: "", id = weather.id, - main = weather.main + main = weather.main ?: "" ) } 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 index 9e49a549..bcad0ff8 100644 --- a/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManager.kt +++ b/app/src/main/java/bose/ankush/weatherify/data/preference/PreferenceManager.kt @@ -16,7 +16,8 @@ import javax.inject.Singleton * Implementation of PreferenceManager that uses DataStore */ @Singleton -class PreferenceManagerImpl @Inject constructor(@ApplicationContext private val context: Context) : PreferenceManager { +class PreferenceManagerImpl @Inject constructor(@get:ApplicationContext private val context: Context) : + PreferenceManager { private val Context.dataStore: DataStore by preferencesDataStore(name = APP_PREFERENCE_KEY) @@ -28,4 +29,11 @@ class PreferenceManagerImpl @Inject constructor(@ApplicationContext private val preferences[PreferenceManager.USER_LON_LOCATION] = coordinates.second } } + + override suspend fun savePremiumStatus(isPremium: Boolean, expiryMillis: Long) { + context.dataStore.edit { preferences -> + preferences[PreferenceManager.IS_PREMIUM] = isPremium + preferences[PreferenceManager.PREMIUM_EXPIRY] = expiryMillis + } + } } 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..4f8f76fa 100644 --- a/app/src/main/java/bose/ankush/weatherify/di/NetworkModule.kt +++ b/app/src/main/java/bose/ankush/weatherify/di/NetworkModule.kt @@ -1,9 +1,16 @@ package bose.ankush.weatherify.di import android.content.Context +import bose.ankush.network.auth.repository.AuthRepository +import bose.ankush.network.auth.storage.TokenStorage 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.createPaymentRepository import bose.ankush.network.di.createWeatherRepository +import bose.ankush.network.repository.FeedbackRepository +import bose.ankush.network.repository.PaymentRepository import bose.ankush.network.repository.WeatherRepository import dagger.Module import dagger.Provides @@ -32,12 +39,49 @@ object NetworkModule { /** * Provides WeatherRepository implementation from the network module + * Uses TokenStorage for JWT authentication in API requests */ @Provides @Singleton fun provideWeatherRepository( - networkConnectivity: NetworkConnectivity + networkConnectivity: NetworkConnectivity, + tokenStorage: TokenStorage ): WeatherRepository { - return createWeatherRepository(networkConnectivity) + return createWeatherRepository(networkConnectivity, tokenStorage) } -} \ No newline at end of file + + /** + * Provides PaymentRepository implementation from the network module + */ + @Provides + @Singleton + fun providePaymentRepository( + networkConnectivity: NetworkConnectivity, + tokenStorage: TokenStorage + ): PaymentRepository { + return createPaymentRepository(networkConnectivity, tokenStorage) + } + + /** + * Provides AuthRepository implementation from the network module + */ + @Provides + @Singleton + fun provideAuthRepository( + tokenStorage: TokenStorage + ): AuthRepository { + return createAuthRepository(tokenStorage) + } + + /** + * Provides FeedbackRepository implementation from the network module + */ + @Provides + @Singleton + fun provideFeedbackRepository( + networkConnectivity: NetworkConnectivity, + tokenStorage: TokenStorage + ): FeedbackRepository { + return createFeedbackRepository(networkConnectivity, tokenStorage) + } +} 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..643d84cd 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 @@ -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..8f599a62 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 @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow */ interface PreferenceManager { /** - * Get the flow of location preferences + * Get the flow of preferences (location, premium, etc.) */ fun getLocationPreferenceFlow(): Flow @@ -18,11 +18,19 @@ interface PreferenceManager { */ suspend fun saveLocationPreferences(coordinates: Pair) + /** + * Save premium subscription status and expiry + */ + suspend fun savePremiumStatus(isPremium: Boolean, expiryMillis: Long) + /** * 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 IS_PREMIUM = androidx.datastore.preferences.core.booleanPreferencesKey("is_premium") + val PREMIUM_EXPIRY = + androidx.datastore.preferences.core.longPreferencesKey("premium_expiry") } } \ No newline at end of file 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..2791509a --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/feedback/SubmitFeedback.kt @@ -0,0 +1,13 @@ +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/payment/CreateOrder.kt b/app/src/main/java/bose/ankush/weatherify/domain/use_case/payment/CreateOrder.kt new file mode 100644 index 00000000..d61b8348 --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/payment/CreateOrder.kt @@ -0,0 +1,13 @@ +package bose.ankush.weatherify.domain.use_case.payment + +import bose.ankush.network.model.CreateOrderRequest +import bose.ankush.network.model.CreateOrderResponse +import bose.ankush.network.repository.PaymentRepository +import javax.inject.Inject + +class CreateOrder @Inject constructor( + private val repository: PaymentRepository +) { + suspend operator fun invoke(request: CreateOrderRequest): Result = + repository.createOrder(request) +} diff --git a/app/src/main/java/bose/ankush/weatherify/domain/use_case/payment/VerifyPayment.kt b/app/src/main/java/bose/ankush/weatherify/domain/use_case/payment/VerifyPayment.kt new file mode 100644 index 00000000..526076ab --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/domain/use_case/payment/VerifyPayment.kt @@ -0,0 +1,13 @@ +package bose.ankush.weatherify.domain.use_case.payment + +import bose.ankush.network.model.VerifyPaymentRequest +import bose.ankush.network.model.VerifyPaymentResponse +import bose.ankush.network.repository.PaymentRepository +import javax.inject.Inject + +class VerifyPayment @Inject constructor( + private val repository: PaymentRepository +) { + suspend operator fun invoke(request: VerifyPaymentRequest): Result = + repository.verifyPayment(request) +} 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..dcc7978d 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/MainActivity.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/MainActivity.kt @@ -9,23 +9,35 @@ 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.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.sunriseui.auth.LoginScreen +import bose.ankush.sunriseui.components.rememberGlassmorphicSnackbarState 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.Extension.openUrlInBrowser import bose.ankush.weatherify.base.common.PERMISSIONS_TO_REQUEST import bose.ankush.weatherify.base.common.startInAppUpdate import bose.ankush.weatherify.base.location.LocationClient @@ -34,20 +46,28 @@ import bose.ankush.weatherify.base.permissions.FineLocationPermissionTextProvide 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 @ExperimentalCoroutinesApi @ExperimentalAnimationApi @AndroidEntryPoint -class MainActivity : AppCompatActivity() { +class MainActivity : AppCompatActivity(), PaymentResultWithDataListener { private val viewModel: MainViewModel by viewModels() @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,57 +77,162 @@ 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) + + // Glassmorphic snackbar state + val (showSnackbar, snackbarContent) = rememberGlassmorphicSnackbarState() + + // Handle authentication state changes + LaunchedEffect(authState) { + when (authState) { + is AuthState.Error -> { + showSnackbar((authState as AuthState.Error).message.asString(this@MainActivity)) + viewModel.resetAuthState() + } + is AuthState.Success -> { + showSnackbar("Authentication successful") + viewModel.resetAuthState() + } + else -> Unit + } } - if (launchNotificationPermissionState.value) { - // request notification permission - RequestNotificationPermission(context) + + // 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) { + showSnackbar( + event.message.ifBlank { + "You need to log in again to continue using the app for security purposes." + } + ) + } + } } - /** - * 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) + // Collect payment events and launch Razorpay Checkout + LaunchedEffect(Unit) { + viewModel.paymentEvents.collect { evt -> + if (evt is bose.ankush.weatherify.presentation.payment.PaymentEvent.LaunchCheckout) { + try { + Checkout.preload(applicationContext) + razorpayCheckout = Checkout() + razorpayCheckout?.setKeyID(evt.keyId) + val options = JSONObject().apply { + put("name", evt.name) + put("description", evt.description) + put("order_id", evt.orderId) + put("currency", evt.currency) + put("amount", evt.amount) + val prefill = JSONObject().apply { + evt.email?.let { put("email", it) } + evt.contact?.let { put("contact", it) } + } + put("prefill", prefill) + } + razorpayCheckout?.open(this@MainActivity, options) + } catch (e: Exception) { + viewModel.onPaymentFailed( + e.message ?: "Unable to open payment checkout" + ) + // Clean up in case of error + Checkout.clearUserData(context) + razorpayCheckout = null + } + } } } - // main container holding all app composable screens + // Main content Box( 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) + } + 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 + ) + }, + onTermsClick = { context.openUrlInBrowser("https://data.androidplay.in/wfy/terms-and-conditions") }, + onPrivacyPolicyClick = { context.openUrlInBrowser("https://data.androidplay.in/wfy/privacy-policy") }, + isLoading = authState is AuthState.Loading + ) + } + } + // Overlay glassmorphic snackbar + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter + ) { snackbarContent() } } } } } + /** + * 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( @@ -118,12 +243,13 @@ class MainActivity : AppCompatActivity() { }) 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), + 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() @@ -132,30 +258,29 @@ class MainActivity : AppCompatActivity() { onGoToAppSettingClick = { context.openAppSystemSettings() }) } - LaunchedEffect(key1 = Unit) { - locationPermissionsResultLauncher.launch(PERMISSIONS_TO_REQUEST) + // 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) + } } - } - @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() - } - ) - - LaunchedEffect(key1 = Unit) { - phoneCallPermissionResultLauncher.launch(ACCESS_PHONE_CALL) + // Also launch when there are items in the queue (e.g., after denial to show rationale) + LaunchedEffect(permissionQueue.size) { + if (permissionQueue.isNotEmpty()) { + 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 -> if (isGranted) { Toast.makeText( @@ -163,13 +288,11 @@ class MainActivity : AppCompatActivity() { "Notification permission granted", Toast.LENGTH_SHORT ).show() - // hide notification banner on settings screen viewModel.updateShowNotificationBannerState(false) } } ) - - LaunchedEffect(key1 = Unit) { + LaunchedEffect(Unit) { notificationPermissionResultLauncher.launch(ACCESS_NOTIFICATION) } } @@ -178,4 +301,33 @@ class MainActivity : AppCompatActivity() { super.onResume() startInAppUpdate(this) } -} + + /** + * Razorpay payment success callback. + */ + 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()) { + viewModel.verifyPayment(orderId, paymentId, signature) + } else { + viewModel.onPaymentFailed("Payment succeeded but missing data") + } + razorpayCheckout = null + } + + /** + * Razorpay payment error callback. + */ + override fun onPaymentError(code: Int, response: String?, paymentData: PaymentData?) { + val message = response ?: "Payment failed with code $code" + viewModel.onPaymentFailed(message) + razorpayCheckout = null + } + + override fun onDestroy() { + super.onDestroy() + razorpayCheckout = null + } +} \ No newline at end of file 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..b7a88602 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/MainViewModel.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/MainViewModel.kt @@ -3,49 +3,55 @@ package bose.ankush.weatherify.presentation import androidx.compose.runtime.mutableStateListOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import bose.ankush.network.auth.model.AuthResponse +import bose.ankush.network.auth.repository.AuthRepository +import bose.ankush.network.model.CreateOrderRequest +import bose.ankush.network.model.VerifyPaymentRequest +import bose.ankush.weatherify.BuildConfig import bose.ankush.weatherify.R import bose.ankush.weatherify.base.common.ENABLE_NOTIFICATION +import bose.ankush.weatherify.base.common.Extension 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.domain.preference.PreferenceManager import bose.ankush.weatherify.domain.remote_config.RemoteConfigService 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.payment.CreateOrder +import bose.ankush.weatherify.domain.use_case.payment.VerifyPayment import bose.ankush.weatherify.domain.use_case.refresh_weather_reports.RefreshWeatherReport +import bose.ankush.weatherify.presentation.payment.PaymentEvent +import bose.ankush.weatherify.presentation.payment.PaymentStage +import bose.ankush.weatherify.presentation.payment.PaymentUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel 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.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber +import java.util.Calendar +import java.util.TimeZone 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 - * - * @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 + * Main ViewModel for Weatherify. + * Handles UI state, authentication, location, notifications, and payment. */ @HiltViewModel class MainViewModel @Inject constructor( @@ -55,198 +61,183 @@ class MainViewModel @Inject constructor( 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 createOrder: CreateOrder, + private val verifyPayment: VerifyPayment ) : ViewModel() { - /** - * Queue of permissions that need to be requested from the user. - * This is exposed to the UI to show appropriate permission dialogs. - */ + private fun friendlyMessageFromThrowable(t: Throwable?): String { + return when (t) { + null -> "Something went wrong. Please try again." + is CancellationException -> "Request was cancelled. Please try again." + else -> "Something went wrong. Please try again." + } + } + + private fun friendlyMessageFromServer(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." + } + } + + // 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())) } - } - } + // 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() + + // Payment state + private val _paymentUiState = MutableStateFlow(PaymentUiState()) + val paymentUiState: StateFlow = _paymentUiState.asStateFlow() + + private val _paymentEvents = Channel(Channel.BUFFERED) + val paymentEvents = _paymentEvents.receiveAsFlow() + + // 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. - */ - fun dismissDialog() { - permissionDialogQueue.removeAt(0) + private val tag = "${MainViewModel::class.simpleName} ->" + + // 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) } + } } - /** - * Handles the result of a permission request. - * If permission is denied, adds it to the dialog queue to show a rationale. - * If permission is granted, proceeds with fetching location coordinates. - * - * @param permission The permission that was requested - * @param isGranted Whether the permission was granted by the user - */ - fun onPermissionResult( - permission: String, - isGranted: Boolean, - ) { - if (!isGranted && !permissionDialogQueue.contains(permission)) { - permissionDialogQueue.add(permission) - } else { - fetchAndSaveLocationCoordinates() + init { + // Observe login state and set auth initialized + viewModelScope.launch { + var initialized = false + authRepository.isLoggedIn().collectLatest { loggedIn -> + _isLoggedIn.value = loggedIn + if (!initialized) { + _isAuthInitialized.value = true + initialized = true + } + } + } + // Load premium status and expiry from preferences + viewModelScope.launch(dispatchers.io) { + try { + val prefs = preferenceManager.getLocationPreferenceFlow().first() + val isPremiumStored = prefs[PreferenceManager.IS_PREMIUM] ?: false + val expiry = prefs[PreferenceManager.PREMIUM_EXPIRY] + val now = System.currentTimeMillis() + val isActive = isPremiumStored && (expiry == null || expiry > now) + withContext(dispatchers.main) { + _paymentUiState.update { state -> + state.copy( + isPremiumActivated = isActive, + expiryMillis = expiry, + stage = if (isActive) PaymentStage.Success else state.stage + ) + } + } + } catch (_: Exception) { + // ignore + } } } - /** - * 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 } + /** Remove first permission dialog from queue. */ + fun dismissDialog() = permissionDialogQueue.removeAt(0) + + /** Handle permission result, fetch location if granted. */ + fun onPermissionResult(permission: String, isGranted: Boolean) { + if (!isGranted && !permissionDialogQueue.contains(permission)) + permissionDialogQueue.add(permission) + else fetchAndSaveLocationCoordinates() } - /** - * Updates the state of the notification permission dialog. - * - * @param launchState True to show the permission dialog, false to hide it - */ - fun updateNotificationPermission(launchState: Boolean) { + /** Show/hide notification permission dialog. */ + fun updateNotificationPermission(launchState: Boolean) = _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") - } + val enabled = remoteConfigService.getBoolean(ENABLE_NOTIFICATION) + _showNotificationCardItem.update { enabled && launchState } + Timber.tag(tag) + .d("Notification feature is ${if (enabled) "enabled" else "disabled"}") } catch (e: CancellationException) { - throw e // Rethrow cancellation exceptions + throw e } catch (e: Exception) { Timber.tag(tag).e(e, "Error updating notification banner state") - _uiState.update { it.copy(error = UiText.DynamicText(e.message.toString())) } + _uiState.update { it.copy(error = errorResponseFromException(e)) } } } } - /** - * 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. - */ + /** Fetch and save user location, then load initial data. */ fun fetchAndSaveLocationCoordinates() { - // Cancel previous job if it exists locationJob?.cancel() - 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 + onSuccess = { loc -> + preferenceManager.saveLocationPreferences(loc.latitude to loc.longitude) performInitialDataLoading() }, onFailure = { e -> - _uiState.update { UIState(error = UiText.DynamicText(e.message.toString())) } + val error = if (e is Exception) errorResponseFromException(e) + else UiText.StringResource(resId = R.string.general_error_txt) + _uiState.update { UIState(error = error) } } ) } catch (e: CancellationException) { - throw e // Rethrow cancellation exceptions + throw e } catch (e: Exception) { Timber.tag(tag).e(e, "Error fetching location coordinates") - _uiState.update { it.copy(error = UiText.DynamicText(e.message.toString())) } + _uiState.update { it.copy(error = errorResponseFromException(e)) } } } } - - /** - * 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 - */ + /** Load weather and air quality data for UI. */ private fun performInitialDataLoading() { - // Cancel previous job if it exists dataLoadingJob?.cancel() - 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 + val prefs = preferenceManager.getLocationPreferenceFlow().first() + val lat = prefs[PreferenceManager.USER_LAT_LOCATION] + val lon = prefs[PreferenceManager.USER_LON_LOCATION] + if (lat != null && lon != null) { + val location = lat to lon 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 -> + .combine(getWeatherReport(location)) { air, weather -> UIState( isLoading = false, userLocation = location, @@ -259,47 +250,258 @@ class MainViewModel @Inject constructor( .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()) - ) - } + val error = if (e is Exception) errorResponseFromException(e) + else UiText.StringResource(resId = R.string.general_error_txt) + _uiState.update { it.copy(isLoading = false, error = error) } } - .onEach { newState -> _uiState.update { newState } } + .onEach { state -> _uiState.value = state } .launchIn(this) } else { - // in case we don't have coordinates, update UI state with appropriate error message - _uiState.update { + _uiState.update { UIState( - isLoading = false, + isLoading = false, error = UiText.StringResource(R.string.default_coordinates_txt) - ) + ) } } } catch (e: CancellationException) { - throw e // Rethrow cancellation exceptions + throw e } catch (e: Exception) { Timber.tag(tag).e(e, "Error in initial data loading") - _uiState.update { + _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. - */ + /** Login with email and password. */ + fun login(email: String, password: String) = viewModelScope.launch { + _authState.value = AuthState.Loading + try { + handleAuthResponse(authRepository.login(email, password)) + } catch (e: Exception) { + _authState.value = AuthState.Error(UiText.DynamicText(e.message ?: "Login failed")) + } + } + + /** Register with email and password. */ + fun register(email: String, password: String) = viewModelScope.launch { + _authState.value = AuthState.Loading + try { + val resp = authRepository.register( + email = email, + password = password, + timestampOfRegistration = Extension.getCurrentUtcTimestamp(), + deviceModel = Extension.getDeviceModel(), + operatingSystem = Extension.getOperatingSystem(), + osVersion = Extension.getOsVersion(), + appVersion = Extension.getAppVersion(), + ipAddress = Extension.getIpAddress(), + registrationSource = Extension.getRegistrationSource(), + firebaseToken = Extension.getFirebaseToken() + ) + handleAuthResponse(resp) + } catch (e: Exception) { + _authState.value = + AuthState.Error(UiText.DynamicText(e.message ?: "Registration failed")) + } + } + + /** Logout user. */ + fun logout() = viewModelScope.launch { + _authState.value = AuthState.LogoutLoading + try { + val result = authRepository.logout() + _authState.value = if (result.isSuccess) AuthState.LoggedOut + else AuthState.Error( + UiText.DynamicText( + result.exceptionOrNull()?.message ?: "Logout failed" + ) + ) + } catch (e: Exception) { + _authState.value = AuthState.Error(UiText.DynamicText(e.message ?: "Logout failed")) + } + } + + /** Handle authentication response. */ + private fun handleAuthResponse(response: AuthResponse) { + val token = response.data?.token + _authState.value = if (response.isSuccess() && !token.isNullOrBlank()) + AuthState.Success + else AuthState.Error(UiText.DynamicText(response.message ?: "Authentication failed")) + } + + /** Reset authentication state. */ + fun resetAuthState() { + _authState.value = AuthState.Initial + } + + // --- Payment --- + + /** Start payment process. */ + fun startPayment(amountPaise: Long = 10_000L, currency: String = "INR") = + viewModelScope.launch { + _paymentUiState.value = + _paymentUiState.value.copy( + loading = true, + message = "Creating order...", + stage = PaymentStage.CreatingOrder + ) + try { + val result = createOrder( + CreateOrderRequest( + amount = amountPaise, + currency = currency, + receipt = "receipt_${System.currentTimeMillis()}", + partialPayment = true, + firstPaymentMinAmount = 500L, + notes = mapOf("note1" to "This is a note", "note2" to "Another note") + ) + ) + result.fold( + onSuccess = { response -> + val data = response.extractData() + val key = BuildConfig.RAZORPAY_KEY + if (data == null) { + _paymentUiState.value = _paymentUiState.value.copy( + loading = false, + message = friendlyMessageFromServer(response.message), + stage = PaymentStage.Failure + ) + } else if (key.isBlank()) { + _paymentUiState.value = _paymentUiState.value.copy( + loading = false, + message = "Payment is temporarily unavailable. Please try again later.", + stage = PaymentStage.Failure + ) + } else if (data.orderId.isBlank() || data.amount <= 0L || data.currency.isBlank()) { + _paymentUiState.value = _paymentUiState.value.copy( + loading = false, + message = "We couldn't start the payment. Please try again.", + stage = PaymentStage.Failure + ) + } else { + _paymentEvents.trySend( + PaymentEvent.LaunchCheckout( + keyId = key, + orderId = data.orderId, + amount = data.amount, + currency = data.currency, + name = "Weatherify Subscription", + description = "Premium Plan" + ) + ) + _paymentUiState.value = _paymentUiState.value.copy( + loading = false, + message = response.message ?: "Order created", + stage = PaymentStage.AwaitingPayment + ) + } + }, + onFailure = { e -> + _paymentUiState.value = _paymentUiState.value.copy( + loading = false, + message = friendlyMessageFromThrowable(e), + stage = PaymentStage.Failure + ) + } + ) + } catch (e: Exception) { + _paymentUiState.value = _paymentUiState.value.copy( + loading = false, + message = friendlyMessageFromThrowable(e), + stage = PaymentStage.Failure + ) + } + } + + /** Verify payment. */ + fun verifyPayment(orderId: String, paymentId: String, signature: String) = + viewModelScope.launch { + _paymentUiState.value = + _paymentUiState.value.copy( + loading = true, + message = "Verifying payment...", + stage = PaymentStage.Verifying + ) + try { + val result = verifyPayment( + VerifyPaymentRequest( + razorpayOrderId = orderId, + razorpayPaymentId = paymentId, + razorpaySignature = signature + ) + ) + result.fold( + onSuccess = { resp -> + if (resp.success) { + val cal = Calendar.getInstance(TimeZone.getDefault()) + cal.timeInMillis = System.currentTimeMillis() + cal.add(Calendar.MONTH, 1) + val expiry = cal.timeInMillis + withContext(dispatchers.io) { + preferenceManager.savePremiumStatus(true, expiry) + } + _paymentUiState.value = _paymentUiState.value.copy( + loading = false, + message = "Payment verified", + stage = PaymentStage.Success, + isPremiumActivated = true, + expiryMillis = expiry + ) + } else { + _paymentUiState.value = _paymentUiState.value.copy( + loading = false, + message = friendlyMessageFromServer(resp.message), + stage = PaymentStage.Failure + ) + } + }, + onFailure = { e -> + _paymentUiState.value = _paymentUiState.value.copy( + loading = false, + message = friendlyMessageFromThrowable(e), + stage = PaymentStage.Failure + ) + } + ) + } catch (e: Exception) { + _paymentUiState.value = _paymentUiState.value.copy( + loading = false, + message = friendlyMessageFromThrowable(e), + stage = PaymentStage.Failure + ) + } + } + + /** Handle payment failure. */ + fun onPaymentFailed(message: String) { + _paymentUiState.value = _paymentUiState.value.copy( + loading = false, + message = friendlyMessageFromServer(message), + stage = PaymentStage.Failure + ) + } + + /** Cancel all jobs on ViewModel clear. */ override fun onCleared() { super.onCleared() - // Cancel all active jobs when ViewModel is cleared 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/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..cd4f1dbf 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 @@ -37,6 +37,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 @@ -89,14 +91,39 @@ fun HandleScreenError( errorText: UiText?, onErrorAction: () -> Unit ) { - ShowError( - modifier = Modifier - .fillMaxSize() - .padding(all = 16.dp), - msg = errorText?.asString(context), - buttonText = stringResource(id = R.string.retry_btn_txt), - buttonAction = onErrorAction - ) + // State to track if retry operation is in progress + val (isRetrying, setRetrying) = remember { mutableStateOf(false) } + + // Reset loading state after a delay to give visual feedback + // In a real app, this would be reset when the operation completes + LaunchedEffect(isRetrying) { + if (isRetrying) { + delay(2000) // Show loading for at least 2 seconds for better UX + setRetrying(false) + } + } + + // Add a background animation that's appropriate for error state + Box(modifier = Modifier.fillMaxSize()) { + // Create a subtle animated background + ErrorBackgroundAnimation() + + ShowError( + modifier = Modifier + .fillMaxSize() + .padding(all = 16.dp), + msg = errorText?.asString(context), + buttonText = stringResource(id = R.string.retry_btn_txt), + isLoading = isRetrying, + buttonAction = { + // Set loading state to true when retry is clicked + setRetrying(true) + + // Call the original action + onErrorAction() + } + ) + } } @Composable @@ -109,6 +136,7 @@ private fun ShowUIContainer( // 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 +145,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 +154,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 } @@ -176,6 +208,28 @@ private fun ShowUIContainer( } } + // 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, + onReadMoreClick = { + // Optional: Add analytics logging or navigation here + } + ) + } + } + } + // Show brief air quality report item(key = "air_quality") { airQualityReports?.let { @@ -188,7 +242,7 @@ private fun ShowUIContainer( ), exit = fadeOut() ) { - BriefAirQualityReportCardLayout(airQualityReports, navController) + BriefAirQualityReportCardLayout(airQualityReports) } } } 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..68c96360 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,167 +1,212 @@ package bose.ankush.weatherify.presentation.home.component import android.annotation.SuppressLint +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.navigation.NavController -import bose.ankush.weatherify.R import bose.ankush.weatherify.base.common.AirQualityIndexAnalyser.getAQIAnalysedText import bose.ankush.weatherify.base.common.AirQualityIndexAnalyser.getFormattedAQI import bose.ankush.weatherify.domain.model.AirQuality -import bose.ankush.weatherify.presentation.navigation.Screen -/** - * This composable is response to show air quality card on HomeScreen. - * Shows what is the current air quality based return value of [getAQIAnalysedText] - */ -@SuppressLint("MissingPermission") +private data class AqiUiState( + val statusText: String, + val qualityColor: Color, + val formattedAqi: String, +) + @Composable -internal fun BriefAirQualityReportCardLayout( - airQuality: AirQuality, - navController: NavController -) { - ShowUI( - aq = airQuality, - onItemClick = { navController.navigate(Screen.AirQualityDetailsScreen.route) } - ) +private fun rememberAqiUiState(aqi: Int): AqiUiState { + return remember(aqi) { + val (fullStatusText, _) = getAQIAnalysedText(aqi) + AqiUiState( + statusText = fullStatusText.split(" at").firstOrNull() ?: "", + qualityColor = getAirQualityColor(aqi), + formattedAqi = aqi.getFormattedAQI() + ) + } } /** - * Air quality UI composable - * This composable has onClick listener, with action to navigate to AirQualityDetailsScreen, - * and carry latitude and longitude as navigation arguments + * This composable is response to show air quality card on HomeScreen. + * Shows what is the current air quality based return value of [getAQIAnalysedText] */ +@SuppressLint("MissingPermission") @Composable -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( + onClick = { isExpanded = !isExpanded }, 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) - ) + .animateContentSize(), + shape = RoundedCornerShape(24.dp) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally + .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 + ) } } + /** * Returns a color based on the air quality index value * Not a composable function since it doesn't use any composable functions @@ -179,34 +224,24 @@ private fun getAirQualityColor(aqi: Int): Color { /** * 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 ) + 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..1b60dbfd 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 @@ -45,6 +45,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 @@ -169,7 +170,7 @@ private fun LocationAndDateHeader( locationName = result } catch (e: Exception) { // If geocoding fails, keep the default "Current Location" - e.printStackTrace() + Timber.e(e, "Geocoding failed; using default location label") } } } 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..ef9e4b42 --- /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.sunriseui.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?.toLong(), + endTime = firstAlert.end?.toLong(), + source = firstAlert.sender_name, + onReadMoreClick = onReadMoreClick + ) +} \ No newline at end of file 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..f0644047 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,11 +26,33 @@ 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), + buttonText: String = stringResource(id = R.string.retry_btn_txt), + isLoading: Boolean = false, buttonAction: () -> Unit ) { Box( @@ -34,33 +62,55 @@ fun ShowError( 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), + 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) + overflow = TextOverflow.Ellipsis ) + + 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/navigation/AppNavigation.kt b/app/src/main/java/bose/ankush/weatherify/presentation/navigation/AppNavigation.kt index c4a5d556..42e98e94 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 @@ -12,13 +12,11 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import androidx.navigation.navigation import bose.ankush.language.presentation.LanguageScreen -import bose.ankush.weatherify.base.common.Extension.callNumber 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.MainViewModel 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 @@ -76,38 +74,6 @@ fun AppNavigation(viewModel: MainViewModel) { ) { 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 - ) - } } /*Account/Profile Screens*/ @@ -132,11 +98,6 @@ fun AppNavigation(viewModel: MainViewModel) { if (!context.hasNotificationPermission()) { viewModel.updateNotificationPermission(launchState = true) } - }, - onAvatarNavAction = { - if (!context.callNumber()) { - viewModel.updatePhoneCallPermission(launchState = true) - } } ) } 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..3d4d7316 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 @@ -9,7 +9,6 @@ sealed class Screen(val route: String, @StringRes val resourceId: Int) { data object HomeNestedNav : Screen("home_nav", R.string.home_nested_nav) data object HomeScreen : Screen("home_screen", R.string.home_screen) data object CitiesListScreen : Screen("city_list_screen", R.string.city_screen) - data object AirQualityDetailsScreen : Screen("air_quality_details_screen", R.string.aq_screen) /*Account/Profile Screens*/ data object ProfileNestedNav : Screen("profile_nav", R.string.profile_nested_nav) diff --git a/app/src/main/java/bose/ankush/weatherify/presentation/payment/PaymentViewModel.kt b/app/src/main/java/bose/ankush/weatherify/presentation/payment/PaymentViewModel.kt new file mode 100644 index 00000000..b474d39f --- /dev/null +++ b/app/src/main/java/bose/ankush/weatherify/presentation/payment/PaymentViewModel.kt @@ -0,0 +1,32 @@ +package bose.ankush.weatherify.presentation.payment + +/** + * Payment UI models used across the app. + * Note: This file intentionally contains no ViewModel to adhere to SRP. + * - MainViewModel owns payment state and business logic. + * - MainActivity owns platform-specific checkout invocation (Razorpay). + * This separation avoids tight coupling and keeps UI models reusable. + */ + +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 +) + +sealed class PaymentEvent { + data class LaunchCheckout( + 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 + ) : PaymentEvent() +} 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 index 186eedd0..62bfd5cb 100644 --- a/app/src/main/java/bose/ankush/weatherify/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/bose/ankush/weatherify/presentation/settings/SettingsScreen.kt @@ -1,16 +1,17 @@ package bose.ankush.weatherify.presentation.settings +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build 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 @@ -20,51 +21,62 @@ 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.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowRight +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.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.TextButton +import androidx.compose.material3.TopAppBarDefaults 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.graphics.vector.ImageVector 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.core.content.ContextCompat import androidx.navigation.NavController -import bose.ankush.weatherify.R +import bose.ankush.sunriseui.premium.PremiumBottomSheetContent +import bose.ankush.weatherify.BuildConfig import bose.ankush.weatherify.base.LocaleConfigMapper +import bose.ankush.weatherify.base.common.Extension.openUrlInBrowser +import bose.ankush.weatherify.presentation.AuthState import bose.ankush.weatherify.presentation.MainViewModel import bose.ankush.weatherify.presentation.navigation.AppBottomBar -import kotlinx.coroutines.CoroutineScope +import bose.ankush.weatherify.presentation.payment.PaymentStage +import bose.ankush.weatherify.presentation.payment.PaymentUiState import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -72,11 +84,8 @@ internal fun SettingsScreen( viewModel: MainViewModel, navController: NavController, onLanguageNavAction: (Array) -> Unit, - onNotificationNavAction: () -> Unit, - onAvatarNavAction: () -> Unit, + onNotificationNavAction: () -> Unit ) { - val isNotificationBannerVisible = viewModel.showNotificationCardItem.collectAsState().value - val scope = rememberCoroutineScope() val languageList = LocaleConfigMapper.getAvailableLanguagesFromJson( jsonFile = "countryConfig.json", context = LocalContext.current @@ -86,303 +95,184 @@ internal fun SettingsScreen( val showPremiumBottomSheet = remember { mutableStateOf(false) } val bottomSheetState = rememberModalBottomSheetState() + val paymentUiState = viewModel.paymentUiState.collectAsState().value + + // Logout dialog state + val showLogoutDialog = remember { mutableStateOf(false) } + + // Observe auth state to reflect logout loading/success + val authState = viewModel.authState.collectAsState().value + val isLoggingOut = authState is AuthState.LogoutLoading + + // Close dialog on successful logout (token cleared -> MainActivity shows Login) + LaunchedEffect(authState) { + if (authState is AuthState.LoggedOut) { + showLogoutDialog.value = false + // Reset to avoid lingering LoggedOut state + viewModel.resetAuthState() + } + } + + // Animation states for screen components + val settingsSectionState = remember { MutableTransitionState(false) } + val legalSectionState = remember { MutableTransitionState(false) } + val logoutButtonState = remember { MutableTransitionState(false) } + + LaunchedEffect(Unit) { + // Reset and start staggered animations to align with app style + settingsSectionState.targetState = false + legalSectionState.targetState = false + logoutButtonState.targetState = false + + delay(100) + settingsSectionState.targetState = true + delay(150) + legalSectionState.targetState = true + delay(150) + logoutButtonState.targetState = true + } + Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - ScreenHeader( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 50.dp), - onAvatarNavAction = onAvatarNavAction, - scope = scope + CenterAlignedTopAppBar( + title = { Text("Profile", fontWeight = FontWeight.SemiBold) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back" + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface + ) ) }, 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 - } + 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 = { showPremiumBottomSheet.value = true } + ) + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + // Settings Section + item { AnimatedVisibility( - visibleState = transitionState, + visibleState = settingsSectionState, enter = fadeIn(animationSpec = tween(durationMillis = 500)) + slideInVertically( animationSpec = tween(durationMillis = 500), - initialOffsetY = { it / 2 } + initialOffsetY = { it / 3 } ), 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 - ) - } - } - } + SettingsSection( + onNotificationNavAction = onNotificationNavAction, + onLanguageNavAction = { onLanguageNavAction(languageList) } + ) } } - // Language block - // Create a transition state for the animation - val languageTransitionState = remember { MutableTransitionState(false) } + item { Spacer(modifier = Modifier.height(24.dp)) } - // Start the animation when the component is first displayed - LaunchedEffect(Unit) { - delay(200) // Small delay for staggered effect - languageTransitionState.targetState = true + // Legal Section + item { + AnimatedVisibility( + visibleState = legalSectionState, + enter = fadeIn(animationSpec = tween(durationMillis = 500)) + + slideInVertically( + animationSpec = tween(durationMillis = 500), + initialOffsetY = { it / 3 } + ), + exit = fadeOut() + ) { + LegalSection() + } } - 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) - ) + item { Spacer(modifier = Modifier.height(24.dp)) } + + // Logout Button + item { + AnimatedVisibility( + visibleState = logoutButtonState, + enter = fadeIn(animationSpec = tween(durationMillis = 500)) + + slideInVertically( + animationSpec = tween(durationMillis = 500), + initialOffsetY = { it / 3 } + ), + exit = fadeOut() ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(all = 20.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + TextButton( + onClick = { showLogoutDialog.value = true }, + modifier = Modifier.fillMaxWidth() ) { - 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) - ) - } + Text( + text = "Logout", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) } } } - // 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 - } + item { Spacer(modifier = Modifier.height(24.dp)) } + } - 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 + if (showLogoutDialog.value) { + AlertDialog( + onDismissRequest = { if (!isLoggingOut) showLogoutDialog.value = false }, + title = { Text(text = "Logout") }, + text = { Text(text = "Are you sure you want to logout?") }, + confirmButton = { + TextButton( + onClick = { viewModel.logout() }, + enabled = !isLoggingOut ) { - 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) - ) - } + Text("Confirm") } - } - - // 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) - ) - ) - } - } + }, + dismissButton = { + TextButton( + onClick = { showLogoutDialog.value = false }, + enabled = !isLoggingOut ) { - PremiumBottomSheetContent( - onDismiss = { showPremiumBottomSheet.value = false } - ) + Text("Cancel") } } + ) + } + + // Premium Bottom Sheet + if (showPremiumBottomSheet.value) { + ModalBottomSheet( + onDismissRequest = { showPremiumBottomSheet.value = false }, + sheetState = bottomSheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + PremiumBottomSheetContent( + onDismiss = { showPremiumBottomSheet.value = false }, + onSubscribe = { + showPremiumBottomSheet.value = false + viewModel.startPayment() + } + ) } } }, @@ -396,218 +286,228 @@ internal fun SettingsScreen( } @Composable -private fun PremiumBottomSheetContent( - onDismiss: () -> Unit +fun PremiumCard( + paymentUiState: PaymentUiState, + onClick: () -> Unit ) { + 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) + } else { + UnsubscribedPremiumCard(onClick) + } + } +} + +@Composable +fun UnsubscribedPremiumCard(onClick: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 16.dp), + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // Simplified Header - Text( - text = "Premium", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + Icon( + imageVector = Icons.Outlined.WorkspacePremium, + contentDescription = "Premium", + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer ) - 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, + text = "Get Premium", + style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onTertiaryContainer ) - + Spacer(modifier = Modifier.height(8.dp)) 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) + text = "Unlock all features and enjoy an ad-free experience.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f) ) - - Spacer(modifier = Modifier.height(24.dp)) - - // Subscribe Button + Spacer(modifier = Modifier.height(16.dp)) Button( - onClick = { onDismiss() }, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), + onClick = onClick, colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFFFB74D) - ), - shape = RoundedCornerShape(8.dp) - ) { - Text( - text = "Subscribe", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = Color.White + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary ) + ) { + Text("Upgrade Now") } - - 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( +fun SubscribedPremiumCard(paymentUiState: PaymentUiState) { + Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Box( - modifier = Modifier - .size(6.dp) - .clip(CircleShape) - .background(Color(0xFFFFB74D)) + Icon( + imageVector = Icons.Outlined.WorkspacePremium, + contentDescription = "Premium", + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer ) - - Spacer(modifier = Modifier.width(12.dp)) - + Spacer(modifier = Modifier.height(16.dp)) Text( - text = feature, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + text = "You are a Premium User", + 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 df = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) + val dateStr = df.format(Date(expiryTop)) + Text( + text = "Expires $dateStr", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) + ) + } else { + Text( + text = "Active", + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF2E7D32), + fontWeight = FontWeight.SemiBold + ) + } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun ScreenHeader( - modifier: Modifier = Modifier, - onAvatarNavAction: () -> Unit, - scope: CoroutineScope, +fun SettingsSection( + onNotificationNavAction: () -> Unit, + onLanguageNavAction: () -> Unit, ) { - 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 + val context = LocalContext.current + + // Determine whether to show notification permission item. + val shouldShowNotificationItem = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + } else { + // For API < 33, permission is not required, so don't show the item. + false } - AnimatedVisibility( - visibleState = headerTransitionState, - enter = fadeIn(animationSpec = tween(durationMillis = 500)) + - slideInVertically( - animationSpec = tween(durationMillis = 500), - initialOffsetY = { -it / 2 } - ), - exit = fadeOut() + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(vertical = 8.dp) ) { - 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 - ) + if (shouldShowNotificationItem) { + SettingsItem( + icon = Icons.Outlined.Notifications, + title = "Notifications", + onClick = onNotificationNavAction + ) + } + SettingsItem( + icon = Icons.Outlined.Language, + title = "Language", + onClick = onLanguageNavAction + ) + } +} +@Composable +fun LegalSection() { + val context = LocalContext.current + 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 = "Privacy Policy", + onClick = { context.openUrlInBrowser("https://data.androidplay.in/wfy/privacy-policy") } + ) + SettingsItem( + icon = Icons.Outlined.Gavel, + title = "Terms of Use", + onClick = { context.openUrlInBrowser("https://data.androidplay.in/wfy/terms-and-conditions") } + ) + SettingsItem( + icon = Icons.Outlined.Info, + title = "App Version", + trailingContent = { Text( - text = "Customize your app experience", + text = BuildConfig.VERSION_NAME, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier.padding(top = 4.dp) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } + ) + } +} - 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() } } - ) - } - } + +@Composable +fun SettingsItem( + icon: ImageVector, + title: String, + onClick: (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null +) { + 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) { + 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 + ) + } + if (trailingContent != null) { + trailingContent() + } else if (onClick != null) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) } } } 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..affa312d 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,125 @@ 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 + +// 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) +) -// 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, + fontFamily = InterFamily, fontWeight = FontWeight.Bold, fontSize = 57.sp, lineHeight = 64.sp, letterSpacing = (-0.25).sp ), displayMedium = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = InterFamily, fontWeight = FontWeight.Bold, fontSize = 45.sp, lineHeight = 52.sp, letterSpacing = 0.sp ), displaySmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Bold, + fontFamily = InterFamily, + fontWeight = FontWeight.SemiBold, fontSize = 36.sp, lineHeight = 44.sp, letterSpacing = 0.sp ), headlineLarge = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = InterFamily, fontWeight = FontWeight.SemiBold, fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.sp ), headlineMedium = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = InterFamily, fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.sp ), headlineSmall = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = InterFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.sp ), titleLarge = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = InterFamily, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.sp ), titleMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 24.sp, - letterSpacing = 0.15.sp + letterSpacing = 0.1.sp ), titleSmall = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = InterFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp ), bodyLarge = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = InterFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp ), bodyMedium = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = InterFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, - letterSpacing = 0.25.sp + letterSpacing = 0.2.sp ), bodySmall = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = InterFamily, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp, - letterSpacing = 0.4.sp + letterSpacing = 0.3.sp ), labelLarge = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = InterFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp ), labelMedium = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = InterFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, - letterSpacing = 0.5.sp + letterSpacing = 0.4.sp ), labelSmall = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = InterFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, - letterSpacing = 0.5.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-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 76e03445..527efa45 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -34,6 +34,8 @@ शहर नहीं मिला! लगता है सर्वर में समस्या है! क्या आप इंटरनेट कनेक्टिविटी की दोबारा जांच कर सकते हैं! + क्या आप इंटरनेट कनेक्टिविटी की दोबारा जांच कर सकते हैं! + अनुरोध का समय समाप्त हो गया। कृपया बाद में पुनः प्रयास करें। ओह! कुछ गलत हो गया है। स्थान निर्देशांक अभी तक अपडेट नहीं किए गए हैं। ओह तेरी! इस समय कोई शहर नहीं मिला। बाद में जांचें diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 978b0392..f7abbc54 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -34,6 +34,8 @@ העיר לא נמצאה! מתמודד עם בעיה בשרת האם תוכל לבדוק מחדש את חיבור האינטרנט! + האם תוכל לבדוק מחדש את חיבור האינטרנט! + פג הזמן הקצוב לבקשה. אנא נסה שוב מאוחר יותר. אופס!..משהו השתבש. קואורדינטות המיקום עדיין לא עודכנו. גישה לא מורשית! diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e493a926..641951d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,6 +35,8 @@ 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 coordinates not yet updated. Oh no! No cities found at this moment. Check back later 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..7affcb1a 100644 --- a/app/src/test/java/bose/ankush/weatherify/base/DateTimeUtilsTest.kt +++ b/app/src/test/java/bose/ankush/weatherify/base/DateTimeUtilsTest.kt @@ -5,88 +5,60 @@ 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()) 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`() { diff --git a/build.gradle.kts b/build.gradle.kts index d73f6982..65f6306f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,8 +16,9 @@ plugins { id("org.jetbrains.kotlin.plugin.serialization") version Versions.kotlin apply false id("com.google.dagger.hilt.android") version Versions.hilt apply false id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") version Versions.secretPlugin apply false - id("org.jlleitschuh.gradle.ktlint") version Versions.ktLintVersion apply false + id("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 } @@ -30,3 +31,100 @@ 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" + ) + ) + trimTrailingWhitespace() + endWithNewline() + } + kotlinGradle { + target("**/*.gradle.kts") + ktlint(Versions.ktLintCli) + } + } +} + +// Detekt minimal configuration for all subprojects +subprojects { + apply(plugin = "io.gitlab.arturbosch.detekt") + + extensions.configure("detekt") { + buildUponDefaultConfig = true + allRules = false + ignoreFailures = true + autoCorrect = false + parallel = true + } + + tasks.withType().configureEach { + jvmTarget = "21" + reports { + xml.required.set(false) + txt.required.set(false) + sarif.required.set(false) + md.required.set(false) + html.required.set(true) + } + } +} + +// 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" }) +} + +// Convenience task for minimal-risk code cleanup +// This applies formatting (imports/whitespace) only; safe to run locally +// Commit separately to avoid noisy diffs. +tasks.register("applyCodeCleanup") { + group = "formatting" + description = "Applies formatting across all subprojects (spotlessApplyAll)" + dependsOn("spotlessApplyAll") +} 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..e85d7557 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -28,6 +28,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}" } @@ -58,6 +59,7 @@ object Deps { val firebaseConfig by lazy { "com.google.firebase:firebase-config-ktx" } val firebaseAnalytics by lazy { "com.google.firebase:firebase-analytics-ktx" } val firebasePerformanceMonitoring by lazy { "com.google.firebase:firebase-perf" } + val firebaseMessaging by lazy { "com.google.firebase:firebase-messaging-ktx" } // Coroutines val coroutinesCore by lazy { "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutines}" } @@ -68,15 +70,15 @@ object Deps { 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..efb2beeb 100644 --- a/buildSrc/src/main/java/Versions.kt +++ b/buildSrc/src/main/java/Versions.kt @@ -1,70 +1,71 @@ - object Versions { // Kotlin - const val kotlin = "2.0.20" - const val kotlinCompiler = "1.9" + const val kotlin = "2.2.0" // Compose - const val composeBom = "2023.08.00" + const val composeBom = "2025.06.01" // 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" + + // 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 truth = "1.1.5" + const val turbine = "1.0.0" + const val coroutineTest = "1.9.0" const val coreTesting = "2.2.0" const val espresso = "3.5.1" const val mockitoInline = "5.2.0" const val mockitoNhaarman = "2.2.0" - const val mockWebServer = "4.9.3" - const val mockk = "1.13.5" + const val mockWebServer = "4.12.0" + const val mockk = "1.13.8" // 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.1.0" + const val accompanist = "0.36.0" + const val dataStore = "1.1.1" + const val splashScreen = "1.2.0-rc01" // Room - const val room = "2.5.2" + const val room = "2.7.0" // Networking - const val gson = "2.13.1" + const val gson = "2.10.1" // Firebase - const val firebaseBom = "32.2.0" + const val firebaseBom = "33.0.0" // Coroutines - const val coroutines = "1.6.4" + const val coroutines = "1.9.0" // Dependency Injection - const val hilt = "2.52" - const val hiltCompose = "1.0.0" + const val hilt = "2.56" + 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.6.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..a6a383a9 100644 --- a/buildSrc/src/main/kotlin/KmmDeps.kt +++ b/buildSrc/src/main/kotlin/KmmDeps.kt @@ -2,10 +2,10 @@ import org.gradle.api.artifacts.dsl.DependencyHandler object KmmVersions { const val ktor = "2.3.13" - const val kotlinxSerialization = "1.6.0" - const val kotlinxCoroutines = "1.7.3" + const val kotlinxSerialization = "1.7.3" + const val kotlinxCoroutines = "1.9.0" const val koin = "3.5.6" - const val kotlinxDateTime = "0.4.1" + const val kotlinxDateTime = "0.6.1" } object KmmDeps { @@ -33,6 +33,7 @@ object KmmDeps { const val kotlinxDateTime = "org.jetbrains.kotlinx:kotlinx-datetime:${KmmVersions.kotlinxDateTime}" } +@Suppress("unused") fun DependencyHandler.addKmmCommonDependencies() { implementation(KmmDeps.ktorCore) implementation(KmmDeps.ktorSerialization) @@ -45,10 +46,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/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..2740f422 100644 --- a/language/build.gradle.kts +++ b/language/build.gradle.kts @@ -39,14 +39,6 @@ android { jvmTarget = JavaVersion.VERSION_17.toString() } - kotlin { - sourceSets.all { - languageSettings { - languageVersion = Versions.kotlinCompiler - } - } - } - lint { abortOnError = false } 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..33a21e93 100644 --- a/language/src/main/java/bose/ankush/language/util/LocaleHelper.kt +++ b/language/src/main/java/bose/ankush/language/util/LocaleHelper.kt @@ -20,7 +20,7 @@ internal object LocaleHelper { fun String.getDisplayName(): String { val languageCode = this.split("-").firstOrNull() ?: this - val locale = Locale(languageCode) + val locale = Locale.forLanguageTag(languageCode) return locale.getDisplayName(locale) } diff --git a/local.properties b/local.properties index 8136714b..f35a5058 100644 --- a/local.properties +++ b/local.properties @@ -6,4 +6,5 @@ # header note. #Mon Aug 19 11:29:33 IST 2024 sdk.dir=/Users/t0304iw/Library/Android/sdk -OPEN_WEATHER_API=eb1842dacd16299875b9b1eb9299108d \ No newline at end of file +OPEN_WEATHER_API=eb1842dacd16299875b9b1eb9299108d +RAZORPAY_KEY=rzp_test_R8s2pslnQs0bRz \ No newline at end of file diff --git a/network/build.gradle.kts b/network/build.gradle.kts index 9b5a4943..567440bf 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,10 +8,8 @@ plugins { kotlin { androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) } } @@ -42,15 +42,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 +69,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) @@ -86,3 +94,4 @@ android { targetCompatibility = JavaVersion.VERSION_17 } } + 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..175ba216 100644 --- a/network/src/androidMain/kotlin/bose/ankush/network/di/AndroidHttpClient.kt +++ b/network/src/androidMain/kotlin/bose/ankush/network/di/AndroidHttpClient.kt @@ -1,12 +1,12 @@ 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 @@ -20,14 +20,13 @@ actual fun createPlatformHttpClient(json: Json): HttpClient { 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") + 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..b06f6fdd --- /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 +} \ No newline at end of file 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..54d51cd4 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorFeedbackApiService.kt @@ -0,0 +1,29 @@ +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 { + return NetworkUtils.retryWithExponentialBackoff { + httpClient.post("$baseUrl/feedback") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() + } + } +} \ No newline at end of file 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..49c61c71 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorPaymentApiService.kt @@ -0,0 +1,40 @@ +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 { + return NetworkUtils.retryWithExponentialBackoff { + httpClient.post("$baseUrl/create-order") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() + } + } + + override suspend fun verifyPayment(request: VerifyPaymentRequest): VerifyPaymentResponse { + return NetworkUtils.retryWithExponentialBackoff { + httpClient.post("$baseUrl/store-payment") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() + } + } +} \ No newline at end of file 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..4660a060 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/api/KtorWeatherApiService.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/api/KtorWeatherApiService.kt @@ -19,7 +19,7 @@ class KtorWeatherApiService( latitude: String, longitude: String ): AirQuality { - return httpClient.get("$baseUrl/get-air-pollution") { + return httpClient.get("$baseUrl/air-pollution") { parameter("lat", latitude) parameter("lon", longitude) }.body() @@ -29,7 +29,7 @@ class KtorWeatherApiService( latitude: String, longitude: String ): WeatherForecast { - return httpClient.get("$baseUrl/get-weather") { + return httpClient.get("$baseUrl/weather") { parameter("lat", latitude) parameter("lon", longitude) }.body() 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..ba663a3d --- /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 +} \ 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..a84e28a9 --- /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 +} \ No newline at end of file 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..bc03f4f7 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/api/KtorAuthApiService.kt @@ -0,0 +1,66 @@ +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.auth.storage.TokenStorage +import bose.ankush.network.utils.NetworkUtils +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.header +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, + private val tokenStorage: TokenStorage? = null +) : AuthApiService { + + override suspend fun login(request: LoginRequest): AuthResponse { + return NetworkUtils.retryWithExponentialBackoff { + httpClient.post("$baseUrl/login") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() + } + } + + override suspend fun register(request: RegisterRequest): AuthResponse { + return NetworkUtils.retryWithExponentialBackoff { + httpClient.post("$baseUrl/register") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() + } + } + + override suspend fun refreshToken(request: RefreshTokenRequest): AuthResponse { + return NetworkUtils.retryWithExponentialBackoff { + httpClient.post("$baseUrl/refresh-token") { + // Pass token as a query parameter as per new API contract + url { parameters.append("token", request.token) } + + // Optionally include the current token in Authorization header if present + tokenStorage?.getToken()?.let { token -> + if (token.isNotBlank()) { + header("Authorization", "Bearer $token") + } + } + }.body() + } + } + + override suspend fun logout(): LogoutResponse { + return NetworkUtils.retryWithExponentialBackoff { + httpClient.post("$baseUrl/logout").body() + } + } +} \ No newline at end of file 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..c7ca30aa --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/events/AuthEvents.kt @@ -0,0 +1,21 @@ +package bose.ankush.network.auth.events + +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(extraBufferCapacity = 1) + val events: SharedFlow = _events + + suspend fun emit(event: AuthEvent) { + _events.emit(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..9c000b77 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/interceptor/AuthInterceptor.kt @@ -0,0 +1,88 @@ +package bose.ankush.network.auth.interceptor + +import bose.ankush.network.auth.storage.TokenStorage +import bose.ankush.network.auth.token.TokenManager +import io.ktor.client.HttpClientConfig +import io.ktor.client.plugins.HttpResponseValidator +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.observer.ResponseObserver +import io.ktor.client.request.header +import io.ktor.client.statement.request +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) { + // Add authorization header to all requests + defaultRequest { + runBlocking { + val token = tokenManager.getValidToken() + if (!token.isNullOrBlank()) { + header("Authorization", "Bearer $token") + } + } + } + + // Handle 401 Unauthorized responses by refreshing the token + HttpResponseValidator { + handleResponseExceptionWithRequest { exception, request -> + // Re-throw the exception to let the caller handle it + throw exception + } + } + + // Add response observer to handle 401 responses + install(ResponseObserver) { + onResponse { response -> + if (response.status == HttpStatusCode.Unauthorized) { + // Log the 401 response + println("[DEBUG_LOG] Received 401 Unauthorized response from ${response.request.url}") + + // Attempt to refresh the token + runBlocking { + val refreshed = tokenManager.handleUnauthorized() + if (refreshed) { + println("[DEBUG_LOG] Token refreshed successfully after 401 response") + } else { + println("[DEBUG_LOG] Failed to refresh token after 401 response; forcing logout and notifying UI") + try { + // Clear token so that app considers user logged out + tokenManager.forceLogout() + } catch (_: Exception) { + } + // Emit a global unauthorized event for the UI to react (navigate to login + snackbar) + try { + bose.ankush.network.auth.events.AuthEventBus.emit( + bose.ankush.network.auth.events.AuthEvent.Unauthorized( + message = "For security, please log in again to continue using the app." + ) + ) + } catch (e: Exception) { + println("[DEBUG_LOG] Failed to emit Unauthorized event: ${e.message}") + } + } + } + } + } + } +} + +/** + * 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") + } + } + } +} \ No newline at end of file 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..c4353efd --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/model/AuthModels.kt @@ -0,0 +1,76 @@ +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 ipAddress: 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 + */ +@Serializable +data class AuthData( + val token: String? = null, + val email: String? = null, + val role: String? = null, + val isActive: Boolean? = null, + val isPremium: Boolean? = null +) + +/** + * Response model for authentication operations + */ +@Serializable +data class AuthResponse( + val success: Boolean? = null, + val status: Boolean = true, + 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..147abc28 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepository.kt @@ -0,0 +1,66 @@ +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 ipAddress Client's IP address (if obtainable) + * @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, + ipAddress: 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 +} \ No newline at end of file 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..fbfb07bb --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/repository/AuthRepositoryImpl.kt @@ -0,0 +1,136 @@ +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.network.auth.storage.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 != null && token.isNotBlank()) { + tokenStorage.saveToken(token) + + // Verify token was saved correctly + verifyTokenSaved(token) + } + + return response + } + + /** + * Verifies that a token was correctly saved to the database + * @param originalToken The token that was supposed to be saved + */ + private suspend fun verifyTokenSaved(originalToken: String) { + val savedToken = tokenStorage.getToken() + if (savedToken != originalToken) { + println("[DEBUG_LOG] Token verification failed: token mismatch") + } else { + println("[DEBUG_LOG] Token verification successful") + } + } + + override suspend fun register( + email: String, + password: String, + timestampOfRegistration: String?, + deviceModel: String?, + operatingSystem: String?, + osVersion: String?, + appVersion: String?, + ipAddress: String?, + registrationSource: String?, + firebaseToken: String? + ): AuthResponse { + val request = RegisterRequest( + email = email, + password = password, + timestampOfRegistration = timestampOfRegistration, + deviceModel = deviceModel, + operatingSystem = operatingSystem, + osVersion = osVersion, + appVersion = appVersion, + ipAddress = ipAddress, + registrationSource = registrationSource, + firebaseToken = firebaseToken + ) + val response = apiService.register(request) + + // Save token on successful registration + val token = response.data?.token + if (response.isSuccess() && token != null && token.isNotBlank()) { + tokenStorage.saveToken(token) + + // Verify token was saved correctly + verifyTokenSaved(token) + } + + return response + } + + override fun isLoggedIn(): Flow { + return tokenStorage.hasToken() + } + + override suspend fun getToken(): String? { + return 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 != null && token.isNotBlank()) { + tokenStorage.saveToken(token) + + // Verify token was saved correctly + verifyTokenSaved(token) + } + + return response + } + + override suspend fun logout(): Result { + return try { + val response = apiService.logout() + val message = response.message ?: "" + val isSuccess = response.data == null && ( + message.contains("Logout successful", ignoreCase = true) || + message.contains("Logged out successfully", ignoreCase = true) + ) + 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) + } + } +} \ No newline at end of file diff --git a/network/src/commonMain/kotlin/bose/ankush/network/auth/storage/TokenStorage.kt b/network/src/commonMain/kotlin/bose/ankush/network/auth/storage/TokenStorage.kt new file mode 100644 index 00000000..6233c7c7 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/storage/TokenStorage.kt @@ -0,0 +1,32 @@ +package bose.ankush.network.auth.storage + +import kotlinx.coroutines.flow.Flow + +/** + * Interface for secure token storage + * This will be implemented differently on each platform + */ +interface TokenStorage { + /** + * Save a token + * @param token The JWT token to save + */ + suspend fun saveToken(token: String) + + /** + * Get the stored token + * @return The JWT token or null if not available + */ + suspend fun getToken(): String? + + /** + * Check if a token exists + * @return Flow of Boolean indicating if a token exists + */ + fun hasToken(): Flow + + /** + * Clear the stored token + */ + suspend fun clearToken() +} \ No newline at end of file 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..2a9b8bc6 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/auth/token/TokenManager.kt @@ -0,0 +1,79 @@ +package bose.ankush.network.auth.token + +import bose.ankush.network.auth.repository.AuthRepository +import bose.ankush.network.auth.storage.TokenStorage +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 debugLogging: Boolean = true +) { + private val refreshMutex = Mutex() + private var lastRefreshTime: Long = 0 + private val minRefreshInterval = 5 * 60L + + /** + * Returns the current valid token, refreshing if necessary. + */ + suspend fun getValidToken(forceRefresh: Boolean = false): String? { + val currentToken = tokenStorage.getToken() ?: return null + val now = Clock.System.now().epochSeconds + if (forceRefresh || canAttemptRefresh(now)) { + val refreshedToken = refreshToken(now) + if (refreshedToken != null) return refreshedToken + } + return currentToken + } + + private fun canAttemptRefresh(currentTime: Long): Boolean { + return (currentTime - lastRefreshTime) >= minRefreshInterval + } + + /** + * Refreshes the token if possible. + */ + suspend fun refreshToken(currentTime: Long = Clock.System.now().epochSeconds): String? = + refreshMutex.withLock { + lastRefreshTime = currentTime + try { + val response = authRepository.refreshToken() ?: return null + val newToken = response.data?.token + if (response.isSuccess() && !newToken.isNullOrBlank()) { + tokenStorage.saveToken(newToken) + if (debugLogging) println("[DEBUG_LOG] Token refreshed successfully") + return newToken + } else { + if (debugLogging) println("[DEBUG_LOG] Token refresh failed: Invalid response") + } + } catch (e: Exception) { + if (debugLogging) println("[DEBUG_LOG] Token refresh failed: ${e.message}") + } + return null + } + + /** + * Handles 401 Unauthorized by forcing a token refresh. + */ + suspend fun handleUnauthorized(): Boolean { + lastRefreshTime = 0 + return refreshToken() != null + } + + /** + * Forces logout by clearing any stored token. + */ + suspend fun forceLogout() { + try { + tokenStorage.clearToken() + if (debugLogging) println("[DEBUG_LOG] Forced logout: token cleared") + } catch (e: Exception) { + if (debugLogging) println("[DEBUG_LOG] Error during forceLogout: ${e.message}") + } + } +} \ 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..08e49193 --- /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 + ) + } + } +} \ No newline at end of file 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..383f0404 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,31 @@ package bose.ankush.network.di +import bose.ankush.network.api.FeedbackApiService +import bose.ankush.network.api.KtorFeedbackApiService +import bose.ankush.network.api.KtorPaymentApiService import bose.ankush.network.api.KtorWeatherApiService -import bose.ankush.network.utils.NetworkConstants +import bose.ankush.network.api.PaymentApiService +import bose.ankush.network.auth.api.KtorAuthApiService +import bose.ankush.network.auth.interceptor.configureAuth +import bose.ankush.network.auth.repository.AuthRepository +import bose.ankush.network.auth.repository.AuthRepositoryImpl +import bose.ankush.network.auth.storage.TokenStorage +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.PaymentRepository +import bose.ankush.network.repository.PaymentRepositoryImpl import bose.ankush.network.repository.WeatherRepository import bose.ankush.network.repository.WeatherRepositoryImpl +import bose.ankush.network.utils.NetworkConstants 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 +34,168 @@ 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 + } + return HttpClient(createPlatformHttpClient(json).engine) { + 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 { + return 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, + tokenStorage: TokenStorage, baseUrl: String = NetworkConstants.WEATHER_BASE_URL ): WeatherRepository { + // 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 PaymentRepository instance + */ +fun createPaymentRepository( + networkConnectivity: NetworkConnectivity, + tokenStorage: TokenStorage, + baseUrl: String = NetworkConstants.WEATHER_BASE_URL +): PaymentRepository { + // Reuse authenticated client setup similar to weather + val authRepository = createAuthRepository(tokenStorage, baseUrl) + val tokenManager = createTokenManager(tokenStorage, authRepository) + val httpClient = createAuthenticatedHttpClient(tokenManager) + val apiService: PaymentApiService = KtorPaymentApiService(httpClient, baseUrl) + return PaymentRepositoryImpl(apiService, networkConnectivity) +} + +/** + * 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 } - val httpClient = createPlatformHttpClient(json) - val apiService = KtorWeatherApiService(httpClient, baseUrl) - return WeatherRepositoryImpl(apiService, networkConnectivity) + + // Create a platform-specific HttpClient with authentication configuration + return HttpClient(createPlatformHttpClient(json).engine) { + // Install ContentNegotiation plugin + install(ContentNegotiation) { + json(json) + } + + // Add authentication configuration with token refresh + configureAuth(tokenManager) + + // Install Logging plugin + install(Logging) { + logger = Logger.SIMPLE // Ensures logs are printed to stdout + level = LogLevel.ALL + } + } } + +/** + * 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 + } + + // Create a platform-specific HttpClient with authentication configuration + return HttpClient(createPlatformHttpClient(json).engine) { + // Install ContentNegotiation plugin + install(ContentNegotiation) { + json(json) + } + + // Add authentication configuration + configureAuth(tokenStorage) + + // Install Logging plugin + install(Logging) { + logger = Logger.SIMPLE // Ensures logs are printed to stdout + level = LogLevel.ALL + } + } +} + +/** + * 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, tokenStorage) + return AuthRepositoryImpl(apiService, tokenStorage) +} + + +/** + * 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) +} \ No newline at end of file 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..c93bd797 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,73 @@ 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? + ) + } + } + + // Helper properties to maintain backward compatibility + val aqi: Int + get() = data?.list?.firstOrNull()?.main?.aqi ?: 0 + val co: Double + get() = data?.list?.firstOrNull()?.components?.co ?: 0.0 + val no2: Double + get() = data?.list?.firstOrNull()?.components?.no2 ?: 0.0 + val o3: Double + get() = data?.list?.firstOrNull()?.components?.o3 ?: 0.0 + val so2: Double + get() = data?.list?.firstOrNull()?.components?.so2 ?: 0.0 + val pm10: Double + get() = data?.list?.firstOrNull()?.components?.pm10 ?: 0.0 + val pm25: Double + get() = data?.list?.firstOrNull()?.components?.pm25 ?: 0.0 +} 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..852ce81e --- /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/PaymentModels.kt b/network/src/commonMain/kotlin/bose/ankush/network/model/PaymentModels.kt new file mode 100644 index 00000000..abd2646d --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/model/PaymentModels.kt @@ -0,0 +1,81 @@ +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 +) \ No newline at end of file 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..664fcd9a 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,140 @@ 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? + ) { + @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: Int?, + val event: String?, + @SerialName("sender_name") val senderName: String?, + val start: Int?, + ) - @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?, + @SerialName("dt") + val dt: Int?, + @SerialName("feels_like") + val feelsLike: Double?, + @SerialName("humidity") + val humidity: Int?, + @SerialName("pressure") + val pressure: Int?, + @SerialName("sunrise") + val sunrise: Int?, + @SerialName("sunset") + val sunset: Int?, + @SerialName("temp") + val temp: Double?, + @SerialName("uvi") + val uvi: Double?, + @SerialName("weather") + val weather: List?, + @SerialName("wind_gust") + val windGust: Double?, + @SerialName("wind_speed") + val windSpeed: Double? ) - } - @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?, + @SerialName("dew_point") + val dewPoint: Double?, + @SerialName("dt") + val dt: Int?, + @SerialName("humidity") + val humidity: Int?, + @SerialName("pressure") + val pressure: Int?, + @SerialName("rain") + val rain: Double? = null, + @SerialName("summary") + val summary: String?, + @SerialName("sunrise") + val sunrise: Int?, + @SerialName("sunset") + val sunset: Int?, + @SerialName("temp") + val temp: Temp?, + @SerialName("uvi") + val uvi: Double?, + @SerialName("weather") + val weather: List?, + @SerialName("wind_gust") + val windGust: Double?, + @SerialName("wind_speed") + val windSpeed: Double? + ) { + @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?, + @SerialName("dt") + val dt: Int?, + @SerialName("feels_like") + val feelsLike: Double?, + @SerialName("humidity") + val humidity: Int?, + @SerialName("temp") + val temp: Double?, + @SerialName("weather") + val weather: List? + ) + } } 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..ab2b45e3 --- /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 +} \ No newline at end of file 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..d6173e47 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/FeedbackRepositoryImpl.kt @@ -0,0 +1,22 @@ +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) } + } +} \ No newline at end of file diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/PaymentRepository.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/PaymentRepository.kt new file mode 100644 index 00000000..ddf26666 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/PaymentRepository.kt @@ -0,0 +1,23 @@ +package bose.ankush.network.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 + +/** + * Repository interface for payment operations + */ +interface PaymentRepository { + /** + * Create an order on backend which in turn calls Razorpay Orders API + * Returns a Result wrapping either the response or an error + */ + suspend fun createOrder(request: CreateOrderRequest): Result + + /** + * Verify payment signature on backend + * Returns a Result wrapping either the response or an error + */ + suspend fun verifyPayment(request: VerifyPaymentRequest): Result +} \ No newline at end of file diff --git a/network/src/commonMain/kotlin/bose/ankush/network/repository/PaymentRepositoryImpl.kt b/network/src/commonMain/kotlin/bose/ankush/network/repository/PaymentRepositoryImpl.kt new file mode 100644 index 00000000..4d91dae7 --- /dev/null +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/PaymentRepositoryImpl.kt @@ -0,0 +1,31 @@ +package bose.ankush.network.repository + +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 + +/** + * Implementation of PaymentRepository + */ +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) } + } +} \ No newline at end of file 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..3b6d7cf7 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepositoryImpl.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/repository/WeatherRepositoryImpl.kt @@ -1,11 +1,11 @@ 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 @@ -62,11 +62,11 @@ class WeatherRepositoryImpl( // Initialize with a default AirQuality if null if (_airQualityData.value == null) { - _airQualityData.value = AirQuality() + _airQualityData.value = AirQuality(data = null, message = null, status = null) } // Map the nullable flow to a non-nullable flow - return _airQualityData.map { it ?: AirQuality() } + return _airQualityData.map { it ?: AirQuality(data = null, message = null, status = null) } } override fun getWeatherReport(coordinates: Pair): Flow { @@ -124,7 +124,7 @@ class WeatherRepositoryImpl( val airQualityData = airQualityDeferred.await() // Update the weather data flow - _weatherData.value = weatherData.copy(lastUpdated = currentTime) + _weatherData.value = weatherData lastWeatherUpdateTime = currentTime // Update the air quality data flow diff --git a/network/src/commonMain/kotlin/bose/ankush/network/utils/Constants.kt b/network/src/commonMain/kotlin/bose/ankush/network/utils/Constants.kt index 096bca08..a56f69c1 100644 --- a/network/src/commonMain/kotlin/bose/ankush/network/utils/Constants.kt +++ b/network/src/commonMain/kotlin/bose/ankush/network/utils/Constants.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) 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..eee8d1cd 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,7 +14,7 @@ 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, @@ -22,12 +23,22 @@ object NetworkUtils { 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/di/IOSHttpClient.kt b/network/src/iosMain/kotlin/bose/ankush/network/di/IOSHttpClient.kt index 40f030d9..230e9f63 100644 --- a/network/src/iosMain/kotlin/bose/ankush/network/di/IOSHttpClient.kt +++ b/network/src/iosMain/kotlin/bose/ankush/network/di/IOSHttpClient.kt @@ -6,7 +6,6 @@ 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 @@ -22,8 +21,8 @@ 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 { 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..1bca0c03 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,5 +25,4 @@ include( ":network", ":storage", ":sunriseui", - ":payment", ) diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts index 21f40702..f7d246f6 100644 --- a/storage/build.gradle.kts +++ b/storage/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { kotlin("multiplatform") id("com.android.library") @@ -7,10 +9,8 @@ plugins { kotlin { androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) } } @@ -38,6 +38,8 @@ kotlin { implementation(kotlin("test")) } } + + @Suppress("UNUSED_VARIABLE") val androidMain by getting { dependencies { // Room dependencies @@ -47,15 +49,17 @@ kotlin { 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 + // Note: Hilt is provided from app module; storage has no DI annotations now } } + + @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 +69,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) @@ -96,10 +102,8 @@ android { } } -// Apply kapt plugin for Room and Hilt annotation processing +// Apply kapt plugin for Room annotation processing only (Hilt moved to app module) dependencies { // Room annotation processor "kapt"(Deps.roomCompiler) - // Hilt annotation processor - "kapt"(Deps.hiltDaggerAndroidCompiler) } diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/impl/TokenStorageImpl.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/impl/TokenStorageImpl.kt new file mode 100644 index 00000000..3993a901 --- /dev/null +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/impl/TokenStorageImpl.kt @@ -0,0 +1,40 @@ +package bose.ankush.storage.impl + +import bose.ankush.network.auth.storage.TokenStorage +import bose.ankush.storage.room.AuthToken +import bose.ankush.storage.room.WeatherDatabase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +/** + * Android implementation of TokenStorage using Room database + */ +class TokenStorageImpl( + private val database: WeatherDatabase +) : TokenStorage { + + override suspend fun saveToken(token: String) { + withContext(Dispatchers.IO) { + val authToken = AuthToken(token = token) + database.authTokenDao().saveToken(authToken) + } + } + + override suspend fun getToken(): String? { + return withContext(Dispatchers.IO) { + database.authTokenDao().getToken()?.token + } + } + + override fun hasToken(): Flow { + // Flow is already asynchronous, so we don't need to use withContext here + return database.authTokenDao().hasToken() + } + + override suspend fun clearToken() { + withContext(Dispatchers.IO) { + database.authTokenDao().clearTokens() + } + } +} \ No newline at end of file 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..42556825 100644 --- a/storage/src/androidMain/kotlin/bose/ankush/storage/impl/WeatherStorageImpl.kt +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/impl/WeatherStorageImpl.kt @@ -1,8 +1,5 @@ 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 @@ -11,8 +8,9 @@ import bose.ankush.storage.room.WeatherEntity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull import java.io.IOException -import javax.inject.Inject -import javax.inject.Singleton +import bose.ankush.network.model.AirQuality as NetworkAirQuality +import bose.ankush.network.model.WeatherForecast as NetworkWeatherForecast +import bose.ankush.network.repository.WeatherRepository as NetworkWeatherRepository /** * Implementation of WeatherStorage that uses Room database for storage @@ -24,8 +22,7 @@ import javax.inject.Singleton * - Mapping between network models and database entities * - Tracking the last update time for weather data */ -@Singleton -class WeatherStorageImpl @Inject constructor( +class WeatherStorageImpl( private val networkRepository: NetworkWeatherRepository, private val weatherDatabase: WeatherDatabase ) : WeatherStorage { @@ -91,33 +88,26 @@ class WeatherStorageImpl @Inject constructor( * @return A WeatherEntity with all fields mapped from the network model */ private fun mapNetworkWeatherToEntity(weatherData: NetworkWeatherForecast): WeatherEntity { + // Access the data field which contains all the weather information + val data = weatherData.data + 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 -> + // Since data might be null, we need to handle that case + current = data?.current?.let { current -> WeatherEntity.Current( clouds = current.clouds, - dt = current.dt, - feels_like = current.feels_like, + dt = current.dt?.toLong(), + 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?.map { weatherCondition -> - weatherCondition?.let { + weather = current.weather?.mapNotNull { info -> + info?.let { Weather( description = it.description, icon = it.icon, @@ -126,16 +116,16 @@ class WeatherStorageImpl @Inject constructor( ) } }, - wind_gust = current.wind_gust, - wind_speed = current.wind_speed + wind_gust = current.windGust, + wind_speed = current.windSpeed ) }, - daily = weatherData.daily?.map { daily -> + daily = data?.daily?.map { daily -> daily?.let { WeatherEntity.Daily( clouds = it.clouds, - dew_point = it.dew_point, - dt = it.dt, + dew_point = it.dewPoint, + dt = it.dt?.toLong(), humidity = it.humidity, pressure = it.pressure, rain = it.rain, @@ -153,8 +143,8 @@ class WeatherStorageImpl @Inject constructor( ) }, uvi = it.uvi, - weather = it.weather?.map { weatherCondition -> - weatherCondition?.let { + weather = it.weather?.mapNotNull { info -> + info?.let { Weather( description = it.description, icon = it.icon, @@ -163,21 +153,21 @@ class WeatherStorageImpl @Inject constructor( ) } }, - wind_gust = it.wind_gust, - wind_speed = it.wind_speed + wind_gust = it.windGust, + wind_speed = it.windSpeed ) } }, - hourly = weatherData.hourly?.map { hourly -> - hourly?.let { + hourly = data?.hourly?.map { hourly -> + hourly?.let { it -> WeatherEntity.Hourly( clouds = it.clouds, - dt = it.dt, - feels_like = it.feels_like, + dt = it.dt?.toLong(), + feels_like = it.feelsLike, humidity = it.humidity, temp = it.temp, - weather = it.weather?.map { weatherCondition -> - weatherCondition?.let { + weather = it.weather?.mapNotNull { info -> + info?.let { Weather( description = it.description, icon = it.icon, @@ -188,7 +178,9 @@ class WeatherStorageImpl @Inject constructor( } ) } - } + }, + // We don't have alerts in the new model structure, so set it to null + alerts = null ) } 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..527382db --- /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() +) \ No newline at end of file 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..b5111bee --- /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 +} \ No newline at end of file diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDatabase.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDatabase.kt index fe2b4805..3ec8139a 100644 --- a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDatabase.kt +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherDatabase.kt @@ -4,11 +4,13 @@ import androidx.room.Database import androidx.room.RoomDatabase @Database( - entities = [WeatherEntity::class, AirQualityEntity::class], - version = 2, + entities = [WeatherEntity::class, AirQualityEntity::class, AuthToken::class], + version = 3, exportSchema = false ) abstract class WeatherDatabase : RoomDatabase() { abstract fun weatherDao(): WeatherDao + + abstract fun authTokenDao(): AuthTokenDao } \ No newline at end of file diff --git a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherEntity.kt b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherEntity.kt index 3589b5ca..eb005eb0 100644 --- a/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherEntity.kt +++ b/storage/src/androidMain/kotlin/bose/ankush/storage/room/WeatherEntity.kt @@ -76,8 +76,8 @@ data class WeatherEntity( } data class Weather( - val description: String, - val icon: String, + val description: String? = null, + val icon: String? = null, val id: Int, - val main: String + val main: String? = null ) diff --git a/sunriseui/build.gradle.kts b/sunriseui/build.gradle.kts index 5fc8a3ba..9307b42f 100644 --- a/sunriseui/build.gradle.kts +++ b/sunriseui/build.gradle.kts @@ -10,7 +10,9 @@ dependencies { implementation(Deps.composeMaterial3) implementation(Deps.composeUiToolingPreview) debugImplementation(Deps.composeUiTooling) + implementation(Deps.composeIconsExtended) implementation(Deps.coroutinesCore) + // Removed Lottie dependency as per requirements testImplementation(Deps.junit) androidTestImplementation(Deps.extJunit) diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/auth/LoginScreen.kt b/sunriseui/src/main/java/bose/ankush/sunriseui/auth/LoginScreen.kt new file mode 100644 index 00000000..313b7f82 --- /dev/null +++ b/sunriseui/src/main/java/bose/ankush/sunriseui/auth/LoginScreen.kt @@ -0,0 +1,457 @@ +package bose.ankush.sunriseui.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.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.layout.size +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.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.rememberUpdatedState +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Login Screen composable that displays a login form with email and password fields, + * login/register toggle, and terms & conditions link. + * + * @param onLoginClick Callback when the login button is clicked + * @param onRegisterClick Callback when the register button is clicked + * @param onTermsClick Callback when the terms & conditions link is clicked + * @param onPrivacyPolicyClick Callback when the privacy policy 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, + onTermsClick: () -> Unit = {}, + onPrivacyPolicyClick: () -> Unit = {}, + isLoading: Boolean = false +) { + // State for form fields and validation + 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) } + + // State for header text animations + var isTitleClicked by remember { mutableStateOf(false) } + var isSubtitleClicked by remember { mutableStateOf(false) } + + // Focus manager for keyboard navigation + val focusManager = LocalFocusManager.current + + // Email validation function + val isEmailValid = { email: String -> + android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches() + } + + // Password validation function + val isPasswordValid = { password: String -> + password.length >= 6 + } + + // Validate form inputs + 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 + } + } + } + + // Handle form submission + val handleSubmit = { + if (!isLoading && validateInputs()) { + if (isLoginMode) { + onLoginClick(email, password) + } else { + onRegisterClick(email, password) + } + } + } + + // Main layout + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + // Header Section with creative typography - aligned to the left with increased top margin + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopStart) + .padding(top = 80.dp) + ) { + // Animate the scale of the title when clicked + val titleScale by animateFloatAsState( + targetValue = if (isTitleClicked) 1.1f else 1.0f, + animationSpec = spring( + dampingRatio = 0.4f, + stiffness = 300f + ), + label = "titleScale" + ) + + // Animate the color of the title when clicked + 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 { + // Toggle the clicked state + isTitleClicked = !isTitleClicked + } + ) + + // Animate the scale of the subtitle when clicked + val subtitleScale by animateFloatAsState( + targetValue = if (isSubtitleClicked) 1.1f else 1.0f, + animationSpec = tween( + durationMillis = 300, + easing = androidx.compose.animation.core.FastOutSlowInEasing + ), + label = "subtitleScale" + ) + + // Animate the color of the subtitle when clicked + 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 { + // Toggle the clicked state + isSubtitleClicked = !isSubtitleClicked + } + ) + } + + // Form Section - positioned at the bottom of the screen + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Email field + 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) + ) + + // Password field + 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) + ) + + // Error message + if (errorMessage != null) { + Text( + text = errorMessage!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Login/Register button + 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 Section - moved outside the card + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Login/Register toggle + 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 + ) + } + + // Terms & Conditions - using BasicText with pointerInput for links + 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() + } + + // Store the latest layout result for tap detection + var textLayoutResult by remember { mutableStateOf(null) } + val currentOnTermsClick by rememberUpdatedState(onTermsClick) + val currentOnPrivacyPolicyClick by rememberUpdatedState(onPrivacyPolicyClick) + + 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" -> currentOnTermsClick() + "privacy" -> currentOnPrivacyPolicyClick() + } + } + } + } + } + }, + onTextLayout = { textLayoutResult = it } + ) + } + } + } + } +} + +/** + * Preview of the LoginScreen in login mode (default state). + */ +@Preview(showBackground = true, name = "Login Screen") +@Composable +fun LoginScreenPreview() { + MaterialTheme { + LoginScreen( + onLoginClick = { _, _ -> }, + onRegisterClick = { _, _ -> }, + isLoading = false + ) + } +} + +/** + * Preview of the LoginScreen in register mode. + */ +@Preview(showBackground = true, name = "Register Screen") +@Composable +fun RegisterScreenPreview() { + MaterialTheme { + LoginScreen( + onLoginClick = { _, _ -> }, + onRegisterClick = { _, _ -> }, + isLoading = false + ) + } +} + +/** + * Preview of the LoginScreen with loading state. + */ +@Preview(showBackground = true, name = "Loading State Screen") +@Composable +fun LoadingStatePreview() { + MaterialTheme { + LoginScreen( + onLoginClick = { _, _ -> }, + onRegisterClick = { _, _ -> }, + isLoading = true + ) + } +} \ No newline at end of file diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/components/GlassmorphicSnackbar.kt b/sunriseui/src/main/java/bose/ankush/sunriseui/components/GlassmorphicSnackbar.kt new file mode 100644 index 00000000..43a6d467 --- /dev/null +++ b/sunriseui/src/main/java/bose/ankush/sunriseui/components/GlassmorphicSnackbar.kt @@ -0,0 +1,131 @@ +package bose.ankush.sunriseui.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.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +/** + * A custom glassmorphic Snackbar component for displaying error messages. + * + * @param message The error message to display + * @param isVisible Whether the Snackbar is visible + * @param onDismiss Callback when the Snackbar is dismissed + * @param durationMillis Duration in milliseconds before the Snackbar is automatically dismissed + * @param modifier Modifier for the Snackbar + */ +@Composable +fun GlassmorphicSnackbar( + message: String, + isVisible: Boolean, + onDismiss: () -> Unit, + durationMillis: Long = 3000, + modifier: Modifier = Modifier +) { + // Auto-dismiss after durationMillis + LaunchedEffect(isVisible) { + if (isVisible) { + delay(durationMillis) + onDismiss() + } + } + + // Animation for the Snackbar + 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 } + ) + ) { + // Glassmorphic Snackbar + Box( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + .shadow(8.dp, RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(12.dp)) + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.error.copy(alpha = 0.7f), + MaterialTheme.colorScheme.error.copy(alpha = 0.5f) + ) + ) + ) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.error.copy(alpha = 0.3f), + shape = RoundedCornerShape(12.dp) + ) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onError + ) + } + } +} + +/** + * A composable that manages the state of a GlassmorphicSnackbar. + * + * @param modifier Modifier for the Snackbar + * @return A pair of (showSnackbar: (String) -> Unit, SnackbarContent: @Composable () -> Unit) + */ +@Composable +fun rememberGlassmorphicSnackbarState( + modifier: Modifier = Modifier +): Pair<(String) -> Unit, @Composable () -> Unit> { + var isVisible by remember { mutableStateOf(false) } + var message by remember { mutableStateOf("") } + + val showSnackbar: (String) -> Unit = { newMessage -> + message = newMessage + isVisible = true + } + + val snackbarContent: @Composable () -> Unit = { + GlassmorphicSnackbar( + message = message, + isVisible = isVisible, + onDismiss = { isVisible = false }, + modifier = modifier + ) + } + + return Pair(showSnackbar, snackbarContent) +} \ No newline at end of file diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherAlertCard.kt b/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherAlertCard.kt new file mode 100644 index 00000000..fdf9cf28 --- /dev/null +++ b/sunriseui/src/main/java/bose/ankush/sunriseui/components/WeatherAlertCard.kt @@ -0,0 +1,363 @@ +package bose.ankush.sunriseui.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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 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 date = Date(timestamp * 1000) // Convert to milliseconds + val formatter = SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) + return formatter.format(date) +} + +/** + * Preview of the WeatherAlertCard in collapsed state. + */ +@Preview(showBackground = true) +@Composable +private fun WeatherAlertCardPreviewCollapsed() { + MaterialTheme { + WeatherAlertCard( + title = "Severe Thunderstorm Warning", + description = "The National Weather Service has issued a severe thunderstorm warning for your area. Expect heavy rain, strong winds, and possible hail. Take necessary precautions and stay indoors if possible.", + startTime = System.currentTimeMillis() / 1000, + endTime = (System.currentTimeMillis() / 1000) + 3600 * 3, // 3 hours later + source = "National Weather Service" + ) + } +} + +/** + * Preview of the WeatherAlertCard in expanded state. + */ +@Preview(showBackground = true) +@Composable +private fun WeatherAlertCardPreviewExpanded() { + MaterialTheme { + WeatherAlertCard( + title = "Severe Thunderstorm Warning", + description = "The National Weather Service has issued a severe thunderstorm warning for your area. Expect heavy rain, strong winds, and possible hail. Take necessary precautions and stay indoors if possible.", + startTime = System.currentTimeMillis() / 1000, + endTime = (System.currentTimeMillis() / 1000) + 3600 * 3, // 3 hours later + source = "National Weather Service", + initiallyExpanded = true + ) + } +} \ No newline at end of file diff --git a/sunriseui/src/main/java/bose/ankush/sunriseui/premium/PremiumBottomSheetContent.kt b/sunriseui/src/main/java/bose/ankush/sunriseui/premium/PremiumBottomSheetContent.kt new file mode 100644 index 00000000..2081249d --- /dev/null +++ b/sunriseui/src/main/java/bose/ankush/sunriseui/premium/PremiumBottomSheetContent.kt @@ -0,0 +1,177 @@ +package bose.ankush.sunriseui.premium + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +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.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * Premium bottom sheet content UI. Pure Compose UI with callbacks only (MPP-friendly) + */ +@Composable +fun PremiumBottomSheetContent( + onDismiss: () -> Unit, + onSubscribe: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Header + Text( + text = "Premium", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Features + 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)) + + // 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 + val isStarting = remember { mutableStateOf(false) } + Button( + onClick = { + if (!isStarting.value) { + isStarting.value = true + onSubscribe() + } + }, + enabled = !isStarting.value, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFFB74D) + ), + shape = RoundedCornerShape(8.dp) + ) { + if (isStarting.value) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = Color.White, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Starting...", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = Color.White + ) + } + } else { + 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 +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 + ) + } +}