Prérequis: Terminez au moins l'étape "Édition d'une tâche" du TP 2
Les APIs qui nous allons utiliser exigent qu'une personne soit connectée, pour commencer nous allons simuler cela en passant directement un token
dans les headers
de nos requêtes HTTP
:
Paramètres > Intégrations > Clé API
et copiez la quelque partcurl
ou un équivalent (ex: httpie en terminal, web ou desktop)Afin de communiquer avec le réseau internet (wifi, ethernet ou mobile), il faut ajouter la permission dans le fichier AndroidManifest
, juste au dessus de la balise application
<uses-permission android:name="android.permission.INTERNET" />
Dans le fichier app/build.gradle.kts
(celui du module):
dependencies {...}
, ajouter les dépendances qui vous manquent (mettre les versions plus récentes si l'IDE vous le propose, il vous permet également de facilement les passer dans le libs.versions.toml
):// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// KotlinX Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
plugins {
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.0"
}
version.ref = "kotlin"
data
object Api
(ses membres et méthodes seront donc static
):object Api {
private const val TOKEN = "COPIEZ_VOTRE_CLE_API_ICI"
private val retrofit by lazy {
// client HTTP
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
.addInterceptor { chain ->
// intercepteur qui ajoute le `header` d'authentification avec votre token:
val newRequest = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $TOKEN")
.build()
chain.proceed(newRequest)
}
.build()
// transforme le JSON en objets kotlin et inversement
val jsonSerializer = Json {
ignoreUnknownKeys = true
coerceInputValues = true
encodeDefaults = true
}
// instance retrofit pour implémenter les webServices:
Retrofit.Builder()
.baseUrl("https://api.todoist.com/")
.client(okHttpClient)
.addConverterFactory(jsonSerializer.asConverterFactory("application/json".toMediaType()))
.build()
}
}
Extrait d'un json renvoyé par la route /sync/v9/user/
:
{
"email": "example@domain.com",
"full_name": "john doe",
"avatar_medium": "https://blablabla/image.jpg"
}
Créer la data class User
correspondante:
@Serializable
data class User(
@SerialName("email")
val email: String,
@SerialName("full_name")
val name: String,
@SerialName("avatar_medium")
val avatar: String? = null
)
UserWebService
pour requêter les infos de l'utilisateur (importez Response
avec alt + enter
et choisissez la version retrofit
):interface UserWebService {
@GET("/sync/v9/user/")
suspend fun fetchUser(): Response<User>
}
object Api {
val userWebService : UserWebService by lazy {
retrofit.create(UserWebService::class.java)
}
}
TextView
tout en haut (vous devrez probablement régler un peu les contraintes)onResume
pour y récupérer les infos de l'utilisateur, en ajoutant cette ligne, une erreur va s'afficher car la définition de fetchUser
contient un mot clé suspend
:// Ici on ne va pas gérer les cas d'erreur donc on force le crash avec "!!"
val user = Api.userWebService.fetchUser().body()!!
lifeCycleScope
qui est déjà défini par le framework Android dans les Activity
et Fragment
:lifecycleScope.launch {
mySuspendMethod()
}
TextView
:userTextView.text = user.name
➡️ Lancez l'app et vérifiez que vos infos s'affichent !
Il est temps de récupérer les tâches depuis le serveur !
Créer un nouveau service TaskWebService
:
interface TasksWebService {
@GET("/rest/v2/tasks/")
suspend fun fetchTasks(): Response<List<Task>>
}
TasksWebService
dans l'objet Api
Extrait d'un json renvoyé par la route /rest/v2/tasks/
:
[
{
"content": "title",
"description": "description",
"id": "123456789"
}
]
Task
pour la rendre "serializable" par KotlinX Serialization (inspirez vous de User
)Créer la classe TaskListViewModel
, avec une liste de tâches Observable grâce au MutableStateFlow
:
class TaskListViewModel : ViewModel() {
private val webService = Api.tasksWebService
public val tasksStateFlow = MutableStateFlow<List<Task>>(emptyList())
fun refresh() {
viewModelScope.launch {
val response = webService.fetchTasks() // Call HTTP (opération longue)
if (!response.isSuccessful) { // à cette ligne, on a reçu la réponse de l'API
Log.e("Network", "Error: ${response.message()}")
return@launch
}
val fetchedTasks = response.body()!!
tasksStateFlow.value = fetchedTasks // on modifie le flow, ce qui déclenche ses observers
}
}
// à compléter plus tard:
fun add(task: Task) {}
fun edit(task: Task) {}
fun remove(task: Task) {}
}
Dans TaskListFragment
, à l'aide du squelette de code plus bas:
TaskListViewModel
onResume()
, utilisez ce VM pour rafraîchir la liste de tasksonViewCreated()
, "abonnez" le fragment aux changements du StateFlow
du VM et mettez à jour la liste et l'adapter
dans la lambda de retourprivate val viewModel: TaskListViewModel by viewModels()
// Dans onResume()
viewModel.refresh() // on demande de rafraîchir les données sans attendre le retour directement
// Dans onViewCreated()
lifecycleScope.launch { // on lance une coroutine car `collect` est `suspend`
viewModel.tasksStateFlow.collect { newList ->
// cette lambda est exécutée à chaque fois que la liste est mise à jour dans le VM
// -> ici, on met à jour la liste dans l'adapter
}
}
Modifier TasksWebService
et ajoutez y les routes manquantes:
@POST("/rest/v2/tasks/")
suspend fun create(@Body task: Task): Response<Task>
@POST("/rest/v2/tasks/{id}")
suspend fun update(@Body task: Task, @Path("id") id: String = task.id): Response<Task>
// Complétez avec les méthodes précédentes, la doc de l'API, et celle de Retrofit:
@...
suspend fun delete(@... id: String): Response<Unit>
refresh()
pour ajouter toutes les autres actions avec le serveur dans le VM, par ex pour l'édition:fun update(task: Task) {
viewModelScope.launch {
val response = ... // TODO: appel réseau
if (!response.isSuccessful) {
Log.e("Network", "Error: ${response.raw()}")
return@launch
}
val updatedTask = response.body()!!
val updatedList = tasksStateFlow.value.map {
if (it.id == updatedTask.id) updatedTask else it
}
tasksStateFlow.value = updatedList
}
}
taskList
locale dans le Fragment et vérifier que vous avez bien tout remplacé par des appels au VM (et donc au serveur), il ne doit rester plus qu'un seul endroit où vous mettez à jour l'adapter: dans le .collect { }