Kihagyás

Labor 07 - Weather Info alkalmazás készítése

Bevezető

A labor során egy időjárás információkat megjelenítő alkalmazás elkészítése a feladat. A korábban látott UI elemek használata mellett láthatunk majd példát hálózati kommunkáció hatékony megvalósítására is a Retrofit library felhasználásával.

Az alkalmazás városok listáját jeleníti meg egy RecyclerView-ban, egy kiválasztott város részletes időjárás adatait pedig az OpenWeatherMap REST API-jának segítségével kérdezi le. A részletező nézeten egy ViewPager-ben két Fragment-en lehet megtekinteni a részleteket. Új város hozzáadására egy FloatingActionButton megnyomásával van lehetőség.

Felhasznált technológiák:

Az alkalmazás specifikációja

Az alkalmazás két Activity-ből áll.

Az alkalmazás indulásakor megjelenő Activity a felhasználó által felvett városok listáját jeleníti meg. Minden lista elemhez tartozik egy Remove gomb, aminek a megnyomására az adott város törlődik a listából. Új várost a nézet jobb alsó sarkában található FloatingActionButton megnyomásával lehet felvenni.

Egy városra való kattintás hatására megnyílik egy új Activity két Fragment-tel, amik között ViewPager-rel lehet váltani. Az első Fragment a kiválasztott város időjárásának leírását és az ahhoz tartozó ikont jeleníti meg. A második Fragment-en a városban mért átlagos, minimum és maximum hőmérséklet, a légnyomás és a páratartalom értéke látható.

Laborfeladatok

A labor során az alábbi feladatokat a laborvezető segítségével, illetve a jelölt feladatokat önállóan kell megvalósítani.

  1. Város lista megvalósítása: 1 pont
  2. Részletező nézet létrehozása és bekötése a navigációba: 1 pont
  3. Hálózati kommunikáció megvalósítása: 1 pont
  4. A hálózati réteg bekötése a részletező nézetbe: 1 pont
  5. Önálló feladat: város listából törlés megvalósítása: 1 pont

A labor során egy komplex időjárás alkalmazás készül el. A labor szűkös időkerete miatt szükség lesz nagyobb kódblokkok másolására, azonban minden esetben figyeljünk a laborvezető magyarázatára, hogy a kódrészek érthetőek legyenek. A cél a bemutatott kódok megértése és a felhasznált libraryk használatának elsajátítása.

Elnézést kérünk az eddigieknél nagyobb kód blokkokért, de egy ilyen, bemutató jellegű feladat kisebb méretben nem oldható meg, illetve a labor elveszítené a lényegét, ha csak egy „hello world” hálózati kommunikációs lekérést valósítanánk meg. Köszönjük a megértést.

Előkészületek

A feladatok megoldása során ne felejtsd el követni a feladat beadás folyamatát.

Git repository létrehozása és letöltése

  1. Moodle-ben keresd meg a laborhoz tartozó meghívó URL-jét és annak segítségével hozd létre a saját repository-dat.

  2. Várd meg, míg elkészül a repository, majd checkout-old ki.

    Egyetemi laborokban, ha a checkout során nem kér a rendszer felhasználónevet és jelszót, és nem sikerül a checkout, akkor valószínűleg a gépen korábban megjegyzett felhasználónévvel próbálkozott a rendszer. Először töröld ki a mentett belépési adatokat (lásd itt), és próbáld újra.

  3. Hozz létre egy új ágat megoldas néven, és ezen az ágon dolgozz.

  4. A neptun.txt fájlba írd bele a Neptun kódodat. A fájlban semmi más ne szerepeljen, csak egyetlen sorban a Neptun kód 6 karaktere.

Projekt létrehozása

Első lépésként indítsuk el az Android Studio-t, majd:
1. Hozzunk létre egy új projektet, válasszuk az Empty Views Activity lehetőséget.
2. A projekt neve legyen WeatherInfo, a kezdő package pedig hu.bme.aut.android.weatherinfo
3. A többi beállítást hagyjuk érintetlenül.

FILE PATH

A projekt a repository-ban lévő WeatherInfo könyvtárba kerüljön, és beadásnál legyen is felpusholva! A kód nélkül nem tudunk maximális pontot adni a laborra!

Nevezzük át a generált Activityt CityActivity, a hozzá tartozó layout fájlt pedig activity_city névre.

