Kihagyás

Labor 05 - Rajzoló alkalmazás készítése

Bevezető

A labor során egy egyszerű rajzoló alkalmazás elkészítése a feladat. Az alkalmazással egy vászonra lehet vonalakat vagy pontokat rajzolni. Ezen kívül szükséges a rajzolt ábrát perzisztensen elmenteni, hogy az alkalmazás újraindítása után is visszatöltődjön.

Android Room

A labor során meg fogunk ismerkedni az SQLite könyvtárral, mellyel egy lokális SQL adatbázisban tudunk adatokat perszisztensen tárolni. A modern Android alapú fejlesztéseknél már általában a Room-ot használják, mely az SQLite-ra építve biztosít egy könnyen használható ORM réteget az Android életciklusokkal kombinálva. Fontosnak tartottuk viszont, hogy könnyen érthető legyen az anyag, ezért most csak az SQLite-os megoldást fogjuk vizsgálni.

IMSc

A laborfeladatok sikeres befejezése után az IMSc feladat-ot megoldva 2 IMSc pont szerezhető.

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.

A projekt előkészítése

A projekt létrehozása

Hozzunk létre egy Simple Drawer nevű projektet Android Studioban:

  1. Hozzunk létre egy új projektet, válasszuk az No Activity lehetőséget.
  2. A projekt neve legyen Simple Drawer, a kezdő package hu.bme.aut.android.simpledrawer, a mentési hely pedig a kicheckoutolt repository-n belül az SimpleDrawer mappa.
  3. Nyelvnek válasszuk a Kotlin-t.
  4. A minimum API szint legyen API24: Android 7.0.
  5. A Build configuration language Kotlin DSL legyen.

FILE PATH

A projekt a repository-ban lévő SimpleDrawer 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!

Adjunk a projekthez egy új Empty Views Activity -t. Activity name-nek adjuk meg, hogy DrawingActivity, és hagyjuk bepipálva azt, hogy generáljon layout fájlt, valamint pipáljuk be a Launcher Activity opciót. Ha ezekkel megvagyunk, akkor rányomhatunk a Finish-re.

Miután létrejött a projekt, töröljük ki a teszt package-eket, mert most nem lesz rá szükségünk.

A resource-ok hozzáadása

Először töltsük le az alkalmazás képeit tartalmazó tömörített fájlt, ami tartalmazza az összes képet, amire szükségünk lesz. A tartalmát másoljuk be az app/src/main/res mappába (ehhez segít, ha Android Studio-ban bal fent a szokásos Android nézetről a Project nézetre váltunk erre az időre).

Az alábbi, alkalmazáshoz szükséges string resource-okat másoljuk be a res/values/strings.xml fájlba:

<resources>
   <string name="app_name">Simple Drawer</string>

   <string name="style">Stílus</string>
   <string name="line">Vonal</string>
   <string name="point">Pont</string>

   <string name="are_you_sure_want_to_exit">Biztosan ki akarsz lépni?</string>
   <string name="ok">OK</string>
   <string name="cancel">Mégse</string>
</resources>

Álló layout kikényszerítése

Az alkalmazásunkban az egyszerűség kedvéért most csak az álló módot támogatjuk. Ehhez az AndroidManifest.xml-ben a DrawingActivity nyitótagjához kell hozzáadni egy sort a következő módon:

<activity
    android:name=".DrawingActivity"
    android:exported="true"
    android:screenOrientation="portrait">

A kezdő layout létrehozása (1 pont)

Első lépésként hozzunk létre egy új package-et az hu.bme.aut.android.simpledrawer-en belül, aminek adjuk a view nevet. Ebben hozzunk létre egy új osztályt, amit nevezzünk el DrawingView-nak, és származzon le a View osztályból (android.view.View).

Hozzuk létre a szükséges konstruktort ezen belül:

class DrawingView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

}

Miután létrehoztuk a DrawingView-t, nyissuk meg a res/layout/activity_drawing.xml-t, és hozzunk létre gyökérelemként egy ConstraintLayout-ot, azon belül alulra egy Toolbar-t rakjunk, fölé pedig a frissen létrehozott DrawingView-nkból helyezzünk el egy példányt fekete háttérrel. Végezetül a layoutnak így kell kinéznie:

