Skip to content

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_id no 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.

POST /api/v1/me/avatar
Authorization: Bearer <TOKEN_JWT>
Content-Type: multipart/form-data

Form Data:

Campo Tipo Obrigatório Descrição
file File Sim Imagem JPEG, PNG ou WebP (max 5 MB)

Resposta (200 OK):

{
  "avatar_id": "507f1f77bcf86cd799439011",
  "message": "Avatar atualizado com sucesso."
}
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.

DELETE /api/v1/me/avatar
Authorization: Bearer <TOKEN_JWT>

Resposta (200 OK):

{
  "message": "Avatar removido com sucesso."
}

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.

GET /api/v1/avatars/{file_id}
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.

PUT /api/v1/me/watch-history
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json

Payload:

{
  "video_id": "507f1f77bcf86cd799439012",
  "timeline": 542.8
}
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.

GET /api/v1/me/watch-history
Authorization: Bearer <TOKEN_JWT>

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:

GET /api/v1/me/watch-history?video_id=507f1f77bcf86cd799439012
{
  "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.

DELETE /api/v1/me/watch-history
Authorization: Bearer <TOKEN_JWT>

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

{
  "message": "Histórico removido.",
  "deleted_count": 5
}

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/avatar com multipart/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)