Kapcsoljuk be a ViewBinding-ot. Ehhez az app modulhoz tartozó build.gradle fájlban az android blokkon belülre illesszük be az engedélyező kódrészletet (majd kattintsunk a jobb felül megjelenő Sync Now gombra).

buildFeatures {
    viewBinding = true
}

Töltsük le és tömörítsük ki az alkalmazáshoz szükséges erőforrásokat , majd másoljuk be őket a projekt app/src/main/res mappájába (Studio-ban a res mappa kijelölése után Ctrl+V)!

Az app modulhoz tartozó build.gradle fájlban a dependencies blokkhoz adjuk hozzá a Retrofit és Glide libraryket:

dependencies{
    //...
    val retrofitVersion = "2.9.0"
    implementation("com.squareup.retrofit2:retrofit:$retrofitVersion")
    implementation("com.squareup.retrofit2:converter-gson:$retrofitVersion")

    val glideVersion = "4.16.0"
    implementation("com.github.bumptech.glide:glide:$glideVersion")
    annotationProcessor("com.github.bumptech.glide:compiler:$glideVersion")
}

Ezután kattintsunk a jobb felső sarokban megjelenő Sync now gombra.

Retrofit

A Retrofit a fejlesztő által leírt egyszerű, megfelelően annotált interfészek alapján kódgenerálással állít elő HTTP hivásokat lebonyolító implementációt. Kezeli az URL-ben inline módon adott paramétereket, az URL queryket, stb. Támogatja a legnépszerűbb szerializáló/deszerializáló megoldásokat is (pl.: Gson, Moshi, Simple XML, stb.), amikkel Java/Kotlin objektumok, és JSON vagy XML formátumú adatok közötti kétirányú átalakítás valósítható meg. A laboron ezek közül a Gsont fogjuk használni a JSON-ban érkező időjárás adatok konvertálására.

Glide

A Glide egy hatékony képbetöltést és -cache-elést megvalósító library Androidra. Egyszerű interfésze és hatékonysága miatt használjuk.

Az alkalmazásban szükségünk lesz internet elérésre. Vegyük fel az AndroidManifest.xml állományban az Internet permission-t az application tagen kívülre:

<uses-permission android:name="android.permission.INTERNET" />

Engedélyek

Androidon API 23-tól (6.0, Marshmallow) az engedélyek két csoportba lettek osztva. A normal csoportba tartozó engedélyeket elég felvenni az AndroidManifest.xml fájlba az előbb látott módon és az alkalmazás automatikusan megkapja őket. A dangerous csoportba tartozó engedélyek esetén ez már nem elég, futás időben explicit módon el kell kérni őket a felhasználótól, aki akármikor meg is tagadhatja az alkalmazástól a kért engedélyt. Az engedélyek kezeléséről bővebben a developer.android.com oldalon lehet tájékozódni.

Vegyük fel az alábbi szöveges erőforrásokat a res/values/strings.xml-be:

<resources>
    <string name="app_name">WeatherInfo</string>

    <string name="action_settings">Settings</string>

    <string name="title_activity_city">Cities</string>
    <string name="remove">Remove</string>

    <string name="new_city">New city</string>
    <string name="new_city_hint">City</string>
    <string name="ok">OK</string>
    <string name="cancel">Cancel</string>

    <string name="title_activity_details">DetailsActivity</string>
    <string name="weather">Weather</string>
    <string name="temperature">Temperature</string>
    <string name="min_temperature">Min temperature</string>
    <string name="max_temperature">Max temperature</string>
    <string name="pressure">Pressure</string>
    <string name="humidity">Humidity</string>
    <string name="main">Main</string>
    <string name="details">Details</string>
</resources>

OpenWeatherMap API kulcs

Regisztráljunk saját felhasználót az OpenWeatherMap oldalon, és hozzunk létre egy API kulcsot, aminek a segítségével használhatjuk majd a szolgáltatást az alkalmazásunkban!

  1. Kattintsunk a Sign in majd a Create an account gombra.
  2. Töltsük ki a regisztrációs formot
  3. A Company mező értéke legyen "BME", a Purpose értéke legyen "Education/Science"
  4. Sikeres regisztráció után az API keys tabon található az alapértelmezettként létrehozott API kulcs.

A kapott API kulcsra később szükségünk lesz az időjárás adatokat lekérő API hívásnál.

1. Városlista megvalósítása (1 pont)

Valósítsuk meg az egy RecyclerView-ból álló, városok listáját megjelenítő CityActivity-t!