<?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=".DrawingActivity">

    <hu.bme.aut.android.simpledrawer.view.DrawingView
        android:id="@+id/canvas"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@android:color/black"
        app:layout_constraintBottom_toTopOf="@+id/toolbar"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:background="?attr/colorPrimary"
        android:minHeight="?attr/actionBarSize"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

BEADANDÓ (1 pont)

Készíts egy képernyőképet, amelyen látszik az elkészült DrawingActivity (emulátoron, készüléket tükrözve vagy képernyőfelvétellel), egy ahhoz tartozó kódrészlet, 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.

Stílusválasztó (1 pont)

Miután létrehoztuk a rajzolás tulajdonságainak állításáért felelős Toolbar-t, hozzuk létre a menüt, amivel be lehet állítani, hogy pontot vagy vonalat rajzoljunk. Ehhez hozzunk létre egy új Android resource directory-t menu néven a res mappában, és Resource type-nak is válasszuk azt, hogy menu. Ezen belül hozzunk létre egy új Menu resource file-t menu_toolbar.xml néven. Ebben hozzunk létre az alábbi hierarchiát:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
        android:id="@+id/menu_style"
        android:icon="@drawable/ic_style"
        android:title="@string/style"
        app:showAsAction="ifRoom">
        <menu>
            <group android:checkableBehavior="single">
                <item
                    android:id="@+id/menu_style_line"
                    android:checked="true"
                    android:title="@string/line" />
                <item
                    android:id="@+id/menu_style_point"
                    android:checked="false"
                    android:title="@string/point" />
            </group>
        </menu>
    </item>
</menu>

Ezután kössük be a menüt, hogy megjelenjen a Toolbar-on. Ahhoz, hogy elérjük a létrehozott erőforrásokat kódból, view binding-ra lesz szükségünk. A modul szintű gradle file-ba fegyük fel a következő elemet. Ne felejtsünk el a Sync now gombra kattintani a módosítást követően.

android {
    ...
    buildFeatures {
        viewBinding = true
    }
}
Ezután hozzunk létre egy binding adattagot a DrawingActivity-n belül toolbarBinding néven és inicializáljuk az onCreate függvényben.

private lateinit var binding: ActivityDrawingBinding

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

Már csak annyi van hátra, hogy a DrawingActivity-ben felüldefiniáljuk az Activity onCreateOptionsMenu() és onOptionsItemSelected() függvényét az alábbi módon:

override fun onCreateOptionsMenu(menu: Menu): Boolean {
    val toolbarMenu: Menu = binding.toolbar.menu
    menuInflater.inflate(R.menu.menu_toolbar, toolbarMenu)
    for (i in 0 until toolbarMenu.size()) {
        val menuItem: MenuItem = toolbarMenu.getItem(i)
        menuItem.setOnMenuItemClickListener { item -> onOptionsItemSelected(item) }
        if (menuItem.hasSubMenu()) {
            val subMenu: SubMenu = menuItem.subMenu!!
            for (j in 0 until subMenu.size()) {
                subMenu.getItem(j)
                    .setOnMenuItemClickListener { item -> onOptionsItemSelected(item) }
            }
        }
    }
    return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
    return when (item.itemId) {
        R.id.menu_style_line -> {
            item.isChecked = true
            true
        }
        R.id.menu_style_point -> {
            item.isChecked = true
            true
        }
        else -> super.onOptionsItemSelected(item)
    }
}

BEADANDÓ (1 pont)

Készíts egy képernyőképet, amelyen látszik a elkészült menü kinyitva (emulátoron, készüléket tükrözve vagy képernyőfelvétellel), egy ahhoz tartozó kódrészlet, 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 DrawingView osztály implementálása (1 pont)

A modellek létrehozása

A rajzprogramunk, ahogy az már az előző feladatban is kiderült, kétféle rajzolási stílust fog támogatni. Nevezetesen a pont- és vonalrajzolást. Ahhoz, hogy a rajzolt alakzatokat el tudjuk tárolni szükségünk lesz két új típusra, modellre, amihez hozzunk létre egy új package-et a hu.bme.aut.android.simpledrawer-en belül model néven.

