📚 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.

📱 Projeto Lista de Tarefas — Frontend Mobile Android (Java + Views XML)

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.


🗺️ 1. Visão Geral da Arquitetura & Papel no Ecossistema

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

🌐 2. Ponte de Rede: O Mistério do 10.0.2.2 vs Celular Físico

Ao desenvolver para Android no computador, o aplicativo é executado dentro de uma máquina virtual emulada baseada em QEMU.

[!IMPORTANT] Por que http://localhost:8080 não funciona no Emulador Android?
Para o sistema operacional Android dentro do emulador, localhost e 127.0.0.1 representam 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/

Ponte de Rede Android Emulator e API REST

Conectividade em Smartphones Físicos:

Se você conectar seu smartphone físico via cabo USB ou Wi-Fi na mesma rede local que seu computador:

  1. Descubra o IP local do seu computador na rede Wi-Fi (ex: 192.168.1.50).
  2. O aplicativo deve apontar para http://192.168.1.50:8080/api/tarefas.
  3. Alternativamente, via cabo USB com depuração ativada, você pode executar o redirecionamento reverso do ADB:
    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!


🏛️ 3. Padrão Arquitetural Clássico (MVC com Views XML)

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:

Arquitetura MVC com Activity e Views XML

  1. Camada View (XML Declarativo):
    • Arquivos XML armazenados em res/layout/ que descrevem as telas e itens visuais (activity_main.xml, item_tarefa.xml, dialog_editar_tarefa.xml).
  2. Camada Controller (MainActivity.java e TarefaAdapter.java):
    • Gerenciam o ciclo de vida da tela (onCreate), instanciam componentes visuais, capturam cliques do usuário e despacham eventos.
    • O 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.
  3. Camada de Comunicação e Modelo (RetrofitClient.java, TarefaApiService.java, Tarefa.java):
    • Executa todas as chamadas de rede estritamente em Worker Threads assíncronas em segundo plano, evitando a temida exceção android.os.NetworkOnMainThreadException.
    • O Retrofit realiza a serialização/deserialização automática entre JSON e objetos Java via Gson.

📑 4. Engenharia de Software & Especificação de Requisitos

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.

4.1. Histórias de Usuário (Mobile)

4.2. Requisitos Funcionais (RF)

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}

4.3. Requisitos Não-Funcionais (RNF)

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).

4.4. Casos de Uso do Sistema Móvel

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

UC01: Visualizar Lista de Tarefas

UC02: Cadastrar Nova Tarefa

UC03: Alternar Status de Conclusão

UC04: Editar Descrição da Tarefa

UC05: Excluir Tarefa com Confirmação


4.5. Matriz de Rastreabilidade de Requisitos (RTM)

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

🛠️ 5. Estrutura de Pastas e Arquivos do Projeto Mobile

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

💻 6. Passo a Passo de Implementação do Zero

Passo 1: Configuração do Gradle (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)
}

Passo 2: Permissões e Segurança de Rede (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>

Passo 3: Telas e Layouts em XML

Layout Principal: activity_main.xml

O 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>

Layout do Item: item_tarefa.xml

Cada 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>

Passo 4: Entidade de Domínio em Java (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);
    }
}

Passo 5: Camada REST com Retrofit 2

Interface do Serviço: TarefaApiService.java

Declara 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);
}

Cliente Singleton: RetrofitClient.java

Garante 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;
    }
}

Passo 6: O Coração do RecyclerView (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, chamar setChecked(...) em um CheckBox pode disparar um listener antigo associado a outra tarefa.
Por isso, em bind(...), 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);
            });
        }
    }
}

Passo 7: O Controller Principal (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();
            }
        });
    }
}

🚀 7. Como Executar e Homologar o Aplicativo

Pré-requisito Obrigatório: A API Central Ativa

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.

Opção A: Executar via Android Studio

  1. Abra o Android Studio.
  2. Selecione Open Project e navegue até a pasta:
    catalogo_projetos/fullstack_tarefas_04_spring_android/mobile-android-xml.
  3. Aguarde o Gradle sincronizar os arquivos.
  4. Escolha um emulador AVD ou dispositivo físico conectado e clique no botão verde Run ▶ (Shift + F10).

Opção B: Executar via Terminal com Gradle e ADB

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

🧪 8. Matriz de Homologação & Testes de Aceitação

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

🏆 9. Conclusão & Próximos Passos

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:

  1. O funcionamento da ponte de rede interna de virtualização do Android (10.0.2.2).
  2. O padrão assíncrono com Retrofit 2 e serialização Gson.
  3. A reciclagem de memória e componentes de alto desempenho com RecyclerView e ViewHolder.
  4. A consistência de dados corporativa consumindo uma Fonte Única da Verdade (SSOT).

👉 Sequência Cronológica: Parte 05 — Arquitetura Corporativa, DevOps e Docker + PostgreSQL 16

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!

📱 Evolução Arquitetural Mobile: Parte 10 — De Android Clássico para Kotlin + Jetpack 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!