Construindo um Aplicativo de Receitas para Casais com HarmonyOS: Arquitetura e Gestão de Papéis

Visão Geral do Projeto

Este artigo detalha a criação de uma solução mobile para casais organizarem e compartilharem momentos gastronômicos. O aplicativo resolve problemas cotidianos como decisões de cardápio, registro de pratos favoritos e documentação de experiências culinárias compartilhadas, utilizando o framework ArkUI do HarmonyOS.

Requisitos Funcionais

O sistema implementa dois perfis distintos com funcionalidades específicas:

  • Cozinheiro: Gerencia receitas, processa pedidos e controla o conteúdo
  • Comensal: Explora cardápio, realiza pedidos e interage com fotos
Módulo Funcionalidades do Cozinheiro Funcionalidades do Comensal
Gestão de Papéis Assuma o papel de cozinheiro Assuma o papel de comansal
Receitas CRIAR, EDITAR, EXCLUIR pratos VISUALIZAR e buscar receitas
Pedidos ACEITAR e finalizar solicitações FAZER pedidos e acompanhar status
Galeria UPLOAD de imagens Visualizar e curtir fotos
Configurações Alterar perfil e limpar dados Alterar perfil e limpar dados

Stack Tecnológico

  • Framework: ArkUI (Declarativo)
  • Linguagem: ArkTS
  • Persistência: Banco de Dados Relacional (RDB)
  • Gerenciamento de Estado: Decorators @State, @Prop, @Link
  • Armazenamento de Configurações: Preferences API

Estrutura de Diretórios

entry/src/main/ets/
├── utilitarios/              # Classes utilitárias
│   ├── Constantes.ets        # Definições globais
│   ├── Logger.ets            # Sistema de logs
│   ├── BancoDadosUtil.ets    # Gerenciamento RDB
│   └── ConfigUtil.ets        # Armazenamento de preferências
├── modelos/                  # Entidades de dados
│   ├── Receita.ets           # Modelo de receita
│   ├── Pedido.ets            # Modelo de pedido
│   ├── FotoGaleria.ets       # Modelo de imagem
│   └── ConfiguracaoCasal.ets # Configuração do espaço
├── paginas/                  # Componentes de UI
│   ├── TelaInicial.ets       # Splash screen
│   ├── TelaSelecaoPapel.ets  # Escolha de perfil
│   ├── TelaPrincipalChef.ets # Dashboard do cozinheiro
│   ├── TelaPrincipalComensal.ets # Dashboard do comensal
│   ├── GerenciadorReceitas.ets   # CRUD de receitas
│   ├── GerenciadorPedidos.ets    # Controle de pedidos
│   ├── GaleriaFotos.ets      # Album de imagens
│   └── Ajustes.ets           # Configurações
└── entryability/
    └── EntryAbility.ets      # Ponto de entrada

Modelagem do Banco de Dados

Tabela de Configuração do Espaço

CREATE TABLE IF NOT EXISTS configuracao_casal (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  nome_espaco TEXT NOT NULL,
  nome_cozinheiro TEXT NOT NULL,
  nome_comensal TEXT NOT NULL,
  data_criacao INTEGER NOT NULL
)

Tabela de Receitas

CREATE TABLE IF NOT EXISTS receitas (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  titulo TEXT NOT NULL,
  categoria INTEGER NOT NULL,
  imagem_url TEXT,
  ingredientes TEXT,
  modo_preparo TEXT,
  data_criacao INTEGER NOT NULL
)

Tabela de Pedidos

CREATE TABLE IF NOT EXISTS pedidos (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  receita_id INTEGER NOT NULL,
  receita_titulo TEXT NOT NULL,
  receita_imagem TEXT,
  status INTEGER DEFAULT 0,
  observacao TEXT,
  data_pedido INTEGER NOT NULL,
  data_aceitacao INTEGER,
  data_conclusao INTEGER,
  FOREIGN KEY (receita_id) REFERENCES receitas(id) ON DELETE CASCADE
)

Tabela de Fotos

CREATE TABLE IF NOT EXISTS fotos_galeria (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  caminho_imagem TEXT NOT NULL,
  descricao TEXT,
  data_registro INTEGER NOT NULL
)