Ezen belül először hozzunk létre egy Point osztályt, ami értelemszerűen a pontokat fogja reprezentálni. Kétparaméteres konstruktort fogunk létrehozni, amihez alapértékeket rendelünk.

data class Point(
    var x: Float = 0F,
    var y: Float = 0F
)

Miután ezzel megvagyunk, hozzunk létre egy Line osztályt. Mivel egy vonalat a két végpontjának megadásával ki tudunk rajzoltatni, így elegendő két Point-ot tartalmaznia az osztálynak.

data class Line(
    var start: Point,
    var end: Point
)

A rajzolási stílus beállítása

Most, hogy megvannak a modelljeink el lehet kezdeni magának a rajzolás funkciójának fejlesztését. Ehhez a DrawingView osztályt fogjuk ténylegesen is elkészíteni. Először vegyünk fel az osztályon belül egy companion object-et, amiben a rajzolási stílus konstansait fogjuk meghatározni. Ehhez kapcsolódóan vegyünk fel egy új field-et az osztályunkba, amiben eltároljuk, hogy jelenleg milyen stílus van kiválasztva.

companion object {
        const val DRAWING_STYLE_LINE = 1
        const val DRAWING_STYLE_POINT = 2
}

var currentDrawingStyle = DRAWING_STYLE_LINE

Ha ezek megvannak, akkor egészítsük ki a DrawingActivity-ben a menükezelést, úgy, hogy a megfelelő függvények hívódjanak meg. Az onOptionsItemSelected() függvény megfelelő case ágában meg kell hívnunk a canvas-ra a setDrawingStyle() függvényt a megfelelő paraméterrel.

override fun onOptionsItemSelected(item: MenuItem): Boolean {
    return when (item.itemId) {
        R.id.menu_style_line -> {
            binding.canvas.currentDrawingStyle = DrawingView.DRAWING_STYLE_LINE
            item.isChecked = true
            true
        }
        R.id.menu_style_point -> {
            binding.canvas.currentDrawingStyle = DrawingView.DRAWING_STYLE_POINT
            item.isChecked = true
            true
        }
        else -> super.onOptionsItemSelected(item)
    }
}

Inicializálások

A rajzolási funkció megvalósításához fel kell vennünk néhány további field-et a DrawingView osztályban, amiket a konstruktorban inicializálnunk kell. A paint objektumhoz hozzáadjuk a lateinit kulcsszót, hogy elég legyen az init blokkban inicializálnunk. A Point osztály import-ja során használjuk a korábban definiált osztályunkat.

private lateinit var paint: Paint

private var startPoint: Point? = null

private var endPoint: Point? = null

var lines: MutableList<Line> = mutableListOf()
var points: MutableList<Point> = mutableListOf()

init {
    initPaint()
}

private fun initPaint() {
    paint = Paint()
    paint.color = Color.GREEN
    paint.style = Paint.Style.STROKE
    paint.strokeWidth = 5F
}

Gesztusok kezelése

Ahhoz, hogy vonalat vagy pontot tudjunk rajzolni a View-nkra, kezelnünk kell a felhasználótól kapott gesztusokat, mint például amikor hozzáér a kijelzőhöz, elhúzza rajta vagy felemeli róla az ujját. Szerencsére ezeket a gesztusokat nem szükséges manuálisan felismernünk és lekezelnünk, a View ősosztály onTouchEvent() függvényének felüldefiniálásával egyszerűen megolható a feladat.

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
    endPoint = Point(event.x, event.y)
    when (event.action) {
        MotionEvent.ACTION_DOWN -> startPoint = Point(event.x, event.y)
        MotionEvent.ACTION_MOVE -> {
        }
        MotionEvent.ACTION_UP -> {
            when (currentDrawingStyle) {
                DRAWING_STYLE_POINT -> addPointToTheList(endPoint!!)
                DRAWING_STYLE_LINE -> addLineToTheList(startPoint!!, endPoint!!)
            }
            startPoint = null
            endPoint = null
        }
        else -> return false
    }
    invalidate()
    return true
}

private fun addPointToTheList(startPoint: Point) {
    points.add(startPoint)
}

private fun addLineToTheList(startPoint: Point, endPoint: Point) {
    lines.add(Line(startPoint, endPoint))
}

