diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..fd3ba31 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index bfb934f..6832b8d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -35,6 +35,10 @@ android { } buildFeatures{ viewBinding true + compose true + } + composeOptions{ + kotlinCompilerExtensionVersion'1.1.1' } } @@ -42,7 +46,17 @@ dependencies { def lifecycle_version = "2.5.0-alpha03" def fragment_version = '1.4.1' def nav_version = '2.4.1' + def core_version = '1.6.0' + def compose_version = '1.1.1' + + implementation "androidx.compose.ui:ui:$compose_version" + implementation "androidx.compose.material:material:$compose_version" + implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1' + implementation 'androidx.activity:activity-compose:1.4.0' + implementation "io.coil-kt:coil-compose:2.0.0-rc03" + implementation "androidx.core:core:$core_version" implementation "com.google.android.gms:play-services-location:19.0.1" implementation "androidx.fragment:fragment:$fragment_version" implementation "androidx.fragment:fragment-ktx:$fragment_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 674e353..f0e6284 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ + + + - + diff --git a/app/src/main/java/com/example/weatherapp/ApplicationModule.kt b/app/src/main/java/com/example/weatherapp/ApplicationModule.kt index 354171e..60d9db6 100644 --- a/app/src/main/java/com/example/weatherapp/ApplicationModule.kt +++ b/app/src/main/java/com/example/weatherapp/ApplicationModule.kt @@ -5,11 +5,12 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.components.ServiceComponent import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory @Module -@InstallIn(ActivityComponent::class) +@InstallIn(ActivityComponent::class, ServiceComponent::class) object ApplicationModule { @Provides fun provideApi(): Api { diff --git a/app/src/main/java/com/example/weatherapp/DayForecast.kt b/app/src/main/java/com/example/weatherapp/DayForecast.kt index 1603af0..bf7bf8b 100644 --- a/app/src/main/java/com/example/weatherapp/DayForecast.kt +++ b/app/src/main/java/com/example/weatherapp/DayForecast.kt @@ -1,7 +1,10 @@ package com.example.weatherapp +import android.os.Parcelable import com.squareup.moshi.Json +import kotlinx.android.parcel.Parcelize +@Parcelize data class DayForecast( @Json(name = "dt") val date: Long, val sunrise: Long, @@ -9,6 +12,7 @@ data class DayForecast( val temp: ForecastTemp, val pressure: Float, val humidity: Int, - val weather: List -) + val weather: List, + val speed: Float +) : Parcelable diff --git a/app/src/main/java/com/example/weatherapp/ForecastDetailFragment.kt b/app/src/main/java/com/example/weatherapp/ForecastDetailFragment.kt new file mode 100644 index 0000000..70591ec --- /dev/null +++ b/app/src/main/java/com/example/weatherapp/ForecastDetailFragment.kt @@ -0,0 +1,87 @@ +package com.example.weatherapp + +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.navArgs +import coil.compose.AsyncImage +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition + +class ForecastDetailFragment : Fragment(){ + + private val args: ForecastDetailFragmentArgs by navArgs() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return ComposeView(requireContext()).apply { + setContent { + detailScreen() + } + } + } + + @Composable + fun detailScreen(){ + Column { + iconAndTemp() + description() + } + } + + @Composable + fun iconAndTemp(){ + val dayTemp = args.dayForecast.temp.day.toInt() + val iconName = args.dayForecast.weather.firstOrNull()!!.icon + val iconURL = "https://openweathermap.org/img/wn/${iconName}@2x.png" + + Row(horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth()) { + + AsyncImage(model = iconURL, contentDescription = null, modifier = Modifier.size(100.dp)) + Text("$dayTemp °", fontSize = 75.sp) + } + } + @Preview + @Composable + fun description(){ + val minTemp = args.dayForecast.temp.min + val maxTemp = args.dayForecast.temp.max + val humidity = args.dayForecast.humidity + val pressure = args.dayForecast.pressure + val windSpeed = args.dayForecast.speed + val description = args.dayForecast.weather.firstOrNull()!!.description + Column(modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 15.dp, vertical = 5.dp)) { + Text(text = "Low: $minTemp", fontSize = 20.sp) + Text(text = "High: $maxTemp", fontSize = 20.sp) + Text(text = "Humidity: $humidity %", fontSize = 20.sp) + Text(text = "Pressure: $pressure hPa", fontSize = 20.sp) + Text(text = "Wind Speed: $windSpeed", fontSize = 20.sp) + Text(text = "$description", fontSize = 20.sp) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/weatherapp/ForecastFragment.kt b/app/src/main/java/com/example/weatherapp/ForecastFragment.kt index a1e223a..88754e4 100644 --- a/app/src/main/java/com/example/weatherapp/ForecastFragment.kt +++ b/app/src/main/java/com/example/weatherapp/ForecastFragment.kt @@ -2,7 +2,9 @@ package com.example.weatherapp import android.os.Bundle import android.view.View +import android.widget.AdapterView import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -25,7 +27,15 @@ class ForecastFragment: Fragment(R.layout.fragment_forecast) { super.onResume() viewModel.loadData(args.coordinates) viewModel.dailyForecast.observe(this){ dailyForecast -> - recyclerView.adapter = ForecastRecyclerViewAdapter(dailyForecast.forecastList) + recyclerView.adapter = ForecastRecyclerViewAdapter( + dailyForecast.forecastList, + object: ItemClicked{ + override fun onItemClicked(info: DayForecast) { + val action = ForecastFragmentDirections + .actionForecastFragmentToForecastDetailFragment(info) + findNavController().navigate(action) + } + }) } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/weatherapp/ForecastRecyclerViewAdapter.kt b/app/src/main/java/com/example/weatherapp/ForecastRecyclerViewAdapter.kt index bc5c5db..56aaa6e 100644 --- a/app/src/main/java/com/example/weatherapp/ForecastRecyclerViewAdapter.kt +++ b/app/src/main/java/com/example/weatherapp/ForecastRecyclerViewAdapter.kt @@ -3,6 +3,7 @@ package com.example.weatherapp import android.os.Build import android.view.LayoutInflater import android.view.ViewGroup +import android.widget.AdapterView import androidx.annotation.RequiresApi import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide @@ -12,13 +13,12 @@ import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter -class ForecastRecyclerViewAdapter(private val data: List) +class ForecastRecyclerViewAdapter(private val data: List, private val itemClicked: ItemClicked ) : RecyclerView.Adapter() { class ViewHolder(private val binding: ForecastDataBinding) : RecyclerView.ViewHolder(binding.root) { - @RequiresApi(Build.VERSION_CODES.O) - fun bind(info: DayForecast){ + fun bind(info: DayForecast, itemClicked: ItemClicked){ val dateInstant = Instant.ofEpochSecond(info.date) val dateTime = LocalDateTime.ofInstant(dateInstant, ZoneId.systemDefault()) val sunriseInstant = Instant.ofEpochSecond(info.sunrise) @@ -43,7 +43,9 @@ class ForecastRecyclerViewAdapter(private val data: List) .load(iconURL) .into(binding.forecastImage) - + itemView.setOnClickListener{ + itemClicked.onItemClicked(info) + } } } @@ -55,7 +57,7 @@ class ForecastRecyclerViewAdapter(private val data: List) @RequiresApi(Build.VERSION_CODES.O) override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind(data[position]) + holder.bind(data[position], itemClicked) } override fun getItemCount() = data.size diff --git a/app/src/main/java/com/example/weatherapp/ForecastTemp.kt b/app/src/main/java/com/example/weatherapp/ForecastTemp.kt index 52627a1..bb28212 100644 --- a/app/src/main/java/com/example/weatherapp/ForecastTemp.kt +++ b/app/src/main/java/com/example/weatherapp/ForecastTemp.kt @@ -1,6 +1,10 @@ package com.example.weatherapp +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +@Parcelize class ForecastTemp ( val day: Float, val min: Float, - val max:Float) \ No newline at end of file + val max:Float) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/example/weatherapp/ItemClicked.kt b/app/src/main/java/com/example/weatherapp/ItemClicked.kt new file mode 100644 index 0000000..699cbb5 --- /dev/null +++ b/app/src/main/java/com/example/weatherapp/ItemClicked.kt @@ -0,0 +1,5 @@ +package com.example.weatherapp + +interface ItemClicked { + fun onItemClicked(info: DayForecast) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/weatherapp/MainActivity.kt b/app/src/main/java/com/example/weatherapp/MainActivity.kt index 6c2dc26..c8621c2 100644 --- a/app/src/main/java/com/example/weatherapp/MainActivity.kt +++ b/app/src/main/java/com/example/weatherapp/MainActivity.kt @@ -17,7 +17,4 @@ class MainActivity : AppCompatActivity() { super.onResume() } - - - } \ No newline at end of file diff --git a/app/src/main/java/com/example/weatherapp/NotificationService.kt b/app/src/main/java/com/example/weatherapp/NotificationService.kt new file mode 100644 index 0000000..3d7b6fb --- /dev/null +++ b/app/src/main/java/com/example/weatherapp/NotificationService.kt @@ -0,0 +1,171 @@ +package com.example.weatherapp + +import android.Manifest +import android.app.AlertDialog +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.os.Build +import android.os.IBinder +import android.os.Looper +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.graphics.drawable.toBitmap +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.google.android.gms.location.* +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class NotificationService: Service() { + @Inject lateinit var viewModel: NotificationServiceViewModel + lateinit var locationRequest: LocationRequest + private lateinit var fusedLocationProviderClient: FusedLocationProviderClient + private lateinit var locationCallback: LocationCallback + private val CHANNEL_ID = "channel1" + private val notificationId = 1 + + @RequiresApi(Build.VERSION_CODES.O) + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int{ + super.onStartCommand(intent, flags, startId) + Log.d("Service", "service Started") + val notificationId = 1 + val name = "notification Channel" + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(CHANNEL_ID, name, importance) + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + + fusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(this) + + getLocation() + + locationRequest = LocationRequest.create().apply { + setInterval(60000) + setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY) + } + + locationCallback = object : LocationCallback(){ + override fun onLocationResult(locationResults: LocationResult) { + super.onLocationResult(locationResults) + if(locationResults.lastLocation != null){ + val coordinate = Coordinate(locationResults.lastLocation.latitude, + locationResults.lastLocation.longitude) + viewModel.loadData(coordinate) + sendNotification() + Log.d("Location Service", "Getting Locations") + } + } + } + + startLocationUpdate() + + return START_REDELIVER_INTENT + } + + override fun onBind(p0: Intent?): IBinder? { + return null + } + + override fun onTaskRemoved(rootIntent: Intent?) { + val restartServiceIntent = Intent(applicationContext, NotificationService::class.java ) + restartServiceIntent.setPackage(packageName) + startService(restartServiceIntent) + super.onTaskRemoved(rootIntent) + } + + private fun getLocation(){ + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_COARSE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + fusedLocationProviderClient.lastLocation.addOnSuccessListener { + val coordinate = Coordinate(it.latitude, it.longitude) + viewModel.loadData(coordinate) + sendNotification() + Log.d("Location Service", "Got first location") + } + } + + private fun startLocationUpdate(){ + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_COARSE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { + // TODO: Consider calling + // ActivityCompat#requestPermissions + // here to request the missing permissions, and then overriding + // public void onRequestPermissionsResult(int requestCode, String[] permissions, + // int[] grantResults) + // to handle the case where the user grants the permission. See the documentation + // for ActivityCompat#requestPermissions for more details. + return + } + fusedLocationProviderClient.requestLocationUpdates( + locationRequest, + locationCallback, + Looper.getMainLooper() + ) + } + + private fun sendNotification(){ + val currentConditions = viewModel.currentConditions.value + val locationName = currentConditions!!.name + val currentTemp = currentConditions!!.main.temp + var icon: Bitmap? = null + val iconName = viewModel.currentConditions.value!!.weather.firstOrNull()?.icon + val iconURL = "https://openweathermap.org/img/wn/${iconName}@2x.png" + Glide.with(this) + .load(iconURL) + .into( object : CustomTarget(){ + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + icon = resource.toBitmap() + } + + override fun onLoadCleared(placeholder: Drawable?) { + } + }) + + var builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.common_google_signin_btn_icon_dark) + .setContentTitle("Weather App") + .setContentText("${locationName}, ${currentTemp}°") + .setLargeIcon(icon) + + startForeground(notificationId, builder.build()) + + with(NotificationManagerCompat.from(this)) { + notify(notificationId, builder.build()) + } + } + + override fun onDestroy() { + super.onDestroy() + fusedLocationProviderClient.removeLocationUpdates(locationCallback) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/weatherapp/NotificationServiceViewModel.kt b/app/src/main/java/com/example/weatherapp/NotificationServiceViewModel.kt new file mode 100644 index 0000000..dc85b14 --- /dev/null +++ b/app/src/main/java/com/example/weatherapp/NotificationServiceViewModel.kt @@ -0,0 +1,18 @@ +package com.example.weatherapp + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +class NotificationServiceViewModel @Inject() constructor(private val api: Api) : ViewModel() { + private val _currentConditions: MutableLiveData = MutableLiveData() + val currentConditions: LiveData + get() = _currentConditions + + fun loadData(coordinate: Coordinate) = runBlocking{ + launch { _currentConditions.value = api.getCurrentConditionsLatLon(coordinate.latitude, coordinate.longitude) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/weatherapp/SearchFragment.kt b/app/src/main/java/com/example/weatherapp/SearchFragment.kt index fef780b..fae4d13 100644 --- a/app/src/main/java/com/example/weatherapp/SearchFragment.kt +++ b/app/src/main/java/com/example/weatherapp/SearchFragment.kt @@ -2,6 +2,7 @@ package com.example.weatherapp import android.Manifest import android.app.AlertDialog +import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle @@ -87,6 +88,29 @@ class SearchFragment :Fragment(R.layout.fragment_search){ } } + binding.notificationButton.setOnClickListener { + requestLocation() + if (ActivityCompat.checkSelfPermission( + context!!, + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission( + context!!, + Manifest.permission.ACCESS_COARSE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { + + }else { + if (viewModel.getNotificationStatus() == false) { + viewModel.setNotificationsToTrue() + requireActivity().startService(Intent(context, NotificationService::class.java)) + } else { + viewModel.setNotificationsToFalse() + requireActivity().stopService(Intent(context, NotificationService::class.java)) + } + } + updateNotificationButton() + } + locationPermissionRequest = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ){permissions -> @@ -104,18 +128,18 @@ class SearchFragment :Fragment(R.layout.fragment_search){ private fun requestLocation(){ - if(shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_COARSE_LOCATION)){ + if(shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)){ AlertDialog.Builder(context) .setTitle(R.string.location_rationale) .setNeutralButton("Ok"){_, _-> locationPermissionRequest.launch( - arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION) + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) ) } .show() }else { locationPermissionRequest.launch( - arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION) + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) ) } } @@ -123,6 +147,7 @@ class SearchFragment :Fragment(R.layout.fragment_search){ override fun onResume() { super.onResume() requestLocationUpdate() + updateNotificationButton() } private fun requestLocationUpdate(){ @@ -140,8 +165,17 @@ class SearchFragment :Fragment(R.layout.fragment_search){ val locationProvider = LocationServices.getFusedLocationProviderClient(context!!) locationProvider.lastLocation.addOnSuccessListener { - viewModel.updateLatLon(it.latitude, it.longitude) + if(it != null) { + viewModel.updateLatLon(it.latitude, it.longitude) + } } } + private fun updateNotificationButton(){ + if (!viewModel.getNotificationStatus()){ + binding.notificationButton.text = "Turn Notifications On" + } else{ + binding.notificationButton.text = "Turn Notifications Off" + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/weatherapp/SearchFragmentViewModel.kt b/app/src/main/java/com/example/weatherapp/SearchFragmentViewModel.kt index b48c66f..dc53b6d 100644 --- a/app/src/main/java/com/example/weatherapp/SearchFragmentViewModel.kt +++ b/app/src/main/java/com/example/weatherapp/SearchFragmentViewModel.kt @@ -1,5 +1,6 @@ package com.example.weatherapp +import android.content.Intent import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -21,6 +22,8 @@ class SearchFragmentViewModel @Inject constructor(private val api: Api) : ViewMo private val _showErrorDialog = MutableLiveData(false) + private var notificaitonStatus = false + val currentConditions: LiveData get() = _currentConditions @@ -58,5 +61,16 @@ class SearchFragmentViewModel @Inject constructor(private val api: Api) : ViewMo launch{ _currentConditions.value = api.getCurrentConditionsLatLon(latitude, longitude)} } + fun getNotificationStatus(): Boolean{ + return notificaitonStatus + } + + fun setNotificationsToTrue(){ + notificaitonStatus = true + } + fun setNotificationsToFalse(){ + notificaitonStatus = false + } + } \ No newline at end of file diff --git a/app/src/main/java/com/example/weatherapp/WeatherConditions.kt b/app/src/main/java/com/example/weatherapp/WeatherConditions.kt index c8ce7cb..644f7a5 100644 --- a/app/src/main/java/com/example/weatherapp/WeatherConditions.kt +++ b/app/src/main/java/com/example/weatherapp/WeatherConditions.kt @@ -6,5 +6,6 @@ import kotlinx.android.parcel.Parcelize @Parcelize data class WeatherConditions( val main: String, - val icon: String + val icon: String, + val description: String ): Parcelable \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index ee7baf0..721f795 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -19,7 +19,7 @@