Implementação do Sistema de Papéis

Tela de Boas-Vindas

A tela inicial gerencia a inicialização do aplicativo e redirecionamento baseado no perfil armazenado.

// TelaInicial.ets
@Entry
@Component
struct TelaInicial {
  private TAG: string = 'TelaInicial'
  
  async onPageShow(): Promise<void> {
    try {
      await this.configurarAmbiente()
      await this.verificarPerfilExistente()
    } catch (erro) {
      Logger.erro(this.TAG, `Falha na inicialização: ${erro}`)
      this.navegarParaSelecao()
    }
  }
  
  private async configurarAmbiente(): Promise<void> {
    await BancoDadosUtil.inicializar(this.getContext())
    await ConfigUtil.inicializar(this.getContext())
  }
  
  private async verificarPerfilExistente(): Promise<void> {
    const perfilAtual = await ConfigUtil.obterValor('perfil_ativo', '')
    
    switch(perfilAtual) {
      case 'cozinheiro':
        this.navegarParaPrincipal('paginas/TelaPrincipalChef')
        break
      case 'comensal':
        this.navegarParaPrincipal('paginas/TelaPrincipalComensal')
        break
      default:
        this.navegarParaSelecao()
    }
  }
  
  private navegarParaPrincipal(rota: string): void {
    router.replaceUrl({ url: rota })
  }
  
  private navegarParaSelecao(): void {
    router.replaceUrl({ url: 'paginas/TelaSelecaoPapel' })
  }
  
  build() {
    Column({ space: 12 }) {
      Progress({ value: 30, type: ProgressType.Circular })
        .width(60)
      Text('Inicializando...')
        .fontSize(16)
        .fontColor('#666')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}
</void></void></void>

Interface de Seleção de Perfil

Permite configurar o espaço compartilhado e definir papéis para cada parceiro.

// TelaSelecaoPapel.ets
@Entry
@Component
struct TelaSelecaoPapel {
  @State nomeEspaco: string = ''
  @State nomeCozinheiro: string = ''
  @State nomeComensal: string = ''
  @State papelSelecionado: string = ''
  
  async aboutToAppear() {
    await this.carregarConfiguracaoExistente()
  }
  
  private async carregarConfiguracaoExistente(): Promise<void> {
    const predicados = new relationalStore.RdbPredicates('configuracao_casal')
    const resultado = await BancoDadosUtil.consultar(predicados)
    
    if (resultado.goToFirstRow()) {
      this.nomeEspaco = resultado.getString(resultado.getColumnIndex('nome_espaco'))
      this.nomeCozinheiro = resultado.getString(resultado.getColumnIndex('nome_cozinheiro'))
      this.nomeComensal = resultado.getString(resultado.getColumnIndex('nome_comensal'))
    }
    resultado.close()
  }
  
  private async salvarPerfil(papel: string): Promise<void> {
    if (!this.validarFormulario()) {
      promptAction.showToast({ message: 'Preencha todos os campos' })
      return
    }
    
    const dados: relationalStore.ValuesBucket = {
      nome_espaco: this.nomeEspaco.trim(),
      nome_cozinheiro: this.nomeCozinheiro.trim(),
      nome_comensal: this.nomeComensal.trim(),
      data_criacao: Date.now()
    }
    
    const predicados = new relationalStore.RdbPredicates('configuracao_casal')
    const registros = await BancoDadosUtil.contarRegistros(predicados)
    
    try {
      if (registros > 0) {
        await BancoDadosUtil.atualizar('configuracao_casal', dados, predicados)
      } else {
        await BancoDadosUtil.inserir('configuracao_casal', dados)
      }
      
      await ConfigUtil.salvarValor('perfil_ativo', papel)
      
      const destino = (papel === 'cozinheiro') 
        ? 'paginas/TelaPrincipalChef' 
        : 'paginas/TelaPrincipalComensal'
      router.replaceUrl({ url: destino })
      
    } catch (erro) {
      Logger.erro('TelaSelecaoPapel', `Erro ao salvar: ${erro}`)
    }
  }
  
  private validarFormulario(): boolean {
    return this.nomeEspaco.length > 0 && 
           this.nomeCozinheiro.length > 0 && 
           this.nomeComensal.length > 0
  }
  
  build() {
    Column({ space: 20 }) {
      Text('Configure seu Espaço')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 30 })
      
      TextInput({ placeholder: 'Nome do espaço' })
        .onChange((valor) => this.nomeEspaco = valor)
        .maxLength(50)
      
      TextInput({ placeholder: 'Apelido do cozinheiro' })
        .onChange((valor) => this.nomeCozinheiro = valor)
        .maxLength(30)
      
      TextInput({ placeholder: 'Apelido do comensal' })
        .onChange((valor) => this.nomeComensal = valor)
        .maxLength(30)
      
      Row({ space: 16 }) {
        Button('Cozinhar')
          .width('40%')
          .onClick(() => this.salvarPerfil('cozinheiro'))
        
        Button('Comer')
          .width('40%')
          .onClick(() => this.salvarPerfil('comensal'))
      }
      .margin({ top: 20 })
    }
    .width('90%')
    .padding(24)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .shadow({ radius: 10, color: '#1A000000' })
  }
}
</void></void>