A város nevére kattintva jelenik majd meg egy részletező nézet (DetailsAcitivity), ahol az időjárás információk letöltése fog történni. Új város felvételére egy FloatingActionButton fog szolgálni.

Cseréljük le az activity_city.xml tartalmát egy RecyclerView-ra és egy FloatingActionButton-re:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".CityActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/mainRecyclerView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|end"
        android:layout_margin="24dp"
        android:src="@drawable/ic_add_white_36dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Az egyes funkciókhoz tartozó osztályokat külön package-ekbe fogjuk szervezni. Előfordulhat, hogy a másolások miatt az Android Studio nem ismeri fel egyből a package szerkezetet, így ha ilyen problémánk lenne, az osztály néven állva Alt+Enter után állítassuk be a megfelelő package nevet.

A hu.bme.aut.android.weatherinfo package-ben hozzunk létre egy feature nevű package-et. A feature package-ben hozzunk létre egy city nevű package-et. Drag and drop módszerrel helyezzük át a CityActivity-t a city package-be, a felugró dialógusban pedig kattintsunk a Refactor gombra.

A CityActivity kódját cseréljük le a következőre:

class CityActivity : AppCompatActivity(), CityAdapter.OnCitySelectedListener,
    AddCityDialogFragment.AddCityDialogListener {

    private lateinit var binding: ActivityCityBinding
    private lateinit var adapter: CityAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityCityBinding.inflate(layoutInflater)
        setContentView(binding.root)
        initFab()
        initRecyclerView()
    }

    private fun initFab() {
        binding.fab.setOnClickListener {
            // TODO: Show new city dialog
        }
    }

    private fun initRecyclerView() {
        binding.mainRecyclerView.layoutManager = LinearLayoutManager(this)
        adapter = CityAdapter(this)
        adapter.addCity("Budapest")
        adapter.addCity("Debrecen")
        adapter.addCity("Sopron")
        adapter.addCity("Szeged")
        binding.mainRecyclerView.adapter = adapter
    }

    override fun onCitySelected(city: String?) {
        // Todo: Start DetailsActivity with the selected city
    }

    override fun onCityAdded(city: String?) {
        adapter.addCity(city!!)
    }
}

A city package-ben hozzuk létre a CityAdapter osztályt:

class CityAdapter(private val listener: OnCitySelectedListener) : RecyclerView.Adapter<CityAdapter.CityViewHolder>() {
    private val cities: MutableList<String> = ArrayList()

    interface OnCitySelectedListener {
        fun onCitySelected(city: String?)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CityViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_city, parent, false)
        return CityViewHolder(view)
    }

    override fun onBindViewHolder(holder: CityViewHolder, position: Int) {
        val item = cities[position]
        holder.bind(item)
    }

    override fun getItemCount(): Int = cities.size

    fun addCity(newCity: String) {
        cities.add(newCity)
        notifyItemInserted(cities.size - 1)
    }

    fun removeCity(position: Int) {
        cities.removeAt(position)
        notifyItemRemoved(position)
        if (position < cities.size) {
            notifyItemRangeChanged(position, cities.size - position)
        }
    }

    inner class CityViewHolder(private val itemView: View) : RecyclerView.ViewHolder(itemView) {
        var binding = ItemCityBinding.bind(itemView)
        var item: String? = null

        init {
            binding.root.setOnClickListener { listener.onCitySelected(item) }
        }

        fun bind(newCity: String?) {
            item = newCity
            binding.CityItemNameTextView.text = item
        }
    }
}

Hozzuk létre a res/layout mappában az item_city.xml layoutot:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:paddingBottom="8dp"
    android:paddingLeft="16dp"
    android:paddingRight="16dp"
    android:paddingTop="8dp"
    android:weightSum="3">

    <TextView
        android:id="@+id/CityItemNameTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="2"
        tools:text="Budapest" />

    <Button
        android:id="@+id/CityItemRemoveButton"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:text="@string/remove" />

</LinearLayout>

Igény szerint vizsgáljuk meg a laborvezetővel a CityAdapter osztályban az alábbiakat:
- Hogyan történik a lista tartalmi elemeinek kezelése?
- Hogyan épül fel egy lista elem?
- Hogyan történik a lista elemen a kiválasztás események kezelése? Hogyan értesítjük a CityActivity-t egy elem kiválasztásáról?
- Hogyan kerültek megvalósításra az addCity(...) és removeCity(…) metódusok?

