Aplicación NotesApp en Kotlin
App en Kotlin que se comunica con el API Rest de notas creado en el VPS laravel.alumno.me
¿Esquema de la red?
Aplicación en Kotlin
Crear el proyecto NotesAppWeb
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <uses-permission android:name="android.permission.INTERNET" /> <application android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.NotesAppWeb" tools:targetApi="31"> <activity android:name=".LoginActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <activity android:name=".RegisterActivity" android:exported="false" android:windowSoftInputMode="adjustNothing" android:parentActivityName=".LoginActivity"> </activity> <activity android:name=".MainActivity" android:exported="false" android:parentActivityName=".LoginActivity"> </activity> </application> </manifest>
lib.versions.toml
[versions] agp = "8.9.3" kotlin = "2.0.21" coreKtx = "1.16.0" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" appcompat = "1.7.0" material = "1.12.0" activity = "1.10.1" constraintlayout = "2.2.1" datastorePreferences = "1.1.7" kotlinxCoroutinesCore = "1.10.2" kotlinxCoroutinesAndroid = "1.10.2" lifecycleCommonJava11 = "2.9.0" okhttp = "4.12.0" retrofit = "3.0.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleCommonJava11" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
buid.gradle (del módulo)
plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) } android { namespace = "com.example.notesappweb" compileSdk = 35 defaultConfig { applicationId = "com.example.notesappweb" minSdk = 28 targetSdk = 35 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildFeatures { viewBinding = true } buildTypes { release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = "11" } } dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.datastore.preferences) implementation (libs.androidx.lifecycle.viewmodel.ktx) // implementation ("com.squareup.retrofit2:retrofit:3.0.0") // implementation ("com.squareup.retrofit2:converter-gson:3.0.0") implementation(libs.okhttp) implementation (libs.retrofit) implementation (libs.converter.gson) implementation(libs.kotlinx.coroutines.core) implementation (libs.kotlinx.coroutines.android) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) }
model/Login
data class Login( val token: String, val name: String )
model/LoginResponse
data class LoginResponse( @SerializedName("success") var success: Boolean, @SerializedName("data") var login: Login, @SerializedName("message") var message: String )
model/Note
data class Note( @SerializedName("id") val id: Int, @SerializedName("title") val title: String, @SerializedName("description") val description: String, @SerializedName("user_id") val user_id: Int, /* @SerializedName("created_at") val created_at: Timestamp, @SerializedName("updated_at") val updated_at: Timestamp */ ) : Serializable
model/NoteResponse
data class NoteResponse( @SerializedName("success") var success: Boolean, @SerializedName("data") var note: Note?, @SerializedName("message") var message: String )
model/NotesResponse
data class NotesResponse( @SerializedName("success") var success: Boolean, @SerializedName("data") var notes: ArrayList<Note>, @SerializedName("message") var message: String )
network/ApiService
interface ApiService { @POST("api/login") @FormUrlEncoded suspend fun login( @Field("email") email: String, @Field("password") password: String ) : Response<LoginResponse> @POST("api/register") @FormUrlEncoded suspend fun register( @Field("name") name: String, @Field("email") email: String, @Field("password") password: String, @Field("confirm_password") confirmPassword: String ) : Response<LoginResponse> @POST("api/logout") suspend fun logout() : Response<LogoutResponse> @Headers( "Accept: application/json", ) @GET("api/note") suspend fun getNotes(): Response<NotesResponse> @Headers( "Accept: application/json", ) @POST("api/note") @FormUrlEncoded suspend fun addNote( @Field("title") title: String, @Field("description") description: String ): Response<NoteResponse> @Headers( "Accept: application/json", ) @PUT("api/note/{id}") @FormUrlEncoded suspend fun updateNote( @Path("id") id: Int, @Field("title") title: String, @Field("description") description: String ): Response<NoteResponse> @Headers( "Accept: application/json", ) @DELETE("api/note/{id}") suspend fun deleteNote( @Path("id") id: Int ): Response<DeleteResponse> }
network/ApiAdapter
object ApiAdapter { private var API_SERVICE: ApiService? = null private const val BASE_URL = "https://laravel.alumno.me/" public var API_TOKEN: String = "" @get:Synchronized val instance: ApiService? get() { if (API_SERVICE == null) { val client = OkHttpClient.Builder().addInterceptor(Interceptor { chain -> val request = chain.request().newBuilder().addHeader("Authorization", "Bearer ${API_TOKEN}").build() chain.proceed(request) }).build() val retrofit = Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .client(client) .build() API_SERVICE = retrofit.create(ApiService::class.java) } return API_SERVICE } }
network/ApiHelper
class ApiHelper(private val apiService: ApiService) { suspend fun login(email: String, password: String) : LoginResponse { return withContext(Dispatchers.IO) { val response = apiService.login(email, password) response.body() ?: LoginResponse(false, Login("", ""), "") } } suspend fun register(name: String, email: String, password: String, confirmPassword: String) : LoginResponse { return withContext(Dispatchers.IO) { val response = apiService.register(name, email, password, confirmPassword) response.body() ?: LoginResponse(false, Login("", ""), "") } } suspend fun logout() : LogoutResponse { return withContext(Dispatchers.IO) { val response = apiService.logout() response.body() ?: LogoutResponse(false, Logout(""), "") } } suspend fun getNotes(): NotesResponse { return withContext(Dispatchers.IO) { val response = apiService.getNotes() response.body() ?: NotesResponse(false, arrayListOf(), "") } } suspend fun addNote(note: Note): NoteResponse { return withContext(Dispatchers.IO) { val response = apiService.addNote(note.title, note.description) response.body() ?: NoteResponse(false, null, "") } } suspend fun updateNote(note: Note): NoteResponse { return withContext(Dispatchers.IO) { val response = apiService.updateNote(note.id, note.title, note.description) response.body() ?: NoteResponse(false, null, "") } } suspend fun deleteNote(note: Note): DeleteResponse { return withContext(Dispatchers.IO) { val response = apiService.deleteNote(note.id) response.body() ?: DeleteResponse(false, arrayListOf(), "") } } }
repository/NoteRepository
class NoteRepository (private val apiHelper: ApiHelper) { suspend fun login(email: String, password: String) = apiHelper.login(email, password) suspend fun register(name: String, email: String, password: String, confirmPassword: String) = apiHelper.register(name, email, password, confirmPassword) suspend fun logout() = apiHelper.logout() suspend fun getNotes() = apiHelper.getNotes() suspend fun addNote(note: Note) = apiHelper.addNote(note) suspend fun updateNote(note: Note) = apiHelper.updateNote(note) suspend fun deleteNote(note: Note) = apiHelper.deleteNote(note) }
viewmodel/MainActivityViewModel
class MainActivityViewModel(application: Application) : AndroidViewModel(application) { private val _notes: MutableLiveData<List<Note>> = MutableLiveData() val notes: LiveData<List<Note>> get() = _notes private var repository: NoteRepository = NoteRepository(ApiHelper(instance!!)) suspend fun getNotes(): NotesResponse { val notesResponse = repository.getNotes() _notes.value = notesResponse.notes return notesResponse } suspend fun addNote(note: Note): Boolean { val noteResponse = repository.addNote(note) if (noteResponse.success) { // Añadimos la nota a la lista, y reasignamos el valor de LiveData para que se actualice //add note to _notes _notes.value = _notes.value?.toMutableList()?.apply { add(noteResponse.note!!) } } return noteResponse.success } suspend fun updateNote(note: Note, newNote: Note): Boolean { val noteResponse = repository.updateNote(newNote) if (noteResponse.success) { // Reasignamos el valor de LiveData para que se actualice _notes.value = _notes.value.orEmpty().map { if (it == note) newNote else it } _notes.value = _notes.value } return noteResponse.success } suspend fun deleteNote(note: Note): Boolean { val noteResponse = repository.deleteNote(note) if (noteResponse.success) { // Reasignamos el valor de LiveData para que se actualice _notes.value = _notes.value?.toMutableList()?.apply { remove(note) } } return noteResponse.success } suspend fun logout() : Boolean { val logoutResponse = repository.logout() return logoutResponse.success } }
LoginActivity
class LoginActivity : AppCompatActivity() { private lateinit var binding: ActivityLoginBinding private var repository: NoteRepository = NoteRepository(ApiHelper(ApiAdapter.instance!!)) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityLoginBinding.inflate(layoutInflater) setContentView(binding.root) // Cargamos los datos de usuario getLogin() binding.btLogin.setOnClickListener { toggleUI(false) lifecycleScope.launch { val email = binding.etEmail.text.toString() val password = binding.etPassword.text.toString() // Validamos que todos los datos del login estén correctos if (validateLogin(email, password)) { val response = repository.login(email, password) if (response.success) { withContext(Dispatchers.Main) { toggleUI(true) } // Guardamos los datos del usuario saveLogin(email, password) // Lanzamos la actividad principal val mainActivityIntent = Intent(this@LoginActivity, MainActivity::class.java) mainActivityIntent.putExtra("API_TOKEN", response.login.token) startActivity(mainActivityIntent) finish() } else { withContext(Dispatchers.Main) { val alertDialog: AlertDialog = AlertDialog.Builder(this@LoginActivity).create().apply { setTitle("Error") setMessage("Error al iniciar sesión: " + response.message.ifEmpty { "No se ha podido iniciar sesión" }) setButton( AlertDialog.BUTTON_POSITIVE, "Aceptar" ) { dialog, _ -> dialog.dismiss() } } alertDialog.show() toggleUI(true) } } } } } binding.btRegister.setOnClickListener { startActivity(Intent(this@LoginActivity, RegisterActivity::class.java)) } } private fun toggleUI(state: Boolean) { binding.btLogin.isEnabled = state binding.btRegister.isEnabled = state } private suspend fun saveLogin(email: String, password: String) { dataStore.edit { preferences -> preferences[booleanPreferencesKey("rememberLogin")] = binding.cbRememberLogin.isChecked if (binding.cbRememberLogin.isChecked) { preferences[stringPreferencesKey("email")] = email preferences[stringPreferencesKey("password")] = password } else { preferences.remove(stringPreferencesKey("email")) preferences.remove(stringPreferencesKey("password")) } } } private fun getLogin() { runBlocking { dataStore.data.map { preferences -> binding.cbRememberLogin.isChecked = preferences[booleanPreferencesKey("rememberLogin")] ?: false if (binding.cbRememberLogin.isChecked) { binding.etEmail.setText(preferences[stringPreferencesKey("email")].orEmpty()) binding.etPassword.setText(preferences[stringPreferencesKey("password")].orEmpty()) } }.first() } } private fun validateLogin(email: String, password: String): Boolean { var loginValidated = true // Comprobamos si hay campos vacios if (email.isEmpty() || password.isEmpty()) { loginValidated = false showMessage("No puedes dejar campos sin rellenar") if (email.isEmpty()) binding.etEmail.error = "El email no puede estar vacio" if (password.isEmpty()) binding.etPassword.error = "La contraseña no puede estar vacia" toggleUI(true) } // Comprobamos si el email cumple el estandar en regex if (loginValidated && !Patterns.EMAIL_ADDRESS.matcher(email).matches()) { loginValidated = false showMessage("El email no es válido") binding.etEmail.error = "El email no es válido" toggleUI(true) } return loginValidated } private fun showMessage(message: String) { Toast.makeText(this@LoginActivity, message, Toast.LENGTH_SHORT).show() } }
RegisterActivity
class RegisterActivity: AppCompatActivity() { private lateinit var binding: ActivityRegisterBinding private var repository: NoteRepository = NoteRepository(ApiHelper(ApiAdapter.instance!!)) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityRegisterBinding.inflate(layoutInflater) setContentView(binding.root) binding.btRegistro.setOnClickListener { binding.btRegistro.isEnabled = false lifecycleScope.launch { val name = binding.etTitle.text.toString() val email = binding.etEmail.text.toString() val password = binding.etPassword.text.toString() val confirmPassword = binding.etConfirmPassword.text.toString() // Validamos que todos los datos estén correctos if (validateRegister(name, email, password, confirmPassword)) { val response = repository.register( name, email, password, confirmPassword ) if (response.success) { withContext(Dispatchers.Main) { binding.btRegistro.isEnabled = true } val mainActivityIntent = Intent(this@RegisterActivity, MainActivity::class.java) mainActivityIntent.putExtra("API_TOKEN", response.login.token) startActivity(mainActivityIntent) finish() } else { withContext(Dispatchers.Main) { val alertDialog: AlertDialog = AlertDialog.Builder(this@RegisterActivity).create().apply { setTitle("Error") setMessage("Error al registrarse") setButton( AlertDialog.BUTTON_POSITIVE, "Aceptar" ) { dialog, _ -> dialog.dismiss() } } alertDialog.show() binding.btRegistro.isEnabled = true } } } } } } private fun validateRegister( name: String, email: String, password: String, confirmationPassword: String ): Boolean { var registerValidated = true // Comprobamos que todos los campos estén rellenos if (name.isEmpty() || email.isEmpty() || password.isEmpty() || confirmationPassword.isEmpty()) { registerValidated = false if (name.isEmpty()) binding.etTitle.error = "El nombre de usuario no puede estar vacio" if (email.isEmpty()) binding.etEmail.error = "El email no puede estar vacio" if (password.isEmpty()) binding.etPassword.error = "La contraseña no puede estar vacia" if (confirmationPassword.isEmpty()) binding.etConfirmPassword.error = "La confirmación de contraseña no puede estar vacia" showMessage( "No se puede registrar. Hay campos sin rellenar") binding.btRegistro.isEnabled = true } if (registerValidated && !Patterns.EMAIL_ADDRESS.matcher(email).matches()) { registerValidated = false showMessage("El email no es válido") binding.etEmail.error = "El email no es válido" binding.btRegistro.isEnabled = true } if (registerValidated && password != confirmationPassword) { registerValidated = false showMessage("Las contraseñas no coinciden") binding.etConfirmPassword.error = "Las contraseñas no coinciden" binding.btRegistro.isEnabled = true } return registerValidated } private fun showMessage(message: String) { Toast.makeText(this@RegisterActivity, message, Toast.LENGTH_SHORT).show() } }
adapter/NoteAdapter
class NoteAdapter() : RecyclerView.Adapter<NoteAdapter.NoteViewHolder>() { var notesList: List<Note> = emptyList() var onItemClick: ((Note) -> Unit)? = null var onLongItemClick: ((Note) -> Unit)? = null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteViewHolder { val itemBinding = NoteItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) return NoteViewHolder(itemBinding) } override fun getItemCount(): Int = notesList.size override fun onBindViewHolder(holder: NoteViewHolder, position: Int) { val item = notesList[position] holder.render(item) } fun getItem(position: Int): Note { return notesList.get(position) } inner class NoteViewHolder(binding: NoteItemBinding) : RecyclerView.ViewHolder(binding.root) { val title = binding.tvTitle val description = binding.tvDescription init { itemView.setOnClickListener { onItemClick?.invoke(notesList[layoutPosition]) } itemView.setOnLongClickListener { onLongItemClick?.invoke(notesList[layoutPosition]) false } } fun render(note: Note) { title.text = note.title description.text = note.description } } }
MainActivity
class MainActivity : AppCompatActivity() { lateinit var binding: ActivityMainBinding private lateinit var viewModel: MainActivityViewModel private lateinit var adapter: NoteAdapter private var backPressedOnce = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ApiAdapter.API_TOKEN = intent.getStringExtra("API_TOKEN").toString() binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) viewModel = ViewModelProvider(this)[MainActivityViewModel::class.java] setSupportActionBar(binding.toolbar) initRecyclerView() binding.fab.setOnClickListener { addNote(this) } // Confirmación para salir de la actividad onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { if (backPressedOnce) { logout() } else { backPressedOnce = true Toast.makeText(this@MainActivity, "Presiona de nuevo para cerrar sesión", Toast.LENGTH_SHORT).show() Handler(Looper.getMainLooper()).postDelayed(Runnable { backPressedOnce = false }, 2000) } } }) } private fun initRecyclerView() { binding.recyclerNotes.layoutManager = LinearLayoutManager(this) getNotes() val itemSwipe = object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.RIGHT) { override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { return false } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { if (binding.recyclerNotes.isEnabled) { deleteDialog(viewHolder) } else { adapter.notifyItemChanged(viewHolder.layoutPosition) } } } val swap = ItemTouchHelper(itemSwipe) swap.attachToRecyclerView(binding.recyclerNotes) } private fun getNotes() { lifecycleScope.launch { val response = viewModel.getNotes() if (response.success) { adapter = NoteAdapter() binding.recyclerNotes.adapter = adapter adapter.onLongItemClick = { editNote(it, this@MainActivity) } // Cuando los datos de LiveData cambian, reasignamos la lista, ordenada por id, // falta usar DiffUtil para actualizar el RecyclerView en lugar de notifyDataSetChanged viewModel.notes.observe(this@MainActivity) { notesList -> adapter.notesList = notesList.sortedWith(compareBy({ it.id })) adapter.notifyDataSetChanged() } } else { withContext(Dispatchers.Main) { showMessage("No se han podido cargar las notas") } } } } private fun updateNotes() { lifecycleScope.launch { if (viewModel.getNotes().success) { showMessage("Notas actualizadas") } else { showMessage("Error al actualizar las notas") } } } private fun addNote(context: Context) { val dialogBinding: AddNoteBinding = AddNoteBinding.inflate(LayoutInflater.from(context)) val title = dialogBinding.etTitle val description = dialogBinding.etTDescription val addDialog = AlertDialog.Builder(context) addDialog.setView(dialogBinding.root) addDialog.setTitle("Añadir nota") addDialog.setPositiveButton("Añadir") { dialog, _ -> val titleNote = title.text.toString() val descriptionNote = description.text.toString() if (validateNote(titleNote, descriptionNote)) { val note = Note( 0, titleNote, descriptionNote, 0, ) lifecycleScope.launch { if (viewModel.addNote(note)) { showMessage("Se ha añadido la nota") } else { showMessage("No se ha podido añadir la nota") } } } } addDialog.setNegativeButton("Cancelar") { dialog, _ -> dialog.dismiss() showMessage("Cancelado") } addDialog.create() addDialog.show() } private fun editNote(note: Note, context: Context) { val dialogBinding: AddNoteBinding = AddNoteBinding.inflate(LayoutInflater.from(context)) val titleNote = dialogBinding.etTitle val descriptionNote = dialogBinding.etTDescription titleNote.setText(note.title) descriptionNote.setText(note.description) val editDialog = AlertDialog.Builder(context) editDialog.setView(dialogBinding.root) editDialog.setTitle("Editar nota") editDialog.setPositiveButton("Editar") { dialog, _ -> val title = titleNote.text.toString() val description = descriptionNote.text.toString() if (validateNote(title, description)) { val newNote = Note(note.id, title, description, note.user_id) lifecycleScope.launch { if (viewModel.updateNote(note, newNote)) { showMessage("Se ha editado la nota") dialog.dismiss() } else showMessage("No se ha podido editar la nota") } } } editDialog.setNegativeButton("Cancelar") { dialog, _ -> dialog.dismiss() showMessage("Cancelado") } editDialog.create() editDialog.show() } private fun deleteDialog(viewHolder: RecyclerView.ViewHolder) { val builder = AlertDialog.Builder(this) builder.setTitle("Eliminar Nota") builder.setMessage("¿Estás seguro de eliminar esta nota de la lista?") builder.setPositiveButton("Aceptar") { _, _ -> lifecycleScope.launch { if (viewModel.deleteNote(adapter.getItem(viewHolder.layoutPosition))) { showMessage("La nota se ha eliminado correctamente") } else showMessage("No se ha podido eliminar la nota") } } builder.setNegativeButton("Cancelar") { _, _ -> val position = viewHolder.adapterPosition adapter.notifyItemChanged(position) } builder.show() } // Menu options override fun onCreateOptionsMenu(menu: Menu?): Boolean { menuInflater.inflate(R.menu.menu_items, menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_refresh -> { updateNotes() } R.id.action_logout -> { logout() } else -> { super.onOptionsItemSelected(item) } } return true } private fun logout() { lifecycleScope.launch { if (viewModel.logout()){ startActivity(Intent(this@MainActivity, LoginActivity::class.java)) finish() } else withContext(Dispatchers.Main) { showMessage("Error al cerrar la sesión") } } } private fun validateNote(title: String, description: String) : Boolean { var validated = true if (title.isEmpty() || description.isEmpty() ) { validated = false showMessage("No se ha validado la nota. Hay campos sin rellenar") } return validated } private fun showMessage(message: String) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() } }
Mejoras:
– Usar DiffUtil en el adapter
– Usar fragments (HomeFragment, AddFragment y EditFragment) y navegación entre ellos en la interface de usuario
Tarea presencial de la unidad 6 de HLC
Deja una respuesta
Lo siento, debes estar conectado para publicar un comentario.