Camada de Persistência

Implementação do utilitário de preferências para manter o estado do perfil ativo.

// ConfigUtil.ets
export class ConfigUtil {
  private static instancia: preferences.Preferences | null = null
  
  static async inicializar(contexto: Context): Promise<void> {
    if (!ConfigUtil.instancia) {
      ConfigUtil.instancia = await preferences.getPreferences(contexto, 'config_app_casal')
    }
  }
  
  static async salvarValor(chave: string, valor: string): Promise<boolean> {
    try {
      await ConfigUtil.instancia?.put(chave, valor)
      await ConfigUtil.instancia?.flush()
      return true
    } catch (erro) {
      Logger.erro('ConfigUtil', `Falha ao salvar ${chave}: ${erro}`)
      return false
    }
  }
  
  static async obterValor(chave: string, padrao: string): Promise<string> {
    try {
      return await ConfigUtil.instancia?.get(chave, padrao) || padrao
    } catch (erro) {
      Logger.erro('ConfigUtil', `Falha ao ler ${chave}: ${erro}`)
      return padrao
    }
  }
}
</string></boolean></void>

Desenvolvimento das Telas Principais

Dashboard do Cozinheiro

// TelaPrincipalChef.ets
@Entry
@Component
struct TelaPrincipalChef {
  @State configuracao: ConfiguracaoCasal = {
    id: 0,
    nome_espaco: 'Meu Espaço',
    nome_cozinheiro: 'Chef',
    nome_comensal: 'Parceiro',
    data_criacao: 0
  }
  
  async onPageShow(): Promise<void> {
    await this.carregarDadosCasal()
  }
  
  private async carregarDadosCasal(): Promise<void> {
    const predicados = new relationalStore.RdbPredicates('configuracao_casal')
    const resultado = await BancoDadosUtil.consultar(predicados)
    
    if (resultado.goToFirstRow()) {
      this.configuracao = {
        id: resultado.getLong(resultado.getColumnIndex('id')),
        nome_espaco: resultado.getString(resultado.getColumnIndex('nome_espaco')),
        nome_cozinheiro: resultado.getString(resultado.getColumnIndex('nome_cozinheiro')),
        nome_comensal: resultado.getString(resultado.getColumnIndex('nome_comensal')),
        data_criacao: resultado.getLong(resultado.getColumnIndex('data_criacao'))
      }
    }
    resultado.close()
  }
  