Ahogy a fenti kódrészletből is látszik minden gesztusnál elmentjük az adott TouchEvent pontját, mint a rajzolás végpontját, illetve ha MotionEvent.ACTION_DOWN történt, tehát a felhasználó hozzáért a View-hoz, elmentjük ezt kezdőpontként is. Amíg a felhasználó mozgatja az ujját a View-n (MotionEvent.ACTION_MOVE), addig nem csinálunk semmit, de amint felemeli (MotionEvent.ACTION_UP), elmentjük az adott elemet a korábban már definiált listákba. Ezen kívül minden egyes alkalommal meghívjuk az invalidate() függvényt, ami kikényszeríti a View újrarajzolását.

A rajzolás

A rajzolás megvalósításához a View ősosztály onDraw() metódusát kell felüldefiniálnunk. Egyrészt ki kell rajzolnunk a már meglévő objektumokat (amiket a MotionEvent.ACTION_UP eseménynél beleraktunk a listába), valamint ki kell rajzolnunk az aktuális kezdőpont (a MotionEvent.ACTION_DOWN eseménytől) és a felhasználó ujja közötti vonalat.

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    for (point in points) {
        drawPoint(canvas, point)
    }
    for (line in lines) {
        drawLine(canvas, line.start, line.end)
    }
    when (currentDrawingStyle) {
        DRAWING_STYLE_POINT -> drawPoint(canvas, endPoint)
        DRAWING_STYLE_LINE -> drawLine(canvas, startPoint, endPoint)
    }
}

private fun drawPoint(canvas: Canvas, point: Point?) {
    if (point == null) {
        return
    }
    canvas.drawPoint(point.x, point.y, paint)
}

private fun drawLine(canvas: Canvas, startPoint: Point?, endPoint: Point?) {
    if (startPoint == null || endPoint == null) {
        return
    }
    canvas.drawLine(
        startPoint.x,
        startPoint.y,
        endPoint.x,
        endPoint.y,
        paint
    )
}

BEADANDÓ (1 pont)

Készíts egy képernyőképet, amelyen látszik az elkészült kirajzolás (emulátoron, készüléket tükrözve vagy képernyőfelvétellel), egy ahhoz tartozó kódrészlet, 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.

Perzisztencia megvalósítása SQLite adatbázis segítségével (1 pont)

Ahhoz, hogy az általunk rajzolt objektumok megmaradjanak az alkalmazásból való kilépés után is, az adatainkat valahogy olyan formába kell rendeznünk, hogy azt könnyedén el tudjuk tárolni egy SQLite adatbázisban.

Hozzunk létre egy új package-et az hu.bme.aut.android.simpledrawer-en belül, aminek adjuk az sqlite nevet.

Táblák definiálása

Az adatbáziskezelés során sok konstans jellegű változóval kell dolgoznunk, mint például a táblákban lévő oszlopok nevei, táblák neve, adatbázis fájl neve, séma létrehozó és törlő szkiptek, stb. Ezeket érdemes egy közös helyen tárolni, így szerkesztéskor vagy új entitás bevezetésekor nem kell a forrásfájlok között ugrálni, valamint egyszerűbb a teljes adatbázist létrehozó és törlő szkripteket generálni. Hozzunk létre egy új singleton osztályt az object kulcsszóval az sqlite package-en belül DbConstants néven.

Ezen belül először is konstansként felvesszük az adatbázis nevét és verzióját is. Ha az adatbázisunk sémáján szeretnénk változtatni, akkor ez utóbbit kell inkrementálnunk, így elkerülhetjük az inkompatibilitás miatti nem kívánatos hibákat.

object DbConstants {

    const val DATABASE_NAME = "simpledrawer.db"
    const val DATABASE_VERSION = 1
}

Ezek után a DbConstants nevű osztályba hozzuk létre a Point osztályhoz a konstansokat. Az osztályokon belül létrehozunk egy enum-ot is, hogy könnyebben tudjuk kezelni a tábla oszlopait, majd konstansokban eltároljuk a tábla létrehozását szolgáló SQL utasítást valamint a tábla nevét is. Végezetül elkészítjük azokat a függvényeket, amelyeket a tábla létrehozásakor, illetve upgrade-elésekor kell meghívni:

object DbConstants {

    const val DATABASE_NAME = "simpledrawer.db"
    const val DATABASE_VERSION = 1

    object Points {
        const val DATABASE_TABLE = "points"

        enum class Columns {
            ID, COORD_X, COORD_Y
        }

        private val DATABASE_CREATE = """create table if not exists $DATABASE_TABLE (
            ${Columns.ID.name} integer primary key autoincrement,
            ${Columns.COORD_X.name} real not null,
            ${Columns.COORD_Y} real not null
            );"""

        private const val DATABASE_DROP = "drop table if exists $DATABASE_TABLE;"

        fun onCreate(database: SQLiteDatabase) {
            database.execSQL(DATABASE_CREATE)
        }

        fun onUpgrade(database: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
            Log.w(
                Points::class.java.name,
                "Upgrading from version $oldVersion to $newVersion"
            )
            database.execSQL(DATABASE_DROP)
            onCreate(database)
        }
    }
}

Figyeljük meg, hogy a DbConstants osztályon belül létrehoztunk egy belső Points nevű osztályt, amiben a Points entitásokat tároló táblához tartozó konstans értékeket tároljuk. Amennyiben az alkalmazásunk több entitást is adatbázisban tárol, akkor érdemes az egyes osztályokhoz tartozó konstansokat külön-külön belső osztályokban tárolni. Így sokkal átláthatóbb és karbantarthatóbb lesz a kód, mint ha ömlesztve felvennénk a DbConstants-ba az összes tábla összes konstansát. Ezek a belső osztályok praktikusan ugyanolyan névvel léteznek, mint az entitás osztályok. Vegyük tehát fel hasonló módon a Lines nevű osztályt is:

object Lines {
    const val DATABASE_TABLE = "lines"

    enum class Columns {
        ID, START_X, START_Y, END_X, END_Y
    }

    private val DATABASE_CREATE ="""create table if not exists $DATABASE_TABLE (
    ${Columns.ID.name} integer primary key autoincrement,
    ${Columns.START_X} real not null,
    ${Columns.START_Y} real not null,
    ${Columns.END_X} real not null,
    ${Columns.END_Y} real not null

    );"""

    private const val DATABASE_DROP = "drop table if exists $DATABASE_TABLE;"

    fun onCreate(database: SQLiteDatabase) {
        database.execSQL(DATABASE_CREATE)
    }

    fun onUpgrade(database: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        Log.w(
            Lines::class.java.name,
            "Upgrading from version $oldVersion to $newVersion"
        )
        database.execSQL(DATABASE_DROP)
        onCreate(database)
    }
}

Érdemes megfigyelni továbbá azt is, hogy az osztályokat nem a class kulcsszóval deklaráltuk. Helyette az object-et használjuk, amivel a Kotlin nyelv azt biztosítja számunkra, hogy a DbConstants és a benne lévő Points és Lines osztály is singletonként viselkednek, azaz az alkalmazás futtatásakor létrejön belőlük egy példány, további példányokat pedig nem lehet létrehozni belőlük.

A segédosztályok létrehozása

Az adatbázis létrehozásához szükség van egy olyan segédosztályra, ami létrehozza magát az adatbázist, és azon belül inicializálja a táblákat is. Esetünkben ez lesz a DbHelper osztály, ami az SQLiteOpenHelper osztályból származik. Vegyük fel ezt is az sqlite package-be.

class DbHelper(context: Context) :
    SQLiteOpenHelper(context, DbConstants.DATABASE_NAME, null, DbConstants.DATABASE_VERSION) {

    override fun onCreate(sqLiteDatabase: SQLiteDatabase) {
        DbConstants.Lines.onCreate(sqLiteDatabase)
        DbConstants.Points.onCreate(sqLiteDatabase)
    }

    override fun onUpgrade(
        sqLiteDatabase: SQLiteDatabase,
        oldVersion: Int,
        newVersion: Int
    ) {
        DbConstants.Lines.onUpgrade(sqLiteDatabase, oldVersion, newVersion)
        DbConstants.Points.onUpgrade(sqLiteDatabase, oldVersion, newVersion)
    }
}

Ezen kívül szükségünk van még egy olyan segédosztályra is, ami ezt az egészet összefogja, és amivel egyszerűen tudjuk kezelni az adatbázisunkat. Ez lesz a PersistentDataHelper továbbra is az sqlite package-ben. Ebben olyan függényeket fogunk megvalósítani, mint pl. az open() és a close(), amikkel az adatbáziskapcsolatot nyithatjuk meg, illetve zárhatjuk le. Ezen kívül ebben az osztályban valósítjuk meg azokat a függvényeket is, amik az adatok adatbázisba való kiírásáért, illetve az onnan való kiolvasásáért felelősek. Figyeljünk rá, hogy a saját Point osztályunkat válasszuk az import során.

class PersistentDataHelper(context: Context) {
    private var database: SQLiteDatabase? = null
    private val dbHelper: DbHelper = DbHelper(context)

    private val pointColumns = arrayOf(
        DbConstants.Points.Columns.ID.name,
        DbConstants.Points.Columns.COORD_X.name,
        DbConstants.Points.Columns.COORD_Y.name
    )

    private val lineColumns = arrayOf(
        DbConstants.Lines.Columns.ID.name,
        DbConstants.Lines.Columns.START_X.name,
        DbConstants.Lines.Columns.START_Y.name,
        DbConstants.Lines.Columns.END_X.name,
        DbConstants.Lines.Columns.END_Y.name

    )

    @Throws(SQLiteException::class)
    fun open() {
        database = dbHelper.writableDatabase
    }

    fun close() {
        dbHelper.close()
    }

    fun persistPoints(points: List<Point>) {
        clearPoints()
        for (point in points) {
            val values = ContentValues()
            values.put(DbConstants.Points.Columns.COORD_X.name, point.x)
            values.put(DbConstants.Points.Columns.COORD_Y.name, point.y)
            database!!.insert(DbConstants.Points.DATABASE_TABLE, null, values)
        }
    }

    fun restorePoints(): MutableList<Point> {
        val points: MutableList<Point> = ArrayList()
        val cursor: Cursor =
            database!!.query(DbConstants.Points.DATABASE_TABLE, pointColumns, null, null, null, null, null)
        cursor.moveToFirst()
        while (!cursor.isAfterLast) {
            val point: Point = cursorToPoint(cursor)
            points.add(point)
            cursor.moveToNext()
        }
        cursor.close()
        return points
    }

    fun clearPoints() {
        database!!.delete(DbConstants.Points.DATABASE_TABLE, null, null)
    }

    private fun cursorToPoint(cursor: Cursor): Point {
        val point = Point()
        point.x =cursor.getFloat(DbConstants.Points.Columns.COORD_X.ordinal)
        point.y =cursor.getFloat(DbConstants.Points.Columns.COORD_Y.ordinal)
        return point
    }

    fun persistLines(lines: List<Line>) {
        clearLines()
        for (line in lines) {
            val values = ContentValues()
            values.put(DbConstants.Lines.Columns.START_X.name, line.start.x)
            values.put(DbConstants.Lines.Columns.START_Y.name, line.start.y)
            values.put(DbConstants.Lines.Columns.END_X.name, line.end.x)
            values.put(DbConstants.Lines.Columns.END_Y.name, line.end.y)
            database!!.insert(DbConstants.Lines.DATABASE_TABLE, null, values)
        }
    }

    fun restoreLines(): MutableList<Line> {
        val lines: MutableList<Line> = ArrayList()
        val cursor: Cursor =
            database!!.query(DbConstants.Lines.DATABASE_TABLE, lineColumns, null, null, null, null, null)
        cursor.moveToFirst()
        while (!cursor.isAfterLast) {
            val line: Line = cursorToLine(cursor)
            lines.add(line)
            cursor.moveToNext()
        }
        cursor.close()
        return lines
    }

    fun clearLines() {
        database!!.delete(DbConstants.Lines.DATABASE_TABLE, null, null)
    }

    private fun cursorToLine(cursor: Cursor): Line {
        val startPoint = Point(
            cursor.getFloat(DbConstants.Lines.Columns.START_X.ordinal),
            cursor.getFloat(DbConstants.Lines.Columns.START_Y.ordinal)
        )
        val endPoint = Point(
            cursor.getFloat(DbConstants.Lines.Columns.END_X.ordinal),
            cursor.getFloat(DbConstants.Lines.Columns.END_Y.ordinal)
        )
        return Line(startPoint, endPoint)
    }

}

