App Mobile Omnigrejas
Implementação completa em Flutter
Esta documentação guia o desenvolvimento do aplicativo mobile Omnigrejas usando Flutter, integrando com a API REST completa para gestão de igrejas.
Flutter
Framework moderno para desenvolvimento cross-platform
REST API
Integração completa com todos os endpoints
Padrão Dados
Todos os responses seguem o padrão {'dados': ...}
Setup do Projeto Flutter
📦 Dependências Necessárias
dependencies:
flutter:
sdk: flutter
http: ^1.1.0
provider: ^6.0.5
shared_preferences: ^2.2.2
flutter_secure_storage: ^9.0.0
connectivity_plus: ^5.0.2
cached_network_image: ^3.3.0
intl: ^0.19.0
flutter_local_notifications: ^16.3.2
url_launcher: ^6.2.2
image_picker: ^1.0.4
permission_handler: ^11.3.0
flutter_dotenv: ^5.1.0
🏗️ Estrutura do Projeto
├── models/ # Modelos de dados
├── services/ # Serviços da API
├── providers/ # State management
├── screens/ # Telas do app
├── widgets/ # Componentes reutilizáveis
├── utils/ # Utilitários
├── constants/ # Constantes
└── main.dart # Ponto de entrada
🌐 Configuração da API
// lib/constants/api_constants.dart
class ApiConstants {
static const String baseUrl = 'https://api.omnigrejas.com/api/v1';
static const String login = '/auth/login';
static const String register = '/auth/register';
static const String forgotPassword = '/auth/forgot-password';
static const String resetPassword = '/auth/reset-password';
static const Duration timeout = Duration(seconds: 30);
}
Sistema de Autenticação
Login
/v1/auth/login
Autentica o usuário e retorna tokens de acesso.
📱 Implementação Flutter:
// lib/services/auth_service.dart
Future login(String email, String password) async {
final response = await http.post(
Uri.parse('${ApiConstants.baseUrl}/auth/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return LoginResponse.fromJson(data['dados']);
} else {
throw Exception('Falha no login');
}
}
📋 Response:
{
"dados": {
"ok": true,
"user": {...},
"access_token": "token_jwt",
"refresh_token": "refresh_token"
}
}
Registro
/v1/auth/register
Cria uma nova conta de usuário.
📱 Código Flutter:
// lib/screens/register_screen.dart
void _register() async {
try {
final response = await _authService.register(
name: _nameController.text,
email: _emailController.text,
password: _passwordController.text,
);
if (response.ok) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Registro realizado!')),
);
Navigator.pushReplacementNamed(context, '/login');
} else {
// Tratar erros de validação
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro no registro')),
);
}
}
Esqueci Minha Senha
/v1/auth/forgot-password
Solicita redefinição de senha via email.
📱 Exemplo de Uso - Flutter:
// lib/screens/forgot_password_screen.dart
void _forgotPassword() async {
try {
final response = await http.post(
Uri.parse('${ApiConstants.baseUrl}/auth/forgot-password'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': _emailController.text}),
);
if (response.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Email de recuperação enviado!'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context); // Volta para tela de login
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erro ao enviar email'),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erro de conexão: $e'),
backgroundColor: Colors.red,
),
);
}
}
📋 Request Body:
{
"email": "usuario@email.com"
}
🔐 Rate Limiting:
• Máximo 3 tentativas por minuto
• Email personalizado em português
Resetar Senha
/v1/auth/reset-password
Redefine a senha usando token do email.
📱 Exemplo de Uso - Flutter:
// lib/screens/reset_password_screen.dart
void _resetPassword() async {
try {
// Extrair token da URL (se veio por deep link)
String? token = _extractTokenFromUrl();
if (token == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Token inválido')),
);
return;
}
final response = await http.post(
Uri.parse('${ApiConstants.baseUrl}/auth/reset-password'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'email': _emailController.text,
'token': token,
'password': _passwordController.text,
'password_confirmation': _confirmPasswordController.text,
}),
);
if (response.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Senha redefinida com sucesso!'),
backgroundColor: Colors.green,
),
);
Navigator.pushReplacementNamed(context, '/login');
} else {
final errorData = jsonDecode(response.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorData['message'] ?? 'Erro ao redefinir senha'),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erro de conexão: $e'),
backgroundColor: Colors.red,
),
);
}
}
String? _extractTokenFromUrl() {
// Extrair token da URL ou deep link
final uri = Uri.parse(ModalRoute.of(context)!.settings.arguments as String? ?? '');
return uri.queryParameters['token'];
}
📋 Request Body:
{
"email": "usuario@email.com",
"token": "token_recebido_por_email",
"password": "nova_senha",
"password_confirmation": "nova_senha"
}
⚠️ Segurança:
• Token SHA-256 hash
• Expiração automática (60 min)
• Rate limiting: 5 tentativas por minuto
Integração com API
📋 Padrão de Response
Todos os endpoints da API retornam dados seguindo o padrão consistente:
{
"dados": {
// Dados específicos do endpoint
}
}
💡 Implementação:
// Sempre acesse os dados através da chave 'dados'
final userData = response['dados'];
final posts = response['dados'] as List;
🔧 Http Service Base
// lib/services/http_service.dart
class HttpService {
final String baseUrl = ApiConstants.baseUrl;
Future
🚨 Tratamento de Erros
// lib/utils/error_handler.dart
class ErrorHandler {
static String getErrorMessage(dynamic error) {
if (error is http.ClientException) {
return 'Erro de conexão. Verifique sua internet.';
} else if (error is TimeoutException) {
return 'Tempo limite excedido. Tente novamente.';
} else if (error is FormatException) {
return 'Erro no formato dos dados recebidos.';
} else {
return 'Erro desconhecido. Tente novamente.';
}
}
}
Foto de Perfil do Usuário
📋 Visão Geral
As fotos de perfil dos usuários são armazenadas no Supabase Storage seguindo uma estrutura organizada por igreja.
O campo photo_url no modelo User contém a URL pública da imagem.
💡 Estrutura de Armazenamento
As fotos são organizadas automaticamente por igreja e tipo de usuário:
IGREJAS/{NOME_IGREJA}/profile/{nome_arquivo}.jpg
IGREJAS/{NOME_IGREJA}/church-logo/{nome_arquivo}.png
Acessar Foto de Perfil
📱 Como Acessar no Flutter:
// Modelo User
class User {
final String? photoUrl;
// ... outros campos
factory User.fromJson(Map json) {
return User(
photoUrl: json['photo_url'],
// ... outros campos
);
}
}
// Widget para exibir foto
CircleAvatar(
radius: 50,
backgroundImage: user.photoUrl != null
? CachedNetworkImageProvider(user.photoUrl!)
: null,
child: user.photoUrl == null
? Icon(Icons.person, size: 50)
: null,
)
📋 Estrutura da Response:
{
"dados": {
"id": "uuid-do-usuario",
"name": "Nome do Usuário",
"photo_url": "https://supabase-url.com/storage/v1/object/public/IGREJAS/IGREJA_EXEMPLO/profile/foto_123.jpg",
"role": "membro",
// ... outros campos
}
}
Upload de Foto de Perfil
/v1/users/upload-image
📱 Código Flutter - Upload:
// Selecionar imagem da galeria
Future _pickAndUploadProfileImage() async {
final picker = ImagePicker();
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
File imageFile = File(pickedFile.path);
await _uploadProfileImage(imageFile);
}
}
// Fazer upload
Future _uploadProfileImage(File imageFile) async {
try {
var request = http.MultipartRequest(
'POST',
Uri.parse('\${ApiConstants.baseUrl}/users/upload-image'),
);
request.headers['Authorization'] = 'Bearer \${await _getToken()}';
request.fields['folder'] = 'profile';
// Se já tem foto, passar o caminho antigo para deletar
if (user.photoUrl != null) {
request.fields['old_path'] = _extractPathFromUrl(user.photoUrl!);
}
request.files.add(await http.MultipartFile.fromPath(
'image',
imageFile.path,
filename: 'profile_image.jpg',
));
final response = await request.send();
final responseData = await response.stream.bytesToString();
final data = jsonDecode(responseData);
if (data['success']) {
// Atualizar photo_url do usuário
await _updateUserPhotoUrl(data['url']);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Foto atualizada com sucesso!')),
);
} else {
throw Exception(data['message'] ?? 'Erro no upload');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro: \$e')),
);
}
}
// Extrair caminho do Supabase da URL completa
String _extractPathFromUrl(String url) {
// Exemplo: https://xxx.supabase.co/storage/v1/object/public/IGREJAS/IGREJA_EXEMPLO/profile/foto.jpg
// Retorna: IGREJAS/IGREJA_EXEMPLO/profile/foto.jpg
return url.split('/storage/v1/object/public/')[1];
}
// Atualizar photo_url do usuário
Future _updateUserPhotoUrl(String newUrl) async {
final response = await http.put(
Uri.parse('\${ApiConstants.baseUrl}/users/\${user.id}'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer \${await _getToken()}',
},
body: jsonEncode({'photo_url': newUrl}),
);
if (response.statusCode == 200) {
// Atualizar estado local
setState(() {
user.photoUrl = newUrl;
});
}
}
📋 Request (Multipart):
Content-Type: multipart/form-data
Authorization: Bearer {token}
Form Data:
image: {arquivo_imagem}
folder: profile
old_path: {caminho_antigo_opcional}
✅ Response de Sucesso:
{
"success": true,
"path": "IGREJAS/NOME_IGREJA/profile/profile_1234567890_abc123.jpg",
"url": "https://supabase-url.com/storage/v1/object/public/IGREJAS/NOME_IGREJA/profile/profile_1234567890_abc123.jpg"
}
✅ Validações e Limites
📁 Tipos Aceitos
- • 🖼️ Imagens: JPEG, JPG, PNG, GIF, WebP
- • 📏 Tamanho máximo: 2MB
- • 🔒 Autenticação: Bearer Token obrigatória
🗂️ Organização
- • 🏢 Por igreja: IGREJAS/{NOME_IGREJA}/
- • 👤 Perfil: profile/ subpasta
- • 🆔 Nome único: timestamp + uniqid
- • 🗑️ Auto limpeza: remove foto antiga
🚫 Segurança
- • ✅ Verificação de tipo MIME real
- • ✅ Validação de extensão do arquivo
- • ✅ Limitação de tamanho de arquivo
- • ✅ Autenticação obrigatória
- • ✅ Middleware de billing ativo
🚨 Tratamento de Erros
Arquivo Inválido:
{
"success": false,
"message": "Tipo de arquivo não permitido. Use apenas imagens (JPG, PNG, GIF, WebP)"
}
Arquivo Muito Grande:
{
"success": false,
"message": "Arquivo muito grande. Tamanho máximo: 2MB"
}
📱 Tratamento no Flutter:
try {
final result = await uploadProfileImage(file);
// Sucesso
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erro no upload: \$e'),
backgroundColor: Colors.red,
),
);
}
Sistema de Seguir/Usuários
📋 Visão Geral
O Sistema de Seguir permite que usuários sigam outros usuários, recebam notificações sobre suas atividades e vejam um feed personalizado. Similar as redes socias como instagram, mas focado no contexto do sistema Omnigrejas.
Seguir/Deixar de Seguir
Conecte-se com outros membros
Notificações
Seja notificado das atividades
Feed de Atividades
Veja o que seus seguidores fazem
Seguir um Usuário
/v1/user-follows
📱 Como Seguir um Usuário - Flutter:
// lib/services/follow_service.dart
Future followUser(String userId) async {
try {
final response = await http.post(
Uri.parse('\${ApiConstants.baseUrl}/user-follows'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer \${await _getToken()}',
},
body: jsonEncode({'followed_id': userId}),
);
if (response.statusCode == 201) {
final data = jsonDecode(response.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Usuário seguido com sucesso!')),
);
return true;
} else {
final errorData = jsonDecode(response.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(errorData['error'] ?? 'Erro ao seguir usuário')),
);
return false;
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro de conexão: \$e')),
);
return false;
}
}
📋 Request Body:
{
"followed_id": "uuid-do-usuario-a-ser-seguido"
}
✅ Response de Sucesso:
{
"message": "Usuário seguido com sucesso",
"dados": {
"followed_id": "uuid-do-usuario",
"seguidores_count": 15,
"seguindo_count": 8
}
}
Deixar de Seguir
/v1/user-follows/{followedId}
📱 Como Deixar de Seguir - Flutter:
// Deixar de seguir um usuário
Future unfollowUser(String userId) async {
try {
final response = await http.delete(
Uri.parse('\${ApiConstants.baseUrl}/user-follows/\$userId'),
headers: {
'Authorization': 'Bearer \${await _getToken()}',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deixou de seguir o usuário')),
);
return true;
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro ao deixar de seguir')),
);
return false;
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro de conexão: \$e')),
);
return false;
}
}
✅ Response de Sucesso:
{
"message": "Deixou de seguir o usuário com sucesso",
"dados": {
"followed_id": "uuid-do-usuario",
"seguidores_count": 14,
"seguindo_count": 7
}
}
Verificar Status de Seguir
/v1/user-follows/check/{userId}
📱 Verificar se Está Seguindo - Flutter:
// Verificar status de seguir
Future checkFollowStatus(String userId) async {
try {
final response = await http.get(
Uri.parse('\${ApiConstants.baseUrl}/user-follows/check/\$userId'),
headers: {
'Authorization': 'Bearer \${await _getToken()}',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return FollowStatus.fromJson(data['dados']);
} else {
throw Exception('Erro ao verificar status');
}
} catch (e) {
throw Exception('Erro de conexão: \$e');
}
}
// Modelo FollowStatus
class FollowStatus {
final String userId;
final bool estaSeguindo;
final int seguidoresCount;
final int seguindoCount;
FollowStatus({
required this.userId,
required this.estaSeguindo,
required this.seguidoresCount,
required this.seguindoCount,
});
factory FollowStatus.fromJson(Map json) {
return FollowStatus(
userId: json['user_id'],
estaSeguindo: json['esta_seguindo'],
seguidoresCount: json['seguidores_count'],
seguindoCount: json['seguindo_count'],
);
}
}
✅ Response:
{
"dados": {
"user_id": "uuid-do-usuario",
"esta_seguindo": true,
"seguidores_count": 15,
"seguindo_count": 8
}
}
Sugestões de Usuários
/v1/user-follows/suggestions
📱 Buscar Sugestões - Flutter:
// Buscar sugestões de usuários para seguir
Future> getFollowSuggestions() async {
try {
final response = await http.get(
Uri.parse('\${ApiConstants.baseUrl}/user-follows/suggestions'),
headers: {
'Authorization': 'Bearer \${await _getToken()}',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final usersData = data['dados'] as List;
return usersData.map((user) => User.fromJson(user)).toList();
} else {
throw Exception('Erro ao buscar sugestões');
}
} catch (e) {
throw Exception('Erro de conexão: \$e');
}
}
✅ Response:
{
"dados": [
{
"id": "uuid-usuario-1",
"name": "João Silva",
"email": "joao@email.com",
"photo_url": "https://...",
"role": "membro"
},
{
"id": "uuid-usuario-2",
"name": "Maria Santos",
"email": "maria@email.com",
"photo_url": null,
"role": "obreiro"
}
]
}
Listar Notificações
/v1/user-follow-notifications
📱 Listar Notificações - Flutter:
// Listar notificações
Future> getNotifications({
String status = 'all', // 'all', 'lidas', 'nao_lidas'
int page = 1
}) async {
try {
final response = await http.get(
Uri.parse('\${ApiConstants.baseUrl}/user-follow-notifications?page=\$page&status=\$status'),
headers: {
'Authorization': 'Bearer \${await _getToken()}',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final notificationsData = data['dados']['data'] as List;
return notificationsData.map((n) => FollowNotification.fromJson(n)).toList();
} else {
throw Exception('Erro ao buscar notificações');
}
} catch (e) {
throw Exception('Erro de conexão: \$e');
}
}
// Modelo FollowNotification
class FollowNotification {
final String id;
final String type;
final String title;
final String message;
final bool isRead;
final DateTime createdAt;
final User? followedUser;
FollowNotification({
required this.id,
required this.type,
required this.title,
required this.message,
required this.isRead,
required this.createdAt,
this.followedUser,
});
factory FollowNotification.fromJson(Map json) {
return FollowNotification(
id: json['id'],
type: json['type'],
title: json['title'],
message: json['message'],
isRead: json['is_read'],
createdAt: DateTime.parse(json['created_at']),
followedUser: json['followed_user'] != null
? User.fromJson(json['followed_user'])
: null,
);
}
}
📋 Parâmetros de Query:
- status: 'all' (padrão), 'lidas', 'nao_lidas'
- tipo: Filtrar por tipo de notificação (opcional)
- page: Página para paginação
✅ Response:
{
"dados": {
"data": [
{
"id": "uuid-notificacao",
"type": "post_created",
"title": "Novo post",
"message": "João publicou um novo post",
"is_read": false,
"created_at": "2025-01-15T10:30:00Z",
"followed_user": {
"id": "uuid-joao",
"name": "João Silva",
"photo_url": "https://..."
}
}
],
"current_page": 1,
"total": 25
}
}
Marcar Todas como Lidas
/v1/user-follow-notifications/mark-all-read
📱 Marcar Todas como Lidas - Flutter:
// Marcar todas as notificações como lidas
Future markAllNotificationsAsRead() async {
try {
final response = await http.post(
Uri.parse('\${ApiConstants.baseUrl}/user-follow-notifications/mark-all-read'),
headers: {
'Authorization': 'Bearer \${await _getToken()}',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['dados']['notificacoes_marcadas'];
} else {
throw Exception('Erro ao marcar notificações');
}
} catch (e) {
throw Exception('Erro de conexão: \$e');
}
}
✅ Response:
{
"message": "Notificações marcadas como lidas",
"dados": {
"notificacoes_marcadas": 5,
"nao_lidas_restantes": 0
}
}
Feed de Atividades
/v1/user-follow-activities/feed
📱 Feed de Atividades - Flutter:
// Buscar feed de atividades dos usuários seguidos
Future> getActivitiesFeed({int dias = 7}) async {
try {
final response = await http.get(
Uri.parse('\${ApiConstants.baseUrl}/user-follow-activities/feed?dias=\$dias'),
headers: {
'Authorization': 'Bearer \${await _getToken()}',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final activitiesData = data['dados']['data'] as List;
return activitiesData.map((a) => FollowActivity.fromJson(a)).toList();
} else {
throw Exception('Erro ao buscar feed');
}
} catch (e) {
throw Exception('Erro de conexão: \$e');
}
}
// Modelo FollowActivity
class FollowActivity {
final String id;
final String activityType;
final String? description;
final DateTime createdAt;
final User user;
FollowActivity({
required this.id,
required this.activityType,
this.description,
required this.createdAt,
required this.user,
});
factory FollowActivity.fromJson(Map json) {
return FollowActivity(
id: json['id'],
activityType: json['activity_type'],
description: json['description'],
createdAt: DateTime.parse(json['created_at']),
user: User.fromJson(json['user']),
);
}
String getActivityMessage() {
final userName = user.name;
switch (activityType) {
case 'post_created':
return '\$userName publicou um novo post';
case 'post_liked':
return '\$userName curtiu um post';
case 'comment_created':
return '\$userName fez um comentário';
case 'event_created':
return '\$userName criou um novo evento';
default:
return '\$userName teve uma nova atividade';
}
}
}
📋 Parâmetros de Query:
- dias: Número de dias para buscar atividades (padrão: 7)
- page: Página para paginação
✅ Response:
{
"dados": {
"data": [
{
"id": "uuid-atividade",
"activity_type": "post_created",
"description": "João publicou uma mensagem inspiradora",
"created_at": "2025-01-15T10:30:00Z",
"user": {
"id": "uuid-joao",
"name": "João Silva",
"photo_url": "https://..."
}
}
],
"current_page": 1,
"total": 45
}
}
💡 Boas Práticas de Implementação
🎯 UX/UI
- • 👆 Botão Seguir: Mude o texto dinamicamente ("Seguir"/"Seguindo")
- • 🔔 Notificações: Badge vermelho para notificações não lidas
- • 🔄 Real-time: Use WebSocket para notificações em tempo real
- • 📱 Push: Notificações push para atividades importantes
- • 💾 Cache: Cache local das listas de seguidores
- • ⚡ Loading: Estados de loading para ações assíncronas
⚡ Performance
- • 📄 Paginação: Sempre use paginação nas listas
- • 🔍 Filtros: Permita filtrar notificações por tipo
- • 🗑️ Limpeza: Remova notificações antigas automaticamente
- • 📊 Contadores: Cache dos contadores de seguidores
- • 🚀 Lazy Loading: Carregue dados sob demanda
- • 💾 Offline: Funcionalidades básicas offline
⚠️ Considerações de Segurança
- • ✅ Não permitir seguir a si mesmo
- • ✅ Rate limiting nas ações de seguir
- • ✅ Validação de permissões para ver atividades privadas
- • ✅ Auditoria completa de todas as ações
- • ✅ Soft delete para relacionamentos (não hard delete)
Upload de Mídia para Posts
📋 Visão Geral
O sistema de upload de mídia permite que usuários façam upload de imagens e vídeos para seus posts. O endpoint só deve ser acionado quando há um arquivo para enviar.
⚠️ Importante
O endpoint POST /v1/posts
deve ser usado para criar posts com ou sem mídia.
Se houver arquivo selecionado, ele será processado automaticamente.
🔄 Fluxo de Upload
Selecionar Arquivo
Usuário escolhe imagem/vídeo
Fazer Upload
Enviar para /upload-media
Criar Post
Usar dados retornados
📱 Implementação Flutter:
Future _createPostWithMedia() async {
// 1. Preparar dados do post
final postData = {
'content': _contentController.text,
'titulo': _titleController.text,
};
// 2. Se há arquivo selecionado, incluir na requisição multipart
if (_selectedFile != null) {
var request = http.MultipartRequest(
'POST',
Uri.parse('\${ApiConstants.baseUrl}/posts'),
);
request.headers['Authorization'] = 'Bearer \${await _getToken()}';
request.fields['content'] = postData['content'];
request.fields['titulo'] = postData['titulo'] ?? '';
request.files.add(await http.MultipartFile.fromPath(
'media',
_selectedFile!.path,
filename: basename(_selectedFile!.path),
));
final response = await request.send();
return await _handleResponse(response);
} else {
// Post sem mídia - requisição JSON normal
final response = await http.post(
Uri.parse('\${ApiConstants.baseUrl}/posts'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer \${await _getToken()}',
},
body: jsonEncode(postData),
);
return await _handleResponse(response);
}
}
Upload de Mídia
/v1/posts
📱 Código Flutter - Upload:
// lib/services/post_service.dart
Future> uploadMedia(File file) async {
try {
var request = http.MultipartRequest(
'POST',
Uri.parse('${ApiConstants.baseUrl}/posts/upload-media'),
);
// Adicionar headers de autenticação
final token = await _getToken();
request.headers['Authorization'] = 'Bearer $token';
// Adicionar arquivo
request.files.add(await http.MultipartFile.fromPath(
'media',
file.path,
filename: basename(file.path),
));
final response = await request.send();
final responseData = await response.stream.bytesToString();
final data = jsonDecode(responseData);
if (response.statusCode == 200 && data['success']) {
return data['data'];
} else {
throw Exception(data['message'] ?? 'Erro no upload');
}
} catch (e) {
throw Exception('Erro ao fazer upload: $e');
}
}
📋 Request (Multipart):
Content-Type: multipart/form-data
Authorization: Bearer {token}
Form Data:
media: {arquivo_selecionado}
✅ Response de Sucesso:
{
"success": true,
"message": "Mídia enviada com sucesso",
"data": {
"url": "IGREJAS/NOME_IGREJA/Posts/post_1234567890_abc123.jpg",
"nome": "foto_perfil.jpg",
"tamanho": 245760,
"mime_type": "image/jpeg",
"tipo": "image",
"is_video": false
}
}
Criar Post com Mídia
/v1/posts
📱 Código Flutter - Criar Post:
// Usar dados retornados do upload
Future createPostWithMedia(String content, Map mediaData) async {
try {
final response = await http.post(
Uri.parse('${ApiConstants.baseUrl}/posts'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ${await _getToken()}',
},
body: jsonEncode({
'content': content,
'media_url': mediaData['url'],
'media_type': mediaData['tipo'],
'is_video': mediaData['is_video'],
}),
);
if (response.statusCode == 201) {
final data = jsonDecode(response.body);
return Post.fromJson(data['dados']);
} else {
throw Exception('Erro ao criar post');
}
} catch (e) {
throw Exception('Erro de conexão: $e');
}
}
📋 Request Body:
{
"content": "Texto do post",
"media_url": "IGREJAS/NOME_IGREJA/Posts/post_1234567890_abc123.jpg",
"media_type": "image",
"is_video": false
}
✅ Validações e Limites
📁 Tipos de Arquivo Aceitos
- • 🖼️ Imagens: JPEG, JPG, PNG, GIF, WebP
- • 🎥 Vídeos: MP4, AVI, MOV, WMV, FLV, WebM
- • 📄 Outros: Apenas mídia para posts
📏 Limites de Tamanho
- • 📊 Tamanho máximo: 10MB por arquivo
- • 🖼️ Imagens: Recomendado até 4MB
- • 🎥 Vídeos: Recomendado até 10MB
- • ⚡ Performance: Arquivos menores carregam mais rápido
🚫 Validações de Segurança
- • ✅ Verificação de tipo MIME real do arquivo
- • ✅ Validação de extensão do arquivo
- • ✅ Limitação de tamanho de arquivo
- • ✅ Autenticação obrigatória (Bearer Token)
- • ✅ Middleware de billing ativo
🚨 Tratamento de Erros
Erro de Arquivo Inválido:
{
"success": false,
"message": "Tipo de arquivo não permitido. Use apenas imagens (JPG, PNG, GIF, WebP)"
}
Erro de Arquivo Muito Grande:
{
"success": false,
"message": "Arquivo muito grande. Tamanho máximo: 10MB"
}
📱 Tratamento no Flutter:
try {
final mediaData = await uploadMedia(file);
// Sucesso - prosseguir
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erro no upload: $e'),
backgroundColor: Colors.red,
),
);
}
💡 Boas Práticas
🎯 UX/UI
- • 📱 Preview: Mostrar preview antes do upload
- • ⏳ Loading: Indicador de progresso durante upload
- • ❌ Cancel: Permitir cancelar upload em andamento
- • 📊 Compressão: Comprimir imagens automaticamente
- • 🔄 Retry: Opção de tentar novamente em caso de falha
⚡ Performance
- • 🗜️ Otimização: Reduzir qualidade para upload mais rápido
- • 📱 Conectividade: Verificar conexão antes do upload
- • 💾 Cache: Cache de imagens já carregadas
- • 🔄 Background: Upload em background quando possível
- • 📏 Chunking: Dividir arquivos grandes em partes
💬 Upload de Mídia para Mensagens
Mensagens Privadas
/v1/mensagens-privadas/upload-media
// lib/services/mensagem_privada_service.dart
Future> uploadMedia(File file, String destinatarioId, String tipo) async {
try {
var request = http.MultipartRequest(
'POST',
Uri.parse('${ApiConstants.baseUrl}/mensagens-privadas/upload-media'),
);
request.headers['Authorization'] = 'Bearer ${await _getToken()}';
request.fields['destinatario_id'] = destinatarioId;
request.fields['tipo'] = tipo; // 'imagem', 'audio', 'video', 'arquivo'
request.files.add(await http.MultipartFile.fromPath('media', file.path));
final response = await request.send();
return await _handleResponse(response);
} catch (e) {
throw Exception('Erro no upload: $e');
}
}
✅ Response:
{
"success": true,
"data": {
"anexo_url": "IGREJAS/NOME_IGREJA/chat/private/123_456/imagem/file.jpg",
"anexo_nome": "foto.jpg",
"anexo_tamanho": 245760,
"anexo_tipo": "image/jpeg",
"tipo_mensagem": "imagem",
"destinatario_id": "456"
}
}
Chat de Igreja
/v1/igreja-chat-mensagens/{chatId}/upload-media
// lib/services/chat_service.dart
Future> uploadMedia(File file, String chatId, String tipo) async {
try {
var request = http.MultipartRequest(
'POST',
Uri.parse('${ApiConstants.baseUrl}/igreja-chat-mensagens/$chatId/upload-media'),
);
request.headers['Authorization'] = 'Bearer ${await _getToken()}';
request.fields['tipo'] = tipo; // 'imagem', 'audio', 'video', 'arquivo'
request.files.add(await http.MultipartFile.fromPath('media', file.path));
final response = await request.send();
return await _handleResponse(response);
} catch (e) {
throw Exception('Erro no upload: $e');
}
}
✅ Response:
{
"success": true,
"data": {
"anexo_url": "IGREJAS/NOME_IGREJA/chat/audio_chat/NOME_CHAT/audio.mp3",
"anexo_nome": "audio.mp3",
"anexo_tamanho": 2048576,
"anexo_tipo": "audio/mpeg",
"tipo_mensagem": "audio",
"chat_id": "789"
}
}
🔄 Fluxo Completo: Mensagem com Mídia
// 1. Upload da mídia
final mediaData = await chatService.uploadMedia(file, chatId, 'imagem');
// 2. Criar mensagem com os dados retornados
final mensagem = await chatService.enviarMensagem(chatId, {
'conteudo': 'Veja esta foto!',
'tipo_mensagem': mediaData['tipo_mensagem'],
'anexo_url': mediaData['anexo_url'],
'anexo_nome': mediaData['anexo_nome'],
'anexo_tamanho': mediaData['anexo_tamanho'],
'anexo_tipo': mediaData['anexo_tipo'],
'duracao_audio': mediaData['duracao_audio'],
});
⚠️ Importante
Os endpoints de upload retornam apenas os dados da mídia. Você deve usar esses dados para criar a mensagem real através dos endpoints normais de criação de mensagens.
Exemplos de Código
🎯 Provider Pattern para State Management
// lib/providers/auth_provider.dart
class AuthProvider extends ChangeNotifier {
User? _user;
String? _token;
User? get user => _user;
bool get isAuthenticated => _token != null;
Future login(String email, String password) async {
try {
final response = await _authService.login(email, password);
if (response.ok) {
_user = response.user;
_token = response.accessToken;
await _saveToken(_token!);
notifyListeners();
}
} catch (e) {
throw Exception('Erro no login: \$e');
}
}
Future logout() async {
await _authService.logout();
_user = null;
_token = null;
await _removeToken();
notifyListeners();
}
}
🔗 Serviço de API Genérico
// lib/services/api_service.dart
class ApiService {
final HttpService _httpService = HttpService();
Future> getPosts({int page = 1}) async {
try {
final response = await _httpService.get('/posts?page=\$page');
final postsData = response['dados'] as List;
return postsData.map((post) => Post.fromJson(post)).toList();
} catch (e) {
throw Exception('Erro ao carregar posts: \$e');
}
}
Future createPost(String content, {String? imageUrl}) async {
try {
final response = await _httpService.post('/posts', {
'content': content,
if (imageUrl != null) 'media_url': imageUrl,
'media_type': imageUrl != null ? 'image' : null,
});
return Post.fromJson(response['dados']);
} catch (e) {
throw Exception('Erro ao criar post: \$e');
}
}
}
📋 Modelo de Dados (User)
// lib/models/user.dart
class User {
final String id;
final String name;
final String email;
final String? phone;
final String? photoUrl;
final String role;
final bool isActive;
User({
required this.id,
required this.name,
required this.email,
this.phone,
this.photoUrl,
this.role = 'membro',
this.isActive = true,
});
factory User.fromJson(Map json) {
return User(
id: json['id'],
name: json['name'],
email: json['email'],
phone: json['phone'],
photoUrl: json['photo_url'],
role: json['role'] ?? 'membro',
isActive: json['is_active'] ?? true,
);
}
Map toJson() {
return {
'id': id,
'name': name,
'email': email,
'phone': phone,
'photo_url': photoUrl,
'role': role,
'is_active': isActive,
};
}
}
Boas Práticas
Performance
- • 📱 Use paginação em listas grandes
- • 🖼️ Implemente cache de imagens
- • 🔄 Use FutureBuilder para async operations
- • 💾 Cache local com SharedPreferences
- • 📊 Lazy loading para listas
- • ⚡ Minimize rebuilds com const widgets
Segurança
- • 🔐 Armazene tokens com segurança
- • 🚫 Não logue dados sensíveis
- • ✅ Valide inputs do usuário
- • 🔒 Use HTTPS sempre
- • ⏰ Implemente timeout nas requests
- • 🛡️ Trate erros adequadamente
UX/UI
- • 📱 Design mobile-first
- • 🔄 Loading states consistentes
- • ❌ Tratamento de erros amigável
- • ♿ Acessibilidade (screen readers)
- • 🌙 Suporte a dark mode
- • 📏
- • 📏 Design system consistente
- • 🎨 Tema personalizado
- • 📱 Responsividade nativa
Arquitetura
- • 🏗️ Separação clara de responsabilidades
- • 🔧 Services para lógica de negócio
- • 📦 Models imutáveis
- • 🎯 Dependency injection
- • 🧪 Testes unitários
- • 📚 Documentação do código
📋 Checklist de Implementação
🔧 Setup Inicial
🚀 Funcionalidades Core
Sistema de Seguir
📋 Visão Geral
O Sistema de Seguir permite que usuários sigam outros usuários, recebam notificações sobre suas atividades e vejam um feed personalizado. Similar as redes socias como instagram, mas focado no contexto do sistema Omnigrejas.
Seguir/Deixar de Seguir
Conecte-se com outros membros
Notificações
Seja notificado das atividades
Feed de Atividades
Veja o que seus seguidores fazem
Seguir um Usuário
/v1/user-follows
📱 Como Seguir um Usuário - Flutter:
// lib/services/follow_service.dart
Future followUser(String userId) async {
try {
final response = await http.post(
Uri.parse('${ApiConstants.baseUrl}/user-follows'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ${await _getToken()}',
},
body: jsonEncode({'followed_id': userId}),
);
if (response.statusCode == 201) {
final data = jsonDecode(response.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Usuário seguido com sucesso!')),
);
return true;
} else {
final errorData = jsonDecode(response.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(errorData['error'] ?? 'Erro ao seguir usuário')),
);
return false;
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro de conexão: $e')),
);
return false;
}
}
📋 Request Body:
{
"followed_id": "uuid-do-usuario-a-ser-seguido"
}
✅ Response de Sucesso:
{
"message": "Usuário seguido com sucesso",
"dados": {
"followed_id": "uuid-do-usuario",
"seguidores_count": 15,
"seguindo_count": 8
}
}
Deixar de Seguir
/v1/user-follows/{followedId}
📱 Como Deixar de Seguir - Flutter:
// Deixar de seguir um usuário
Future unfollowUser(String userId) async {
try {
final response = await http.delete(
Uri.parse('${ApiConstants.baseUrl}/user-follows/$userId'),
headers: {
'Authorization': 'Bearer ${await _getToken()}',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deixou de seguir o usuário')),
);
return true;
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro ao deixar de seguir')),
);
return false;
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro de conexão: $e')),
);
return false;
}
}
✅ Response de Sucesso:
{
"message": "Deixou de seguir o usuário com sucesso",
"dados": {
"followed_id": "uuid-do-usuario",
"seguidores_count": 14,
"seguindo_count": 7
}
}
Verificar Status de Seguir
/v1/user-follows/check/{userId}
📱 Verificar se Está Seguindo - Flutter:
// Verificar status de seguir
Future checkFollowStatus(String userId) async {
try {
final response = await http.get(
Uri.parse('${ApiConstants.baseUrl}/user-follows/check/$userId'),
headers: {
'Authorization': 'Bearer ${await _getToken()}',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return FollowStatus.fromJson(data['dados']);
} else {
throw Exception('Erro ao verificar status');
}
} catch (e) {
throw Exception('Erro de conexão: $e');
}
}
// Modelo FollowStatus
class FollowStatus {
final String userId;
final bool estaSeguindo;
final int seguidoresCount;
final int seguindoCount;
FollowStatus({
required this.userId,
required this.estaSeguindo,
required this.seguidoresCount,
required this.seguindoCount,
});
factory FollowStatus.fromJson(Map json) {
return FollowStatus(
userId: json['user_id'],
estaSeguindo: json['esta_seguindo'],
seguidoresCount: json['seguidores_count'],
seguindoCount: json['seguindo_count'],
);
}
}
✅ Response:
{
"dados": {
"user_id": "uuid-do-usuario",
"esta_seguindo": true,
"seguidores_count": 15,
"seguindo_count": 8
}
}
Sugestões de Usuários
/v1/user-follows/suggestions
📱 Buscar Sugestões - Flutter:
// Buscar sugestões de usuários para seguir
Future> getFollowSuggestions() async {
try {
final response = await http.get(
Uri.parse('${ApiConstants.baseUrl}/user-follows/suggestions'),
headers: {
'Authorization': 'Bearer ${await _getToken()}',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final usersData = data['dados'] as List;
return usersData.map((user) => User.fromJson(user)).toList();
} else {
throw Exception('Erro ao buscar sugestões');
}
} catch (e) {
throw Exception('Erro de conexão: $e');
}
}
✅ Response:
{
"dados": [
{
"id": "uuid-usuario-1",
"name": "João Silva",
"email": "joao@email.com",
"photo_url": "https://...",
"role": "membro"
},
{
"id": "uuid-usuario-2",
"name": "Maria Santos",
"email": "maria@email.com",
"photo_url": null,
"role": "obreiro"
}
]
}
Listar Notificações
/v1/user-follow-notifications
📱 Listar Notificações - Flutter:
// Listar notificações
Future> getNotifications({
String status = 'all', // 'all', 'lidas', 'nao_lidas'
int page = 1
}) async {
try {
final response = await http.get(
Uri.parse('${ApiConstants.baseUrl}/user-follow-notifications?page=$page&status=$status'),
headers: {
'Authorization': 'Bearer ${await _getToken()}',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final notificationsData = data['dados']['data'] as List;
return notificationsData.map((n) => FollowNotification.fromJson(n)).toList();
} else {
throw Exception('Erro ao buscar notificações');
}
} catch (e) {
throw Exception('Erro de conexão: $e');
}
}
// Modelo FollowNotification
class FollowNotification {
final String id;
final String type;
final String title;
final String message;
final bool isRead;
final DateTime createdAt;
final User? followedUser;
FollowNotification({
required this.id,
required this.type,
required this.title,
required this.message,
required this.isRead,
required this.createdAt,
this.followedUser,
});
factory FollowNotification.fromJson(Map json) {
return FollowNotification(
id: json['id'],
type: json['type'],
title: json['title'],
message: json['message'],
isRead: json['is_read'],
createdAt: DateTime.parse(json['created_at']),
followedUser: json['followed_user'] != null
? User.fromJson(json['followed_user'])
: null,
);
}
}
📋 Parâmetros de Query:
- status: 'all' (padrão), 'lidas', 'nao_lidas'
- tipo: Filtrar por tipo de notificação (opcional)
- page: Página para paginação
✅ Response:
{
"dados": {
"data": [
{
"id": "uuid-notificacao",
"type": "post_created",
"title": "Novo post",
"message": "João publicou um novo post",
"is_read": false,
"created_at": "2025-01-15T10:30:00Z",
"followed_user": {
"id": "uuid-joao",
"name": "João Silva",
"photo_url": "https://..."
}
}
],
"current_page": 1,
"total": 25
}
}
Feed de Atividades
/v1/user-follow-activities/feed
📱 Feed de Atividades - Flutter:
// Buscar feed de atividades dos usuários seguidos
Future> getActivitiesFeed({int dias = 7}) async {
try {
final response = await http.get(
Uri.parse('${ApiConstants.baseUrl}/user-follow-activities/feed?dias=$dias'),
headers: {
'Authorization': 'Bearer ${await _getToken()}',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final activitiesData = data['dados']['data'] as List;
return activitiesData.map((a) => FollowActivity.fromJson(a)).toList();
} else {
throw Exception('Erro ao buscar feed');
}
} catch (e) {
throw Exception('Erro de conexão: $e');
}
}
// Modelo FollowActivity
class FollowActivity {
final String id;
final String activityType;
final String? description;
final DateTime createdAt;
final User user;
FollowActivity({
required this.id,
required this.activityType,
this.description,
required this.createdAt,
required this.user,
});
factory FollowActivity.fromJson(Map json) {
return FollowActivity(
id: json['id'],
activityType: json['activity_type'],
description: json['description'],
createdAt: DateTime.parse(json['created_at']),
user: User.fromJson(json['user']),
);
}
String getActivityMessage() {
final userName = user.name;
switch (activityType) {
case 'post_created':
return '$userName publicou um novo post';
case 'post_liked':
return '$userName curtiu um post';
case 'comment_created':
return '$userName fez um comentário';
case 'event_created':
return '$userName criou um novo evento';
default:
return '$userName teve uma nova atividade';
}
}
}
📋 Parâmetros de Query:
- dias: Número de dias para buscar atividades (padrão: 7)
- page: Página para paginação
✅ Response:
{
"dados": {
"data": [
{
"id": "uuid-atividade",
"activity_type": "post_created",
"description": "João publicou uma mensagem inspiradora",
"created_at": "2025-01-15T10:30:00Z",
"user": {
"id": "uuid-joao",
"name": "João Silva",
"photo_url": "https://..."
}
}
],
"current_page": 1,
"total": 45
}
}
💡 Boas Práticas de Implementação
🎯 UX/UI
- • 👆 Botão Seguir: Mude o texto dinamicamente ("Seguir"/"Seguindo")
- • 🔔 Notificações: Badge vermelho para notificações não lidas
- • 🔄 Real-time: Use WebSocket para notificações em tempo real
- • 📱 Push: Notificações push para atividades importantes
- • 💾 Cache: Cache local das listas de seguidores
- • ⚡ Loading: Estados de loading para ações assíncronas
⚡ Performance
- • 📄 Paginação: Sempre use paginação nas listas
- • 🔍 Filtros: Permita filtrar notificações por tipo
- • 🗑️ Limpeza: Remova notificações antigas automaticamente
- • 📊 Contadores: Cache dos contadores de seguidores
- • 🚀 Lazy Loading: Carregue dados sob demanda
- • 💾 Offline: Funcionalidades básicas offline
⚠️ Considerações de Segurança
- • ✅ Não permitir seguir a si mesmo
- • ✅ Rate limiting nas ações de seguir
- • ✅ Validação de permissões para ver atividades privadas
- • ✅ Auditoria completa de todas as ações
- • ✅ Soft delete para relacionamentos (não hard delete)
Sistema de Gamificação
🎯 Sistema de Engajamento Automático
O sistema de gamificação concede pontos automaticamente por ações dos usuários, incentivando o engajamento saudável na comunidade. Badges são conquistados por marcos de pontuação e rankings mostram a posição dos membros.
Pontos Automáticos
Por ações do usuário
Badges
Conquistas especiais
Ranking
Posição na igreja
🏆 Regras de Pontuação
const pontosRegras = {
'login_diario': 5,
'comentario_post': 10,
'reacao_post': 2,
'post_criado': 15,
'evento_participado': 20,
'pedido_oracao': 8,
'doacao_online': 25,
'voluntario_escala': 30,
'curso_concluido': 50,
};
🎖️ Sistema de Badges
Iniciante
50 pontos
Ativo
200 pontos
Engajado
500 pontos
Líder
1000 pontos
Mestre
2000 pontos
Lenda
5000 pontos
📱 Endpoints da API
Meus Pontos
/v1/engajamento-pontos/meus-pontos
📱 Flutter - Buscar Pontos:
// lib/services/engajamento_service.dart
Future> getMeusPontos() async {
try {
final response = await http.get(
Uri.parse('\${ApiConstants.baseUrl}/engajamento-pontos/meus-pontos'),
headers: {
'Authorization': 'Bearer \${await _getToken()}',
},
);
if (response.statusCode == 200) {
return jsonDecode(response.body)['dados'];
} else {
throw Exception('Erro ao buscar pontos');
}
} catch (e) {
throw Exception('Erro de conexão: \$e');
}
}
✅ Response:
{
"pontos_totais": 150,
"historico": [...],
"estatisticas": {
"nivel_atual": "Ativo",
"proximo_badge": {
"nome": "Engajado",
"pontos_requeridos": 500,
"pontos_faltando": 350
}
}
}
Minhas Badges
/v1/engajamento-badges/minhas-badges
✅ Response:
{
"badges_conquistadas": [
{
"nome": "Iniciante",
"icone": "🌱",
"cor": "#10B981",
"conquistado_em": "2025-01-15T10:30:00Z"
}
],
"badges_disponiveis": [...]
}
Ranking da Igreja
/v1/engajamento/ranking?limite=50
✅ Response:
{
"ranking": [...],
"minha_posicao": 5
}
🔧 Como Funciona Automaticamente
📊 Observers Ativos
- • PostObserver: Registra pontos quando usuário cria posts
- • ComentarioObserver: Registra pontos por comentários
- • PostReactionObserver: Registra pontos por reações
- • AuthController: Registra pontos de login diário
🎖️ Sistema de Badges Automático
- • Verificação automática após cada pontuação
- • Concessão automática quando usuário atinge marcos
- • Notificação de conquistas (futuramente)
📈 Exemplo de Fluxo:
1. Usuário faz login → +5 pontos (login diário)
2. Usuário cria post → +15 pontos
3. Usuário comenta → +10 pontos
4. Usuário reage → +2 pontos
5. Sistema verifica badges → concede automaticamente
6. Usuário consulta ranking → vê posição na igreja