  build() {
    Column() {
      this.cabecalhoPersonalizado()
      
      Grid() {
        GridItem() { this.cardFuncao('Receitas', $r('app.media.ic_cookbook')) }
          .onClick(() => router.pushUrl({ url: 'paginas/GerenciadorReceitas' }))
        
        GridItem() { this.cardFuncao('Pedidos', $r('app.media.ic_orders')) }
          .onClick(() => router.pushUrl({ url: 'paginas/GerenciadorPedidos' }))
        
        GridItem() { this.cardFuncao('Galeria', $r('app.media.ic_gallery')) }
          .onClick(() => router.pushUrl({ url: 'paginas/GaleriaFotos' }))
        
        GridItem() { this.cardFuncao('Ajustes', $r('app.media.ic_settings')) }
          .onClick(() => router.pushUrl({ url: 'paginas/Ajustes' }))
      }
      .columnsTemplate('1fr 1fr')
      .rowsTemplate('1fr 1fr')
      .height('60%')
      .padding(16)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
  
  @Builder
  cabecalhoPersonalizado() {
    Row() {
      Text(`Chef: ${this.configuracao.nome_cozinheiro}`)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
      
      Blank()
      
      Text(this.configuracao.nome_espaco)
        .fontSize(14)
        .fontColor('#666')
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 12, bottom: 12 })
    .backgroundColor('#FFFFFF')
  }
  
  @Builder
  cardFuncao(titulo: string, icone: Resource) {
    Column({ space: 8 }) {
      Image(icone)
        .width(48)
        .height(48)
      Text(titulo)
        .fontSize(14)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#FFFFFF')
    .borderRadius(8)
  }
}
</void></void>

Dashboard do Comensal

Similar ao dashboard do cozinheiro, mas com ações limitadas a visualização e criação de pedidos.

Problemas de Implementação e Soluções

Problema: Exibição de Valores Padrão Após Configuração

Sintoma: Após salvar as configurações, a tela principal exibe "Chef" ao invés do nome configurado.

Causa: Incompatibilidade entre o modelo de dados TypeScript e o schema SQLite, com falha silenciosa na inserção.

Solução:

// BancoDadosUtil.ets
static async inicializarComRecuperacao(contexto: Context): Promise<void> {
  const config: relationalStore.StoreConfig = {
    name: 'casal_culinaria.db',
    securityLevel: relationalStore.SecurityLevel.S1
  }
  
  try {
    BancoDadosUtil.banco = await relationalStore.getRdbStore(contexto, config)
    await BancoDadosUtil.criarTabelas()
  } catch (erro) {
    Logger.erro('BancoDadosUtil', `Erro crítico: ${erro}`)
    // Força recriação em caso de corrupção
    await relationalStore.deleteRdbStore(contexto, 'casal_culinaria.db')
    BancoDadosUtil.banco = await relationalStore.getRdbStore(contexto, config)
    await BancoDadosUtil.criarTabelas()
  }
}

private static async criarTabelas(): Promise<void> {
  const sqlConfiguracao = `
    CREATE TABLE IF NOT EXISTS configuracao_casal (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      nome_espaco TEXT NOT NULL,
      nome_cozinheiro TEXT NOT NULL,
      nome_comensal TEXT NOT NULL,
      data_criacao INTEGER NOT NULL
    )
  `
  await BancoDadosUtil.banco.executeSql(sqlConfiguracao)
}
</void></void>

Problema: Perda de Estado Após Limpeza de Cache

Sintoma: Limpar os dados do aplicativo remove as preferências, mas mantém o banco, causando estado inconsistente.

Solução: Implementar sincronização bidirecional na inicialização:

// TelaSelecaoPapel.ets
async sincronizarEstado(): Promise<void> {
  const perfilSalvo = await ConfigUtil.obterValor('perfil_ativo', '')
  const configuracaoExiste = await this.verificarConfiguracaoBanco()
  
  if (!perfilSalvo && configuracaoExiste) {
    // Recupera automaticamente baseado nos dados do banco
    await ConfigUtil.salvarValor('perfil_ativo', 'cozinheiro') // Valor padrão
    promptAction.showDialog({
      title: 'Recuperação Detectada',
      message: 'Encontramos dados do seu espaço. Selecione seu papel novamente.',
      buttons: [{ text: 'OK', color: '#FF6B6B' }]
    })
  }
}
</void>

Considerações Finais

Esta implementação estabelece a base para um aplicativo colaborativo com separação clara de responsabilidades entre perfis. A arquitetura modular permite evolução para funcionalidades como sincronização em nuvem e compartilhamento de receitas entre múltiplos casais.

Os próximos dseenvolvimentos abordarão o CRUD completo de receitas, workflow de pedidos e integração de galeria de fotos com armazenamento de mídia otimizado.

Tags: HarmonyOS ArkUI ArkTS sqlite RDB

Publicado em 10-5 00:42