Implementando Detalhes de Jogos em Flutter para OpenHarmony com Funcionalidades Avançadas

A tela de detalhes de um jogo é crucial para fornecer aos usuários informações abrangentes. Ao contrário de uma simples listagem, ela deve apresentar um panorama completo, incluindo sinopse, capturas de tela e requisitos de sistema. Este artigo detalha a construção de uma página de detalhes robusta em Flutter, incorporando tradução de sinopse, exibição de galeria de imagens, requisitos de sistema e uma funcionalidade para iniciar o jogo.

Estrutura Principal da Tela

A página recebe um identificador único para o jogo. Os dados do jogo, bem como o estado da interface do usuário (carregamento, erros, informações de tradução), são gerenciados dentro do estado do widget.

class GameDetailsPage extends StatefulWidget {
  final int gameIdentifier;

  const GameDetailsPage({super.key, required this.gameIdentifier});

  @override
  State<GameDetailsPage> createState() => _GameDetailsPageState();
}

class _GameDetailsPageState extends State<GameDetailsPage> {
  final GameDataService _dataService = GameDataService();
  Map<String, dynamic>? _currentGameDetails;
  bool _isFetchingData = true;
  String? _errorMessage;

  // Variáveis relacionadas à tradução
  String? _translatedSynopsis;
  bool _translationInProgress = false;
  bool _showTranslatedContent = false;

  // ... outros métodos
}

O widget recebe gameIdentifier. As variáveis _translatedSynopsis, _translationInProgress e _showTranslatedContent controlam o estado da funcionalidade de tradução da descrição do jogo, um dos pontos de destaque desta implementação.

Carregamento dos Dados do Jogo

Os detalhes do jogo são carregados no ciclo de vida inicial do estado. É fundamental incluir uma verificação de mounted para evitar operações de setState em um widget que possa ter sido descartado, prevenindo assim vazamentos de memória e erros.

  @override
  void initState() {
    super.initState();
    _fetchGameDetails();
  }

  Future<void> _fetchGameDetails() async {
    setState(() {
      _isFetchingData = true;
      _errorMessage = null;
    });
    try {
      final details = await _dataService.getGameInformation(widget.gameIdentifier);
      if (mounted) {
        setState(() {
          _currentGameDetails = details;
          _isFetchingData = false;
        });
      }
    } catch (e) {
      if (mounted) {
        setState(() {
          _errorMessage = 'Falha ao carregar detalhes: ${e.toString()}';
          _isFetchingData = false;
        });
      }
    }
  }

Abertura do Link do Jogo

A função _openExternalGameLink é responsável por abrir o URL do jogo em um navegador externo, utilizando um serviço nativo. Isso garante que o usuário possa acessar o jogo diretamente da aplicação.

  Future<void> _openExternalGameLink() async {
    final gameWebLink = _currentGameDetails?['game_url'];
    if (gameWebLink == null || gameWebLink.toString().isEmpty) {
      _displaySnackBar('Link do jogo não disponível.');
      return;
    }

    final linkOpened = await PlatformInteraction.launchUrlInBrowser(gameWebLink.toString());
    if (!linkOpened && mounted) {
      _displaySnackBar('Não foi possível abrir o navegador.');
    }
  }

O método verifica a validade do URL e utiliza um serviço customizado (PlatformInteraction.launchUrlInBrowser) para interagir com o sistema operacional e abrir o navegador. Em caso de falha, uma notificação é exibida.

Tradução da Sinopse do Jogo

Um dos recursos mais valiosos desta tela é a capacidade de traduzir a sinopse do jogo. Implementamos um mecanismo de fallback que tenta diferentes APIs de tradução, garantindo que mesmo que uma falhe, outra possa ser utilizada.

  Future<void> _initiateContentTranslation() async {
    final originalSynopsis = _currentGameDetails?['description']?.toString();
    if (originalSynopsis == null || originalSynopsis.isEmpty) {
      _displaySnackBar('Nenhuma descrição disponível para traduzir.');
      return;
    }

    if (_translatedSynopsis != null) { // Se já traduziu, apenas alterna a exibição
      setState(() => _showTranslatedContent = !_showTranslatedContent);
      return;
    }

    setState(() => _translationInProgress = true);

    try {
      String? tempTranslatedText = await _attemptLibreTranslation(originalSynopsis);
      tempTranslatedText ??= await _attemptLingvaTranslation(originalSynopsis);
      tempTranslatedText ??= _performBasicKeywordTranslation(originalSynopsis);

      if (mounted) {
        setState(() {
          _translatedSynopsis = tempTranslatedText;
          _showTranslatedContent = true;
          _translationInProgress = false;
        });
      }
    } catch (e) {
      if (mounted) {
        setState(() => _translationInProgress = false);
        _displaySnackBar('Falha na tradução. Tente novamente mais tarde.');
      }
    }
  }

Se a tradução já foi realizada, o botão apenas alterna entre a exibição do texto original e o traduzido. Caso contrário, ele tenta traduzir usando uma sequência de APIs.

Integração com LibreTranslate

A primeira tentativa de tradução é feita com a API LibreTranslate, um serviço de código aberto.

  Future<String?> _attemptLibreTranslation(String inputToTranslate) async {
    try {
      final apiResponse = await http.post(
        Uri.parse('https://libretranslate.com/translate'),
        headers: {'Content-Type': 'application/json'},
        body: jsonEncode({
          'q': inputToTranslate,
          'source': 'en',
          'target': 'pt', // Traduzir para Português
        }),
      ).timeout(const Duration(seconds: 10));

      if (apiResponse.statusCode == 200) {
        final responseData = jsonDecode(apiResponse.body);
        return responseData['translatedText']?.toString();
      }
    } catch (e) {
      debugPrint('LibreTranslate falhou: $e');
    }
    return null;
  }

A requisição HTTP POST é configurada para enviar o texto em inglês (en) e solicitar a tradução para português (pt), com um tempo limite de 10 segundos.

Integração com Lingva Translate

Como alternativa, a API Lingva Translate é utilizada. Lingva é um espelho de código aberto do Google Translate.

  Future<String?> _attemptLingvaTranslation(String inputToTranslate) async {
    try {
      final encodedText = Uri.encodeComponent(inputToTranslate);
      final apiResponse = await http.get(
        Uri.parse('https://lingva.ml/api/v1/en/pt/$encodedText'),
      ).timeout(const Duration(seconds: 10));

      if (apiResponse.statusCode == 200) {
        final responseData = jsonDecode(apiResponse.body);
        return responseData['translation']?.toString();
      }
    } catch (e) {
      debugPrint('Lingva Translate falhou: $e');
    }
    return null;
  }

Aqui, a requisição é um GET, com o texto a ser traduzido codificado e incluído na URL. Um timeout similar de 10 segundos é aplicado.

Tradução Básica por Palavras-Chave

Se todas as APIs externas falharem, um método de tradução básica por palavras-chave oferece uma solução de último recurso. Isso, embora não seja uma tradução completa, pode fornecer contexto essencial.

  String _performBasicKeywordTranslation(String rawText) {
    final keywordMap = {
      'free': 'Gratuito',
      'game': 'Jogo',
      'play': 'Jogar',
      'online': 'Online',
      'multiplayer': 'Multijogador',
      'action': 'Ação',
      'adventure': 'Aventura',
      'strategy': 'Estratégia',
      // ... adicione mais traduções
    };

    String processedText = rawText.toLowerCase();
    keywordMap.forEach((en, pt) {
      processedText = processedText.replaceAll(RegExp('\\b$en\\b', caseSensitive: false), pt);
    });

    return '【Tradução Simplificada】$processedText\n\n(Serviço de tradução completo indisponível, resultado baseado em substituição de palavras-chave.)';
  }

Este método substitui palavras-chave conhecidas. A utilização de expressões regulares com \\b (limite de palavra) previne substituições parciais, como "free" em "freedom".

Estrutura da Interface do Usuário

A tela utiliza um CustomScrollView para criar uma experiência de rolagem complexa e visualmente atraente. Tratamento de estados de carregamento e erro são prioritários.

  @override
  Widget build(BuildContext context) {
    if (_isFetchingData) {
      return Scaffold(
        appBar: AppBar(),
        body: const Center(child: CircularProgressIndicator()),
      );
    }

    if (_errorMessage != null || _currentGameDetails == null) {
      return Scaffold(
        appBar: AppBar(),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Icon(Icons.error_outline, size: 64, color: Colors.redAccent),
              const SizedBox(height: 16),
              Text(_errorMessage ?? 'Falha ao carregar os dados.', textAlign: TextAlign.center),
              const SizedBox(height: 16),
              ElevatedButton.icon(
                onPressed: _fetchGameDetails,
                icon: const Icon(Icons.refresh),
                label: const Text('Tentar Novamente'),
              ),
            ],
          ),
        ),
      );
    }

    final gameImageGallery = _currentGameDetails!['screenshots'] as List? ?? [];

    return Scaffold(
      body: CustomScrollView(
        slivers: [
          // SliverAppBar e outros Slviers serão adicionados aqui
        ],
      ),
    );
  }

Primeiramente, são verificados os estados de carregamento e erro. Somente após a obtenção bem-sucedida dos dados, a estrutura principal com CustomScrollView é renderizada.

Componente SliverAppBar

O SliverAppBar é dinâmico, exibindo a miniatura do jogo como fundo e um botão de favoritos no canto superior direito. O estado do favorito é gerenciado com Consumer do Provider.

          SliverAppBar(
            expandedHeight: 220,
            pinned: true,
            flexibleSpace: FlexibleSpaceBar(
              background: Image.network(
                _currentGameDetails!['thumbnail']?.toString() ?? '',
                fit: BoxFit.cover,
                errorBuilder: (context, error, stackTrace) =>
                    const Center(child: Icon(Icons.broken_image, size: 50)),
              ),
            ),
            actions: [
              Consumer<UserFavoritesProvider>(
                builder: (context, favoritesProvider, _) {
                  final isCurrentlyFavorite = favoritesProvider.isGameFavorited(widget.gameIdentifier.toString());
                  return IconButton(
                    icon: Icon(
                      isCurrentlyFavorite ? Icons.favorite : Icons.favorite_border,
                      color: isCurrentlyFavorite ? Colors.red : null,
                    ),
                    onPressed: _handleFavoriteToggle,
                  );
                },
              ),
            ],
          ),

Seção de Sinopse do Jogo

O método _createDescriptionDisplay renderiza a sinopse do jogo e o botão de tradução. O estado de tradução é claramente indicado ao usuário.

  Widget _createDescriptionDisplay() {
    final gameSynopsis = _currentGameDetails!['description']?.toString() ?? 'Nenhuma sinopse disponível.';

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text('Sinopse do Jogo', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
            TextButton.icon(
              onPressed: _translationInProgress ? null : _initiateContentTranslation,
              icon: _translationInProgress
                  ? const SizedBox(
                      width: 16,
                      height: 16,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : Icon(
                      _showTranslatedContent ? Icons.translate : Icons.g_translate,
                      size: 18,
                    ),
              label: Text(
                _translationInProgress
                    ? 'Traduzindo...'
                    : (_showTranslatedContent ? 'Mostrar Original' : 'Traduzir'),
                style: const TextStyle(fontSize: 13),
              ),
            ),
          ],
        ),
        const SizedBox(height: 12),
        AnimatedCrossFade(
          duration: const Duration(milliseconds: 300),
          crossFadeState: _showTranslatedContent ? CrossFadeState.showSecond : CrossFadeState.showFirst,
          firstChild: Text(gameSynopsis, style: Theme.of(context).textTheme.bodyMedium),
          secondChild: Container(
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
              borderRadius: BorderRadius.circular(8),
              border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.3)),
            ),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  children: [
                    Icon(Icons.translate, size: 14, color: Theme.of(context).colorScheme.primary),
                    const SizedBox(width: 4),
                    Text(
                      'Tradução para Português',
                      style: TextStyle(
                        fontSize: 12,
                        color: Theme.of(context).colorScheme.primary,
                        fontWeight: FontWeight.w500,
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 8),
                Text(
                  _translatedSynopsis ?? 'Tradução indisponível.',
                  style: Theme.of(context).textTheme.bodyMedium,
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }

O AnimatedCrossFade proporciona uma transição suave entre a sinopse original e a traduzida, que é exibida em um contêiner estilizado para fácil distinção.

Galeria de Capturas de Tela

As capturas de tela do jogo são apresentadas em um ListView horizontal, permitindo que o usuário as visualize deslizando. Ao clicar em uma imagem, um diálogo exibe a versão em tamanho maior.

                  if (gameImageGallery.isNotEmpty) ...[
                    const SizedBox(height: 24),
                    Text('Capturas de Tela', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
                    const SizedBox(height: 12),
                    SizedBox(
                      height: 150,
                      child: ListView.builder(
                        scrollDirection: Axis.horizontal,
                        itemCount: gameImageGallery.length,
                        itemBuilder: (context, index) {
                          final imageUrl = gameImageGallery[index]['image']?.toString() ?? '';
                          return Padding(
                            padding: const EdgeInsets.only(right: 12),
                            child: GestureDetector(
                              onTap: () => _showImageFullscreen(imageUrl),
                              child: ClipRRect(
                                borderRadius: BorderRadius.circular(12),
                                child: Image.network(
                                  imageUrl,
                                  width: 250,
                                  height: 150,
                                  fit: BoxFit.cover,
                                  errorBuilder: (context, error, stackTrace) =>
                                      const Center(child: Icon(Icons.broken_image, size: 50)),
                                ),
                              ),
                            ),
                          );
                        },
                      ),
                    ),
                  ],

Requisitos de Sistema

O método _renderSystemSpecifications exibe os requisitos mínimos de sistema do jogo, formatados em um Card com linhas individuais para cada especificação.

  Widget _renderSystemSpecifications() {
    final rawRequirements = _currentGameDetails!['minimum_system_requirements'];
    if (rawRequirements == null || rawRequirements is! Map) return const SizedBox();

    final parsedSpecs = Map<String, dynamic>.from(rawRequirements);

    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      elevation: 2,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Requisitos Mínimos de Sistema', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
            const SizedBox(height: 12),
            if (parsedSpecs['os'] != null) _createSpecRow('Sistema Operacional', parsedSpecs['os'].toString()),
            if (parsedSpecs['processor'] != null) ...[
              const Divider(height: 16),
              _createSpecRow('Processador', parsedSpecs['processor'].toString()),
            ],
            if (parsedSpecs['memory'] != null) ...[
              const Divider(height: 16),
              _createSpecRow('Memória', parsedSpecs['memory'].toString()),
            ],
            if (parsedSpecs['graphics'] != null) ...[
              const Divider(height: 16),
              _createSpecRow('Placa Gráfica', parsedSpecs['graphics'].toString()),
            ],
            if (parsedSpecs['storage'] != null) ...[
              const Divider(height: 16),
              _createSpecRow('Armazenamento', parsedSpecs['storage'].toString()),
            ],
          ],
        ),
      ),
    );
  }

  Widget _createSpecRow(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4.0),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(flex: 1, child: Text(label, style: const TextStyle(fontWeight: FontWeight.w500))),
          const SizedBox(width: 8),
          Expanded(flex: 2, child: Text(value)),
        ],
      ),
    );
  }

Cada requisito é exibido condicionalmente e separado por um Divider, garantindo uma apresentação clara e organizada.

Integração da Funcionalidade de Favoritos

O método _handleFavoriteToggle gerencia a adição ou remoção de um jogo da lista de favoritos do usuário, utilizando um Provider para gerenciar o estado global.

  void _handleFavoriteToggle() {
    if (_currentGameDetails == null) return;

    final favoritesProvider = context.read<UserFavoritesProvider>();
    final favoriteEntry = FavoriteGameEntry(
      id: widget.gameIdentifier.toString(),
      type: 'free_game',
      name: _currentGameDetails!['title']?.toString() ?? 'Jogo Desconhecido',
      thumbnailUrl: _currentGameDetails!['thumbnail']?.toString(),
      metadata: {
        'gameId': widget.gameIdentifier,
        'gameTitle': _currentGameDetails!['title'],
        'gameThumbnail': _currentGameDetails!['thumbnail'],
        'gameGenre': _currentGameDetails!['genre'],
      },
    );

    favoritesProvider.toggleGameFavoriteStatus(favoriteEntry);

    final isCurrentlyFavorite = favoritesProvider.isGameFavorited(widget.gameIdentifier.toString());
    _displaySnackBar(isCurrentlyFavorite ? 'Adicionado aos favoritos!' : 'Removido dos favoritos.');
  }

Um objeto FavoriteGameEntry é criado com as informações essenciais do jogo e enviado ao UserFavoritesProvider para alternar seu status de favorito. Uma mensagem de feedback é exibida ao usuário.

Tags: Flutter OpenHarmony Dart APIIntegration GerenciamentoDeEstado

Publicado em 6-15 19:56 por Thomas