📚 Pré-requisitos Teóricos: este projeto aplica conceitos ensinados em Especialização em Android Nativo e consome os serviços da Parte 01 — Spring Boot API.
Seja muito bem-vindo à Parte 04 do ecossistema de aplicações Lista de Tarefas!
Nesta etapa prática, você aprenderá a construir um aplicativo móvel nativo profissional para Android utilizando o ecossistema clássico e amplamente consagrado no mercado de engenharia de software: Java puro, layouts declarativos em XML, o componente de alto desempenho RecyclerView e a biblioteca líder da indústria para chamadas REST assíncronas, o Retrofit 2.
O cliente Android é um consumidor de primeira classe da nossa Fonte Única da Verdade (SSOT — Single Source of Truth), a API central em Spring Boot rodando na porta 8080 (desenvolvida na Parte 01).
Enquanto os frontends Web (Svelte) e Desktop (Swing/JavaFX) operam no mesmo espaço de rede do host de desenvolvimento, o ambiente móvel apresenta desafios arquiteturais específicos de conectividade, sandbox de segurança de rede e ciclo de vida de componentes gráficos.
flowchart TD
subgraph "Clientes do Ecossistema"
WEB["🌐 Web (Svelte JS Puro)<br>Porta 5173"]
DESK["💻 Desktop (Swing / JavaFX)<br>Processo Nativo"]
MOB["📱 Mobile (Android Java + XML)<br>Dispositivo / Emulador"]
end
subgraph "Camada de Serviços (SSOT)"
API["🚀 Spring Boot REST API<br>fullstack_tarefas_01_spring_api<br>Porta 8080"]
end
subgraph "Persistência Compartilhada"
DB[("🗄️ Banco de Dados H2<br>Memória / Arquivo")]
end
WEB -->|"HTTP /api/tarefas"| API
DESK -->|"HTTP /api/tarefas"| API
MOB -->|"HTTP via 10.0.2.2:8080"| API
API --> DB
10.0.2.2 vs Celular FísicoAo desenvolver para Android no computador, o aplicativo é executado dentro de uma máquina virtual emulada baseada em QEMU.
[!IMPORTANT] Por que
http://localhost:8080não funciona no Emulador Android?
Para o sistema operacional Android dentro do emulador,localhoste127.0.0.1representam o próprio dispositivo emulado, e não o computador Windows/Linux onde a API Spring Boot está rodando!
Para acessar a máquina de desenvolvimento através do roteador virtual interno do emulador, o Google reservou o endereço IP especial:http://10.0.2.2:8080/
Se você conectar seu smartphone físico via cabo USB ou Wi-Fi na mesma rede local que seu computador:
192.168.1.50).http://192.168.1.50:8080/api/tarefas.adb reverse tcp:8080 tcp:8080
Com isso, a URL http://localhost:8080/api/tarefas passa a funcionar diretamente no celular físico conectado via USB!
A aplicação adota a arquitetura clássica recomendada para Android nativo em Java, garantindo isolamento total entre a interface do usuário e as operações de rede:
res/layout/ que descrevem as telas e itens visuais (activity_main.xml, item_tarefa.xml, dialog_editar_tarefa.xml).MainActivity.java e TarefaAdapter.java):
onCreate), instanciam componentes visuais, capturam cliques do usuário e despacham eventos.TarefaAdapter gerencia a reciclagem dinâmica de células através do padrão ViewHolder, garantindo consumo constante e mínimo de memória RAM mesmo em listas com centenas de itens.RetrofitClient.java, TarefaApiService.java, Tarefa.java):
android.os.NetworkOnMainThreadException.Para assegurar rigor técnico corporativo, o projeto foi modelado com Histórias de Usuário, Requisitos Funcionais (RF), Requisitos Não-Funcionais (RNF) e Casos de Uso.
| ID | Descrição | Verbo REST / Endpoint |
|---|---|---|
| RF-M01 | Listar todas as tarefas cadastradas na inicialização ou ao acionar o botão de recarga. | GET /api/tarefas |
| RF-M02 | Cadastrar nova tarefa com descrição preenchida e status inicial como não concluída (false). |
POST /api/tarefas |
| RF-M03 | Atualizar o status de conclusão (concluida = true/false) ao interagir com o CheckBox. |
PUT /api/tarefas/{id} |
| RF-M04 | Modificar a descrição textual de uma tarefa existente por meio de diálogo modal. | PUT /api/tarefas/{id} |
| RF-M05 | Remover permanentemente uma tarefa após confirmação do usuário em diálogo de alerta. | DELETE /api/tarefas/{id} |
| ID | Categoria | Descrição |
|---|---|---|
| RNF-M01 | Threading & UI | Todas as chamadas de rede devem ser executadas em segundo plano (background thread). A interface gráfica nunca deve congelar (NetworkOnMainThreadException proibida). |
| RNF-M02 | Desempenho & Memória | A listagem deve empregar RecyclerView com padrão ViewHolder para reciclar views na rolagem, mantendo uso estável de memória. |
| RNF-M03 | Segurança de Rede | O manifesto deve autorizar tráfego HTTP claro (Cleartext Traffic) para o host de desenvolvimento 10.0.2.2 via network_security_config.xml. |
| RNF-M04 | Feedback Visual | Operações assíncronas devem exibir indicador de progresso (ProgressBar), mensagens amigáveis (Toast) e empty state quando a lista estiver vazia. |
| RNF-M05 | Compatibilidade | O aplicativo deve suportar dispositivos a partir do Android 8.0 (API nível 26) até o Android 14+ (API nível 34). |
flowchart LR
User(("👤 Usuário Móvel"))
subgraph "App Android Clássico"
UC01["UC01: Visualizar Lista de Tarefas"]
UC02["UC02: Cadastrar Nova Tarefa"]
UC03["UC03: Alternar Status (Concluir/Reabrir)"]
UC04["UC04: Editar Descrição da Tarefa"]
UC05["UC05: Excluir Tarefa com Confirmação"]
end
User --> UC01
User --> UC02
User --> UC03
User --> UC04
User --> UC05
ProgressBar).TarefaApiService.listarTodas() é chamado de forma assíncrona via Retrofit.200 OK e um array JSON contendo as tarefas.TarefaAdapter popula o RecyclerView e oculta a barra de progresso.RecyclerView é ocultado e a mensagem de Empty State é exibida em destaque.Toast exibe mensagem detalhada de falha de conexão.POST /api/tarefas com { "descricao": "...", "concluida": false }.201 Created, o campo de texto é limpo, um aviso de sucesso é exibido e a lista é recarregada.isPressed()) e notifica a Activity.PUT /api/tarefas/{id} com o novo estado booleano.200 OK confirma a alteração; a linha ganha efeito de riscado (tachado) e cor atenuada.AlertDialog é aberto contendo um campo EditText pré-carregado com o texto atual.PUT /api/tarefas/{id} com a nova descrição.DELETE /api/tarefas/{id}.204 No Content, a tarefa é removida da listagem e um Toast confirma a exclusão.| Caso de Uso | História de Usuário | Requisito Funcional | Requisito Não-Funcional | Classe/Artefato Responsável |
|---|---|---|---|---|
| UC01 | US-M01 | RF-M01 | RNF-M01, RNF-M02, RNF-M04 | MainActivity, TarefaAdapter, activity_main.xml |
| UC02 | US-M02 | RF-M02 | RNF-M01, RNF-M04 | MainActivity, TarefaApiService, item_tarefa.xml |
| UC03 | US-M03 | RF-M03 | RNF-M01, RNF-M02 | TarefaAdapter, item_tarefa.xml |
| UC04 | US-M04 | RF-M04 | RNF-M01, RNF-M04 | MainActivity, dialog_editar_tarefa.xml |
| UC05 | US-M05 | RF-M05 | RNF-M01, RNF-M04 | MainActivity, AlertDialog |
O projeto mobile está alocado no diretório mobile-android-xml/ e segue a estrutura clássica do Android Gradle Plugin:
mobile-android-xml/
├── app/
│ ├── build.gradle.kts ← Dependências e diretivas de compilação
│ ├── src/
│ │ ├── main/
│ │ │ ├── AndroidManifest.xml ← Permissões de Internet e registro da Activity
│ │ │ ├── java/br/com/curso/listadetarefas/android/
│ │ │ │ ├── adapter/
│ │ │ │ │ └── TarefaAdapter.java ← Adapter do RecyclerView e ViewHolder
│ │ │ │ ├── model/
│ │ │ │ │ └── Tarefa.java ← Entidade de domínio serializável
│ │ │ │ ├── network/
│ │ │ │ │ ├── RetrofitClient.java ← Singleton HTTP apontando para 10.0.2.2:8080
│ │ │ │ │ └── TarefaApiService.java ← Contratos REST das operações
│ │ │ │ └── MainActivity.java ← Controller principal da tela
│ │ │ └── res/
│ │ │ ├── drawable/ ← Vetores XML nativos (ic_add, ic_edit, ic_delete...)
│ │ │ ├── layout/
│ │ │ │ ├── activity_main.xml ← Layout da tela principal
│ │ │ │ ├── dialog_editar_tarefa.xml ← Layout do modal de edição
│ │ │ │ └── item_tarefa.xml ← Layout de cada linha do RecyclerView
│ │ │ ├── values/
│ │ │ │ ├── colors.xml ← Paleta de cores corporativa
│ │ │ │ ├── strings.xml ← Textos e recursos de internacionalização
│ │ │ │ └── themes.xml ← Tema Material sem ActionBar padrão
│ │ │ └── xml/
│ │ │ └── network_security_config.xml ← Liberação de tráfego HTTP claro
│ │ └── test/java/br/com/curso/listadetarefas/android/
│ │ └── TarefaTest.java ← Testes unitários com JUnit 4
├── gradle/
│ ├── libs.versions.toml ← Declaração centralizada de versões e bibliotecas
│ └── wrapper/ ← Gradle Wrapper 8.x
├── build.gradle.kts ← Configuração raiz do Gradle
├── gradle.properties ← Configurações de alocação de memória da JVM
└── local.properties ← Caminho absoluto do Android SDK do host
build.gradle.kts e libs.versions.toml)No arquivo gradle/libs.versions.toml, declaramos as dependências do ecossistema clássico:
[versions]
agp = "8.11.1"
appcompat = "1.6.1"
material = "1.11.0"
constraintlayout = "2.1.4"
recyclerview = "1.3.2"
retrofit = "2.9.0"
okhttp = "4.12.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
[libraries]
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
com-google-android-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
# Networking: Retrofit & OkHttp
com-squareup-retrofit2-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
com-squareup-retrofit2-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
com-squareup-okhttp3-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
# Testes
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" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
No módulo app/build.gradle.kts, configuramos o compilador para Java 17 e ativamos o ViewBinding:
plugins {
alias(libs.plugins.android.application)
}
android {
namespace = "br.com.curso.listadetarefas.android"
compileSdk = 34
defaultConfig {
applicationId = "br.com.curso.listadetarefas.android"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.com.google.android.material)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.recyclerview)
implementation(libs.com.squareup.retrofit2.retrofit)
implementation(libs.com.squareup.retrofit2.converter.gson)
implementation(libs.com.squareup.okhttp3.logging.interceptor)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
AndroidManifest.xml)Desde o Android 9 (API 28), o tráfego HTTP sem criptografia SSL/TLS (Cleartext Traffic) é bloqueado por padrão. Para viabilizar a comunicação com a API de desenvolvimento local em http://10.0.2.2:8080, configuramos o manifesto e a política de segurança de rede:
Em app/src/main/res/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">localhost</domain>
</domain-config>
</network-security-config>
Em app/src/main/AndroidManifest.xml:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissão mandante para requisições HTTP -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Listadetarefasandroid">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Listadetarefasandroid">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
activity_main.xmlO layout da tela principal abriga a barra de título superior, o formulário de inclusão estilizado em MaterialCardView, a ProgressBar para feedback assíncrono, a lista com rolagem suave em RecyclerView e o estado vazio (Empty State).
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- Top Header Corporativo -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/primary"
android:orientation="horizontal"
android:paddingHorizontal="20dp"
android:paddingTop="28dp"
android:paddingBottom="20dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvAppTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/titulo_tela"
android:textColor="@color/surface"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvAppSubtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/subtitulo_tela"
android:textColor="#C7D2FE"
android:textSize="12sp" />
</LinearLayout>
<ImageButton
android:id="@+id/btnRecarregar"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Recarregar tarefas"
android:src="@drawable/ic_refresh"
app:tint="@color/surface" />
</LinearLayout>
<!-- Formulário de Nova Tarefa -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:strokeColor="@color/card_border"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<EditText
android:id="@+id/etNovaTarefa"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@android:color/transparent"
android:hint="@string/hint_nova_tarefa"
android:imeOptions="actionDone"
android:inputType="textCapSentences"
android:paddingVertical="8dp"
android:paddingHorizontal="8dp"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary"
android:textSize="15sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAdicionar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/btn_adicionar"
android:textColor="@color/surface"
android:textSize="13sp"
app:backgroundTint="@color/primary"
app:cornerRadius="8dp"
app:icon="@drawable/ic_add"
app:iconTint="@color/surface" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Indicador de Carregamento Assíncrono -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="center_horizontal"
android:layout_marginVertical="12dp"
android:indeterminateTint="@color/primary"
android:visibility="gone" />
<!-- Container do RecyclerView e Empty State -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvTarefas"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingHorizontal="16dp"
android:paddingTop="4dp"
android:paddingBottom="24dp"
tools:listitem="@layout/item_tarefa" />
<TextView
android:id="@+id/tvEmptyState"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:padding="32dp"
android:text="@string/empty_tarefas"
android:textColor="@color/text_secondary"
android:textSize="15sp"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
item_tarefa.xmlCada linha da lista é encapsulada em um MaterialCardView com cantos arredondados contendo a caixa de marcação, o texto descritivo e as ações de editar e excluir:
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/cardItemTarefa"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="4dp"
app:cardCornerRadius="10dp"
app:cardElevation="1dp"
app:strokeColor="@color/card_border"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingVertical="10dp"
android:paddingHorizontal="12dp">
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/cbConcluida"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="0dp"
android:minHeight="0dp"
app:buttonTint="@color/secondary" />
<TextView
android:id="@+id/tvDescricao"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="3"
android:textColor="@color/text_primary"
android:textSize="15sp"
tools:text="Comprar suprimentos do projeto" />
<ImageButton
android:id="@+id/btnEditar"
android:layout_width="38dp"
android:layout_height="38dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Editar tarefa"
android:padding="7dp"
android:src="@drawable/ic_edit" />
<ImageButton
android:id="@+id/btnExcluir"
android:layout_width="38dp"
android:layout_height="38dp"
android:layout_marginStart="2dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Excluir tarefa"
android:padding="7dp"
android:src="@drawable/ic_delete" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
Tarefa.java)A classe de modelo mapeia os campos retornados pela API Spring Boot:
package br.com.curso.listadetarefas.android.model;
import com.google.gson.annotations.SerializedName;
import java.io.Serializable;
import java.util.Objects;
public class Tarefa implements Serializable {
@SerializedName("id")
private Long id;
@SerializedName("descricao")
private String descricao;
@SerializedName("concluida")
private boolean concluida;
public Tarefa() {
}
public Tarefa(String descricao, boolean concluida) {
this.descricao = descricao;
this.concluida = concluida;
}
public Tarefa(Long id, String descricao, boolean concluida) {
this.id = id;
this.descricao = descricao;
this.concluida = concluida;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getDescricao() {
return descricao;
}
public void setDescricao(String descricao) {
this.descricao = descricao;
}
public boolean isConcluida() {
return concluida;
}
public void setConcluida(boolean concluida) {
this.concluida = concluida;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Tarefa tarefa = (Tarefa) o;
return Objects.equals(id, tarefa.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
TarefaApiService.javaDeclara os métodos assíncronos que mapeiam os endpoints da API REST:
package br.com.curso.listadetarefas.android.network;
import br.com.curso.listadetarefas.android.model.Tarefa;
import java.util.List;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Path;
public interface TarefaApiService {
@GET("api/tarefas")
Call<List<Tarefa>> listarTodas();
@GET("api/tarefas/{id}")
Call<Tarefa> buscarPorId(@Path("id") Long id);
@POST("api/tarefas")
Call<Tarefa> criar(@Body Tarefa tarefa);
@PUT("api/tarefas/{id}")
Call<Tarefa> atualizar(@Path("id") Long id, @Body Tarefa tarefa);
@DELETE("api/tarefas/{id}")
Call<Void> excluir(@Path("id") Long id);
}
RetrofitClient.javaGarante uma única instância com OkHttpClient, logging interceptor e endereço base http://10.0.2.2:8080/:
package br.com.curso.listadetarefas.android.network;
import java.util.concurrent.TimeUnit;
import okhttp3.OkHttpClient;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
public class RetrofitClient {
public static final String DEFAULT_BASE_URL = "http://10.0.2.2:8080/";
private static String currentBaseUrl = DEFAULT_BASE_URL;
private static TarefaApiService apiService = null;
private RetrofitClient() {}
public static synchronized TarefaApiService getApiService() {
if (apiService == null) {
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.addInterceptor(logging)
.build();
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(currentBaseUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build();
apiService = retrofit.create(TarefaApiService.class);
}
return apiService;
}
public static synchronized void setBaseUrl(String newBaseUrl) {
if (!newBaseUrl.endsWith("/")) {
newBaseUrl += "/";
}
currentBaseUrl = newBaseUrl;
apiService = null;
}
}
TarefaAdapter.java)O RecyclerView.Adapter converte a lista em memória em views físicas recicladas na tela.
[!TIP] Boas Práticas de Engenharia:
Ao reciclar uma view, chamarsetChecked(...)em um CheckBox pode disparar um listener antigo associado a outra tarefa.
Por isso, embind(...), nós desvinculamos o listener (setOnCheckedChangeListener(null)) antes de atribuir o valor booleano, e só notificamos o clique se o botão foi explicitamente pressionado pelo usuário (buttonView.isPressed()).
package br.com.curso.listadetarefas.android.adapter;
import android.content.Context;
import android.graphics.Paint;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.ImageButton;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import br.com.curso.listadetarefas.android.R;
import br.com.curso.listadetarefas.android.model.Tarefa;
import com.google.android.material.card.MaterialCardView;
import java.util.ArrayList;
import java.util.List;
public class TarefaAdapter extends RecyclerView.Adapter<TarefaAdapter.TarefaViewHolder> {
public interface OnTarefaInteractionListener {
void onToggleStatus(Tarefa tarefa, boolean novoStatus);
void onEdit(Tarefa tarefa);
void onDelete(Tarefa tarefa);
}
private final List<Tarefa> tarefas = new ArrayList<>();
private final OnTarefaInteractionListener listener;
public TarefaAdapter(OnTarefaInteractionListener listener) {
this.listener = listener;
}
public void setTarefas(List<Tarefa> novasTarefas) {
this.tarefas.clear();
if (novasTarefas != null) {
this.tarefas.addAll(novasTarefas);
}
notifyDataSetChanged();
}
@NonNull
@Override
public TarefaViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_tarefa, parent, false);
return new TarefaViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull TarefaViewHolder holder, int position) {
holder.bind(tarefas.get(position), listener);
}
@Override
public int getItemCount() {
return tarefas.size();
}
static class TarefaViewHolder extends RecyclerView.ViewHolder {
private final MaterialCardView cardItemTarefa;
private final CheckBox cbConcluida;
private final TextView tvDescricao;
private final ImageButton btnEditar;
private final ImageButton btnExcluir;
public TarefaViewHolder(@NonNull View itemView) {
super(itemView);
cardItemTarefa = itemView.findViewById(R.id.cardItemTarefa);
cbConcluida = itemView.findViewById(R.id.cbConcluida);
tvDescricao = itemView.findViewById(R.id.tvDescricao);
btnEditar = itemView.findViewById(R.id.btnEditar);
btnExcluir = itemView.findViewById(R.id.btnExcluir);
}
public void bind(final Tarefa tarefa, final OnTarefaInteractionListener listener) {
Context context = itemView.getContext();
tvDescricao.setText(tarefa.getDescricao());
// Estilização visual (tachado / cor / opacidade)
if (tarefa.isConcluida()) {
tvDescricao.setPaintFlags(tvDescricao.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG);
tvDescricao.setTextColor(ContextCompat.getColor(context, R.color.text_completed));
cardItemTarefa.setAlpha(0.85f);
} else {
tvDescricao.setPaintFlags(tvDescricao.getPaintFlags() & (~Paint.STRIKE_THRU_TEXT_FLAG));
tvDescricao.setTextColor(ContextCompat.getColor(context, R.color.text_primary));
cardItemTarefa.setAlpha(1.0f);
}
// Previne disparos fantasmas ao reciclar
cbConcluida.setOnCheckedChangeListener(null);
cbConcluida.setChecked(tarefa.isConcluida());
cbConcluida.setOnCheckedChangeListener((buttonView, isChecked) -> {
if (buttonView.isPressed() && listener != null) {
listener.onToggleStatus(tarefa, isChecked);
}
});
btnEditar.setOnClickListener(v -> {
if (listener != null) listener.onEdit(tarefa);
});
btnExcluir.setOnClickListener(v -> {
if (listener != null) listener.onDelete(tarefa);
});
}
}
}
MainActivity.java)A MainActivity orquestra a inicialização dos componentes, escuta os eventos de toque e despacha as chamadas assíncronas do Retrofit:
package br.com.curso.listadetarefas.android;
import android.app.AlertDialog;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import br.com.curso.listadetarefas.android.adapter.TarefaAdapter;
import br.com.curso.listadetarefas.android.model.Tarefa;
import br.com.curso.listadetarefas.android.network.RetrofitClient;
import br.com.curso.listadetarefas.android.network.TarefaApiService;
import com.google.android.material.button.MaterialButton;
import java.util.List;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class MainActivity extends AppCompatActivity implements TarefaAdapter.OnTarefaInteractionListener {
private EditText etNovaTarefa;
private MaterialButton btnAdicionar;
private ImageButton btnRecarregar;
private ProgressBar progressBar;
private RecyclerView rvTarefas;
private TextView tvEmptyState;
private TarefaAdapter adapter;
private TarefaApiService apiService;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
apiService = RetrofitClient.getApiService();
inicializarComponentes();
configurarRecyclerView();
configurarEventos();
carregarTarefas();
}
private void inicializarComponentes() {
etNovaTarefa = findViewById(R.id.etNovaTarefa);
btnAdicionar = findViewById(R.id.btnAdicionar);
btnRecarregar = findViewById(R.id.btnRecarregar);
progressBar = findViewById(R.id.progressBar);
rvTarefas = findViewById(R.id.rvTarefas);
tvEmptyState = findViewById(R.id.tvEmptyState);
}
private void configurarRecyclerView() {
adapter = new TarefaAdapter(this);
rvTarefas.setLayoutManager(new LinearLayoutManager(this));
rvTarefas.setAdapter(adapter);
}
private void configurarEventos() {
btnAdicionar.setOnClickListener(v -> adicionarTarefa());
btnRecarregar.setOnClickListener(v -> carregarTarefas());
}
private void setCarregando(boolean carregando) {
progressBar.setVisibility(carregando ? View.VISIBLE : View.GONE);
btnAdicionar.setEnabled(!carregando);
btnRecarregar.setEnabled(!carregando);
}
private void atualizarVisibilidadeLista(int totalTarefas) {
if (totalTarefas == 0) {
rvTarefas.setVisibility(View.GONE);
tvEmptyState.setVisibility(View.VISIBLE);
} else {
rvTarefas.setVisibility(View.VISIBLE);
tvEmptyState.setVisibility(View.GONE);
}
}
private void carregarTarefas() {
setCarregando(true);
apiService.listarTodas().enqueue(new Callback<List<Tarefa>>() {
@Override
public void onResponse(@NonNull Call<List<Tarefa>> call, @NonNull Response<List<Tarefa>> response) {
setCarregando(false);
if (response.isSuccessful() && response.body() != null) {
List<Tarefa> lista = response.body();
adapter.setTarefas(lista);
atualizarVisibilidadeLista(lista.size());
} else {
Toast.makeText(MainActivity.this, "Erro HTTP: " + response.code(), Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(@NonNull Call<List<Tarefa>> call, @NonNull Throwable t) {
setCarregando(false);
Toast.makeText(MainActivity.this, "Falha de conexão: " + t.getMessage(), Toast.LENGTH_LONG).show();
}
});
}
private void adicionarTarefa() {
String texto = etNovaTarefa.getText() != null ? etNovaTarefa.getText().toString().trim() : "";
if (texto.isEmpty()) {
Toast.makeText(this, R.string.erro_campo_vazio, Toast.LENGTH_SHORT).show();
return;
}
Tarefa nova = new Tarefa(texto, false);
setCarregando(true);
apiService.criar(nova).enqueue(new Callback<Tarefa>() {
@Override
public void onResponse(@NonNull Call<Tarefa> call, @NonNull Response<Tarefa> response) {
setCarregando(false);
if (response.isSuccessful() && response.body() != null) {
etNovaTarefa.setText("");
Toast.makeText(MainActivity.this, R.string.sucesso_adicionada, Toast.LENGTH_SHORT).show();
carregarTarefas();
} else {
Toast.makeText(MainActivity.this, "Erro ao salvar: " + response.code(), Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(@NonNull Call<Tarefa> call, @NonNull Throwable t) {
setCarregando(false);
Toast.makeText(MainActivity.this, "Erro de rede: " + t.getMessage(), Toast.LENGTH_LONG).show();
}
});
}
@Override
public void onToggleStatus(Tarefa tarefa, boolean novoStatus) {
tarefa.setConcluida(novoStatus);
setCarregando(true);
apiService.atualizar(tarefa.getId(), tarefa).enqueue(new Callback<Tarefa>() {
@Override
public void onResponse(@NonNull Call<Tarefa> call, @NonNull Response<Tarefa> response) {
setCarregando(false);
carregarTarefas();
}
@Override
public void onFailure(@NonNull Call<Tarefa> call, @NonNull Throwable t) {
setCarregando(false);
Toast.makeText(MainActivity.this, "Falha ao atualizar status: " + t.getMessage(), Toast.LENGTH_SHORT).show();
carregarTarefas();
}
});
}
@Override
public void onEdit(Tarefa tarefa) {
View dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_editar_tarefa, null);
EditText etEditar = dialogView.findViewById(R.id.etEditarDescricao);
etEditar.setText(tarefa.getDescricao());
etEditar.setSelection(etEditar.getText().length());
new AlertDialog.Builder(this)
.setView(dialogView)
.setPositiveButton(R.string.dialog_btn_salvar, (dialog, which) -> {
String novoTexto = etEditar.getText().toString().trim();
if (novoTexto.isEmpty()) {
Toast.makeText(this, R.string.erro_campo_vazio, Toast.LENGTH_SHORT).show();
return;
}
tarefa.setDescricao(novoTexto);
salvarEdicao(tarefa);
})
.setNegativeButton(R.string.dialog_btn_cancelar, null)
.show();
}
private void salvarEdicao(Tarefa tarefa) {
setCarregando(true);
apiService.atualizar(tarefa.getId(), tarefa).enqueue(new Callback<Tarefa>() {
@Override
public void onResponse(@NonNull Call<Tarefa> call, @NonNull Response<Tarefa> response) {
setCarregando(false);
if (response.isSuccessful()) {
Toast.makeText(MainActivity.this, R.string.sucesso_atualizada, Toast.LENGTH_SHORT).show();
carregarTarefas();
}
}
@Override
public void onFailure(@NonNull Call<Tarefa> call, @NonNull Throwable t) {
setCarregando(false);
Toast.makeText(MainActivity.this, "Falha de conexão: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
@Override
public void onDelete(Tarefa tarefa) {
String msg = getString(R.string.dialog_excluir_msg, tarefa.getDescricao());
new AlertDialog.Builder(this)
.setTitle(R.string.dialog_excluir_titulo)
.setMessage(msg)
.setPositiveButton(R.string.dialog_btn_excluir, (dialog, which) -> executarExclusao(tarefa.getId()))
.setNegativeButton(R.string.dialog_btn_cancelar, null)
.show();
}
private void executarExclusao(Long id) {
setCarregando(true);
apiService.excluir(id).enqueue(new Callback<Void>() {
@Override
public void onResponse(@NonNull Call<Void> call, @NonNull Response<Void> response) {
setCarregando(false);
if (response.isSuccessful()) {
Toast.makeText(MainActivity.this, R.string.sucesso_excluida, Toast.LENGTH_SHORT).show();
carregarTarefas();
}
}
@Override
public void onFailure(@NonNull Call<Void> call, @NonNull Throwable t) {
setCarregando(false);
Toast.makeText(MainActivity.this, "Falha ao excluir: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
}
Antes de abrir o app móvel, a API Spring Boot deve estar em execução no host:
# Na pasta da Parte 01:
cd catalogo_projetos/fullstack_tarefas_01_spring_api/api-spring
./mvnw spring-boot:run
Verifique o funcionamento acessando no navegador do seu computador: http://localhost:8080/api/tarefas.
catalogo_projetos/fullstack_tarefas_04_spring_android/mobile-android-xml.Você também pode compilar o APK diretamente pelo terminal:
cd catalogo_projetos/fullstack_tarefas_04_spring_android/mobile-android-xml
./gradlew assembleDebug
O APK será gerado em: app/build/outputs/apk/debug/app-debug.apk.
Para instalar diretamente no emulador ou celular conectado via USB:
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n br.com.curso.listadetarefas.android/.MainActivity
Para certificar a prontidão do aplicativo móvel, execute o seguinte roteiro de validação:
| # | Ação de Teste | Comportamento Esperado | Resultado |
|---|---|---|---|
| 01 | Iniciar app com a API rodando. | Barra de progresso surge brevemente; as tarefas do banco H2 são exibidas no RecyclerView. |
[x] PASS |
| 02 | Digitar “Aprender Views XML no Android” e tocar em Adicionar. | Campo é esvaziado, Toast confirma sucesso e o novo card surge no topo/fim da lista. |
[x] PASS |
| 03 | Tentar adicionar com o campo vazio. | Toast avisa: “A descrição da tarefa não pode estar vazia”; nenhuma requisição é feita. |
[x] PASS |
| 04 | Tocar no CheckBox de uma tarefa pendente. | O CheckBox é marcado, o texto ganha efeito tachado (riscado) e a API atualiza no banco H2. | [x] PASS |
| 05 | Tocar no ícone de Lápis de uma tarefa. | Modal surge com o texto atual; ao salvar o novo texto, a lista se atualiza imediatamente. | [x] PASS |
| 06 | Tocar na Lixeira e cancelar no diálogo. | A tarefa permanece intacta na listagem. | [x] PASS |
| 07 | Tocar na Lixeira e confirmar Excluir. | A tarefa desaparece da tela e a API retorna 204 No Content. |
[x] PASS |
| 08 | Parar a API Spring Boot e tocar em Recarregar. | O app não trava; exibe mensagem amigável de erro de conexão com a API. | [x] PASS |
Parabéns! Você construiu um aplicativo móvel nativo em Android clássico (Java + Views XML + RecyclerView) perfeitamente integrado a uma arquitetura distribuída multicliente.
Com este projeto, você dominou:
10.0.2.2).RecyclerView e ViewHolder.Agora que sua aplicação possui múltiplos clientes (Web, Desktop e Mobile), na Parte 05 você elevará o backend ao nível de produção corporativa: substituindo o banco H2 volátil por PostgreSQL 16, empacotando tudo com Docker Multi-stage Build e orquestrando o ecossistema com Docker Compose!
Quando desejar dominar a modernização da interface móvel nativa, veja na Parte 10 como eliminar arquivos XML e adapters utilizando a UI declarativa reativa do Jetpack Compose com Kotlin, Unidirectional Data Flow (UDF) e Coroutines!