A CityActivity-vel kapcsolatos következő lépés az új város nevét bekérő dialógus (DialogFragment) megvalósítása és bekötése.

Hozzunk létre egy dialog_new_city.xml nevű layout fájlt a res/layout mappában a következő tartalommal:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="24dp">

    <EditText
        android:id="@+id/NewCityDialogEditText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="@string/new_city_hint"
        android:inputType="text" />

</LinearLayout>

A city package-ben hozzuk létre az AddCityDialogFragment osztályt:

class AddCityDialogFragment : AppCompatDialogFragment() {

    private lateinit var binding: DialogNewCityBinding
    private lateinit var listener: AddCityDialogListener

    interface AddCityDialogListener {
        fun onCityAdded(city: String?)
    }

    override fun onAttach(context: Context) {
        super.onAttach(context)
        binding = DialogNewCityBinding.inflate(LayoutInflater.from(context))

        listener = context as? AddCityDialogListener
            ?: throw RuntimeException("Activity must implement the AddCityDialogListener interface!")
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        return AlertDialog.Builder(requireContext())
            .setTitle(R.string.new_city)
            .setView(binding.root)
            .setPositiveButton(R.string.ok) { _, _ ->
                listener.onCityAdded(
                    binding.NewCityDialogEditText.text.toString()
                )
            }
            .setNegativeButton(R.string.cancel, null)
            .create()
    }
}

Igény szerint vizsgáljuk meg a laborvezetővel az AddCityDialogFragment implementációjában az alábbiakat:
- Hogyan ellenőrizzük azt, hogy az Activity, amihez a DialogFragment felcsatolódott implementálja-e az AddCityDialogListener interfészt?
- Hogyan kerül beállításra az egyedi layout a DialogFragment-ben?
- Hogyan térünk vissza a beírt városnévvel?

Szorgalmi feladat otthonra: az alkalmazás ne engedje a város létrehozását, ha a városnév mező üres! Tipp: http://stackoverflow.com/questions/13746412/prevent-dialogfragment-from-dismissing-when-button-is-clicked

Végül egészítsük ki a CityActivity initFab(…) függvényét úgy, hogy a gombra kattintva jelenjen meg az új dialógus:

private fun initFab() {
    binding.fab.setOnClickListener {
            AddCityDialogFragment().show(supportFragmentManager, AddCityDialogFragment::class.java.simpleName)
    }
}

Indítsuk el az alkalmazást, amely már képes városnevek bekérésére és megjelenítésére.

BEADANDÓ (1 pont)

Készíts egy képernyőképet, amelyen látszik a városnevek listája egy újonnan hozzáadott várossal, az AddCityDialogFragment kódja, valamint a neptun kódod a kódban valahol kommentként. A képet a megoldásban a repository-ba f1.png néven töltsd föl.

A képernyőkép szükséges feltétele a pontszám megszerzésének.

2. Részletező nézet létrehozása és bekötése a navigációba (1 pont)

A következő lépésben a hu.bme.aut.android.weatherinfo.feature package-en belül hozzunk létre egy details nevű packaget. (Ehhez a legegyszerűbb megoldás, ha a Project nézeten az Options-re (3 pötty) kattintva kiszedjük a pipát a Tree Appearance -> Compact Middle Packages elemnél.)

A details package-ben hozzunk létre egy Empty Views Activity típusú Activity-t DetailsActivity néven.

A hozzá tartozó activity_details.xml layout kódja:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin">

    <com.google.android.material.tabs.TabLayout 
        android:id="@+id/tabLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="top" />

    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/mainViewPager"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

</LinearLayout>

Hozzunk létre a hiányzó dimen erőforrásokat (Alt+Enter -> Create dimen value...), értékük legyen 16dp!

A felület gyakorlatilag egy ViewPager-t tartalmaz, melyben két Fragment-et fogunk megjeleníteni. A TabLayout biztosítja a Tab jellegű fejlécet.

A DetailsActivity.kt kódja legyen a következő:

class DetailsActivity : AppCompatActivity() {

    private lateinit var binding: ActivityDetailsBinding
    private var city: String? = null