A DrawingView kiegészítése

Ahhoz, hogy a rajzolt objektumainkat el tudjuk menteni az adatbázisba, fel kell készíteni a DrawingView osztályunkat arra, hogy átadja, illetve meg lehessen adni neki kívülről is őket. Ehhez a következő függvényeket kell felvennünk:

fun restoreObjects(points: MutableList<Point>?, lines: MutableList<Line>?) {
    points?.also { this.points = it }
    lines?.also { this.lines = it }
    invalidate()
}

A DrawingActivity kiegészítése

A perzisztencia megvalósításához már csak egy feladatunk maradt hátra, mégpedig az, hogy bekössük a frissen létrehozott osztályainkat a DrawingActivity-nkbe. Ehhez először is példányosítanunk kell a PersistentDataHelper osztályunkat. Mivel az adatbázishozzáférés drága erőforrás, ezért ne felejtsük el az Activity onResume() függvényében megnyitni, az onPause() függvényében pedig lezárni a vele való kapcsolatot:

private lateinit var dataHelper: PersistentDataHelper

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

    dataHelper = PersistentDataHelper(this)
    dataHelper.open()
    restorePersistedObjects()
}

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

override fun onPause() {
    dataHelper.close()
    super.onPause()
}

private fun restorePersistedObjects() {
    binding.canvas.restoreObjects(dataHelper.restorePoints(), dataHelper.restoreLines())
}

Végezetül szeretnénk, hogy amikor a felhasználó ki szeretne lépni az alkalmazásból, akkor egy dialógusablak jelenjen meg, hogy biztos kilép-e, és ha igen, csak abban az esetben mentsük el a rajzolt objektumokat, és lépjünk ki az alkalmazásból. Ehhez felül kell definiálnunk az Activity onBackPressed() függvényét. Az AlertDialog-nál válasszuk az androidx.appcompat.app-ba tartozó verziót.

override fun onBackPressed() {
    AlertDialog.Builder(this)
        .setMessage(R.string.are_you_sure_want_to_exit)
        .setPositiveButton(R.string.ok) { _, _ -> onExit() }
        .setNegativeButton(R.string.cancel, null)
        .show()
}

private fun onExit() {
    dataHelper.persistPoints(binding.canvas.points)
    dataHelper.persistLines(binding.canvas.lines)
    dataHelper.close()
    finish()
}

BEADANDÓ (1 pont)

Készíts egy képernyőképet, amelyen látszik a kilépő dialógus (emulátoron, készüléket tükrözve vagy képernyőfelvétellel), egy a perzisztens mentéshez tartozó kódrészlet, 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.

Önálló feladat: A vászon törlése (1 pont)

Vegyünk fel a vezérlők közé egy olyan gombot, amelynek segíségével a törölhetjük a vásznat, valósítsuk is meg a funkciót!

BEADANDÓ (1 pont)

Készíts egy képernyőképet, amelyen látszik a törlés gomb (emulátoron, készüléket tükrözve vagy képernyőfelvétellel), a törlést elvégző kódrészlet, 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.

Kiegészítő iMSc feladat (2 iMSc pont)

Vegyünk fel az alkalmazásba egy olyan vezérlőt, amivel változtatni lehet a rajzolás színét a 3 fő szín között (RGB).

Figyelem: az adatbázisban is el kell menteni az adott objektum színét!

BEADANDÓ (1 iMSc pont)

Készíts egy képernyőképet, amelyen látszik a rajzoló oldal a különböző színekkel (emulátoron, készüléket tükrözve vagy képernyőfelvétellel), egy ahhoz tartozó kódrészlet, valamint a neptun kódod a kódban valahol kommentként. A képet a megoldásban a repository-ba f6.png néven töltsd föl.

BEADANDÓ (1 iMSc pont)

Készíts egy képernyőképet, amelyen látszik a különböző színek mentését végző kódrészletet, valamint a neptun kódod a kódban valahol kommentként. A képet a megoldásban a repository-ba f7.png néven töltsd föl.


2023-11-23 Szerzők