commit 4511338576f504fe088912da86c6e0d4bde6af7c Author: Andrey Kassaev Date: Mon Apr 1 01:14:09 2024 +0400 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..db4b078 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..0897082 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..44ca2d9 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,41 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..fdf8d99 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..0ad17cb --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/other.xml b/.idea/other.xml new file mode 100644 index 0000000..f3d4a2e --- /dev/null +++ b/.idea/other.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..46819f3 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Weather Widget +Android weather forecast application with widget. Tracks user location and update weather every 5 min. +Currently in russian. [Click here to get apk][1] + +Built with: +Jetpack Compose +Jetpack Glance +Foreground Service +Retrofit +Hilt +https://www.weatherapi.com/ as Weather Forecast Provider + +On initial start GPS and Internet must be ON and permission granted. + + + + + + +[1]: \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..6e14ff5 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,94 @@ +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.jetbrainsKotlinAndroid) + kotlin("kapt") + id("com.google.dagger.hilt.android") +} + +android { + namespace = "com.kassaev.weatherwidget" + compileSdk = 34 + + defaultConfig { + applicationId = "com.kassaev.weatherwidget" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + // For AppWidgets support + implementation("androidx.glance:glance-appwidget:1.0.0") + // For interop APIs with Material 3 + implementation("androidx.glance:glance-material3:1.0.0") + + //hilt + val hilt_version = "2.51" + implementation("com.google.dagger:hilt-android:$hilt_version") + kapt("com.google.dagger:hilt-android-compiler:$hilt_version") + + //retrofit + val retrofit_version = "2.10.0" + implementation("com.squareup.retrofit2:retrofit:$retrofit_version") + implementation("com.squareup.retrofit2:converter-gson:2.10.0") + + //location tracking + implementation("com.google.android.gms:play-services-location:21.2.0") + + } + + kapt { + correctErrorTypes = true + } + } +} + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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 \ No newline at end of file diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..590d2b2 Binary files /dev/null and b/app/release/baselineProfiles/0/app-release.dm differ diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..4d1645f Binary files /dev/null and b/app/release/baselineProfiles/1/app-release.dm differ diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..86eb3b2 --- /dev/null +++ b/app/release/output-metadata.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "com.kassaev.weatherwidget", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0", + "outputFile": "app-release.apk" + } + ], + "elementType": "File", + "baselineProfiles": [ + { + "minApi": 28, + "maxApi": 30, + "baselineProfiles": [ + "baselineProfiles/1/app-release.dm" + ] + }, + { + "minApi": 31, + "maxApi": 2147483647, + "baselineProfiles": [ + "baselineProfiles/0/app-release.dm" + ] + } + ], + "minSdkVersionForDexing": 26 +} \ No newline at end of file diff --git a/app/release/weatherWidget.apk b/app/release/weatherWidget.apk new file mode 100644 index 0000000..dde51f4 Binary files /dev/null and b/app/release/weatherWidget.apk differ diff --git a/app/src/androidTest/java/com/kassaev/weatherwidget/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/kassaev/weatherwidget/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..7b70e0d --- /dev/null +++ b/app/src/androidTest/java/com/kassaev/weatherwidget/ExampleInstrumentedTest.kt @@ -0,0 +1,27 @@ +package com.kassaev.weatherwidget + +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( + "com.kassaev.weatherwidget", + appContext.packageName + ) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a3360e8 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..5171f6b Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/kassaev/weatherwidget/ContextExt.kt b/app/src/main/java/com/kassaev/weatherwidget/ContextExt.kt new file mode 100644 index 0000000..015840b --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/ContextExt.kt @@ -0,0 +1,17 @@ +package com.kassaev.weatherwidget + +import android.content.Context +import androidx.core.content.ContextCompat +import android.Manifest +import android.content.pm.PackageManager + +fun Context.hasLocationPermission(): Boolean { + return ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/DefaultLocationClient.kt b/app/src/main/java/com/kassaev/weatherwidget/DefaultLocationClient.kt new file mode 100644 index 0000000..77e1cfc --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/DefaultLocationClient.kt @@ -0,0 +1,74 @@ +package com.kassaev.weatherwidget + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import android.location.LocationManager +import android.os.Looper +import androidx.core.app.PendingIntentCompat.send +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +class DefaultLocationClient( + private val context: Context, + private val client: FusedLocationProviderClient +): LocationClient { + private val locationManager = + context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + fun isGpsEnabled(): Boolean = + locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + + @SuppressLint("MissingPermission") + override fun getLocationUpdates(interval: Long): Flow { + return callbackFlow { + + if(!context.hasLocationPermission()){ + throw LocationClient.LocationException("Permission not granted") + } + + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + val isGpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) + val isNetworkEnabled = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + if(!isGpsEnabled && !isNetworkEnabled){ + throw LocationClient.LocationException("GPS is disabled") + } + + val request = LocationRequest.create() + .setInterval(interval) +// .setFastestInterval(interval) + + val locationCallback = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + super.onLocationResult(result) + println("CALLBACK CALLED") + result.locations.lastOrNull()?.let { location -> + launch { + println("SEND ${location.latitude}, ${location.longitude}") + send(location) + } + } + } + } + + client.requestLocationUpdates( + request, + locationCallback, + Looper.getMainLooper() + ) + + awaitClose { + client.removeLocationUpdates(locationCallback) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/LocationClient.kt b/app/src/main/java/com/kassaev/weatherwidget/LocationClient.kt new file mode 100644 index 0000000..f09aa21 --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/LocationClient.kt @@ -0,0 +1,10 @@ +package com.kassaev.weatherwidget + +import android.location.Location +import kotlinx.coroutines.flow.Flow + +interface LocationClient { + fun getLocationUpdates(interval: Long): Flow + + class LocationException(message: String): Exception() +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/LocationObserver.kt b/app/src/main/java/com/kassaev/weatherwidget/LocationObserver.kt new file mode 100644 index 0000000..7ef8362 --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/LocationObserver.kt @@ -0,0 +1,24 @@ +package com.kassaev.weatherwidget + +import android.content.Context +import android.location.LocationManager +import android.location.LocationProvider +import android.os.Build +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class LocationObserver(context: Context) { + + private val locationManager = + context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + fun observe(): Flow = + flow { + while (true){ + emit(locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) + delay(1000 * 5) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/MainActivity.kt b/app/src/main/java/com/kassaev/weatherwidget/MainActivity.kt new file mode 100644 index 0000000..b9b2f1e --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/MainActivity.kt @@ -0,0 +1,82 @@ +package com.kassaev.weatherwidget + +import android.Manifest +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.app.ActivityCompat +import com.kassaev.weatherwidget.ui.theme.WeatherWidgetTheme +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + @Inject lateinit var networkObserver: NetworkObserver + @Inject lateinit var locationObserver: LocationObserver + @Inject lateinit var viewModel: WeatherViewModel + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ActivityCompat.requestPermissions( + this, + arrayOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + ), + 0 + ) + Intent( + applicationContext, + WeatherForegroundService::class.java + ).apply { + action = Actions.START.toString() + setPackage(packageName) + startService(this) + } + + setContent { + + WeatherWidgetTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val networkStatus by networkObserver.observe().collectAsState(initial = false) + val locationStatus by locationObserver.observe().collectAsState(initial = false) + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Network is " + if(networkStatus)"ON" else "OFF" + ) + Text( + text = "GPS is " + if(locationStatus)"ON" else "OFF" + ) + Text( + text = "${viewModel.currentWeather?.city}" + ) + Text( + text = "${viewModel.currentWeather?.temp}" + ) + Text( + text = "${viewModel.currentWeather?.desc}" + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/kassaev/weatherwidget/NetworkObserver.kt b/app/src/main/java/com/kassaev/weatherwidget/NetworkObserver.kt new file mode 100644 index 0000000..d89abf5 --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/NetworkObserver.kt @@ -0,0 +1,47 @@ +package com.kassaev.weatherwidget + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +class NetworkObserver(context: Context) { + + private val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + fun observe(): Flow = + callbackFlow { + val callback = object : ConnectivityManager.NetworkCallback(){ + override fun onAvailable(network: Network) { + super.onAvailable(network) + launch { + send(true) + } + } + + override fun onLost(network: Network) { + super.onLost(network) + launch { + send(false) + } + } + + override fun onUnavailable() { + super.onUnavailable() + launch { + send(false) + } + } + } + connectivityManager.registerDefaultNetworkCallback(callback) + awaitClose{ + connectivityManager.unregisterNetworkCallback(callback) + } + }.distinctUntilChanged() + +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/WeatherForegroundService.kt b/app/src/main/java/com/kassaev/weatherwidget/WeatherForegroundService.kt new file mode 100644 index 0000000..b1081a0 --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/WeatherForegroundService.kt @@ -0,0 +1,130 @@ +package com.kassaev.weatherwidget + +import android.app.Notification +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.appwidget.updateAll +import com.google.android.gms.location.LocationServices +import com.kassaev.weatherwidget.data.WeatherApiService +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.util.Locale +import javax.inject.Inject + +@AndroidEntryPoint +class WeatherForegroundService: Service() { + + @Inject lateinit var viewModel: WeatherViewModel + @Inject lateinit var networkObserver: NetworkObserver + var isNetworkEnabled: Boolean = false + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private lateinit var locationClient: LocationClient + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onCreate() { + super.onCreate() + serviceScope.launch { + networkObserver.observe().collect{ + isNetworkEnabled = it + } + } + locationClient = DefaultLocationClient( + applicationContext, + LocationServices.getFusedLocationProviderClient(applicationContext) + ) + start() + } + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int + ): Int { + + when(intent?.action){ + Actions.START.toString() -> start() + Actions.STOP.toString() -> stop() + } + + return super.onStartCommand( + intent, + flags, + startId + ) + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel() + } + + private fun stop(){ + stopForeground(STOP_FOREGROUND_DETACH) + stopSelf() + } + + private fun start() { + val interval: Long = 1000 * 60 * 5 + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = Notification.Builder(this, "weather_channel") + .setSmallIcon(R.drawable.logo_svg) + .setContentTitle("Weather") + .setOngoing(true) + + locationClient + .getLocationUpdates(interval) + .catch { e -> e.printStackTrace() } + .onEach { location -> + val formattedLocationString = "${location.latitude},${location.longitude}" + + if (isNetworkEnabled){ + + Locale.getDefault().language + val currentWeather = serviceScope.async { + WeatherApiService.weatherApi.getWeatherForecast(formattedLocationString) + }.await().toModel() + + GlanceAppWidgetManager(applicationContext).getGlanceIds(WeatherWidget::class.java).forEach { glanceId -> + updateAppWidgetState(applicationContext, glanceId){ prefs -> + prefs[stringPreferencesKey("city")] = currentWeather.city + prefs[stringPreferencesKey("temp")] = currentWeather.temp + prefs[stringPreferencesKey("desc")] = currentWeather.desc + prefs[stringPreferencesKey("iconUri")] = currentWeather.iconUri + } + } + val updatedNotification = notification.setContentText( + "${currentWeather.city}, ${currentWeather.temp}℃, ${currentWeather.desc}" + ) + notificationManager.notify( + 1, + updatedNotification.build() + ) + WeatherWidget().updateAll(applicationContext) + viewModel.currentWeather = currentWeather + } + + }.launchIn(serviceScope) + startForeground(1, notification.build()) + + } +} +enum class Actions{ + START, STOP +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/WeatherViewModel.kt b/app/src/main/java/com/kassaev/weatherwidget/WeatherViewModel.kt new file mode 100644 index 0000000..e664a63 --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/WeatherViewModel.kt @@ -0,0 +1,19 @@ +package com.kassaev.weatherwidget + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.kassaev.weatherwidget.data.WeatherRepository +import com.kassaev.weatherwidget.domain.WeatherModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class WeatherViewModel @Inject constructor( + private val repository: WeatherRepository +): ViewModel() { + + var currentWeather by mutableStateOf(WeatherModel()) + +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/WeatherWidget.kt b/app/src/main/java/com/kassaev/weatherwidget/WeatherWidget.kt new file mode 100644 index 0000000..370662c --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/WeatherWidget.kt @@ -0,0 +1,106 @@ +package com.kassaev.weatherwidget + +import android.content.Context +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.action.actionStartService +import androidx.glance.appwidget.appWidgetBackground +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.fillMaxSize +import androidx.glance.state.GlanceStateDefinition +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import java.io.File + +class WeatherWidget: GlanceAppWidget() { + override var stateDefinition = CustomGlanceStateDefinition + + override suspend fun provideGlance( + context: Context, + id: GlanceId + ) { + provideContent { + + val prefs = currentState() + + Box( + modifier = GlanceModifier + .appWidgetBackground() + .fillMaxSize() + .background(Color(0xBEFFFFFF)) + .clickable( + actionStartService( + isForegroundService = true + ) + ), + contentAlignment = Alignment.Center + ) { + if (prefs[stringPreferencesKey("city")] != null){ + Column( + verticalAlignment = Alignment.Vertical.CenterVertically, + horizontalAlignment = Alignment.Horizontal.CenterHorizontally + ) { + Text( + text = "${prefs[stringPreferencesKey("city")]}" + ) + Text( + text = "${prefs[stringPreferencesKey("temp")]}" + " \u2103", + style = TextStyle( + fontSize = 32.sp + ) + ) + Row { + Text( + text = "${prefs[stringPreferencesKey("desc")]}" + ) + } + } + } else { + Text(text = "Tap me!") + } + } + } + } + companion object { + + object CustomGlanceStateDefinition : GlanceStateDefinition { + override suspend fun getDataStore(context: Context, fileKey: String): DataStore { + return context.dataStore + } + + override fun getLocation(context: Context, fileKey: String): File { + // Note: The Datastore Preference file resides is in the context.applicationContext.filesDir + "datastore/" + return File(context.applicationContext.filesDir, "datastore/$fileName") + } + + private const val fileName = "widget_store" + private val Context.dataStore: DataStore + by preferencesDataStore(name = fileName) + } + + } + +} + + +class WeatherWidgetReceiver : GlanceAppWidgetReceiver(){ + override val glanceAppWidget: GlanceAppWidget + get() = WeatherWidget() +} + + diff --git a/app/src/main/java/com/kassaev/weatherwidget/data/IWeatherApi.kt b/app/src/main/java/com/kassaev/weatherwidget/data/IWeatherApi.kt new file mode 100644 index 0000000..7e95aca --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/data/IWeatherApi.kt @@ -0,0 +1,9 @@ +package com.kassaev.weatherwidget.data + +import retrofit2.http.GET +import retrofit2.http.Query + +interface IWeatherApi { + @GET("current.json?key=5a5667212db44b7c8f195312242603&aqi=no&lang=ru") + suspend fun getWeatherForecast(@Query("q") location: String): WeatherEntity +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/data/WeatherApiService.kt b/app/src/main/java/com/kassaev/weatherwidget/data/WeatherApiService.kt new file mode 100644 index 0000000..e4c148d --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/data/WeatherApiService.kt @@ -0,0 +1,13 @@ +package com.kassaev.weatherwidget.data + +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +object WeatherApiService { + val baseUri = "https://api.weatherapi.com/v1/" + val retrofit = Retrofit.Builder() + .baseUrl(baseUri) + .addConverterFactory(GsonConverterFactory.create()) + .build() + val weatherApi = retrofit.create(IWeatherApi::class.java) +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/data/WeatherEntity.kt b/app/src/main/java/com/kassaev/weatherwidget/data/WeatherEntity.kt new file mode 100644 index 0000000..f41d0b2 --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/data/WeatherEntity.kt @@ -0,0 +1,60 @@ +package com.kassaev.weatherwidget.data + +import com.kassaev.weatherwidget.domain.WeatherModel + +data class WeatherEntity( + val location: Location, + val current: Current, +) { + fun toModel(): WeatherModel = + WeatherModel( + city = this.location.name, + temp = this.current.temp_c.toInt().toString(), + desc = this.current.condition.text, + iconUri = this.current.condition.icon + ) + +} + +data class Location( + val name: String, + val region: String, + val country: String, + val lat: Double, + val lon: Double, + val tz_id: String, + val localtime_epoch: Long, + val localtime: String +) + +data class Current( + val last_updated_epoch: Long, + val last_updated: String, + val temp_c: Double, + val temp_f: Double, + val is_day: Int, + val condition: Condition, + val wind_mph: Double, + val wind_kph: Double, + val wind_degree: Int, + val wind_dir: String, + val pressure_mb: Double, + val pressure_in: Double, + val precip_mm: Double, + val precip_in: Double, + val humidity: Int, + val cloud: Double, + val feelslike_c: Double, + val feelslike_f: Double, + val vis_km: Double, + val vis_miles: Double, + val uv: Double, + val gust_mph: Double, + val gust_kph: Double +) + +data class Condition( + val text: String, + val icon: String, + val code: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/data/WeatherRepository.kt b/app/src/main/java/com/kassaev/weatherwidget/data/WeatherRepository.kt new file mode 100644 index 0000000..b8972b4 --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/data/WeatherRepository.kt @@ -0,0 +1,4 @@ +package com.kassaev.weatherwidget.data + +class WeatherRepository { +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/di/App.kt b/app/src/main/java/com/kassaev/weatherwidget/di/App.kt new file mode 100644 index 0000000..ed6fed4 --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/di/App.kt @@ -0,0 +1,28 @@ +package com.kassaev.weatherwidget.di + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class App: Application() { + + override fun onCreate() { + super.onCreate() + context = applicationContext + val channel = NotificationChannel( + "weather_channel", + "Weather", + NotificationManager.IMPORTANCE_LOW + ) + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + companion object { + lateinit var context: Context + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/di/Module.kt b/app/src/main/java/com/kassaev/weatherwidget/di/Module.kt new file mode 100644 index 0000000..20afea5 --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/di/Module.kt @@ -0,0 +1,34 @@ +package com.kassaev.weatherwidget.di + +import android.content.Context +import com.kassaev.weatherwidget.LocationObserver +import com.kassaev.weatherwidget.NetworkObserver +import com.kassaev.weatherwidget.WeatherViewModel +import com.kassaev.weatherwidget.data.WeatherRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object Module { + + @Provides + @Singleton + fun provideViewModel(repository: WeatherRepository): WeatherViewModel = WeatherViewModel(repository) + + @Provides + @Singleton + fun provideRepository(): WeatherRepository = WeatherRepository() + + @Provides + @Singleton + fun provideNetworkObserver(@ApplicationContext context: Context): NetworkObserver = NetworkObserver(context) + + @Provides + @Singleton + fun provideLocationObserver(@ApplicationContext context: Context): LocationObserver = LocationObserver(context) +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/domain/WeatherModel.kt b/app/src/main/java/com/kassaev/weatherwidget/domain/WeatherModel.kt new file mode 100644 index 0000000..d1ebd13 --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/domain/WeatherModel.kt @@ -0,0 +1,15 @@ +package com.kassaev.weatherwidget.domain + +data class WeatherModel( + val city: String, + val temp: String, + val desc: String, + val iconUri: String +) { + constructor(): this( + city = "", + temp = "", + desc = "", + iconUri = "" + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/ui/theme/Color.kt b/app/src/main/java/com/kassaev/weatherwidget/ui/theme/Color.kt new file mode 100644 index 0000000..936b5ad --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.kassaev.weatherwidget.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/ui/theme/Theme.kt b/app/src/main/java/com/kassaev/weatherwidget/ui/theme/Theme.kt new file mode 100644 index 0000000..5595e76 --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/ui/theme/Theme.kt @@ -0,0 +1,73 @@ +package com.kassaev.weatherwidget.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun WeatherWidgetTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController( + window, + view + ).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/kassaev/weatherwidget/ui/theme/Type.kt b/app/src/main/java/com/kassaev/weatherwidget/ui/theme/Type.kt new file mode 100644 index 0000000..c7e92b6 --- /dev/null +++ b/app/src/main/java/com/kassaev/weatherwidget/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.kassaev.weatherwidget.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/logo_svg.xml b/app/src/main/res/drawable/logo_svg.xml new file mode 100644 index 0000000..79568ea --- /dev/null +++ b/app/src/main/res/drawable/logo_svg.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/init_widget_layout.xml b/app/src/main/res/layout/init_widget_layout.xml new file mode 100644 index 0000000..11554b1 --- /dev/null +++ b/app/src/main/res/layout/init_widget_layout.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..30a80b9 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..79de661 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..4a92e0c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..5f9483c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..46e6fcb Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..d510a12 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..e20652b Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..283a151 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1430be4 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..666c2f8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..4dbcece Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..8056015 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..132c3f8 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..10164ed Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..07aeb41 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..d5d1118 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #B5AADD + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..20d9a2f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + WeatherWidget + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c7744fa --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +