Article écrit par Matteo
Kotlin porte des promesses de transformation et d’innovation en matière de développement.
Dans cet article nous allons parler de Kotlin, un langage de programmation qui suscite beaucoup d’intérêt chez les développeurs Android.
Qu’est-ce que Kotlin ?
Kotlin est un langage de programmation créé le 19 juillet 2011 par l’équipe de JetBrains. Le langage a été principalement créé pour le développement mobile, Android, mais peut désormais être utilisé pour le développement d’applications sur d’autres plates-formes également.
Il présente de nombreux avantages par rapport à Java, notamment une syntaxe plus concise, une gestion plus sûre des exceptions nulles et d’autres fonctionnalités modernes telles que les coroutines pour la programmation asynchrone.
Kotlin est également utilisé pour le développement côté serveur, les scripts et dispose d’une version JavaScript transpilée pour le développement Web. En 2017, Google a annoncé que Kotlin était le langage de programmation officiel pour le développement d’Android.
Dans cet article, nous allons voir comment créer une application Android avec Kotlin, puis je vous présenterai également une application que j’ai créée pour vous montrer des exemples de code.
Création d’une application Android avec Kotlin
Pour le développement Kotlin, vous aurez besoin d’installer qquelques outils sur votre ordinateur. Pour le développement Kotlin les outils les plus utilisés sont IntelliJ IDEA et Android Studio, deux environnements de développement intégré conçu par également par Jetbrains.
Je vais de mon côté utiliser Android Studio.
Installation d’Android Studio
Pour télécharger Android Studio, il suffit de se rendre sur le site officiel d’Android ou en utilisant ce lien de téléchargement direct
Création d’un nouveau projet
Si vous avez déjà un projet de lancé sur Android Studio il suffit de vous rendre dans “File” > “New” > “New project” mais si c’est la première fois que vous le lancez il vous suffira de cliquer sur le bouton “New Project” de la page de lancement :
Vous allez arriver sur un écran dans lequel choisir quelle activité de base vous voulez, ici je vais choisir une activité vide pour partir de zéro.
Maintenant, il nous est demandé de choisir le nom de l’application, le nom de package, son emplacement de sauvegarde ainsi que la version minimum d’Android sous laquelle notre application va fonctionner.
Ici, j’ai choisi pour version minimale la version 7.0 d’Android pour que mon application soit compatible sur environ 95 % des téléphones Android.
Et voilà votre application est bootstrappée.
Je vais maintenant vous montrer comment la lancer sur un émulateur Android. Pour cela, il va falloir d’abord installer un émulateur Android. L’avantage d’Android Studio c’est qu’il comporte nativement un Device Manager
qui va nous permettre d’installer et d’utiliser un téléphone Android virtuel.
Pour cela, il suffit de se rendre dans “Tools”>”Device Manager” ou directement dans la barre latérale tout à droite de votre IDE.
Vous allez voir apparaître une fenêtre avec la liste des appareils virtuels déjà installés sur votre ordinateur. Pour en installer un nouveau, il suffit de cliquer sur le bouton Create Virtual Device
en bas à gauche de la fenêtre.
Vous allez arriver sur une page qui va vous demander de choisir un appareil virtuel. Pour cet article, je vais choisir un Pixel 6 pro.
Il va maintenant vous être demandé de choisir une version d’Android. Pour cet article, je vais choisir la version 13.0 d’Android.
Je choisi maintenant, un nom pour l’appareil virtuel :”Pixel 6 pro API 33″.
Et voilà, votre appareil virtuel est prêt à être utilisé. Maintenant, si vous retournez dans le Device Manager
vous allez voir apparaître votre appareil virtuel dans la liste des appareils virtuels.
Pour lancer votre appareil virtuel, il suffit de cliquer sur le bouton “Play” à droite de votre appareil virtuel et l’onglet “Running device” va s’ouvrir pour laisser apparaitre votre téléphone virtuel.
Maintenant que votre appareil virtuel est lancé, il ne vous reste plus qu’à lancer votre application dessus. Pour cela, il suffit de cliquer sur le bouton “Run” en haut à droite de votre IDE ou d’utiliser le raccourci clavier “Control + R” sous macOS ou “Maj + F10” sur windows et linux. Et l’application va s’ouvrir sur votre appareil virtuel.
Vous pouvez dès à présent vous amuser à la modifier et à la personnaliser. Pour cela, je vous invite à lire la documentation officielle d’Android Studio qui est très bien faite et qui vous permettra de comprendre comment fonctionne Android Studio et comment créer une application Android.
Passons maintenant à la présentation d’une application que j’ai réalisée en Kotlin pour vous permettre de découvrir un peu plus le langage Kotlin et comment fonctionne Android Studio.
Présentation d’une application Kotlin
Cette application utilise l’API “foodToFork” qui permet de récupérer des recettes de cuisine. Le but de l’application est de consulter une liste de recettes de cuisine et de consulter les détails d’une recette, mais aussi permettre de rechercher des recettes et de filtrer des recettes par type d’aliment.
Cette application doit fonctionner quand l’utilisateur n’a pas accès à internet. Pour cela j’ai utilisé une base de données locale pour stocker les recettes.
Aperçu de l’application
Lorsque je lance mon application, je vais arriver sur cette page.
En haut de la page, je vais avoir une barre de recherche, qui va me permettre de rechercher des recettes en fonction d’un ingrédient par exemple.
Avec en dessous une liste de boutons qui vont me permettre de filtrer les recettes en fonction du type d’aliment.
Puis la liste de recettes.
L’application affiche par défaut 30 recettes mais un bouton Load more
est présent en bas de la liste pour permettre de charger 30 recettes supplémentaires.
Si je clique sur une recette je vais arriver sur cette page.
Sur cette page, je vais retrouver en haut de la page le nom de la recette avec à côté une flèche qui permet de retourner sur la page d’accueil de l’application et une image de la recette.
En dessous la liste d’ingrédients et en dessous de la liste d’ingrédients.
Sur la page d’accueil, lorsque je vais cliquer sur un bouton de filtrage, je vais arriver sur une page qui va contenir que des recettes correspondant au filtrage.
Pour finir, si je fais une recherche, je vais arriver sur une page qui va contenir les recettes correspondant à ma recherche.
Je vais maintenant vous présenter quelques morceaux du code qui ont permis de développer ces fonctionnalités.
Architecture du projet
Pour cette application, j’ai choisi une architecture relativement simple. Tout d’abord, je vais disposer d’un package api
qui contiendra tous les fichiers me permettant d’effectuer des requêtes à l’API FoodToFork
.
J’ajoute un package database
qui regroupera tous les fichiers nécessaires pour gérer la base de données locale ainsi qu’un package data
qui inclura mes méthodes pour récupérer les données provenant de l’API ou de la base de données locale.
Tous les fichiers qui me permettront de gérer l’affichage de l’application seront contenus dans le package screens
. Pour finir, j’ai un package ui.theme
, qui est un package par défaut, contenant des fichiers qui modifieront le style d’affichage de l’application.
En dehors de ces packages, j’ai un fichier MainActivity.kt
qui contiendra le code exécuté au lancement de l’application et le fichier Navigation.kt
qui gérera la navigation entre les différentes pages.
API
Pour pouvoir effectuer des requêtes à l’API FoodToFork
j’ai utilisé le module Ktor
. Il comprend un client HTTP asynchrone, ce qui signifie que vous pouvez l’utiliser pour effectuer des requêtes HTTP à partir de votre application.
Le client HTTP est lui-même basé sur des coroutines. Une coroutine est une unité de traitement qui s’apparente à une routine, à ceci près que, alors que la sortie d’une routine met fin à la routine, la sortie de la coroutine peut être le résultat d’une suspension de son traitement jusqu’à ce qu’il lui soit signalé de reprendre son cours. Ce qui facilite la gestion des opérations d’entrée-sortie asynchrones.
Pour utiliser cette API j’ai du créer quatre fichiers dans le package api
. Le premier fichier que j’ai du créer est le fichier HttpClientApi
. Ce fichier va contenir une classe abstraite qui va gérer la création d’un client Http
abstract class HttpClientApi {
companion object {
private var client : HttpClient? = null
@Synchronized
fun getClient() : HttpClient {
if (client == null) {
client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
}
return client!!
}
}
}
Dans ce fichier, un modèle de conception Singleton est utilisé pour s’assurer qu’il n’y a qu’une seule instance du client HTTP dans toute l’application. Cela est réalisé en utilisant une variable privée client et une fonction getClient()
qui retourne cette instance unique.
Si client est nul au moment de l’appel de la fonction getClient()
, une nouvelle instance de HttpClient est créée et stockée dans client.
Ensuite je vais avoir un fichier Recipe
. Ce fichier définit une classe de donnée qui va contenir les informations d’une recette.
@Serializable
data class Recipe(
@SerialName("pk")
val pk: Int,
@SerialName("title")
val title: String,
@SerialName("description")
val description: String,
@SerialName("ingredients")
val ingredients: List<String>,
@SerialName("publisher")
val publisher: String,
@SerialName("source_url")
val sourceUrl: String,
@SerialName("featured_image")
val featuredImage: String,
@SerialName("rating")
val rating: Int,
@SerialName("date_added")
val dateAdded: String,
@SerialName("date_updated")
val dateUpdated: String,
@SerialName("long_date_added")
val longDateAdded: String,
@SerialName("long_date_updated")
val longDateUpdated: String,
)
Cette classe est marquée comme @Serializable
pour indiquer que cette classe peut être sérialisée ou désérialisée en JSON pour convertir les objets en JSON pour les envoyer à l’API ou pour les convertir à partir de JSON reçu.
J’ai également un fichier RecipeResponse
. Il définit une classe de données qui va contenir la réponse de l’API.
@Serializable
data class RecipeResponse(
@SerialName("count")
val count: Int,
@SerialName("next")
val next: String,
@SerialName("results")
val results: List<Recipe>
) {
operator fun plus(other: RecipeResponse): RecipeResponse {
return RecipeResponse(
count = count + other.count,
next = other.next,
results = results + other.results
)
}
}
La méthode plus
est utilisée pour combiner deux objets RecipeResponse. La méthode prend un autre RecipeResponse en tant que paramètre et retourne un nouvel objet RecipeResponse qui a :
- Un count égal à la somme des count des deux objets RecipeResponse.
- Un next qui est le next de l’autre objet RecipeResponse.
- Une liste de results qui contient tous les results des deux objets RecipeResponse.
Cette méthode est utile pour combiner les résultats de plusieurs appels API, par exemple pour une pagination où vous voulez récupérer toutes les recettes de toutes les pages.
Pour finir, j’ai un fichier RecipeService
. Ce fichier va contenir une classe qui va gérer les requêtes à l’API.
class RecipeService {
companion object {
private val client = HttpClientApi.getClient()
private const val TOKEN = "Token XXX"
private const val URL = "https://food2fork.ca/api"
}
suspend fun getRecipes(page: Int): RecipeResponse {
val query = "beef%20carrot%20potato%20onion"
val response = client.get("$URL/recipe/search/?page=$page&query=$query") {
header("Authorization", TOKEN)
}
return response.body()
}
suspend fun getRecipe(id: Int): Recipe {
val response = client.get("$URL/recipe/get/?id=$id") {
header("Authorization", TOKEN)
}
return response.body()
}
suspend fun searchRecipes(query: String): RecipeResponse? {
if (query == "All"){
return getRecipes(2)
}
val response = client.get("$URL/recipe/search/?query=$query") {
header("Authorization", TOKEN)
}
return if (response.status.isSuccess()) response.body() else null
}
}
La classe RecipeService a trois méthodes :
getRecipes(page: Int)
: cette méthode envoie une requête GET à l’API pour obtenir une liste de recettes. La page de résultats à récupérer est passée en tant que paramètre. La requête inclut un header “Authorization” avec un token d’autorisation. La méthode retourne une RecipeResponse qui contient la liste des recettes obtenues de l’API.getRecipe(id: Int)
: cette méthode envoie une requête GET à l’API pour obtenir une recette spécifique. L’identifiant de la recette à obtenir est passé en tant que paramètre. Comme la méthode précédente, elle inclut un header “Authorization” avec un token d’autorisation. La méthode retourne un objet Recipe qui contient les détails de la recette obtenue de l’API.searchRecipes(query: String)
: cette méthode envoie une requête GET à l’API pour rechercher des recettes en fonction d’une chaîne de recherche. La chaîne de recherche est passée en tant que paramètre. Si la chaîne de recherche estAll
, la méthode retourne les résultats degetRecipes(2)
. Sinon, elle envoie la requête de recherche à l’API. La méthode retourne une RecipeResponse si la requête a réussi, et null si elle a échoué.
Chaque méthode est marquée avec le mot-clé suspend, ce qui signifie qu’elles sont des coroutines.
Création d’une database locale
J’ai créé le fichier RecipeDatabase
qui va contenir la database et utiliser la bibliothèque Room
. Celle-ci offre notamment une couche d’abstraction à SQLite.
@Database(entities = [RecipeDb::class], version = 2, exportSchema = true)
@TypeConverters(ListToStringConverter::class)
abstract class RecipeDatabase : RoomDatabase() {
abstract fun recipeDao(): RecipeDao
companion object {
@Volatile
private var INSTANCE: RecipeDatabase? = null
fun getDatabase(context: Context): RecipeDatabase {
if(INSTANCE == null){
INSTANCE = Room.databaseBuilder(context.applicationContext,
RecipeDatabase::class.java,
"recipe_database"
).fallbackToDestructiveMigration().build()
}
return INSTANCE!!
}
}
}
La classe RecipeDatabase est marquée avec l’annotation @Database
. Cette annotation indique que cette classe est une database. Elle prend trois paramètres :
- entities : c’est un tableau d’entités qui indique les entités qui seront stockées dans la database.
- version : c’est un entier qui indique la version de la database. Si vous modifiez la structure de la database, vous devez augmenter le numéro de version.
- exportSchema : c’est un booléen qui indique si le schéma de la database doit être exporté ou non.
Dans l’objet compagnon, une instance de la base de données est créée en utilisant le modèle Singleton, pour s’assurer qu’il n’y a qu’une seule instance de la base de données dans toute l’application.
La méthode getDatabase()
fournit cette instance unique. Si l’instance n’existe pas encore, elle est créée en utilisant Room.databaseBuilder()
.
Le nom de la base de données est recipe_database
, et fallbackToDestructiveMigration() signifie que si la version de la base de données est augmentée (c’est-à-dire si le schéma de la base de données change), toutes les données existantes seront détruites et la base de données sera recréée.
Ensuite, je vais avoir un fichier “RecipeDao”. Ce fichier va contenir une interface qui va définir les méthodes pour accéder aux données de la database.
@Dao
interface RecipeDao {
@Query("SELECT * FROM recipeDb Limit :limit Offset :offset")
suspend fun getAll(limit: Int, offset: Int) : List<RecipeDb>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(recipe : RecipeDb)
@Query("SELECT * FROM recipeDb WHERE pk = :pk")
suspend fun getById(pk: Int) : RecipeDb
@Delete
suspend fun delete(recipe : RecipeDb)
@Query("SELECT * FROM recipeDb WHERE ingredients LIKE '%' || :query || '%'")
suspend fun search(query: String) : List<RecipeDb>
}
Ce fichier Kotlin définit une interface RecipeDao qui est marquée avec l’annotation @Dao
de la bibliothèque Room. Un DAO (Data Access Object) est une interface qui définit les méthodes pour interagir avec les données dans une base de données. Dans ce cas, l’interface RecipeDao fournit les méthodes pour interagir avec les données de recettes dans la base de données.
Voici ce que font les différentes méthodes de l’interface RecipeDao :
getAll(limit: Int, offset: Int)
: cette méthode retourne une liste de recettes de la base de données. Elle utilise une requête SQL SELECT pour obtenir toutes les recettes de la table recipeDb. Le nombre de recettes retournées est limité par le paramètre limit, et l’offset (l’index de départ dans les résultats) est spécifié par le paramètre offset.insert(recipe : RecipeDb)
: cette méthode insère une recette dans la base de données. Si une recette avec le même ID existe déjà dans la base de données, elle est remplacée par la nouvelle recette (grâce à onConflict = OnConflictStrategy.REPLACE).getById(pk: Int)
: cette méthode retourne une recette spécifique de la base de données en fonction de son ID primaire (pk).delete(recipe : RecipeDb)
: cette méthode supprime une recette spécifique de la base de données.search(query: String)
: cette méthode recherche des recettes dans la base de données en fonction d’une chaîne de recherche. Elle retourne toutes les recettes dont les ingrédients contiennent la chaîne de recherche.
Toutes ces méthodes sont suspendues, ce qui signifie qu’elles sont des coroutines. Dans ce cas, cela signifie que l’exécution de ces méthodes n’interférera pas avec l’interface utilisateur de l’application, même si elles prennent du temps à exécuter.
J’ai également créé un fichier RecipeDb
qui va contenir la classe RecipeDb
. Cette classe est une entité qui représente une recette dans la base de données.
@Entity
public class RecipeDb(
@PrimaryKey val pk: Int,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "description") val description: String,
@TypeConverters(ListToStringConverter::class)
@ColumnInfo(name = "ingredients") val ingredients: List<String>,
@ColumnInfo(name = "publisher") val publisher: String,
@ColumnInfo(name = "source_url") val sourceUrl: String,
@ColumnInfo(name = "featured_image") val featuredImage: String,
@ColumnInfo(name = "rating") val rating: Int,
@ColumnInfo(name = "date_added") val dateAdded: String,
@ColumnInfo(name = "date_updated") val dateUpdated: String,
@ColumnInfo(name = "long_date_added") val longDateAdded: String,
@ColumnInfo(name = "long_date_updated") val longDateUpdated: String,
)
Cette classe définit la structure de la table recipeDb
dans la base de données SQLite. Elle détermine comment les objets RecipeDb sont convertis en rangées dans la table de la base de données, et comment ces rangées sont converties en objets RecipeDb.
Pour finir, j’ai un fichier ListToStringConverter"
qui va contenir une classe ListToStringConverter
.
class ListToStringConverter {
@TypeConverter
fun fromString(value: String?): List<String> {
return value?.split("/")?.map { it.trim() } ?: emptyList()
}
@TypeConverter
fun fromList(list: List<String>?): String {
return list?.joinToString(separator = "/") ?: ""
}
}
Cette classe est une classe utilitaire qui convertit une liste de chaînes en une chaîne et vice versa.
Elle est utilisée pour convertir la liste des ingrédients d’une recette en une chaîne pour la stocker dans la base de données, et pour convertir la chaîne des ingrédients d’une recette en une liste pour l’afficher dans l’application.
Utilisation de la database ou l’API dans l’application
Pour utiliser la database ou l’api dans l’application, j’ai créé un package data
qui va contenir trois fichiers. Ces fichiers vont permettre de faire le lien entre la database ou l’api et l’application.
Le premier fichier est getAllData.kt
qui va contenir une classe getAllData
. Cette classe va permettre de récupérer les données de la database lorsque l’on a pas internet ou de l’api lorsqu’on a internet.
Cette classe va également permettre de sauvegarder les données de l’api dans la database lorsqu’on a internet. Cas 1: On a internet
scope.launch {
try {
var newResult = RecipeService().getRecipes(page)
var oldRecipeList = recipes.value?.results ?: emptyList()
var allRecipeList = oldRecipeList + newResult.results
recipes.value = RecipeResponse(
newResult.count,
newResult.next,
allRecipeList.distinct()
)
IsConnected.value = true
withContext(Dispatchers.IO) {
for (recipe in allRecipeList.distinct()) {
val recipeDb = RecipeDb(
recipe.pk,
recipe.title,
recipe.description,
recipe.ingredients,
recipe.publisher,
recipe.sourceUrl,
recipe.featuredImage,
recipe.rating,
recipe.dateAdded,
recipe.dateUpdated,
recipe.longDateAdded,
recipe.longDateUpdated
)
recipeDao.insert(recipeDb)
}
}
//catch...
Dans ce cas, on récupère les données de l’api et on les sauvegarde dans la database. On affiche ensuite les données de l’api dans l’application.
Cas 2: On a pas internet
//try....
catch (e: Exception) {
IsConnected.value = false
}
var offset = (page - 1) * 30
if (!IsConnected.value) {
val recipeDbList = recipeDao.getAll(30, offset)
val oldRecipeList = recipes.value?.results ?: emptyList()
val allRecipeList = oldRecipeList + recipeDbList.map { recipeDb ->
Recipe(
recipeDb.pk,
recipeDb.title,
recipeDb.description,
recipeDb.ingredients,
recipeDb.publisher,
recipeDb.sourceUrl,
recipeDb.featuredImage,
recipeDb.rating,
recipeDb.dateAdded,
recipeDb.dateUpdated,
recipeDb.longDateAdded,
recipeDb.longDateUpdated
)
}
recipes.value = RecipeResponse(0, "", allRecipeList.distinct())
if( oldRecipeList.distinct().size == allRecipeList.distinct().size){
hasMoreResults.value = false
}
}
Dans ce cas, on récupère les données de la database et on les affiche dans l’application.
À noter, dans les deux cas, les données sont traitées dans une coroutine. On le voit grâce à la fonction scope.launch
. Voici comment ça fonctionne :
- scope : C’est un CoroutineScope. Il définit la portée de la coroutine. Par exemple, il peut être lié au cycle de vie d’une activité ou d’un fragment dans une application Android. Lorsque le scope est annulé ou terminé (par exemple, lorsque l’activité est détruite), toutes les coroutines lancées dans ce scope sont aussi annulées.
- launch : C’est une fonction qui lance une nouvelle coroutine. Elle est non bloquante, ce qui signifie qu’elle retourne immédiatement et que le code suivant s’exécute avant que la coroutine ait terminé son travail. La coroutine lancée par launch est une “coroutine légère”, ce qui signifie qu’elle n’occupe pas un thread entier et qu’il est possible d’en exécuter de nombreuses en même temps.
Le deuxième fichier est getRecipeById.kt
qui va contenir une classe getRecipeById
. Cette classe va permettre de récupérer une recette de la database ou de l’api en fonction de son id. Le fonctionnement de cette classe est similaire à celui de la classe getAllData
.
Et pour finir, le troisième fichier est searchRecipe.kt
qui va contenir une classe searchRecipe
. Cette classe va permettre de récupérer les recettes de la database ou de l’api en fonction d’une recherche. Le fonctionnement de cette classe est également similaire à celui de la classe getAllData
.
On va voir maintenant comment afficher les données dans l’application.
Affichage des données dans l’application
Pour l’affichage j’ai créé 2 fichiers (1/page). La première page va afficher la liste des recettes et la deuxième page va afficher les détails d’une recette.
Affichage de la liste des recettes
Pour pouvoir affiché une liste de recette il fallait d’abord récupérer les données de la database ou de l’api. Pour cela j’ai utilisé la classe getAllData
que j’ai créer précédemment.
LaunchedEffect(true) {
getAllData(
recipes = recipes,
IsConnected = IsConnected,
scope = scope,
recipeDao = recipeDao,
page = CurrentPage.value,
hasMoreResults = hasMoreResults
)
}
Ce code va permettre de récupérer les données de la database ou de l’api en fonction de la valeur de IsConnected
. Si IsConnected
est vrai alors, on récupère les données de l’api, sinon on récupère les données de la database.
Ensuite, pour afficher ces données, j’ai créé un composant RecipeList
qui va afficher la liste des recettes.
@Composable
fun RecipeList(
//all params here
){
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
) {
if (recipes.value?.results?.isNotEmpty() == true) {
items(recipes.value?.results ?: emptyList()) { recipe ->
Card(
shape = RoundedCornerShape(8.dp),
backgroundColor = Color.White,
elevation = 4.dp,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable { navController.navigate("recipe/${recipe.pk}") }
) {
Column(Modifier.padding(16.dp)) {
Image(
painter = rememberImagePainter(recipe.featuredImage),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
Text(
text = recipe.title,
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
Ce composant va afficher une liste de Card
qui contiennent une image et un titre. Lorsque l’on clique sur une Card
, on est redirigé vers la page des détails de la recette qui va fonctionner de la même manière à la différence que les données sont récupérées par rapport à son Id.
Conclusion
Ce projet était ma première expérience en développement mobile. Le développement mobile n’était pas quelque chose qui m’attirait particulièrement, mais après avoir fait ce projet, je me suis rendu compte que c’était très intéressant.
J’ai appris beaucoup de choses sur le développement mobile et sur Kotlin, notamment utiliser une api et une database. J’ai eu quelques difficultés au début du projet, mais j’ai réussi à les surmonter. Je suis satisfait du résultat final et je pense que ce projet m’a permis d’acquérir de nouvelles compétences.
Si vous voulez plus de détails sur le projet, vous pouvez consulter le code source sur mon github :