Miniprojekti: Kyselysovellus
Learning objectives
- Kertaat materiaalia ja tutustut hieman isomman mobiilisovelluksen rakentamiseen.
Tässä osassa toteutat ohjeita noudattaen kysymysten näyttämiseen tarkoitetun mobiilisovelluksen. Mobiilisovellus käyttää verkossa olevaa JSON-muotoista dataa tarjoavaa rajapintaa.
Sovellukselle rakennettava toiminnallisuus on seuraava:
Sovelluksessa on kaksi näkymää, aloitusnäkymä ja kyselynäkymä.
Aloitusnäkymässä käyttäjää tervehditään: mikäli käyttäjä ei ole ennen käyttänyt sovellusta, hänelle näytetään teksti "Tervetuloa!", muulloin hänelle näytetään teksti "Heippa taas!". Aloitusnäkymässä on lisäksi nappi, jossa on teksti "Kysy kysymys!". Nappia painamalla pääsee kyselynäkymään.
Kyselynäkymässä käyttäjälle näytetään kysymys sekä vastausvaihtoehdot. Kun käyttäjä painaa jotain vastausvaihtoehdoista, sovellus tarkistaa onko vastaus oikein. Mikäli vastaus on oikein, käyttäjälle näytetään teksti "Oikein meni!". Mikäli vastaus on väärin, käyttäjälle näytetään teksti "Väärin meni, hups!". Kummassakin tapauksessa käyttäjälle näytetään lisäksi nappi, jossa on teksti "Seuraava!".
Kun käyttäjä painaa nappia "Seuraava!", käyttäjälle näytetään uusi kysymys.
Materiaalin lopussa oleva tehtävänanto pyytää tekemään materiaalissa olevan esimerkin. Voit joko lukea esimerkin ennen tehtävän tekemistä, tai hakea tehtäväpohjan jo nyt, ja aloittaa tehtävän tekemisen saman tien.
Rajapinnan kuvaus
Kysymysten hakemiseen käytetty rajapinta sijaitsee osoitteessa https://fitech-api.deno.dev/quiz. Rajapinnalla on seuraavat polut ja toiminnallisuudet:
- GET-pyyntö polkuun
/users/new
luo uuden käyttäjätunnuksen ja palauttaa sen JSON-muotoisena dokumenttina. Dokumentin muoto on seuraava, missä luotu tunnus on rajapinnan käyttäjää varten luoma tunnus.
{
"userId": "luotu tunnus"
}
- GET-pyyntö polkuun
/points
hakee rajapinnasta käyttäjätunnukseen liittyvät pisteet. Pyynnön parametreissa tulee lähettää aiemmin saatu käyttäjätunnususerId
. Pyyntö tehdään osoitteeseenhttps://fitech-api.deno.dev/quiz/points?userId=tunnus
, missä tunnus on aiemmin palvelimelta saatu käyttäjätunnus. Vastaus on seuraavan muotoinen -- alla pisteitä on 3:
{
"points": 3
}
- GET-pyyntö polkuun
/questions
hakee rajapinnasta uuden kysymyksen. Pyynnön parametrinauserId
tulee lähettää aiemmin saatu käyttäjätunnus. Pyyntö tehdään osoitteeseenhttps://fitech-api.deno.dev/quiz/questions?userId=tunnus
, missä tunnus on aiemmin palvelimelta saatu käyttäjätunnus. Vastaus on seuraavan muotoinen -- alla kysymyksellä on neljä vastausvaihtoehtoa.
{
"questionId":"kysymyksen tunnus",
"questionText":"How much is 2-8",
"answerOptions":[
{"answerOptionId":"vastauksen 1 tunnus","answerText":"10"},
{"answerOptionId":"vastauksen 2 tunnus","answerText":"2"},
{"answerOptionId":"vastauksen 3 tunnus","answerText":"8"},
{"answerOptionId":"vastauksen 4 tunnus","answerText":"-6"}
]
}
- POST-pyyntö polkuu
/answers
lähettää rajapintaan vastauksen. Vastauksen tulee sisältää kysymyksen tunnus, vastauksen tunnus, sekä käyttäjätunnus. Pyyntö saa vastauksena tiedon siitä, oliko valittu vastaus oikein vai ei. Pyynnön muoto on seuraavanlainen.
{
"questionId":"kysymyksen tunnus",
"answerOptionId":"valitun vastauksen tunnus",
"userId":"käyttäjän tunnus"
}
Vastauksen muoto on seuraava -- vastaus voi toki olla myös väärin.
{
"correct":true
}
Sovelluksen kansiorakenne ja hahmotelma
Sovellusta rakennettaessa on mielekästä luoda hahmotelma sovelluksen toiminnasta sekä minimaalinen toimiva sovellus, jonka päälle toiminnallisuutta aletaan rakentamaan. Samalla, mikäli tiedetään, että sovellus tulee sisältämään useita luokkia, on mielekästä päättää sovellukselle jonkinlainen kansiorakenne.
Käytämme tässä seuraavaa kansiorakennetta -- nämä kansiot ovat projektin lib
-kansion sisällä:
- Kansio
api
sisältää sovelluksen käyttämät ohjelmointirajapinnat kapseloivat luokat. - Kansio
components
sisältää sovelluksessa käytetyt yksittäiset käyttöliittymäkomponentit. - Kansio
storage
sisältää laitteeseen tallennettavia tietoja käsitteleviä luokkia. - Kansio
views
sisältää sovelluksessa käytetyt näkymät.
Sovelluksen käynnistämiseen käytetty main.dart
on sovelluksen lib
-kansiossa.
Kansiorakenteesta
Edellä kuvattu kansiorakenne on eräs mahdollinen kansiorakenne mobiilisovellusta ajatellen. Muutkin kansiorakenteet ovat hyviä -- oleellista on se, että käytetyt kansiot kuvaavat niiden sisällä olevia tiedostoja. Pitäydymme tässä esimerkissä edellä kuvatussa kansiorakenteessa.
Aloitetaan luomalla minimaalinen toimiva sovellus, joka sisältää kaksi näkymää sekä niiden käynnistämiseen käytetän main.dart
-tiedoston. Näkymät ovat aloitusnäkymä (aloitusnakyma.dart
) ja kyselynäkymä (kyselynakyma.dart
), jotka molemmat asetetaan kansioon views
.
Sovelluksen ensimmäisessä versiossa aloitusnäkymä sisältää napin, jolla voi siirtyä kyselynäkymään. Alla on luokan Aloitusnakyma
lähdekoodi, joka sijaitsee tiedostossa aloitusnakyma.dart
.
import 'package:flutter/material.dart';
class Aloitusnakyma extends StatelessWidget {
Widget build(BuildContext context) {
final nappi = ElevatedButton(
child: Text('Kysy kysymys'),
onPressed: () => Navigator.pushNamed(context, '/kysely')
);
return Scaffold(body: nappi);
}
}
Kyselynäkymä sisältää tekstin Hei maailma!
. Ensimmäisessä versiossa kyselynäkymä on tilaton. Alla on luokan Kyselynakyma
lähdekoodi, joka asetetaan tiedostoon kyselynakyma.dart
.
import 'package:flutter/material.dart';
class Kyselynakyma extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(body: Text('Hei maailma!'));
}
}
Sovelluksen käynnistämiseen tarkoitettu tiedosto main.dart
määrittelee sovellukselle polut sekä poluissa näytetyt näkymät. Alla on main.dart
-tiedoston lähdekoodi.
import 'package:flutter/material.dart';
import 'views/aloitusnakyma.dart';
import 'views/kyselynakyma.dart';
main() {
final sovellus = MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => Aloitusnakyma(),
'/kysely': (context) => Kyselynakyma()
},
);
runApp(sovellus);
}
Nyt, kun sovelluksen käynnistää, käyttäjä näkee "Kysy kysymys"-napin, jota painettaessa sovelluksessa siirrytään kyselynäkymään.
Aloitusnäkymä ja aiempi käyttö
Käyttäjää tulee tervehtiä aloitusnäkymässä kahdella eri tapaa. Kun käyttäjä tulee sovellukseen ensimmäistä kertaa, hänelle tulee näyttää teksti "Tervetuloa!". Muulloin hänelle tulee näyttää teksti "Heippa taas!".
Tieto siitä, onko käyttäjä tulossa sovellukseen ensimmäistä kertaa, voidaan säilöä SharedPreferences
-luokan avulla. Luodaan kansioon storage
tiedosto kayttajatiedot.dart
, joka kapseloi käyttäjätietojen käsittelyyn tarkoitetun logiikan. Tässä kohtaa olemme kiinnostuneita vain siitä, onko käyttäjä käyttänyt sovellusta aiemmin.
Tämä voidaan toteuttaa siten, että SharedPreferences
-oliosta haetaan käyttäjän aiempaa käyntiä ilmaisevaa avainta. Mikäli avainta ei ole, käyttäjä ei ole käyttänyt sovellusta aiemmin. Avaimelle asetetaan arvo tarkastelun yhteydessä.
Luodaan tiedostoon kayttajatiedot.dart
luokka Kayttajatiedot
, joka tarjoaa asynkronisen metodin tuttuKayttaja
. Metodi palauttaa arvon true
mikäli käyttäjä on tuttu, muulloin metodi palauttaa arvon false
. Luokan toteutus on seuraavanlainen.
import 'package:shared_preferences/shared_preferences.dart';
class Kayttajatiedot {
tuttuKayttaja() async {
SharedPreferences asetukset = await SharedPreferences.getInstance();
var tuttuKayttaja = asetukset.containsKey('TUTTU_KAYTTAJA');
asetukset.setString('TUTTU_KAYTTAJA', 'KYLLA');
return tuttuKayttaja;
}
}
Käyttäjätietojen käyttö osana aloitusnäkymää onnistuu tuomalla luokka aloitusnäkymän käyttöön. Koska metodi tuttuKayttaja
on asynkroninen, käytämme FutureBuilder
-luokkaa aloitusnäkymässä näytetyn sisällön valintaan. Alla on kuvattuna toiminnallisuus tekstin näyttämiseen.
final teksti = FutureBuilder(
future: Kayttajatiedot().tuttuKayttaja(),
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
if (snapshot.data) {
return Text('Heippa taas!');
} else {
return Text('Tervetuloa!');
}
} else if (snapshot.hasError) {
return Text('Virhe käyttäjätietojen hakemisessa.');
} else {
return Text('Haetaan käyttäjätietoja..');
}
}
);
Kun yhdistämme edellä kuvatun FutureBuilder
-olion aloitusnäkymään, on aloitusnäkymä seuraavanlainen.
import 'package:flutter/material.dart';
import '../storage/kayttajatiedot.dart';
class Aloitusnakyma extends StatelessWidget {
Widget build(BuildContext context) {
final teksti = FutureBuilder(
future: Kayttajatiedot().tuttuKayttaja(),
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
if (snapshot.data) {
return Text('Heippa taas!');
} else {
return Text('Tervetuloa!');
}
} else if (snapshot.hasError) {
return Text('Virhe käyttäjätietojen hakemisessa.');
} else {
return Text('Haetaan käyttäjätietoja..');
}
}
);
final nappi = ElevatedButton(
child: Text('Kysy kysymys'),
onPressed: () => Navigator.pushNamed(context, '/kysely')
);
final sarake = Column(children: [
teksti,
nappi
]);
return Scaffold(body: sarake);
}
}
Kun tarkastelemme aloitusnäkymää, huomaamme että sen toiminta vaikuttaa melko monimutkaiselta. Tässä kohtaa on hyvä kohta refaktoroinnille, eli ojelman sisäisen rakenteen muuttamiselle siten, että ohjelman toiminnallisuus säilyy samana. Refaktorointi voi edesauttaa esimerkiksi ohjelmakoodin luettavuutta.
Eriytetään ohjelmassa oleva tervehdyksen näyttäminen omaksi komponentikseen. Luodaan kansioon components
tiedosto tervehdysteksti.dart
ja siirretään aloitusnäkymässä ollut tervehdystekstin luominen kyseiseen tiedostoon. Kutsutaan tervehdystekstin näyttämiseen tarkoitettua komponenttia nimellä Tervehdysteksti
.
import 'package:flutter/material.dart';
import '../storage/kayttajatiedot.dart';
class Tervehdysteksti extends StatelessWidget {
Widget build(BuildContext context) {
return FutureBuilder(
future: Kayttajatiedot().tuttuKayttaja(),
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
if (snapshot.data) {
return Text('Heippa taas!');
} else {
return Text('Tervetuloa!');
}
} else if (snapshot.hasError) {
return Text('Virhe käyttäjätietojen hakemisessa.');
} else {
return Text('Haetaan käyttäjätietoja..');
}
}
);
}
}
Nyt voimme muokata aloitusnäkymästä hieman selkeämmän. Sen sijaan, että tervehdystekstin näyttämiseen käytettävä logiikka on osa aloitusnäkymää, on logiikka eriytetty omaksi komponentikseen, jota käyttöliittymässä käytetään. Tämän muokkauksen jälkeen aloitusnäkymän lähdekoodi on seuraava.
import 'package:flutter/material.dart';
import '../components/tervehdysteksti.dart';
import '../storage/kayttajatiedot.dart';
class Aloitusnakyma extends StatelessWidget {
Widget build(BuildContext context) {
final teksti = Tervehdysteksti();
final nappi = ElevatedButton(
child: Text('Kysy kysymys'),
onPressed: () => Navigator.pushNamed(context, '/kysely')
);
final sarake = Column(children: [
teksti,
nappi
]);
return Scaffold(body: sarake);
}
}
Nyt sovelluksessa näkyy teksti "Tervetuloa!" mikäli käyttäjä käyttää sovellusta ensimmäistä kertaa. Muulloin käyttäjälle näytetään teksti "Heippa taas!".
Käyttäjätunnuksen ja kysymyksen hakeminen
Tarkastellaan seuraavaksi kysymyksen hakemista ja näyttämistä. Kysymyksen hakemista varten tarvitaan käyttäjätunnus, joka saadaan tekemällä pyyntö osoitteeseen https://fitech-api.deno.dev/quiz/users/new. Kysymys taas saadaan lähettämällä käyttäjätunnus osana pyynnön parametreja osoitteeseen https://fitech-api.deno.dev/quiz/questions?userId=tunnus, missä tunnus on aiemmin saatu tunnus.
Muokataan ensin tiedostoa kayttajatiedot.dart
siten, että se tarjoaa metodit tunnuksen olemassaolon tarkasteluun, tunnuksen asettamiseen, ja tunnuksen hakemiseen. Käytämme avainta KAYTTAJATUNNUS
käyttäjätunnuksen säilömiseen. Muokkauksen jälkeen luokka näyttää seuraavalta.
import 'package:shared_preferences/shared_preferences.dart';
class Kayttajatiedot {
tuttuKayttaja() async {
SharedPreferences asetukset = await SharedPreferences.getInstance();
bool tuttuKayttaja = asetukset.containsKey('TUTTU_KAYTTAJA');
asetukset.setString('TUTTU_KAYTTAJA', 'KYLLA');
return tuttuKayttaja;
}
onTunnus() async {
SharedPreferences asetukset = await SharedPreferences.getInstance();
return asetukset.containsKey('KAYTTAJATUNNUS');
}
asetaTunnus(tunnus) async {
SharedPreferences asetukset = await SharedPreferences.getInstance();
asetukset.setString('KAYTTAJATUNNUS', '$tunnus');
}
haeTunnus() async {
SharedPreferences asetukset = await SharedPreferences.getInstance();
return asetukset.getString('KAYTTAJATUNNUS');
}
}
Toteutetaan seuraavaksi ensimmäinen versio kyselyjen hakemiseen käytetystä rajapinnasta. Kyselyjen hakemiseen käytetty rajapinta hyödyntää edellä kuvattua luokkaa Kayttajatiedot
käyttäjätunnuksen säilömiseen ja hakemiseen. Toisaalta, mikäli käyttäjätunnusta ei ole, hakee rajapinta uuden käyttäjätunnuksen ja asettaa sen käyttäjätietoihin.
Käyttäjätunnuksen kysyminen
Sovelluksessamme käyttäjätunnus saadaan rajapinnasta. Mikäli sovelluksessa olisi esimerkiksi kirjautumisnäkymä, annettaisiin käyttäjätunnus luonnollisesti osana tätä näkymää. Tällaisessakin tilanteessa, erillinen luokka käyttäjätietojen säilömiseen ja hakemiseen on hyödyllinen.
Luodaan kyselyrajapintaa käsittelevää toiminnallisuutta varten tiedosto kysely_api.dart
, joka asetetaan kansioon api
. Luodaan tiedostoon luokka KyselyApi
, joka tarjoaa metodit luoTunnus
ja haeKysymys
. Metodi luoTunnus
tekee rajapintaan kyselyn ja palauttaa rajapinnan palauttaman tunnuksen. Metodi haeKysymys
taas hakee rajapinnasta uuden kysymyksen ja palauttaa sen -- kysymystä haettaessa palvelimelle lähetetään myös k äyttäjätunnus.
import 'dart:convert';
import 'package:http/http.dart';
import '../storage/kayttajatiedot.dart';
class KyselyApi {
luoTunnus() async {
var response = await get(
Uri.parse('https://fitech-api.deno.dev/quiz/users/new')
);
var sanakirja = jsonDecode(response.body);
return sanakirja['userId'];
}
haeKysymys() async {
var tunnus = await haeTunnus();
var response = await get(
Uri.parse('https://fitech-api.deno.dev/quiz/questions?userId=$tunnus')
);
return jsonDecode(response.body);
}
haeTunnus() async {
var onTunnus = await Kayttajatiedot().onTunnus();
if (!onTunnus) {
var tunnus = await luoTunnus();
await Kayttajatiedot().asetaTunnus(tunnus);
}
return await Kayttajatiedot().haeTunnus();
}
}
Tunnuksen hakemiseen ja luomiseen tarkoitettu toiminnallisuus on eriytetty metodiin haeTunnus
. Metodi ensin tarkastaa onko käyttäjätietoihin jo tallennettu tunnus. Mikäli ei, rajapinnasta haetaan uusi tunnus, joka asetetaan käyttäjätietoihin. Tämän jälkeen tunnus palautetaan.
Kysymyksen näyttäminen
Rajapinnan kapseloiva KyselyApi
palauttaa rajapinnasta saadun JSON-muotoisen kysymyksen ja vastausvaihtoehdot sisältävän dokumentin sanakirjana. JSON-muodossa kysymyksen sisältävä dokumentti on seuraava.
{
"questionId":"kysymyksen tunnus",
"questionText":"How much is 2-8",
"answerOptions":[
{"answerOptionId":"vastauksen 1 tunnus","answerText":"10"},
{"answerOptionId":"vastauksen 2 tunnus","answerText":"2"},
{"answerOptionId":"vastauksen 3 tunnus","answerText":"8"},
{"answerOptionId":"vastauksen 4 tunnus","answerText":"-6"}
]
}
Kysymystekstin saa sanakirjasta muodossa sanakirja['questionText']
. Vastaavasti vastausvaihtoehtojen läpikäynti ja tulostaminen onnistuu toistolauseella seuraavalla tavalla.
// sanakirja saatu rajapinnasta
for (var vastausvaihtoehto in sanakirja['answerOptions']) {
print(vastausvaihtoehto['answerText']);
}
Hahmotellaan kysymyksen näyttämiseen tarkoitettua käyttöliittymäkomponenttia. Luodaan kansioon components
tiedosto kysymys.dart
, joka kuvaa kysymyksen näyttämiseen tarkoitettua komponenttia. Luodaan tiedostoon kysymys.dart
luokka Kysymys
, joka saa konstruktorin parametrina kysymyksen sisältävän sanakirjan. Ensimmäinen versio kysymyksestä näyttää vain kysymyksen, mutta ei vastausvaihtoehtoja.
import 'package:flutter/material.dart';
class Kysymys extends StatelessWidget {
final Map sanakirja;
Kysymys(this.sanakirja);
Widget build(BuildContext context) {
return Text(sanakirja['questionText']);
}
}
Muokataan seuraavaksi kyselynäkymää siten, että kyselynäkymässä haetaan kysymys rajapinnasta, ja luodaan kysymyksen perusteella Kysymys
-olio, joka näytetään käyttäjälle. Tehdään Kyselynakyma
-luokasta jo tässä kohtaa tilallinen -- kyselynäkymää halutaan myöhemmin päivittää kysymyksen vaihdon yhteydessä.
import 'package:flutter/material.dart';
import '../api/kysely_api.dart';
import '../components/kysymys.dart';
class Kyselynakyma extends StatefulWidget {
KyselynakymaState createState() => KyselynakymaState();
}
class KyselynakymaState extends State {
var kysymysSanakirja;
initState() {
super.initState();
haeKysymys();
}
haeKysymys() async {
kysymysSanakirja = await KyselyApi().haeKysymys();
paivita();
}
paivita() {
setState(() {});
}
Widget build(BuildContext context) {
return Scaffold(body: Kysymys(kysymysSanakirja));
}
}
Kun käynnistämme sovelluksen ja pyydämme sovellusta kysymään kysymystä, huomaamme että sovellus näyttää hetkellisesti virheen. Tämä johtuu siitä, että kysymysSanakirja
-olion arvo asetetaan vasta kun kysymys noudetaan rajapinnasta -- kun Kysymys
-olio luodaan ohjelman alussa, ei kysymysSanakirja
-oliolle ole vielä asetettu arvoa.
Muokataan luokkaa KyselynakymaState
siten, että mikäli kysymysSanakirja
-muuttujalle ei ole asetettu arvoa, ohjelma näyttää tekstin "Kysymystä haetaan".
import 'package:flutter/material.dart';
import '../api/kysely_api.dart';
import '../components/kysymys.dart';
class Kyselynakyma extends StatefulWidget {
KyselynakymaState createState() => KyselynakymaState();
}
class KyselynakymaState extends State {
var kysymysSanakirja;
initState() {
super.initState();
haeKysymys();
}
haeKysymys() async {
kysymysSanakirja = await KyselyApi().haeKysymys();
paivita();
}
paivita() {
setState(() {});
}
Widget build(BuildContext context) {
if (kysymysSanakirja == null) {
return Scaffold(body: Text('Kysymystä haetaan'));
}
return Scaffold(body: Kysymys(kysymysSanakirja));
}
}
Nyt sovellus näyttää kyselynäkymässä kysymyksen.
Vastausvaihtoehtojen listaaminen
Lisätään seuraavaksi sovellukseen kysymysvaihtoehdot. Vastausvaihtoehtojen näyttäminen on loogista toteuttaa osaksi kysymyksen näyttämistä. Luodaan kansioon components
tiedosto vastausvaihtoehto.dart
, johon asetetaan vastausvaihtoehtoa kuvaava käyttöliittymäkomponentti. Ensimmäinen versio vastausvaihtoehtoa kuvaavasta käyttöliittymäkomponentista näyttää vain vastausvaihtoehtoon liittyvän tekstin, jonka komponentti saa konstruktorin parametrina.
import 'package:flutter/material.dart';
class Vastausvaihtoehto extends StatelessWidget {
final String vastausteksti;
Vastausvaihtoehto(this.vastausteksti);
Widget build(BuildContext context) {
return Text(vastausteksti);
}
}
Muokataan seuraavaksi luokkaa Kysymys
siten, että luokan määrittelemä käyttöliittymäkomponentti näyttää sekä kysymyksen että vastausvaihtoehdot. Näytetään vastausvaihtoehdot käyttöliittymässä listana -- vastausvaihtoehdot näytetään ListView
-oliota käyttäen. ListView
-olio asetetaan Expanded
-olion sisälle -- tämä tarjoaa ListView
-oliolle tiedon saatavilla olevasta tilasta (kokeile sovellusta ilman Expanded
-oliota -- huomaat ettei sovellus toimi).
import 'package:flutter/material.dart';
import 'vastausvaihtoehto.dart';
class Kysymys extends StatelessWidget {
final Map kysymys;
Kysymys(this.kysymys);
Widget build(BuildContext context) {
List<Widget> vastausvaihtoehdot = <Widget>[];
for (var vaihtoehto in kysymys['answerOptions']) {
vastausvaihtoehdot.add(Vastausvaihtoehto(vaihtoehto['answerText']));
}
return Column(children: [
Text(kysymys['questionText']),
Expanded(child: ListView(children: vastausvaihtoehdot))
]);
}
}
Nyt sovellus näyttää sekä kysymyksen että kysymykseen liittyvät vastausvaihtoehdot. Vastaustoiminnallisuus kuitenkin puuttuu sovelluksesta.
Kysymykseen vastaaminen
Toteutetaan seuraavaksi kysymykseen vastaamiseen liittyvä toiminnallisuus. Kysymyksen vastaaminen tapahtuu lähettämällä rajapintaan viesti, joka sisältää kysymyksen tunnuksen, vastauksen tunnuksen, sekä käyttäjätunnuksen. Luodaan ensin rajapinnan kapseloivaan luokkaan metodi lahetaVastaus
, joka lähettää vastauksen rajapinnalle ja palauttaa tiedon siitä, menikö vastaus oikein.
Metodi lahetaVastaus
saa parametrina kysymyksen tunnuksen ja vastauksen tunnuksen. Metodi hakee käyttäjän tunnuksen haeTunnus
-metodia käyttäen. Kun metodi on tehnyt kyselyn rajapintaan, palauttaa se vastauksena saadusta JSON-dokumentista saadun correct
-arvon, joka kuvaa oliko vastaus oikein vai ei.
// .. Muu tiedostossa `kysely_api.dart` oleva toiminnallisuus
lahetaVastaus(questionId, answerOptionId) async {
var tunnus = await haeTunnus();
var data = {
"userId": tunnus,
"questionId": questionId,
"answerOptionId": answerOptionId
};
var response = await post(
Uri.parse('https://fitech-api.deno.dev/quiz/answers'),
headers: {'Content-Type': 'application/json; charset=UTF-8'},
body: jsonEncode(data)
);
var sanakirja = jsonDecode(response.body);
return sanakirja['correct'];
}
// ..
Vastaustoiminnallisuus käyttää metodia lahetaVastaus
. Vastaustoiminnallisuus luodaan luokkaan KyselynakymaState
-- luodaan vastaustoiminnallisuutta varten metodi vastaaKysymykseen
, joka saa parametrina kyselyn tunnuksen ja vastauksen tunnuksen. Ensimmäinen versio metodista lähettää kutsun rajapinnalle ja tulostaa rajapinnasta saadun vastauksen konsoliin.
// ...
vastaaKysymykseen(kysymysTunnus, vastausTunnus) async {
var oikein = await KyselyApi().lahetaVastaus(kysymysTunnus, vastausTunnus);
print(oikein);
}
// ...
Muokataan tämän jälkeen luokkaa Kysymys
siten, että luokan konstruktori saa kysymyksen tiedot sisältävän sanakirjan lisäksi funktion vastaaKysymykseen
. Vastaamiseen tarkoitetulla funktiolla ei vielä tehdä mitään.
import 'package:flutter/material.dart';
import 'vastausvaihtoehto.dart';
class Kysymys extends StatelessWidget {
final Map kysymys;
final Function vastaaFunktio;
Kysymys(this.kysymys, this.vastaaFunktio);
Widget build(BuildContext context) {
List<Widget> vastausvaihtoehdot = <Widget>[];
for (var vaihtoehto in kysymys['answerOptions']) {
vastausvaihtoehdot.add(Vastausvaihtoehto(vaihtoehto['answerText']));
}
return Column(children: [
Text(kysymys['questionText']),
Expanded(child: ListView(children: vastausvaihtoehdot))
]);
}
}
Muokataan seuraavaksi luokan KyselynakymaState
-metodia build
siten, että metodi vastaaKysymykseen
annetaan parametrina luokan Kysymys
konstruktorille.
import 'package:flutter/material.dart';
import '../api/kysely_api.dart';
import '../components/kysymys.dart';
class Kyselynakyma extends StatefulWidget {
KyselynakymaState createState() => KyselynakymaState();
}
class KyselynakymaState extends State {
var kysymysSanakirja;
initState() {
super.initState();
haeKysymys();
}
haeKysymys() async {
kysymysSanakirja = await KyselyApi().haeKysymys();
paivita();
}
vastaaKysymykseen(kysymysTunnus, vastausTunnus) async {
var oikein = await KyselyApi().lahetaVastaus(kysymysTunnus, vastausTunnus);
print(oikein);
}
paivita() {
setState(() {});
}
Widget build(BuildContext context) {
if (kysymysSanakirja == null) {
return Scaffold(body: Text('Kysymystä haetaan'));
}
return Scaffold(body: Kysymys(kysymysSanakirja, vastaaKysymykseen));
}
}
Sovelluksessa ei ole vieläkään mahdollisuutta vastata kysymykseen, mutta kysymyksellä on nyt funktio, jolla kysymykseen voi vastata. Muokataan seuraavaksi luokkia Kysymys
ja Vastausvaihtoehto
siten, että vastausvaihtoehdon valinta vastaa kysymykseen. Muokataan ensin luokkaa Vastausvaihtoehto
siten, että se saa konstruktorin parametrina vastaustekstin lisäksi funktion, jota kutsutaan mikäli kyseinen vastausvaihtoehto valitaan. Konkreettinen valinta toteutetaan siten, että vastausvaihtoehdosta tehdään ListTile
-olio, joka sisältää vastaustekstin, ja jota painettaessa (onTap
) kutsutaan parametrina saatua funktiota.
import 'package:flutter/material.dart';
class Vastausvaihtoehto extends StatelessWidget {
final String vastausteksti;
final Function vastaaFunktio;
Vastausvaihtoehto(this.vastausteksti, this.vastaaFunktio);
Widget build(BuildContext context) {
return ListTile(title: Text(vastausteksti), onTap: () => vastaaFunktio());
}
}
Luokalle Vastausvaihtoehto
tulee antaa konstruktorin parametrina funktio, jonka kutsuminen aiheuttaa luokassa KyselynakymaState
määritellyn vastaaKysymykseen
-metodin kutsumisen. Funktion tulee olla vastausvaihtoehtokohtainen, sillä jokaisella vastausvaihtoehdolla on oma tunnus -- tarkemmin ottaen, funktiokutsun tulee olla seuraavaa muotoa.
vastaaFunktio(kysymys['questionId'], vaihtoehto['answerOptionId']);
Rakennetaan funktiokutsu osaksi vastausvaihtoehtojen luomista, joka tapahtuu luokassa Kysely
. Jokaista vastausvaihtoehtoa varten määritellään funktio, joka sisältää yllä kuvatun kutsun.
import 'package:flutter/material.dart';
import 'vastausvaihtoehto.dart';
class Kysymys extends StatelessWidget {
final Map kysymys;
final Function vastaaFunktio;
Kysymys(this.kysymys, this.vastaaFunktio);
Widget build(BuildContext context) {
List<Widget> vastausvaihtoehdot = <Widget>[];
for (var vaihtoehto in kysymys['answerOptions']) {
var vastausvaihtoehto = Vastausvaihtoehto(vaihtoehto['answerText'], () {
vastaaFunktio(kysymys['questionId'], vaihtoehto['answerOptionId']);
});
vastausvaihtoehdot.add(vastausvaihtoehto);
}
return Column(children: [
Text(kysymys['questionText']),
Expanded(child: ListView(children: vastausvaihtoehdot))
]);
}
}
Nyt, kun sovellus käynnistetään, sovelluksessa näkyy kysymys ja vastausvaihtoehdot. Vastausvaihtoehdon valinta kutsuu luokassa KyselynakymaState
määriteltyä funktiota vastaaKysymykseen
. Tällä hetkellä vastauksen oikeellisuus tulostetaan konsoliin.
Palautteen näyttäminen
Muokataan sovellusta seuraavaksi siten, että vastauksen valinta johtaa palautteen näyttämiseen. Mikäli vastaus menee väärin, käyttäjälle näytetään teksti "Väärin meni, hups!". Mikäli vastaus meni oikein, käyttäjälle näytetään teksti "Oikein meni!". Kummassakin tapauksessa käyttäjälle näytetään myös nappi, jossa on teksti "Seuraava!".
Luodaan palautteen näyttämistä varten uusi käyttöliittymäkomponentti. Kutsutaan komponenttia nimellä Palaute
-- luokka luodaan uuteen tiedostoon nimeltä palaute.dart
, joka asetetaan kansioon components
.
Luokka Palaute
saa konstruktorin parametrina totuusarvon, joka kertoo menikö vastaus oikein vai ei. Tämän perusteella valitaan käyttäjälle näytetty teksti. Tehdään ensin tekstin "Seuraava!" sisältävästä napista sellainen, että sen painaminen ei tee mitään.
import 'package:flutter/material.dart';
class Palaute extends StatelessWidget {
final bool oikein;
Palaute(this.oikein);
Widget build(BuildContext context) {
String teksti = 'Oikein meni!';
if (!oikein) {
teksti = 'Väärin meni, hups!';
}
return Column(children: [
Text(teksti),
ElevatedButton(
child: Text('Seuraava!'),
onPressed: null,
)
]);
}
}
Palaute tulee näyttää käyttäjälle sen jälkeen, kun käyttäjä on vastannut kysymykseen. Näyttämisestä vastaa luokkaa KyselynakymaState
. Tarvitsemme luokkaan kaksi uutta muuttujaa. Toinen muuttujista kuvaa sitä, onko kysymykseen vastattu, ja toinen kuvaa vastauksen oikeellisuutta. Sovelluksen käynnistyessä kummankin muuttujan arvo on false
// ...
class KyselynakymaState extends State {
var kysymys;
var vastattu = false;
var vastausOikein = false;
// ...
Muokataan luokkaa KyselynakymaState
seuraavaksi siten, että edellä kuvattujen muuttujien arvot asetetaan uudestaan vastauksen yhteydessä, jolloin myös päivitetään käyttöliittymä.
// ...
vastaaKysymykseen(kysymysTunnus, vastausTunnus) async {
vastausOikein = await KyselyApi().lahetaVastaus(kysymysTunnus, vastausTunnus);
vastattu = true;
paivita();
}
// ...
Käyttöliittymään tulee luonnollisesti lisätä myös toiminnallisuus, jonka perusteella valitaan näytetäänkö kysymys vai palaute. Näytämme palautteen mikäli kysymykseen on vastattu, eli muuttujan vastattu
arvo on true
.
// ...
Widget build(BuildContext context) {
if (kysymysSanakirja == null) {
return Scaffold(body: Text('Kysymystä haetaan'));
}
if (vastattu) {
return Scaffold(body: Palaute(vastausOikein));
} else {
return Scaffold(body: Kysymys(kysymysSanakirja, vastaaKysymykseen));
}
}
// ...
Nyt, kun käyttäjä vastaa kysymyseen, käyttäjälle näytetään kysymykseen liittyvä palaute.
Uuden kysymyksen hakeminen ja näyttäminen
Tällä hetkellä, kun käyttäjä painaa palautteessa näkyvää "Seuraava!"-nappia, ei käyttäjälle näytetä uutta kysymystä. Muokataan sovellusta siten, että napin painaminen johtaa uuden kysymyksen hakemiseen ja näyttämiseen.
Luokassa KyselynakymaState
on jo valmiina metodi haeKysymys
, johon napin voi liittää. Muokataan metodia haeKysymys
siten, että kysymyksen hakeminen asettaa muuttujan vastattu
arvoksi false
. Tällöin, kun kysymys haetaan ja näkymä päivitetään, mahdollinen palaute vaihdetaan kysymykseksi.
// ...
haeKysymys() async {
kysymys = await KyselyApi().haeKysymys();
vastattu = false;
paivita();
}
// ...
Muokataan tämän jälkeen luokkaa Palaute
siten, että luokka saa konstruktorin parametrina kysymyksen hakemiseen käytetyn funktion, jota myös kutsutaan "Seuraava!"-nappia painettaessa.
import 'package:flutter/material.dart';
class Palaute extends StatelessWidget {
final bool oikein;
final Function haeKysymysFunktio;
Palaute(this.oikein, this.haeKysymysFunktio);
Widget build(BuildContext context) {
String teksti = 'Oikein meni!';
if (!oikein) {
teksti = 'Väärin meni, hups!';
}
return Column(children: [
Text(teksti),
ElevatedButton(
child: Text('Seuraava!'),
onPressed: () => haeKysymysFunktio(),
)
]);
}
}
Muokataan vielä luokkaa KyselynakymaState
siten, että Palaute
-luokan konstruktorille annetaan vastauksen oikeellisuutta kuvaavan muuttujan lisäksi kutsuttava metodi.
// ...
Widget build(BuildContext context) {
if (kysymysSanakirja == null) {
return Scaffold(body: Text('Kysymystä haetaan'));
}
if (vastattu) {
return Scaffold(body: Palaute(vastausOikein, haeKysymys));
} else {
return Scaffold(body: Kysymys(kysymysSanakirja, vastaaKysymykseen));
}
}
// ...
Nyt sovellus mahdollistaa kysymysten hakemisen yhä uudestaan ja uudestaan. Tällä hetkellä kysymyksiä tarjoava rajapinta sisältää vain kolme kysymystä, joten kysymykset loppuvat melko nopeasti kesken. Kurssilla Web Software Development opit tekemään muunmuassa tässä tehtävässä käytetyn rajapinnan tarjoavan palvelinsovelluksen.
Tekstien tyylittely
Muokataan sovellusta vielä hieman siten, että sovelluksen tervehdysteksti, kysymysteksti, sekä palauteteksti näytetään hieman isompaa fonttikokoa käyttäen. Eräs mahdollisuus olisi muokata jokaista komponenttia erikseen ja lisätä kuhunkin komponenttiin tyylit, mutta suoraviivaisempi tapa on tehdä uusi käyttöliittymäkomponentti, joka määrittelee tekstille tyylin.
Luodaan kansioon components
tiedosto tyylitelty_teksti.dart
, johon määritellään käyttöliittymäkomponentti TyyliteltyTeksti
. Käyttöliittymäkomponentin konstruktorille annetaan parametrina tyyliteltävä merkkijono -- konkreettinen tyylittely tässä on fonttikoon 32 käyttäminen sekä tilan lisääminen tekstin reunoille.
import 'package:flutter/material.dart';
class TyyliteltyTeksti extends StatelessWidget {
final String teksti;
TyyliteltyTeksti(this.teksti);
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(16.0),
child: Text(teksti,
style: TextStyle(
fontSize: 32,
)
)
);
}
}
Muokataan seuraavaksi aloitusnäkymää siten, että aloitusnäkymä käyttää tyyliteltyä tekstiä. Käytännössä muokkaus tehdään komponenttiin Aloitusteksti
. Muokkauksen jälkeen komponentti näyttää seuraavalta.
import 'package:flutter/material.dart';
import '../storage/kayttajatiedot.dart';
import 'tyylitelty_teksti.dart';
class Tervehdysteksti extends StatelessWidget {
Widget build(BuildContext context) {
return FutureBuilder(
future: Kayttajatiedot().tuttuKayttaja(),
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
if (snapshot.data) {
return TyyliteltyTeksti('Heippa taas!');
} else {
return TyyliteltyTeksti('Tervetuloa!');
}
} else if (snapshot.hasError) {
return Text('Virhe käyttäjätietojen hakemisessa.');
} else {
return Text('Haetaan käyttäjätietoja..');
}
}
);
}
}
Muokataan seuraavaksi kysymystä -- luokan Text
sijaan luokassa Kysymys
käytetään edellä määriteltyä luokkaa TyyliteltyTeksti
.
import 'package:flutter/material.dart';
import 'vastausvaihtoehto.dart';
import 'tyylitelty_teksti.dart';
class Kysymys extends StatelessWidget {
// ...
Widget build(BuildContext context) {
List<Widget> vastausvaihtoehdot = new List();
// ..
return Column(children: [
TyyliteltyTeksti(kysymys['questionText']),
Expanded(child: ListView(children: vastausvaihtoehdot))
]);
}
}
Vastaavasti palautteessa käytetään tekstin sijaan tyyliteltyä tekstiä.
import 'package:flutter/material.dart';
import 'tyylitelty_teksti.dart';
class Palaute extends StatelessWidget {
// ..
Widget build(BuildContext context) {
String teksti = 'Oikein meni!';
// ..
return Column(children: [
TyyliteltyTeksti(teksti),
ElevatedButton(
child: Text('Seuraava!'),
onPressed: () => haeKysymysFunktio(),
)
]);
}
}
Nyt sovelluksen tekstit ovat hieman tyylitellymmät.
Yläpalkki ja SafeArea
Kun sovellusta käytetään emulaattorissa tai puhelimessa, osa puhelimen käyttöliittymään kuuluvista asioista menee sovelluksen sisältöjen päälle. Tämän korjaaminen onnistuu SafeArea
-luokkaa käyttämällä. Muokataan sovellusta siten, että sovellus näyttää yläpalkin, jonka lisäksi näytettävät sisällöt asetetaan SafeArea
-olion sisälle. Keskitetään samalla näytettävät sisällöt.
Aloitusnäkymä näyttää muokkauksen jälkeen seuraavalta. Näytettävien sisältöjen luominen (muuttuja sarake
) on jätetty pois alla olevasta koodista.
import 'package:flutter/material.dart';
import '../components/tervehdysteksti.dart';
class Aloitusnakyma extends StatelessWidget {
Widget build(BuildContext context) {
// ..
return Scaffold(
appBar: AppBar(
title: Text('Kyselysovellus')
),
body: SafeArea(
child: Center(
child: Container(
child: sarake
)
)
)
);
}
}
Vastaavasti KyselynakymaState
-luokan metodi build
näyttää muokkauksen jälkeen seuraavalta. Muuttujaa sisalto
käytetään näytettävän sisällön valintaan, joka suoraviivaistaa ohjelmaa hieman.
// ..
class KyselynakymaState extends State {
// ..
Widget build(BuildContext context) {
if (kysymys == null) {
return Scaffold(body: Text('Kysymystä haetaan'));
}
Widget sisalto;
if (!vastattu) {
sisalto = Kysymys(kysymys, vastaaKysymykseen);
} else {
sisalto = Palaute(vastausOikein, haeKysymys);
}
return Scaffold(
body: SafeArea(
child: Center(
child: sisalto
)
)
);
}
// ..
}
Nyt sovelluksen pitäisi toimia odotetulla tavalla.
Hi! Please help us improve the course!
Please consider the following statements and questions regarding this part of the course. We use your answers for improving the course.
I can see how the assignments and materials fit in with what I am supposed to learn.
I find most of what I learned so far interesting.
I am certain that I can learn the taught skills and knowledge.
I find that I would benefit from having explicit deadlines.
I feel overwhelmed by the amount of work.
I try out the examples outlined in the materials.
I feel that the assignments are too difficult.
I feel that I've been systematic and organized in my studying.
How many hours (estimated with a precision of half an hour) did you spend reading the material and completing the assignments for this part? (use a dot as the decimal separator, e.g 8.5)
How would you improve the material or assignments?