    companion object {
        private const val TAG = "DetailsActivity"
        const val EXTRA_CITY_NAME = "extra.city_name"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityDetailsBinding.inflate(layoutInflater)
        setContentView(binding.root)

        city = intent.getStringExtra(EXTRA_CITY_NAME)

        supportActionBar?.title = getString(R.string.weather, city)
        supportActionBar?.setDisplayHomeAsUpEnabled(true)
    }

    override fun onResume() {
        super.onResume()
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        if (item.itemId == android.R.id.home) {
            finish()
            return true
        }
        return super.onOptionsItemSelected(item)
    }
}

Cseréljük le a strings.xml-ben a weather szöveges erőforrást:

<string name="weather">Weather in %s </string>

A string erőforrásba írt %s jelölő használatával lehetővé válik egy String argumentum beillesztése a stringbe, ahogy a fenti kódrészletben láthatjuk.

Figyeljük meg, hogy a DetailsActivity hogyan állítja be az ActionBar címét a paraméterül kapott város nevével, illetve és azt, hogy az ActionBar bal felső sarkában a vissza gomb kezelése hogyan került megvalósításra.

Valósítsuk meg a CityActivity onCitySelected(…) függvényében azt, hogy egy városnév kiválasztásakor a DetailsActivity megfelelően felparaméterezve induljon el:

    override fun onCitySelected(city: String?) {
        val showDetailsIntent = Intent()
        showDetailsIntent.setClass(this@CityActivity, DetailsActivity::class.java)
        showDetailsIntent.putExtra(DetailsActivity.EXTRA_CITY_NAME, city)
        startActivity(showDetailsIntent)
    }

Próbáljuk ki az alkalmazást, kattintsunk egy város nevére!

BEADANDÓ (1 pont)

Készíts egy képernyőképet, amelyen látszik az üres részletes nézet a megfelelő fejléccel, a DetailsActivity kódja, valamint a neptun kódod a kódban valahol kommentként. A képet a megoldásban a repository-ba f2.png néven töltsd föl.

A képernyőkép szükséges feltétele a pontszám megszerzésének.

3. Hálózati kommunikáció megvalósítása (1 pont)

Modell osztályok létrehozása

A modell osztályok számára a hu.bme.aut.android.weatherinfo package-ben hozzunk létre új package-et model néven.

A model package-ben hozzunk létre egy új osztályt WeatherData néven:

data class WeatherData (
    var coord: Coord,
    var weather: List<Weather>? = null,
    var base: String,
    var main: MainWeatherData? = null,
    var visibility: Int,
    var wind: Wind? = null,
    var clouds: Cloud,
    var dt: Int,
    var sys: Sys,
    var timezone: Int,
    var id: Int,
    var name: String,
    var cod: Int
)

Az időjárás szolgáltatástól kapott JSON válasz alapján egy ilyen WeatherData példány fog létrejönni a Retrofit és a Gson együttműködésének köszönhetően.

A model package-ben hozzuk létre a Weather osztályt:

data class Weather (
    val id: Long = 0,
    val main: String? = null,
    val description: String? = null,
    val icon: String? = null
)

Szintén a model package-ben hozzuk létre a MainWeatherData osztályt:

data class MainWeatherData (
    val temp: Float = 0f,
    val pressure: Float = 0f,
    val humidity: Float = 0f,
    val temp_min: Float = 0f,
    val temp_max: Float = 0f
)

Szintén a model package-ben hozzuk létre a Coord osztályt:

data class Coord (
    var lon: Float = 0f,
    var lat: Float = 0f
)

Szintén a model package-ben hozzuk létre a Cloud osztályt:

data class Cloud (
    var all: Int = 0
)

Szintén a model package-ben hozzuk létre a Sys osztályt:

data class Sys (
    var type: Int = 0,
    var id: Int = 0,
    var country: String? = null,
    var sunrise: Int = 0,
    var sunset: Int = 0
)

Végül hozzuk létre a Wind osztályt is:

class Wind (
    val speed: Float = 0f,
    val deg: Float = 0f
)

A details package-ben hozzuk létre a WeatherDataHolder interfészt:

interface WeatherDataHolder {
    fun getWeatherData(): WeatherData?
}

A WeatherDataHolder -en keresztül fogják lekérni a Fragment-ek az Activity-től az időjárás adatokat.

Vegyünk fel egy WeatherData típusú tagváltozót a DetailsActiviy-be:

private var weatherData: WeatherData? = null

Módosítsuk úgy a DetailsActivity -t, hogy implementálja a WeatherDataHolder interfészt:

class DetailsActivity : AppCompatActivity(), WeatherDataHolder {

Implementáljuk a szükséges függvényt:

    override fun getWeatherData(): WeatherData? {
        return weatherData
    }

A használt weatherData változónak fogunk később értéket adni, amikor visszaérkezett az értéke a hálózati hívás eredményeként. A ViewPager két lapján levő Fragment-ek a WeatherDataHolder interfészen keresztül fogják lekérni az Activity-től a weatherData objekutmot a megjelenítéshez.

A hálózati réteg megvalósítása

A hu.bme.aut.android.weatherinfo package-ben hozzuk létre egy network nevű package-et, amely a hálózati kommunikációhoz kapcsolódó osztályokat fogja tartalmazni.

A network package-en belül hozzuk létre egy WeatherApi nevű interfészt.

interface WeatherApi {
    @GET("/data/2.5/weather")
    fun getWeather(
        @Query("q") cityName: String?,
        @Query("units") units: String?,
        @Query("appid") appId: String?
    ): Call<WeatherData?>?
}

Látható, hogy annotációk alkalmazásával tuduk jelezni, hogy az adott függvényhívás milyen hálózati hívásnak fog megfelelni. A @GET annotáció HTTP GET kérést jelent, a paraméterként adott string pedig azt jelzi, hogy hogy a szerver alap URL-éhez képest melyik végpontra szeretnénk küldeni a kérést.

Hasonló módon tudjuk leírni a többi HTTP kérés típust is: @POST, @UPDATE, @PATCH, @DELETE

A függvény paremétereit a @Query annotációval láttuk el. Ez azt jelenti, hogy a Retrofit az adott paraméter értékét a kéréshez fűzi query paraméterként az annotációban megadott kulccsal.

További említésre méltó annotációk a teljesség igénye nélkül: @HEAD, @Multipart, @Field

A hálózati hívást jelölő interfész függvény visszatérési értéke egyCall<WeatherData> típusú objektum lesz. (A retrofites Callt importáljuk a megjelenő lehetőségek közül.) Ez egy olyan hálózati hívást ír le, aminek a válasza WeatherData típusú objektummá alakítható.

Hozzunk létre a network package-ben egy NetworkManager osztályt:

object NetworkManager {
    private val retrofit: Retrofit
    private val weatherApi: WeatherApi

    private const val SERVICE_URL = "https://api.openweathermap.org"
    private const val APP_ID = "ide_jon_a_token"

    init {
        retrofit = Retrofit.Builder()
            .baseUrl(SERVICE_URL)
            .client(OkHttpClient.Builder().build())
            .addConverterFactory(GsonConverterFactory.create())
            .build()
        weatherApi = retrofit.create(WeatherApi::class.java)
    }

    fun getWeather(city: String?): Call<WeatherData?>? {
        return weatherApi.getWeather(city, "metric", APP_ID)
    }
}

Ez az osztály lesz felelős a hálózati kérések lebonyolításáért. Egyetlen példányra lesz szükségünk belőle, így singletonként implementáltuk. Konstansokban tároljuk a szerver alap címét, valamint a szolgáltatás használatához szükséges API kulcsot.

A Retrofit.Builder() hívással kérhetünk egy pareméterezhető Builder példányt. Ebben adhatjuk meg a hálózati hívásaink tulajdonságait. Jelen példában beállítjuk az elérni kívánt szolgáltatás címét, a HTTP kliens implementációt (OkHttp), valamint a JSON és objektum reprezentációk közötti konvertert (Gson).

A WeatherApi interfészből a Builder-rel létrehozott Retrofit példány segítségével tudjuk elkérni a fordítási időben generált, paraméterezett implementációt.

A retrofit.create(WeatherApi.class) hívás eredményeként kapott objektum megvalósítja a WeatherApi interfészt. Ha ezen az objektumon meghívjuk a getWeather(...) függvényt, akkor megtörténik az általunk az interfészben definiált hálózati hívás.

Az APP_ID paramétert elfedjük az időjárást lekérdező osztályok elől, ezért a NetworkManager is tartalmaz egy getWeather(...) függvényt, ami a WeatherApi implementációba hív tovább.

Cseréljük le az APP_ID értékét az OpenWeatherMap oldalon kapott saját API kulcsunkra!

BEADANDÓ (1 pont)

Készíts egy képernyőképet, amelyen látszanak a Project nézetben a létrehozott modell osztályok, az editorban a WeatherApi osztály kódja, valamint a neptun kódod a kódban valahol kommentként. A képet a megoldásban a repository-ba f3.png néven töltsd föl.

A képernyőkép szükséges feltétele a pontszám megszerzésének.

4. A hálózati réteg bekötése a részletező nézetbe (1 pont)

A modell elemek és a hálózati réteg megvalósítása után a részletező nézetet fogjuk a specifikációnak megfelelően implementálni, majd bekötjük a hálózati réteget is.

A részletező nézetek továbbfejlesztése

A ViewPager megfelelő működéséhez létre kell hoznunk egy FragmentStateAdapter-ből származó osztályt a details package-ben, ami az eddig látott adapterekhez hasonlóan azt határozza meg, hogy milyen elemek jelenjenek meg a hozzájuk tartozó nézeten (jelen esetben az elemek Fragment-ek lesznek):

class DetailsPagerAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) {

