Construindo um App de Controle de Veículos: UI Profissional, Validação e Reviews (Parte 3)
Até agora, nosso app aceitava qualquer dado e a interface era básica. Vamos mudar isso adicionando regras de negócio, validações visuais e um sistema de relacionamento entre tabelas (Carros e Avaliações).
🛠️ Passo 1: Evoluindo o Banco de Dados (Reviews)
Para criar o sistema de reviews, precisamos de uma nova tabela que se relacione com a tabela carros.
Atualize o lib/data/db_helper.dart:
Vamos alterar a criação do banco para incluir a tabela reviews e adicionar uma função de busca.
Nota: Se você já rodou o app antes, desinstale-o do emulador para resetar o banco de dados, pois estamos mudando a estrutura (
onCreatesó roda uma vez).
// ... imports anteriores
class DBHelper {
// ... código singleton anterior ...
Future<Database> _initDB() async {
String path = join(await getDatabasesPath(), 'car_control.db');
return await openDatabase(
path,
version: 1,
onCreate: (db, version) async {
// Tabela de Carros
await db.execute('''
CREATE TABLE carros(
id TEXT PRIMARY KEY,
modelo TEXT,
marca TEXT,
ano INTEGER,
placa TEXT
)
''');
// Tabela de Reviews (Relacionamento 1:N)
await db.execute('''
CREATE TABLE reviews(
id TEXT PRIMARY KEY,
carroId TEXT,
nota INTEGER,
comentario TEXT,
data TEXT,
FOREIGN KEY(carroId) REFERENCES carros(id) ON DELETE CASCADE
)
''');
},
);
}
// --- NOVOS MÉTODOS ---
// Buscar carros pelo nome (Search)
Future<List<Carro>> searchCarros(String query) async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
'carros',
where: 'modelo LIKE ? OR marca LIKE ?',
whereArgs: ['%$query%', '%$query%'],
);
return List.generate(maps.length, (i) => Carro.fromMap(maps[i]));
}
// Inserir Review
Future<void> insertReview(String carroId, int nota, String comentario) async {
final db = await database;
await db.insert('reviews', {
'id': DateTime.now().toIso8601String(),
'carroId': carroId,
'nota': nota,
'comentario': comentario,
'data': DateTime.now().toString(),
});
}
// Pegar Reviews de um Carro
Future<List<Map<String, dynamic>>> getReviews(String carroId) async {
final db = await database;
return await db.query('reviews', where: 'carroId = ?', whereArgs: [carroId]);
}
}
🎨 Passo 2: Formulário com Validação (Refinamento de UI)
Vamos criar a tela AddCarroScreen usando TextFormField e GlobalKey para validar se o usuário digitou tudo corretamente.
Crie/Atualize lib/screens/add_carro_screen.dart:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import '../providers/carro_provider.dart';
class AddCarroScreen extends StatefulWidget {
const AddCarroScreen({super.key});
@override
State<AddCarroScreen> createState() => _AddCarroScreenState();
}
class _AddCarroScreenState extends State<AddCarroScreen> {
final _formKey = GlobalKey<FormState>();
// Controllers para capturar o texto
final _modeloController = TextEditingController();
final _marcaController = TextEditingController();
final _anoController = TextEditingController();
final _placaController = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Novo Veículo')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form( // Widget que habilita validação
key: _formKey,
child: ListView(
children: [
_buildInput(
controller: _modeloController,
label: 'Modelo',
icon: Icons.directions_car,
validator: (value) => value!.isEmpty ? 'Informe o modelo' : null,
),
const SizedBox(height: 16),
_buildInput(
controller: _marcaController,
label: 'Marca',
icon: Icons.branding_watermark,
validator: (value) => value!.isEmpty ? 'Informe a marca' : null,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildInput(
controller: _anoController,
label: 'Ano',
icon: Icons.calendar_today,
keyboardType: TextInputType.number,
validator: (value) {
if (value!.isEmpty) return 'Informe o ano';
if (int.tryParse(value) == null) return 'Inválido';
return null;
},
),
),
const SizedBox(width: 16),
Expanded(
child: _buildInput(
controller: _placaController,
label: 'Placa',
icon: Icons.tag,
validator: (value) => value!.length < 7 ? 'Placa incompleta' : null,
),
),
],
),
const SizedBox(height: 32),
FilledButton.icon( // Botão moderno do Material 3
onPressed: _saveCarro,
icon: const Icon(Icons.save),
label: const Text('SALVAR VEÍCULO'),
style: FilledButton.styleFrom(padding: const EdgeInsets.all(16)),
),
],
),
),
),
);
}
// Método auxiliar para criar inputs bonitos
Widget _buildInput({
required TextEditingController controller,
required String label,
required IconData icon,
String? Function(String?)? validator,
TextInputType keyboardType = TextInputType.text,
}) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: Colors.grey.shade100,
),
keyboardType: keyboardType,
validator: validator,
);
}
void _saveCarro() {
if (_formKey.currentState!.validate()) {
Provider.of<CarroProvider>(context, listen: false).addCarro(
_modeloController.text,
_marcaController.text,
int.parse(_anoController.text),
_placaController.text,
);
context.pop(); // Volta para a Home
}
}
}
🔍 Passo 3: Tela de Detalhes e Reviews
Esta tela mostrará os detalhes do carro e uma lista de avaliações. Usaremos um Modal para adicionar novas avaliações sem sair da tela.
Crie lib/screens/car_detail_screen.dart:
import 'package:flutter/material.dart';
import '../models/carro.dart';
import '../data/db_helper.dart';
class CarDetailScreen extends StatefulWidget {
final Carro carro;
const CarDetailScreen({super.key, required this.carro});
@override
State<CarDetailScreen> createState() => _CarDetailScreenState();
}
class _CarDetailScreenState extends State<CarDetailScreen> {
List<Map<String, dynamic>> _reviews = [];
@override
void initState() {
super.initState();
_loadReviews();
}
Future<void> _loadReviews() async {
final reviews = await DBHelper().getReviews(widget.carro.id);
setState(() => _reviews = reviews);
}
void _addReviewDialog() {
final comentarioController = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Avaliar Carro'),
content: TextField(
controller: comentarioController,
decoration: const InputDecoration(hintText: 'Digite seu comentário...'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar')),
FilledButton(
onPressed: () async {
await DBHelper().insertReview(widget.carro.id, 5, comentarioController.text);
_loadReviews(); // Recarrega a lista
if (mounted) Navigator.pop(ctx);
},
child: const Text('Avaliar'),
)
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.carro.modelo)),
body: Column(
children: [
// Cabeçalho do Carro
Container(
padding: const EdgeInsets.all(24),
width: double.infinity,
color: Theme.of(context).colorScheme.primaryContainer,
child: Column(
children: [
const Icon(Icons.directions_car, size: 80),
Text(widget.carro.marca, style: Theme.of(context).textTheme.headlineSmall),
Text('Ano: ${widget.carro.ano} • Placa: ${widget.carro.placa}'),
],
),
),
// Lista de Reviews
Expanded(
child: _reviews.isEmpty
? const Center(child: Text('Nenhuma avaliação ainda.'))
: ListView.builder(
itemCount: _reviews.length,
itemBuilder: (ctx, i) {
final r = _reviews[i];
return ListTile(
leading: const CircleAvatar(child: Icon(Icons.star, size: 16)),
title: Text(r['comentario']),
subtitle: Text('Nota: ${r['nota']}'),
);
},
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _addReviewDialog,
label: const Text('Avaliar'),
icon: const Icon(Icons.rate_review),
),
);
}
}
🔎 Passo 4: Tela de Busca (Search)
Vamos usar o método showSearch nativo do Flutter, que fornece uma UX excelente para buscas.
Crie lib/delegates/carro_search_delegate.dart:
import 'package:flutter/material.dart';
import '../data/db_helper.dart';
import '../models/carro.dart';
import '../screens/car_detail_screen.dart';
class CarroSearchDelegate extends SearchDelegate {
@override
List<Widget>? buildActions(BuildContext context) {
return [IconButton(icon: const Icon(Icons.clear), onPressed: () => query = '')];
}
@override
Widget? buildLeading(BuildContext context) {
return IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => close(context, null));
}
@override
Widget buildResults(BuildContext context) {
return FutureBuilder<List<Carro>>(
future: DBHelper().searchCarros(query),
builder: (context, snapshot) {
if (!snapshot.hasData) return const Center(child: CircularProgressIndicator());
final results = snapshot.data!;
return ListView.builder(
itemCount: results.length,
itemBuilder: (ctx, i) {
final carro = results[i];
return ListTile(
title: Text(carro.modelo),
subtitle: Text(carro.marca),
onTap: () {
Navigator.push(context, MaterialPageRoute(
builder: (_) => CarDetailScreen(carro: carro),
));
},
);
},
);
},
);
}
@override
Widget buildSuggestions(BuildContext context) => const Center(child: Text('Digite para buscar...'));
}
Para ativar a busca, adicione um botão na HomeScreen (dentro do AppBar):
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () => showSearch(context: context, delegate: CarroSearchDelegate()),
)
],
🌊 Bônus: Splash Screen Nativa
Para uma Splash Screen profissional, não criamos uma tela Flutter, usamos a tela nativa do Android/iOS.
-
Adicione no
pubspec.yaml:dev_dependencies: flutter_native_splash: ^2.3.10
- Configure no mesmo arquivo (no final):
flutter_native_splash: color: "#42a5f5" image: assets/logo.png # Coloque uma imagem na pasta assets
- Rode no terminal:
dart run flutter_native_splash:create
✅ Conclusão
Agora seu app CarControl não é apenas funcional, ele é robusto! Temos:
- [x] Formulários seguros que impedem dados inválidos.
- [x] Relacionamento de dados (Reviews vinculadas a Carros).
- [x] Interface de busca nativa e rápida.
- [x] Splash screen profissional.
No próximo (e último) artigo, falaremos sobre Arquitetura Limpa (Clean Architecture) e como preparar esse app para produção com testes unitários! 🚀