Perfil doUsuário — Avatar e Histórico de Reprodução¶
Novidade (2026-02-15): Endpoints unificados para Admin e Customer gerenciarem avatar e progresso de reprodução de vídeos.
Visão Geral¶
Os endpoints de perfil (/me/...) funcionam para ambos os tipos de usuário (Admin e Customer). O backend identifica automaticamente o user_type via consulta ao MongoDB (padrão STI).
Recursos Implementados¶
| Recurso | Descrição |
|---|---|
| Avatar | Upload, download e remoção de foto de perfil (JPEG, PNG, WebP, max 5 MB) |
| Watch History | Registro de progresso de reprodução por vídeo (posição em segundos) |
Arquitetura¶
Collection: users (STI)
├── avatar_id: ObjectId → GridFS (imagem armazenada)
│
Collection: watch_history (separada)
├── user_id + video_id → índice composto único
├── timeline: float (posição em segundos)
└── updated_at: datetime
Avatar¶
Como Funciona¶
O avatar usa um único endpoint (POST /me/avatar) tanto para inserir quanto para atualizar. Não existe diferença de chamada entre os dois cenários — o backend resolve tudo automaticamente:
- Primeiro upload (inserção): armazena no GridFS e salva o
avatar_idno perfil - Uploads seguintes (atualização): armazena o novo, atualiza o
avatar_id, e deleta o antigo do GridFS
O frontend não precisa verificar se já existe avatar antes de enviar — basta sempre chamar POST /me/avatar.
Fluxo Detalhado — Inserir ou Atualizar Avatar¶
sequenceDiagram
participant App as Flutter App
participant API as FDPlay API
participant GridFS as MongoDB GridFS
participant Users as MongoDB Users
App->>App: Selecionar imagem (ImagePicker)
App->>App: Validar formato (JPEG/PNG/WebP) e tamanho (≤ 5 MB)
App->>API: POST /me/avatar (multipart/form-data)
API->>API: Validar Content-Type e tamanho
alt Formato ou tamanho inválido
API-->>App: 400 {detail: "mensagem de erro"}
end
API->>Users: Buscar usuário pelo token JWT
Note over API,Users: Obtém user_type (STI) e avatar_id atual
API->>GridFS: Armazenar nova imagem
GridFS-->>API: novo_avatar_id (ObjectId)
API->>Users: $set {avatar_id: novo_avatar_id}
alt Tinha avatar anterior (avatar_id antigo)
API->>GridFS: Deletar imagem antiga
Note over API,GridFS: Limpeza automática — sem arquivos órfãos
end
API-->>App: 200 {avatar_id, message}
App->>App: Atualizar UI com novo avatar_id
Endpoints¶
| Ação | Método | Endpoint | Auth | Descrição |
|---|---|---|---|---|
| Upload (inserir/atualizar) | POST |
/api/v1/me/avatar |
JWT | Envia imagem — substitui anterior automaticamente |
| Exibir | GET |
/api/v1/avatars/{avatar_id} |
Pública | Retorna imagem binária para Image.network() |
| Remover | DELETE |
/api/v1/me/avatar |
JWT | Remove do GridFS e limpa avatar_id |
Upload de Avatar (POST)¶
Envia ou substitui a foto de perfil do usuário autenticado. Se já existe um avatar, o antigo é removido automaticamente do GridFS.
Form Data:
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
file |
File |
Sim | Imagem JPEG, PNG ou WebP (max 5 MB) |
Resposta (200 OK):
| Campo | Descrição |
|---|---|
avatar_id |
ObjectId do arquivo no GridFS. Usar em GET /avatars/{avatar_id} para exibir. |
Erros:
| Status | Mensagem | Causa |
|---|---|---|
400 |
Tipo de arquivo não permitido: image/gif. Use JPEG, PNG ou WebP. |
Formato não aceito |
400 |
Arquivo excede o limite de 5 MB. |
Arquivo muito grande |
404 |
Usuário não encontrado. |
Token válido mas usuário não existe no banco |
Remover Avatar (DELETE)¶
Remove a foto de perfil do usuário autenticado. Deleta o arquivo do GridFS e limpa o campo avatar_id do documento.
Resposta (200 OK):
Erros:
| Status | Mensagem | Causa |
|---|---|---|
404 |
Nenhum avatar encontrado. |
Usuário não tem avatar cadastrado |
404 |
Usuário não encontrado. |
Token válido mas usuário não existe |
Exibir Avatar (GET — Público)¶
Endpoint público para exibir a imagem do avatar. Não requer autenticação — o frontend usa a URL diretamente no widget Image.network.
| Parâmetro | Tipo | Descrição |
|---|---|---|
file_id |
string |
ObjectId do avatar (retornado no upload ou em GET /customers/me) |
Resposta: Imagem binária com Content-Type correspondente (image/jpeg, image/png, image/webp).
Erro:
| Status | Mensagem | Causa |
|---|---|---|
404 |
Avatar não encontrado. |
file_id inválido ou arquivo removido |
Watch History (Histórico de Reprodução)¶
Atualizar Progresso¶
Registra ou atualiza a posição de reprodução de um vídeo para o usuário autenticado. Usa upsert: cria novo registro se não existir, ou atualiza o existente.
Payload:
| Campo | Tipo | Obrigatório | Validação | Descrição |
|---|---|---|---|---|
video_id |
string |
Sim | ObjectId válido | ID do vídeo sendo assistido |
timeline |
float |
Sim | >= 0.0 |
Posição de reprodução em segundos |
Resposta (200 OK):
{
"message": "Progresso atualizado.",
"video_id": "507f1f77bcf86cd799439012",
"timeline": 542.8,
"upserted": false
}
| Campo | Descrição |
|---|---|
upserted |
true se criou novo registro, false se atualizou existente |
Quando chamar este endpoint
Recomendamos salvar o progresso a cada 10-15 segundos durante a reprodução e também ao pausar ou sair do player. Isso evita perda de progresso sem sobrecarregar a API.
Consultar Histórico¶
Retorna o histórico de reprodução do usuário autenticado, ordenado pelo mais recente.
Query Parameters:
| Parâmetro | Tipo | Obrigatório | Descrição |
|---|---|---|---|
video_id |
string |
Não | Filtrar por um vídeo específico |
Resposta (200 OK) — Todos os vídeos:
{
"watch_history": [
{
"video_id": "507f1f77bcf86cd799439012",
"timeline": 542.8,
"updated_at": "2026-02-15T14:30:00+00:00"
},
{
"video_id": "507f1f77bcf86cd799439013",
"timeline": 120.0,
"updated_at": "2026-02-15T10:15:00+00:00"
}
]
}
Resposta (200 OK) — Vídeo específico:
{
"watch_history": [
{
"video_id": "507f1f77bcf86cd799439012",
"timeline": 542.8,
"updated_at": "2026-02-15T14:30:00+00:00"
}
]
}
Lista vazia
Se não houver histórico (ou o video_id não for encontrado), retorna {"watch_history": []}.
Limpar Histórico¶
Remove registros de histórico do usuário autenticado. Pode remover um vídeo específico ou todo o histórico.
Query Parameters:
| Parâmetro | Tipo | Obrigatório | Descrição |
|---|---|---|---|
video_id |
string |
Não | Se informado, remove apenas este vídeo. Se omitido, remove todo o histórico. |
Resposta (200 OK):
Fluxo Completo no Flutter¶
Diagrama de Integração¶
flowchart TD
A[App inicia] --> B[Login POST /token]
B --> C[Salvar JWT]
C --> D[GET /customers/me ou /my-user]
D --> E{Tem avatar_id?}
E -->|Sim| F["Exibir: GET /avatars/{avatar_id}"]
E -->|Não| G[Exibir avatar padrão]
C --> H[Abrir Player de Vídeo]
H --> I["GET /me/watch-history?video_id=xxx"]
I --> J{Tem progresso?}
J -->|Sim| K[Seek para timeline]
J -->|Não| L[Iniciar do começo]
K --> M[Reproduzindo...]
L --> M
M -->|A cada 10-15s| N[PUT /me/watch-history]
M -->|Pausar/Sair| N
C --> O[Tela de Perfil]
O --> P[Botão Trocar Avatar]
P --> Q[Selecionar imagem]
Q --> R[POST /me/avatar]
R --> S[Atualizar UI com novo avatar_id]
Service Dart — Avatar¶
import 'dart:io';
import 'dart:convert';
import 'package:http/http.dart' as http;
class ProfileService {
static String get baseUrl => AppConfig.baseUrl;
/// Upload de avatar (imagem do dispositivo).
///
/// Retorna o [avatar_id] do GridFS para exibir via GET /avatars/{id}.
Future<String> uploadAvatar({
required String token,
required File imageFile,
}) async {
final uri = Uri.parse('$baseUrl/me/avatar');
final request = http.MultipartRequest('POST', uri)
..headers['Authorization'] = 'Bearer $token'
..files.add(await http.MultipartFile.fromPath(
'file',
imageFile.path,
));
final streamedResponse = await request.send();
final response = await http.Response.fromStream(streamedResponse);
final body = jsonDecode(response.body);
if (response.statusCode == 200) {
return body['avatar_id'];
}
throw Exception(body['detail'] ?? 'Erro ao fazer upload do avatar');
}
/// Remove o avatar atual.
Future<void> deleteAvatar({required String token}) async {
final response = await http.delete(
Uri.parse('$baseUrl/me/avatar'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode != 200) {
final body = jsonDecode(response.body);
throw Exception(body['detail'] ?? 'Erro ao remover avatar');
}
}
/// URL pública do avatar para usar em Image.network().
///
/// [avatarId] vem de GET /customers/me → campo avatar_id.
static String avatarUrl(String avatarId) {
return '${AppConfig.baseUrl}/avatars/$avatarId';
}
}
Widget Flutter — Avatar com Upload¶
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
class AvatarWidget extends StatefulWidget {
final String token;
final String? avatarId; // De GET /customers/me
const AvatarWidget({
Key? key,
required this.token,
this.avatarId,
}) : super(key: key);
@override
_AvatarWidgetState createState() => _AvatarWidgetState();
}
class _AvatarWidgetState extends State<AvatarWidget> {
final _profileService = ProfileService();
final _picker = ImagePicker();
String? _currentAvatarId;
bool _loading = false;
@override
void initState() {
super.initState();
_currentAvatarId = widget.avatarId;
}
Future<void> _pickAndUpload() async {
final picked = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 800,
maxHeight: 800,
imageQuality: 85,
);
if (picked == null) return;
// Validar tamanho (5 MB)
final file = File(picked.path);
final sizeInBytes = await file.length();
if (sizeInBytes > 5 * 1024 * 1024) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Imagem excede 5 MB. Escolha outra.')),
);
return;
}
setState(() => _loading = true);
try {
final newAvatarId = await _profileService.uploadAvatar(
token: widget.token,
imageFile: file,
);
setState(() => _currentAvatarId = newAvatarId);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro: ${e.toString()}')),
);
} finally {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _loading ? null : _pickAndUpload,
child: Stack(
children: [
CircleAvatar(
radius: 50,
backgroundImage: _currentAvatarId != null
? NetworkImage(ProfileService.avatarUrl(_currentAvatarId!))
: null,
child: _currentAvatarId == null
? const Icon(Icons.person, size: 50)
: null,
),
if (_loading)
const Positioned.fill(
child: CircularProgressIndicator(),
),
Positioned(
bottom: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
child: const Icon(Icons.camera_alt, size: 16, color: Colors.white),
),
),
],
),
);
}
}
Service Dart — Watch History¶
import 'dart:convert';
import 'package:http/http.dart' as http;
class WatchHistoryService {
static String get baseUrl => AppConfig.baseUrl;
/// Salva o progresso de reprodução de um vídeo.
///
/// Chamar a cada 10-15 segundos durante a reprodução
/// e ao pausar ou sair do player.
Future<void> saveProgress({
required String token,
required String videoId,
required double timeline,
}) async {
await http.put(
Uri.parse('$baseUrl/me/watch-history'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode({
'video_id': videoId,
'timeline': timeline,
}),
);
}
/// Recupera o progresso de um vídeo específico.
///
/// Retorna a posição em segundos, ou 0.0 se não houver histórico.
Future<double> getProgress({
required String token,
required String videoId,
}) async {
final response = await http.get(
Uri.parse('$baseUrl/me/watch-history?video_id=$videoId'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode == 200) {
final body = jsonDecode(response.body);
final history = body['watch_history'] as List;
if (history.isNotEmpty) {
return (history[0]['timeline'] as num).toDouble();
}
}
return 0.0;
}
/// Retorna todos os vídeos com progresso (para tela "Continuar Assistindo").
Future<List<WatchHistoryItem>> getAll({required String token}) async {
final response = await http.get(
Uri.parse('$baseUrl/me/watch-history'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode != 200) return [];
final body = jsonDecode(response.body);
final history = body['watch_history'] as List;
return history.map((e) => WatchHistoryItem.fromJson(e)).toList();
}
/// Limpa todo o histórico de reprodução.
Future<int> clearAll({required String token}) async {
final response = await http.delete(
Uri.parse('$baseUrl/me/watch-history'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode == 200) {
final body = jsonDecode(response.body);
return body['deleted_count'] ?? 0;
}
return 0;
}
}
class WatchHistoryItem {
final String videoId;
final double timeline;
final DateTime? updatedAt;
WatchHistoryItem({
required this.videoId,
required this.timeline,
this.updatedAt,
});
factory WatchHistoryItem.fromJson(Map<String, dynamic> json) {
return WatchHistoryItem(
videoId: json['video_id'],
timeline: (json['timeline'] as num).toDouble(),
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'])
: null,
);
}
}
Integração no Video Player¶
import 'dart:async';
import 'package:video_player/video_player.dart';
class VideoPlayerWithHistory extends StatefulWidget {
final String token;
final String videoId;
final String videoUrl;
const VideoPlayerWithHistory({
Key? key,
required this.token,
required this.videoId,
required this.videoUrl,
}) : super(key: key);
@override
_VideoPlayerWithHistoryState createState() => _VideoPlayerWithHistoryState();
}
class _VideoPlayerWithHistoryState extends State<VideoPlayerWithHistory> {
late VideoPlayerController _controller;
final _watchService = WatchHistoryService();
Timer? _saveTimer;
@override
void initState() {
super.initState();
_initPlayer();
}
Future<void> _initPlayer() async {
_controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl));
await _controller.initialize();
// Recuperar progresso anterior
final savedPosition = await _watchService.getProgress(
token: widget.token,
videoId: widget.videoId,
);
if (savedPosition > 0) {
await _controller.seekTo(Duration(seconds: savedPosition.toInt()));
}
// Salvar progresso a cada 10 segundos
_saveTimer = Timer.periodic(const Duration(seconds: 10), (_) {
if (_controller.value.isPlaying) {
_watchService.saveProgress(
token: widget.token,
videoId: widget.videoId,
timeline: _controller.value.position.inSeconds.toDouble(),
);
}
});
setState(() {});
_controller.play();
}
@override
void dispose() {
_saveTimer?.cancel();
// Salvar progresso ao sair
_watchService.saveProgress(
token: widget.token,
videoId: widget.videoId,
timeline: _controller.value.position.inSeconds.toDouble(),
);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_controller.value.isInitialized) {
return const Center(child: CircularProgressIndicator());
}
return AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
);
}
}
Tela "Continuar Assistindo"¶
class ContinueWatchingSection extends StatelessWidget {
final String token;
const ContinueWatchingSection({Key? key, required this.token}) : super(key: key);
@override
Widget build(BuildContext context) {
final watchService = WatchHistoryService();
return FutureBuilder<List<WatchHistoryItem>>(
future: watchService.getAll(token: token),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const SizedBox.shrink(); // Sem historico, nao exibe
}
final items = snapshot.data!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'Continuar Assistindo',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
SizedBox(
height: 180,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return _ContinueWatchingCard(
videoId: item.videoId,
timeline: item.timeline,
onTap: () {
// Abrir player a partir do progresso salvo
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => VideoPlayerWithHistory(
token: token,
videoId: item.videoId,
videoUrl: '...', // Buscar URL do video
),
),
);
},
);
},
),
),
],
);
},
);
}
}
Checklist de Implementação Frontend¶
- [ ] Exibir avatar na tela de perfil (
GET /avatars/{avatar_id}) - [ ] Implementar upload de avatar (
POST /me/avatarcommultipart/form-data) - [ ] Implementar remoção de avatar (
DELETE /me/avatar) - [ ] Validar tamanho (5 MB) e formato (JPEG/PNG/WebP) antes do upload
- [ ] Recuperar progresso ao abrir player (
GET /me/watch-history?video_id=xxx) - [ ] Salvar progresso periodicamente durante reprodução (
PUT /me/watch-history) - [ ] Salvar progresso ao pausar e ao sair do player
- [ ] Implementar seção "Continuar Assistindo" na home (
GET /me/watch-history) - [ ] Implementar "Limpar Histórico" nas configurações (
DELETE /me/watch-history)
Resumo dos Endpoints¶
| Método | Endpoint | Auth | Descrição |
|---|---|---|---|
POST |
/api/v1/me/avatar |
JWT | Upload de avatar (multipart/form-data) |
DELETE |
/api/v1/me/avatar |
JWT | Remover avatar |
GET |
/api/v1/avatars/{file_id} |
Público | Download de imagem do avatar |
PUT |
/api/v1/me/watch-history |
JWT | Atualizar progresso de vídeo (upsert) |
GET |
/api/v1/me/watch-history |
JWT | Listar histórico (filtro opcional por video_id) |
DELETE |
/api/v1/me/watch-history |
JWT | Limpar histórico (filtro opcional por video_id) |