    companion object {
        private const val NUM_PAGES: Int = 2
    }

    override fun createFragment(position: Int): Fragment {
        return when (position) {
            0 -> DetailsMainFragment()
            1 -> DetailsMoreFragment()
            else -> DetailsMainFragment()
        }
    }

    override fun getItemCount(): Int = NUM_PAGES
}

Implementáljuk a hiányzó Fragment-eket a hozzájuk tartozó nézetekkel együtt:

res/layout/fragment_details_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:id="@+id/tvMain"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        tools:text="Clear"/>

    <TextView
        android:id="@+id/tvDescription"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        tools:text="Clear sky"/>

    <ImageView
        android:id="@+id/ivIcon"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginTop="16dp"/>

</LinearLayout>

A details package-ben a DetailsMainFragment:

class DetailsMainFragment : Fragment() {

    private lateinit var binding: FragmentDetailsMainBinding
    private var weatherDataHolder: WeatherDataHolder? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        weatherDataHolder = if (activity is WeatherDataHolder) {
            activity as WeatherDataHolder?
        } else {
            throw RuntimeException(
                "Activity must implement WeatherDataHolder interface!"
            )
        }
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? {
        binding = FragmentDetailsMainBinding.inflate(LayoutInflater.from(context))
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        if (weatherDataHolder?.getWeatherData() != null) {
            displayWeatherData()
        }
    }

    private fun displayWeatherData() {
        val weather = weatherDataHolder?.getWeatherData()?.weather?.first()
        binding.tvMain.text = weather?.main
        binding.tvDescription.text = weather?.description

        Glide.with(this)
            .load("https://openweathermap.org/img/w/${weather?.icon}.png")
            .transition(DrawableTransitionOptions().crossFade())
            .into(binding.ivIcon)
    }
}

Figyeljük meg, hogy hogy használjuk a kódban a Glide libraryt!

Az OpenWeatherMap API-tól a képek lekérhetők a visszakapott adatok alapján, pl: https://openweathermap.org/img/w/10d.png

res/layout/fragment_details_more.xml:

<?xml version="1.0" encoding="utf-8"?>
<TableLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="16dp"
    android:stretchColumns="0">
    <TableRow>
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/temperature"/>
        <TextView
            android:id="@+id/tvTemperature"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            tools:text="25 °C"/>
    </TableRow>
    <TableRow>
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/min_temperature"/>
        <TextView
            android:id="@+id/tvMinTemp"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            tools:text="24 °C"/>
    </TableRow>
    <TableRow>
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/max_temperature"/>
        <TextView
            android:id="@+id/tvMaxTemp"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            tools:text="26 °C"/>
    </TableRow>
    <TableRow>
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/pressure"/>
        <TextView
            android:id="@+id/tvPressure"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            tools:text="100 Pa"/>
    </TableRow>
    <TableRow>
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/humidity"/>
        <TextView
            android:id="@+id/tvHumidity"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            tools:text="50 %"/>
    </TableRow>
</TableLayout>

A details package-ben a DetailsMoreFragment:

class DetailsMoreFragment : Fragment() {

    private lateinit var binding: FragmentDetailsMoreBinding
    private var weatherDataHolder: WeatherDataHolder? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        weatherDataHolder = if (activity is WeatherDataHolder) {
            activity as WeatherDataHolder?
        } else {
            throw RuntimeException("Activity must implement WeatherDataHolder interface!")
        }
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        binding = FragmentDetailsMoreBinding.inflate(LayoutInflater.from(context))
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        if (weatherDataHolder?.getWeatherData() != null) {
            showWeatherData()
        }
    }

    private fun showWeatherData() {
        val weatherData = weatherDataHolder!!.getWeatherData()
        binding.tvTemperature.text = weatherData?.main?.temp.toString()
        binding.tvMinTemp.text = weatherData?.main?.temp_min.toString()
        binding.tvMaxTemp.text = weatherData?.main?.temp_max.toString()
        binding.tvPressure.text = weatherData?.main?.pressure.toString()
        binding.tvHumidity.text = weatherData?.main?.humidity.toString()
    }
}

Figyeljük meg, hogyan ellenőrzi a DetailsMainFragment és a DetailsMoreFragment azt, hogy az Activity implementálja-e a WeatherDataHolder interfészt. Fontos, hogy ezt a két Fragment majd csak azután kerül a DetailsActivity-re a ViewPager-en keresztül, amikor az adatokat lekérő hálózati kérés már adott vissza eredményt.

Ideiglenesen a DetailsActivity onResume() függvénye legyen az alábbi:

override fun onResume() {
    super.onResume()
    val detailsPagerAdapter = DetailsPagerAdapter(this)
    binding.mainViewPager.adapter = detailsPagerAdapter

    TabLayoutMediator(binding.tabLayout, binding.mainViewPager) { tab, position ->
        tab.text = when(position) {
            0 -> getString(R.string.main)
            1 -> getString(R.string.details)
            else -> ""
        }
    }.attach()
}

Próbáljuk ki az alkalmazást, kattintsunk egy városra! jelenleg még nem jelennek meg valós adatok, mivel még nem kötöttük be a az adatok lekéréséért felelős hívást.

Hálózati hívás bekötése

Az időjárás adatok lekérdezésének bekötéséhez implementáljunk egy loadWeatherData() nevű függvényt a DetailsActivity-ben:

private fun loadWeatherData() {
    NetworkManager.getWeather(city)?.enqueue(object : Callback<WeatherData?> {
        override fun onResponse(
            call: Call<WeatherData?>,
            response: Response<WeatherData?>
        ) {
            Log.d(TAG, "onResponse: " + response.code())
            if (response.isSuccessful) {
                displayWeatherData(response.body())
            } else {
                Toast.makeText(this@DetailsActivity, "Error: " + response.message(), Toast.LENGTH_LONG).show()
            }
        }

        override fun onFailure(
            call: Call<WeatherData?>,
            throwable: Throwable
        ) {
            throwable.printStackTrace()
            Toast.makeText(this@DetailsActivity, "Network request error occured, check LOG", Toast.LENGTH_LONG).show()
        }
    })
}

Implementáljuk a hiányzó displayWeatherData(...) függvényt, ami sikeres API hívás esetén megjeleníti az eredményt:

private fun displayWeatherData(receivedWeatherData: WeatherData?) {
    weatherData = receivedWeatherData
    val detailsPagerAdapter = DetailsPagerAdapter(this)
    binding.mainViewPager.adapter = detailsPagerAdapter
}

A DetailsActivity onResume() függvényében hívjuk meg a loadWeatherData() függvényt:

override fun onResume() {
    super.onResume()
    ...
    loadWeatherData()
}

Futtassuk az alkalmazást és figyeljük meg a működését! Próbáljuk ki azt is, hogy mi történik akkor, ha megszakítjuk a futtató eszköz internet kapcsolatát és megpróbáljuk megnyitni a részletező nézetet!

BEADANDÓ (1 pont)

Készíts egy képernyőképet, amelyen látszanak az emulátoron a betöltött adatok, a DetailsActivity kódja, valamint a neptun kódod a kódban valahol kommentként. A képet a megoldásban a repository-ba f4.png néven töltsd föl.

A képernyőkép szükséges feltétele a pontszám megszerzésének.

5. Önálló feladat: város listából törlés megvalósítása (1 pont)

Valósítsuk meg a városok törlését a Remove gomb megnyomásának hatására.

BEADANDÓ (1 pont)

Készíts egy képernyőképet, amelyen látszik az emulátoron a városok listája CSAK Budapesttel, a törlés releváns kódrészlete, valamint a neptun kódod a kódban valahol kommentként. A képet a megoldásban a repository-ba f5.png néven töltsd föl.

A képernyőkép szükséges feltétele a pontszám megszerzésének.


2023-09-29 Szerzők