diff --git a/lib/models/media_item.dart b/lib/models/media_item.dart index 531218b..b6d93b7 100644 --- a/lib/models/media_item.dart +++ b/lib/models/media_item.dart @@ -17,6 +17,10 @@ class MediaItem { final String? imdbId; final List? seasons; // To store season details for TV shows final String? ageRating; + final String? originalTitle; + final String? tagline; + final List? directors; + final List? countries; MediaItem({ required this.id, @@ -37,6 +41,10 @@ class MediaItem { this.imdbId, this.seasons, this.ageRating, + this.originalTitle, + this.tagline, + this.directors, + this.countries, }); String get storageKey => '${mediaType}_$id'; @@ -73,6 +81,35 @@ class MediaItem { return voteAverage.toStringAsFixed(1); } + /// Audience average on a five-star scale, written the Brazilian way (4,1). + String get starRating => + (voteAverage / 2).toStringAsFixed(1).replaceAll('.', ','); + + /// Original title, only when it differs from the localized one. + String? get distinctOriginalTitle { + final original = originalTitle?.trim(); + if (original == null || original.isEmpty) return null; + return original.toLowerCase() == title.toLowerCase() ? null : original; + } + + /// "Dirigido por …" / "Criado por …" line, when the credits are known. + String? get directorLine { + final names = directors; + if (names == null || names.isEmpty) return null; + final joined = names.length == 1 + ? names.first + : '${names.take(names.length - 1).join(', ')} e ${names.last}'; + return '${mediaType == 'tv' ? 'Criado por' : 'Dirigido por'} $joined'; + } + + /// Film-catalogue credit line: "Brasil, França, 2024". + String get originLine { + return [ + ...?countries?.take(2), + if (releaseDate != null && releaseDate!.length >= 4) formattedYear, + ].join(', '); + } + factory MediaItem.fromJson(Map json, {String defaultMediaType = 'movie'}) { final titleStr = json['title'] ?? @@ -114,6 +151,40 @@ class MediaItem { if (gIds.isEmpty) gIds = idsFromGenres; } + List? directors; + final storedDirectors = json['directors']; + if (storedDirectors is List) { + directors = storedDirectors.map((d) => d.toString()).toList(); + } else if (json['credits'] is Map && json['credits']['crew'] is List) { + directors = [ + for (final c in json['credits']['crew'] as List) + if (c is Map && c['job'] == 'Director' && c['name'] != null) + c['name'].toString(), + ]; + } else if (json['created_by'] is List) { + directors = [ + for (final c in json['created_by'] as List) + if (c is Map && c['name'] != null) c['name'].toString(), + ]; + } + + List? countries; + final storedCountries = json['countries']; + if (storedCountries is List) { + countries = storedCountries.map((c) => c.toString()).toList(); + } else if (json['production_countries'] is List) { + countries = [ + for (final c in json['production_countries'] as List) + if (c is Map) + countryName(c['iso_3166_1']?.toString(), c['name']?.toString()), + ].whereType().toList(); + } else if (json['origin_country'] is List) { + countries = [ + for (final c in json['origin_country'] as List) + countryName(c?.toString(), null), + ].whereType().toList(); + } + String? parsedImdbId = json['imdb_id']; if (parsedImdbId == null && json['external_ids'] != null) { parsedImdbId = json['external_ids']['imdb_id']; @@ -140,6 +211,12 @@ class MediaItem { imdbId: parsedImdbId ?? json['imdbId'], seasons: json['seasons'], ageRating: json['ageRating'], + originalTitle: json['original_title'] ?? json['original_name'], + tagline: (json['tagline'] as String?)?.trim().isEmpty ?? true + ? null + : json['tagline'], + directors: directors == null || directors.isEmpty ? null : directors, + countries: countries == null || countries.isEmpty ? null : countries, ); } @@ -163,6 +240,10 @@ class MediaItem { 'imdbId': imdbId, 'seasons': seasons, 'ageRating': ageRating, + 'original_title': originalTitle, + 'tagline': tagline, + 'directors': directors, + 'countries': countries, }; } @@ -191,6 +272,10 @@ class MediaItem { trailerKey: trailerKey, imdbId: imdbId, ageRating: ageRating, + originalTitle: originalTitle, + tagline: tagline, + directors: directors, + countries: countries, ); } @@ -223,6 +308,66 @@ class MediaItem { imdbId: imdbId ?? this.imdbId, seasons: seasons ?? this.seasons, ageRating: ageRating ?? this.ageRating, + originalTitle: originalTitle, + tagline: tagline, + directors: directors, + countries: countries, ); } } + +const _countryNames = { + 'AR': 'Argentina', + 'AU': 'Austrália', + 'AT': 'Áustria', + 'BE': 'Bélgica', + 'BR': 'Brasil', + 'CA': 'Canadá', + 'CL': 'Chile', + 'CN': 'China', + 'CO': 'Colômbia', + 'KR': 'Coreia do Sul', + 'DK': 'Dinamarca', + 'EG': 'Egito', + 'ES': 'Espanha', + 'US': 'Estados Unidos', + 'FI': 'Finlândia', + 'FR': 'França', + 'GR': 'Grécia', + 'HK': 'Hong Kong', + 'HU': 'Hungria', + 'IN': 'Índia', + 'ID': 'Indonésia', + 'IE': 'Irlanda', + 'IR': 'Irã', + 'IS': 'Islândia', + 'IL': 'Israel', + 'IT': 'Itália', + 'JP': 'Japão', + 'MX': 'México', + 'NO': 'Noruega', + 'NZ': 'Nova Zelândia', + 'NL': 'Países Baixos', + 'PE': 'Peru', + 'PL': 'Polônia', + 'PT': 'Portugal', + 'GB': 'Reino Unido', + 'CZ': 'República Tcheca', + 'RO': 'Romênia', + 'RU': 'Rússia', + 'SE': 'Suécia', + 'CH': 'Suíça', + 'TH': 'Tailândia', + 'TW': 'Taiwan', + 'TR': 'Turquia', + 'UA': 'Ucrânia', + 'UY': 'Uruguai', + 'DE': 'Alemanha', + 'ZA': 'África do Sul', +}; + +/// Portuguese country name for an ISO 3166-1 code; TMDB only ships English. +String? countryName(String? code, String? fallback) { + final name = _countryNames[code?.toUpperCase()] ?? fallback ?? code; + return name == null || name.isEmpty ? null : name; +} diff --git a/lib/screens/categories_screen.dart b/lib/screens/categories_screen.dart index f521d2c..7a0cab3 100644 --- a/lib/screens/categories_screen.dart +++ b/lib/screens/categories_screen.dart @@ -43,10 +43,10 @@ class CategoriesScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('DESCUBRA', + Text('EXPLORAR', style: colors.display(fontSize: width >= 800 ? 48 : 34)), const SizedBox(height: 12), - Text('Outras histórias. Novos pontos de vista.', + Text('Cinema de todo o mundo, organizado por gênero.', style: colors.body(fontSize: 17)), ]))), SliverPadding( @@ -121,13 +121,13 @@ class CategoriesScreen extends StatelessWidget { style: colors.caption( color: hasImage ? Colors.white - : colors.accent)), + : colors.textPrimary)), const SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18, color: hasImage ? Colors.white - : colors.accent) + : colors.textPrimary) ]), ]))), ])); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 8eb1d9f..e0691b3 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -8,6 +8,7 @@ import '../widgets/continue_watching_row.dart'; import '../widgets/hero_banner.dart'; import '../widgets/home_skeleton.dart'; import '../widgets/media_row.dart'; +import '../widgets/now_showing_grid.dart'; import '../widgets/wordmark.dart'; import 'profile_selection_screen.dart'; @@ -25,6 +26,17 @@ class HomeScreen extends StatelessWidget { if (catalog.heroItem != null) catalog.heroItem!, ...catalog.trending, ]); + // "Filme do dia": one pick from the head of the catalogue, rotating daily. + final now = DateTime.now(); + final day = DateTime.utc(now.year, now.month, now.day) + .difference(DateTime.utc(2024)) + .inDays; + final hero = + heroes.isEmpty ? null : heroes[day % heroes.length.clamp(1, 8)]; + final showing = settings + .visibleItems(catalog.trending) + .where((m) => m.storageKey != hero?.storageKey) + .toList(); return Scaffold( backgroundColor: SabuflixTheme.of(context).background, body: catalog.isLoading @@ -45,8 +57,8 @@ class HomeScreen extends StatelessWidget { floating: true, backgroundColor: SabuflixTheme.of(context).background, elevation: 0, - centerTitle: false, - title: SabuflixWordmark(fontSize: 19), + centerTitle: true, + title: SabuflixWordmark(fontSize: 18), actions: [ Padding( padding: EdgeInsets.only(right: 16), @@ -61,9 +73,14 @@ class HomeScreen extends StatelessWidget { onRetry: catalog.loadCatalog, ), ), - if (heroes.isNotEmpty) + if (hero != null) + SliverToBoxAdapter(child: HeroBanner(media: hero)), + if (showing.isNotEmpty) SliverToBoxAdapter( - child: HeroBanner(media: heroes.first)), + child: NowShowingGrid( + items: showing.take(isDesktop ? 6 : 4).toList(), + ), + ), SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -92,12 +109,12 @@ class HomeScreen extends StatelessWidget { settings.visibleItems(section.value), ), MediaRow( - title: 'Em Alta Hoje', + title: 'Em Alta', mediaItems: - settings.visibleItems(catalog.trending), + showing.skip(isDesktop ? 6 : 4).toList(), ), MediaRow( - title: 'Filmes Populares', + title: 'Os Mais Vistos', mediaItems: settings.visibleItems( catalog.popularMovies, ), @@ -108,7 +125,7 @@ class HomeScreen extends StatelessWidget { settings.visibleItems(catalog.popularTV), ), MediaRow( - title: 'Mais Bem Avaliados', + title: 'Aclamados pela Crítica', mediaItems: settings.visibleItems(catalog.topRated), ), diff --git a/lib/screens/main_navigation_screen.dart b/lib/screens/main_navigation_screen.dart index 0555497..edbbe58 100644 --- a/lib/screens/main_navigation_screen.dart +++ b/lib/screens/main_navigation_screen.dart @@ -23,21 +23,21 @@ class _MainNavigationScreenState extends State { static const _labels = [ 'Início', 'Pesquisar', - 'Descobrir', + 'Explorar', 'Biblioteca', 'Ajustes', ]; static const _icons = [ Icons.home_outlined, Icons.search, - Icons.explore_outlined, + Icons.grid_view_outlined, Icons.video_library_outlined, Icons.settings_outlined, ]; static const _selectedIcons = [ Icons.home_rounded, Icons.search_rounded, - Icons.explore_rounded, + Icons.grid_view_rounded, Icons.video_library_rounded, Icons.settings_rounded, ]; @@ -70,12 +70,16 @@ class _MainNavigationScreenState extends State { appBar: desktop ? AppBar( backgroundColor: SabuflixTheme.of(context).background, - toolbarHeight: 76, - titleSpacing: 24, + toolbarHeight: 72, + titleSpacing: 40, + shape: Border( + bottom: BorderSide( + color: SabuflixTheme.of(context).border, width: .5), + ), title: Row( children: [ - SabuflixWordmark(fontSize: 21), - SizedBox(width: 24), + SabuflixWordmark(fontSize: 20), + SizedBox(width: 36), for (var i = 0; i < _labels.length; i++) Flexible( child: Padding( @@ -84,20 +88,40 @@ class _MainNavigationScreenState extends State { onPressed: () => _select(i), style: TextButton.styleFrom( foregroundColor: _index == i - ? SabuflixTheme.of(context).accent - : SabuflixTheme.of(context).textSecondary, + ? SabuflixTheme.of(context).textPrimary + : SabuflixTheme.of(context).textMuted, backgroundColor: Colors.transparent, + overlayColor: Colors.transparent, padding: EdgeInsets.symmetric( horizontal: 12, vertical: 18, ), + textStyle: SabuflixTheme.of(context).label( + fontSize: 15, + fontWeight: FontWeight.w800, + letterSpacing: -.2, + ), ), child: Semantics( selected: _index == i, - child: Text( - _labels[i], - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Container( + padding: EdgeInsets.only(bottom: 4), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: _index == i + ? SabuflixTheme.of(context) + .textPrimary + : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + _labels[i], + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), ), ), @@ -117,7 +141,7 @@ class _MainNavigationScreenState extends State { ), ), icon: CircleAvatar( - radius: 16, + radius: 15, backgroundColor: Color( profiles.currentProfile?.colorValue ?? 0xFF4285F4, ), @@ -129,7 +153,7 @@ class _MainNavigationScreenState extends State { ), ), ), - SizedBox(width: 16), + SizedBox(width: 32), ], ) : null, @@ -200,7 +224,7 @@ class _MobileNavItem extends StatelessWidget { @override Widget build(BuildContext context) { final color = selected - ? SabuflixTheme.of(context).accent + ? SabuflixTheme.of(context).textPrimary : SabuflixTheme.of(context).textMuted; return Semantics( button: true, @@ -236,7 +260,8 @@ class _MobileNavItem extends StatelessWidget { duration: SabuflixTheme.durationFast, style: SabuflixTheme.of(context).caption( fontSize: 10.5, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + letterSpacing: .1, color: color, ), child: Text(label, maxLines: 1, overflow: TextOverflow.fade), diff --git a/lib/screens/media_details_screen.dart b/lib/screens/media_details_screen.dart index 1a83d06..7af7f1f 100644 --- a/lib/screens/media_details_screen.dart +++ b/lib/screens/media_details_screen.dart @@ -17,6 +17,7 @@ import '../providers/downloads_provider.dart'; import '../providers/profile_provider.dart'; import '../providers/playlist_provider.dart'; import '../providers/watched_provider.dart'; +import '../utils/formatters.dart'; import '../utils/playback.dart'; import 'video_player_screen.dart'; @@ -151,7 +152,7 @@ class _MediaDetailsScreenState extends State { backgroundColor: Colors.transparent, builder: (ctx) { return GlassContainer( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + borderRadius: BorderRadius.zero, padding: EdgeInsets.all(24), blur: 40, fillOpacity: 0.4, @@ -325,7 +326,7 @@ class _MediaDetailsScreenState extends State { backgroundColor: Colors.transparent, builder: (ctx) { return GlassContainer( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + borderRadius: BorderRadius.zero, padding: EdgeInsets.all(24), blur: 40, fillOpacity: 0.4, @@ -458,7 +459,9 @@ class _MediaDetailsScreenState extends State { onPressed: _loadData, child: Text('Tentar novamente'))))), SliverAppBar( - expandedHeight: isDesktop ? 480 : 280, + expandedHeight: isDesktop + ? (screenWidth * 9 / 16).clamp(320.0, 560.0) + : screenWidth * 9 / 16 + MediaQuery.paddingOf(context).top, pinned: true, backgroundColor: SabuflixTheme.of(context).background, leading: Padding( @@ -483,14 +486,15 @@ class _MediaDetailsScreenState extends State { ), Container( decoration: BoxDecoration( + // Only a top scrim for the back button; the still ends on + // a clean edge above the credits. gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - stops: [0.0, 0.8, 1.0], + stops: [0.0, 0.3], colors: [ + Colors.black.withValues(alpha: .45), Colors.transparent, - Colors.transparent, - SabuflixTheme.of(context).background, ], ), ), @@ -501,126 +505,173 @@ class _MediaDetailsScreenState extends State { ), SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.symmetric(horizontal: 28, vertical: 16), + padding: EdgeInsets.fromLTRB( + isDesktop ? 40 : 20, 20, isDesktop ? 40 : 20, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(media.title.toUpperCase(), - style: SabuflixTheme.of(context) - .display(fontSize: isDesktop ? 48 : 32)), - SizedBox(height: 10), - Row( + style: SabuflixTheme.of(context).display( + fontSize: isDesktop ? 56 : 34, + height: 1, + letterSpacing: isDesktop ? -1.8 : -1.1)), + if (media.distinctOriginalTitle != null) ...[ + SizedBox(height: 8), + Text(media.distinctOriginalTitle!.toUpperCase(), + style: SabuflixTheme.of(context).label( + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: .8)), + ], + SizedBox(height: 16), + if (media.directorLine != null) ...[ + Text(media.directorLine!, + style: SabuflixTheme.of(context).body( + fontSize: 16, + fontWeight: FontWeight.w700, + color: SabuflixTheme.of(context).textPrimary)), + SizedBox(height: 6), + ], + Wrap( + spacing: 10, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - Icon(Icons.star_rounded, - color: SabuflixTheme.of(context).gold, size: 16), - SizedBox(width: 4), Text( - media.formattedRating, - style: SabuflixTheme.of(context).body( - color: SabuflixTheme.of(context).textPrimary, - fontWeight: FontWeight.w600, - fontSize: 14), + [ + if (media.genres?.isNotEmpty ?? false) + media.genres!.take(3).join(', '), + if (media.originLine.isNotEmpty) media.originLine, + if (media.runtime != null && media.runtime! > 0) + '${media.runtime} min', + if (media.mediaType == 'tv' && + (media.numberOfSeasons ?? 0) > 0) + media.numberOfSeasons == 1 + ? '1 temporada' + : '${media.numberOfSeasons} temporadas', + ].join(' · '), + style: SabuflixTheme.of(context).body(fontSize: 14), ), - SizedBox(width: 10), - Text('·', - style: SabuflixTheme.of(context).body( - fontSize: 14, - color: SabuflixTheme.of(context).textMuted)), - SizedBox(width: 10), - Text(media.formattedYear, - style: SabuflixTheme.of(context).body(fontSize: 14)), - if (media.ageRating != null) ...[ - SizedBox(width: 10), + if (media.ageRating != null) Container( padding: - EdgeInsets.symmetric(horizontal: 6, vertical: 2), + EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration( - color: Colors.grey.withValues(alpha: 0.3), - borderRadius: SabuflixTheme.radiusSm, border: Border.all( - color: Colors.grey.withValues(alpha: 0.5)), + color: SabuflixTheme.of(context).textSecondary), ), child: Text( media.ageRating!, - style: TextStyle( - color: SabuflixTheme.of(context).textPrimary, + style: SabuflixTheme.of(context).label( fontSize: 10, - fontWeight: FontWeight.bold), + fontWeight: FontWeight.w800, + letterSpacing: .4, + color: SabuflixTheme.of(context).textSecondary), ), ), - ], ], ), - SizedBox(height: 24), + if (media.voteCount > 0) ...[ + SizedBox(height: 18), + _AudienceRating(media: media), + ], + SizedBox(height: 28), if (isBlocked) Container( width: double.infinity, padding: EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.redAccent.withValues(alpha: 0.15), - borderRadius: SabuflixTheme.radiusMd, - border: Border.all( - color: Colors.redAccent.withValues(alpha: 0.3)), + color: SabuflixTheme.of(context) + .error + .withValues(alpha: 0.12), + border: + Border.all(color: SabuflixTheme.of(context).error), ), child: Row( children: [ Icon(Icons.warning_amber_rounded, - color: Colors.redAccent), + color: SabuflixTheme.of(context).error), SizedBox(width: 12), Expanded( child: Text( 'Este conteúdo possui classificação superior à permitida pelo seu perfil.', style: SabuflixTheme.of(context).body( - color: Colors.redAccent, + color: SabuflixTheme.of(context).error, fontWeight: FontWeight.w600), ), ), ], ), ) - else - Wrap( - spacing: 12, - runSpacing: 12, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - SizedBox( - width: isDesktop ? 240 : 210, - child: ElevatedButton.icon( - onPressed: _loadingDetails || _detailsError != null - ? null - : () { - if (savedProgress != null) { - resumeWatching(context, savedProgress); - } else if (media.mediaType == 'tv') { - _showStreamSelector( - season: _seasonNumber, episode: 1); - } else { - _showStreamSelector(); - } - }, - icon: Icon(Icons.play_arrow_rounded, - size: 22, color: Colors.white), - label: Text( - savedProgress != null - ? savedProgress.resumeLabel - : (media.mediaType == 'tv' - ? 'Assistir S$_seasonNumber:E1' - : 'Assistir Agora'), + else ...[ + SizedBox( + width: isDesktop ? 320 : double.infinity, + child: ElevatedButton.icon( + onPressed: _loadingDetails || _detailsError != null + ? null + : () { + if (savedProgress != null) { + resumeWatching(context, savedProgress); + } else if (media.mediaType == 'tv') { + _showStreamSelector( + season: _seasonNumber, episode: 1); + } else { + _showStreamSelector(); + } + }, + icon: Icon(Icons.play_arrow_rounded, + size: 24, color: Colors.white), + label: Text( + savedProgress != null + ? savedProgress.resumeLabel + : (media.mediaType == 'tv' + ? 'Assistir S$_seasonNumber:E1' + : 'Assistir Agora'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + if (savedProgress != null) ...[ + SizedBox(height: 14), + SizedBox( + width: isDesktop ? 320 : double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator( + value: savedProgress.progress, + minHeight: 2, + backgroundColor: SabuflixTheme.of(context).border, + valueColor: AlwaysStoppedAnimation( + SabuflixTheme.of(context).textPrimary), + ), + SizedBox(height: 7), + Text( + '${savedProgress.subtitleLabel} · ${savedProgress.remainingLabel}', maxLines: 1, overflow: TextOverflow.ellipsis, - style: SabuflixTheme.of(context).body( - fontSize: 15, - fontWeight: FontWeight.w700, - color: Colors.white), + style: SabuflixTheme.of(context).caption( + fontSize: 12, + color: + SabuflixTheme.of(context).textSecondary), ), - ), + ], ), - OutlinedButton.icon( - onPressed: () => - favoritesProvider.toggleFavorite(media), - icon: Icon(isFav ? Icons.check : Icons.add), - label: Text(isFav ? 'Na minha lista' : 'Minha lista'), + ), + ], + SizedBox(height: 20), + Wrap( + spacing: 4, + runSpacing: 8, + children: [ + _ActionTile( + icon: isFav + ? Icons.bookmark_rounded + : Icons.bookmark_border_rounded, + label: isFav ? 'Na lista' : 'Minha lista', + selected: isFav, + onTap: () => favoritesProvider.toggleFavorite(media), ), if (!kIsWeb) _DownloadActionButton( @@ -639,13 +690,18 @@ class _MediaDetailsScreenState extends State { : null, ), ), - OutlinedButton.icon( - onPressed: () => _showPlaylistsSelector(media), - icon: const Icon(Icons.featured_play_list_outlined), - label: const Text('Playlists'), + _ActionTile( + icon: Icons.playlist_add_rounded, + label: 'Playlists', + onTap: () => _showPlaylistsSelector(media), ), - OutlinedButton.icon( - onPressed: () async { + _ActionTile( + icon: isWatched + ? Icons.visibility_rounded + : Icons.visibility_outlined, + label: isWatched ? 'Assistido' : 'Já vi', + selected: isWatched, + onTap: () async { await watchedProvider.toggle(media); if (!isWatched && context.mounted) { await context @@ -653,68 +709,30 @@ class _MediaDetailsScreenState extends State { .remove(media.id, mediaType: media.mediaType); } }, - icon: Icon(isWatched - ? Icons.visibility_rounded - : Icons.visibility_outlined), - label: Text( - isWatched ? 'Assistido' : 'Marcar assistido'), ), ], ), - if (!isBlocked && savedProgress != null) ...[ - SizedBox(height: 16), - SizedBox( - width: 260, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(2)), - child: LinearProgressIndicator( - value: savedProgress.progress, - minHeight: 3, - backgroundColor: SabuflixTheme.of(context).border, - valueColor: AlwaysStoppedAnimation( - SabuflixTheme.of(context).accent), - ), - ), - SizedBox(height: 7), - Text( - '${savedProgress.subtitleLabel} · ${savedProgress.remainingLabel}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: SabuflixTheme.of(context).caption( - fontSize: 12, - color: SabuflixTheme.of(context).textSecondary), - ), - ], - ), - ), ], - SizedBox(height: 24), - if (media.genres != null && media.genres!.isNotEmpty) ...[ - Wrap( - spacing: 8, - runSpacing: 8, - children: media.genres! - .map( - (g) => GlassContainer( - borderRadius: SabuflixTheme.radiusPill, - blur: 16, - fillOpacity: 0.25, - padding: EdgeInsets.symmetric( - horizontal: 16, vertical: 8), - child: Text(g, - style: SabuflixTheme.of(context).body( - fontSize: 12, - fontWeight: FontWeight.w500, - color: SabuflixTheme.of(context) - .textPrimary)), - ), - ) - .toList(), + SizedBox(height: 36), + if (media.tagline != null) ...[ + Container( + padding: EdgeInsets.only(left: 16), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: SabuflixTheme.of(context).textPrimary, + width: 2), + ), + ), + child: Text( + '“${media.tagline}”', + style: SabuflixTheme.of(context).title( + fontSize: isDesktop ? 22 : 19, + fontWeight: FontWeight.w700, + height: 1.3), + ), ), - SizedBox(height: 24), + SizedBox(height: 36), ], if (!isBlocked && (media.mediaType == 'tv' && @@ -722,9 +740,7 @@ class _MediaDetailsScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Episódios', - style: - SabuflixTheme.of(context).title(fontSize: 19)), + _SectionTitle('Episódios'), Container( padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), @@ -890,38 +906,43 @@ class _MediaDetailsScreenState extends State { ), SizedBox(height: 32), ], - Text('Sinopse', - style: SabuflixTheme.of(context).title(fontSize: 19)), - SizedBox(height: 10), - Text( - media.overview != null && media.overview!.isNotEmpty - ? media.overview! - : 'Nenhuma sinopse disponível em português.', - style: SabuflixTheme.of(context) - .body(fontSize: 15, height: 1.6), + _SectionTitle('Sinopse'), + SizedBox(height: 12), + ConstrainedBox( + // Keeps the synopsis at a readable measure on wide screens. + constraints: BoxConstraints(maxWidth: 760), + child: Text( + media.overview != null && media.overview!.isNotEmpty + ? media.overview! + : 'Nenhuma sinopse disponível em português.', + style: SabuflixTheme.of(context).body( + fontSize: 16, + height: 1.65, + color: SabuflixTheme.of(context).textPrimary), + ), ), - SizedBox(height: 32), + SizedBox(height: 40), if (_cast.isNotEmpty) ...[ - Text('Elenco Principal', - style: SabuflixTheme.of(context).title(fontSize: 19)), - SizedBox(height: 14), + _SectionTitle('Elenco'), + SizedBox(height: 16), SizedBox( - height: 145, + height: 150, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: _cast.length, itemBuilder: (context, index) { final actor = _cast[index]; return Container( - width: 95, - margin: EdgeInsets.only(right: 16), + width: 84, + margin: EdgeInsets.only(right: 14), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipOval( + ClipRect( child: CachedNetworkImage( imageUrl: actor.fullProfilePath, - width: 72, - height: 72, + width: 84, + height: 84, fit: BoxFit.cover, errorWidget: (context, url, err) => Container( @@ -937,7 +958,6 @@ class _MediaDetailsScreenState extends State { actor.name, maxLines: 1, overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, style: SabuflixTheme.of(context).body( color: SabuflixTheme.of(context).textPrimary, @@ -948,7 +968,6 @@ class _MediaDetailsScreenState extends State { actor.character, maxLines: 1, overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, style: SabuflixTheme.of(context).body( color: SabuflixTheme.of(context).textMuted, @@ -962,15 +981,18 @@ class _MediaDetailsScreenState extends State { ), SizedBox(height: 32), ], - if (_similar.isNotEmpty) ...[ - MediaRow( - title: 'Títulos Semelhantes', mediaItems: _similar), - SizedBox(height: 40), - ], ], ), ), ), + if (_similar.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only(bottom: 48), + child: MediaRow( + title: 'Você também pode gostar', mediaItems: _similar), + ), + ), ], ), ); @@ -1025,7 +1047,7 @@ class _DownloadActionButton extends StatelessWidget { break; case DownloadStatus.failed: icon = Icons.refresh_rounded; - label = 'Tentar de novo'; + label = 'Repetir'; onTap = () => downloads.resume(item.id); break; } @@ -1033,11 +1055,11 @@ class _DownloadActionButton extends StatelessWidget { final isDone = item?.isCompleted ?? false; - return OutlinedButton.icon( - onPressed: onTap, - icon: Icon(icon, - color: isDone ? SabuflixTheme.of(context).success : null), - label: Text(label), + return _ActionTile( + icon: icon, + label: label, + selected: isDone, + onTap: onTap, ); }, ); @@ -1113,3 +1135,115 @@ class _EpisodeDownloadBadge extends StatelessWidget { ); } } + +/// Uppercase, letter-spaced section heading used across the film page. +class _SectionTitle extends StatelessWidget { + final String text; + const _SectionTitle(this.text); + + @override + Widget build(BuildContext context) { + return Text(text.toUpperCase(), + style: SabuflixTheme.of(context).label( + fontSize: 13, + fontWeight: FontWeight.w800, + letterSpacing: 1.6, + color: SabuflixTheme.of(context).textPrimary)); + } +} + +/// Audience average on a five-star scale with its vote count. +class _AudienceRating extends StatelessWidget { + final MediaItem media; + const _AudienceRating({required this.media}); + + @override + Widget build(BuildContext context) { + final palette = SabuflixTheme.of(context); + final stars = (media.voteAverage / 2).clamp(0.0, 5.0); + return Semantics( + label: 'Nota média ${media.starRating} de 5, ' + '${media.voteCount} avaliações', + child: ExcludeSemantics( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < 5; i++) + Icon( + stars >= i + .75 + ? Icons.star_rounded + : stars >= i + .25 + ? Icons.star_half_rounded + : Icons.star_outline_rounded, + size: 18, + color: palette.textPrimary, + ), + SizedBox(width: 10), + Text(media.starRating, + style: + palette.title(fontSize: 18, fontWeight: FontWeight.w800)), + SizedBox(width: 10), + Flexible( + child: Text( + '${formatCount(media.voteCount)} avaliações', + overflow: TextOverflow.ellipsis, + style: palette.caption(fontSize: 13, color: palette.textMuted), + ), + ), + ], + ), + ), + ); + } +} + +/// Icon-over-label action used in the row beneath the play button. +class _ActionTile extends StatelessWidget { + final IconData icon; + final String label; + final bool selected; + final VoidCallback? onTap; + + const _ActionTile({ + required this.icon, + required this.label, + this.selected = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final palette = SabuflixTheme.of(context); + final color = + onTap == null && !selected ? palette.textMuted : palette.textPrimary; + return Semantics( + button: true, + selected: selected, + enabled: onTap != null, + child: InkWell( + onTap: onTap, + child: SizedBox( + width: 80, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 26, color: color), + SizedBox(height: 6), + Text( + label, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: palette.caption( + fontSize: 12, fontWeight: FontWeight.w700, color: color), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index b4b8fb5..889eda8 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -31,7 +31,10 @@ class _SearchScreenState extends State { final settings = context.watch(); final screenWidth = MediaQuery.sizeOf(context).width; final contentWidth = screenWidth; - final crossAxisCount = (contentWidth / 172).floor().clamp(2, 7); + final crossAxisCount = (contentWidth / 300).floor().clamp(1, 5); + final caption = settings.compactPosters + ? 0.0 + : 16 + MediaQuery.textScalerOf(context).scale(34); final bottomInset = screenWidth < 800 ? 118.0 : 32.0; final results = settings.visibleItems(search.searchResults); final isIdle = @@ -62,7 +65,7 @@ class _SearchScreenState extends State { onChanged: context.read().scheduleSearch, onSubmitted: context.read().search, decoration: InputDecoration( - hintText: 'Filmes, séries e gêneros', + hintText: 'Buscar filmes, séries, gêneros', hintStyle: SabuflixTheme.of(context).body( fontSize: 15, color: SabuflixTheme.of(context).textMuted), @@ -72,7 +75,8 @@ class _SearchScreenState extends State { fillColor: Colors.transparent, contentPadding: EdgeInsets.symmetric(vertical: 14), prefixIcon: Icon(Icons.search_rounded, - color: SabuflixTheme.of(context).accent, size: 22), + color: SabuflixTheme.of(context).textPrimary, + size: 22), suffixIcon: search.query.isNotEmpty ? IconButton( tooltip: 'Limpar busca', @@ -157,14 +161,19 @@ class _SearchScreenState extends State { gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, - childAspectRatio: - settings.compactPosters ? 0.72 : 0.65, - crossAxisSpacing: 14, - mainAxisSpacing: 16, + mainAxisExtent: (contentWidth - + 40 - + 16 * (crossAxisCount - 1)) / + crossAxisCount * + 9 / + 16 + + caption, + crossAxisSpacing: 16, + mainAxisSpacing: 24, ), itemCount: results.length, - itemBuilder: (context, index) => - MediaCard(media: results[index]), + itemBuilder: (context, index) => MediaCard( + media: results[index], landscape: true), ), ), ), @@ -198,8 +207,9 @@ class _DiscoveryState extends StatelessWidget { Row( children: [ Expanded( - child: Text('Buscas recentes', - style: SabuflixTheme.of(context).title(fontSize: 18))), + child: Text('BUSCAS RECENTES', + style: SabuflixTheme.of(context) + .title(fontSize: 17, fontWeight: FontWeight.w800))), TextButton( onPressed: search.clearRecentSearches, child: Text('Limpar')), ], @@ -223,21 +233,25 @@ class _DiscoveryState extends StatelessWidget { ), SizedBox(height: 30), ], - Text('Em alta agora', - style: SabuflixTheme.of(context).title(fontSize: 18)), + Text('EM ALTA AGORA', + style: SabuflixTheme.of(context) + .title(fontSize: 17, fontWeight: FontWeight.w800)), SizedBox(height: 14), if (trending.isEmpty) Text('O catálogo aparecerá aqui quando estiver disponível.', style: SabuflixTheme.of(context).body(fontSize: 13)) else SizedBox( - height: settings.compactPosters ? 222 : 252, + height: 260 * 9 / 16 + + (settings.compactPosters + ? 0 + : 16 + MediaQuery.textScalerOf(context).scale(34)), child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: trending.length, - separatorBuilder: (_, __) => SizedBox(width: 15), - itemBuilder: (context, index) => - MediaCard(media: trending[index]), + separatorBuilder: (_, __) => SizedBox(width: 10), + itemBuilder: (context, index) => MediaCard( + media: trending[index], width: 260, landscape: true), ), ), ], diff --git a/lib/services/tmdb_service.dart b/lib/services/tmdb_service.dart index 5f88da7..70c267f 100644 --- a/lib/services/tmdb_service.dart +++ b/lib/services/tmdb_service.dart @@ -47,7 +47,7 @@ class TMDBService { final http.Client? _client; final Duration timeout; TMDBService({http.Client? client, this.timeout = const Duration(seconds: 15)}) - : _client = client; + : _client = client; Future _get(Uri uri) async { final response = await (_client?.get(uri) ?? http.get(uri)).timeout( timeout, @@ -209,7 +209,7 @@ class TMDBService { Future fetchMediaDetails(int id, String mediaType) async { final append = mediaType == 'tv' ? 'external_ids,content_ratings' - : 'release_dates'; + : 'release_dates,credits'; final endpoint = mediaType == 'tv' ? 'tv/$id?append_to_response=$append&' : 'movie/$id?append_to_response=$append&'; diff --git a/lib/theme/sabuflix_theme.dart b/lib/theme/sabuflix_theme.dart index 488aa11..93c7f16 100644 --- a/lib/theme/sabuflix_theme.dart +++ b/lib/theme/sabuflix_theme.dart @@ -3,23 +3,26 @@ import 'package:flutter/material.dart'; class SabuflixTheme { SabuflixTheme._(); - static const Color background = Color(0xFF0B0C0E); - static const Color surface = Color(0xFF15171A); // systemGray6 - static const Color surfaceLight = Color(0xFF202327); // systemGray5 - static const Color elevated = Color(0xFF2A2E33); // systemGray4 - static const Color border = Color(0xFF30343A); - static const Color borderStrong = Color(0xFF626873); // separator, opaque + // Cinematheque palette: true black canvas, white type, a single electric + // blue reserved for the primary action — the way a curated streaming + // service lets the stills carry all the colour. + static const Color background = Color(0xFF000000); + static const Color surface = Color(0xFF111111); + static const Color surfaceLight = Color(0xFF1C1C1C); + static const Color elevated = Color(0xFF242424); + static const Color border = Color(0xFF262626); + static const Color borderStrong = Color(0xFF5C5C5C); - static const Color accent = Color(0xFF648CFF); - static const Color accentHover = Color(0xFF93AEFF); - static const Color accentMuted = Color(0xFF1645C0); + static const Color accent = Color(0xFF8C9BFF); + static const Color accentHover = Color(0xFFB4BEFF); + static const Color accentMuted = Color(0xFF0012B8); - static const Color gold = Color(0xFFFFD60A); // systemYellow, ratings only - static const Color success = Color(0xFF30D158); // systemGreen + static const Color gold = Color(0xFFFFFFFF); // ratings stay monochrome + static const Color success = Color(0xFF3DDC84); - static const Color textPrimary = Color(0xFFF5F4F0); - static const Color textSecondary = Color(0xFFBFC1C5); - static const Color textMuted = Color(0xFF9CA2AB); + static const Color textPrimary = Color(0xFFFFFFFF); + static const Color textSecondary = Color(0xFFB8B8B8); + static const Color textMuted = Color(0xFF999999); static TextStyle display({ double fontSize = 40, @@ -129,16 +132,16 @@ class SabuflixTheme { ); } - static BorderRadius get radiusSm => - const BorderRadius.all(Radius.circular(3)); + // Square, print-like edges throughout. + static BorderRadius get radiusSm => BorderRadius.zero; static BorderRadius get radiusMd => - const BorderRadius.all(Radius.circular(4)); + const BorderRadius.all(Radius.circular(2)); static BorderRadius get radiusLg => - const BorderRadius.all(Radius.circular(6)); + const BorderRadius.all(Radius.circular(2)); static BorderRadius get radiusXl => - const BorderRadius.all(Radius.circular(8)); - static BorderRadius get radiusPill => const BorderRadius.all(Radius.circular(4)); + static BorderRadius get radiusPill => + const BorderRadius.all(Radius.circular(2)); static const Duration durationFast = Duration(milliseconds: 220); static const Duration durationMed = Duration(milliseconds: 380); @@ -171,7 +174,7 @@ class SabuflixTheme { static ThemeData get themeData => _theme(Brightness.dark); static ThemeData get lightThemeData => _theme(Brightness.light); - static const brandBlue = Color(0xFF204FE0); + static const brandBlue = Color(0xFF001EFF); static ThemeData _theme(Brightness brightness) { final p = brightness == Brightness.light @@ -181,8 +184,8 @@ class SabuflixTheme { final text = const TextStyle( fontFamily: 'Manrope', fontSize: 15, - fontWeight: FontWeight.w700, - letterSpacing: -.4); + fontWeight: FontWeight.w800, + letterSpacing: -.2); final primary = ElevatedButton.styleFrom( backgroundColor: brandBlue, foregroundColor: Colors.white, @@ -239,8 +242,8 @@ class SabuflixTheme { outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( foregroundColor: p.textPrimary, - backgroundColor: p.secondaryFill, - side: BorderSide(color: p.borderStrong), + backgroundColor: Colors.transparent, + side: BorderSide(color: p.textPrimary, width: 1.2), shape: shape, minimumSize: const Size(48, 52), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), @@ -248,7 +251,7 @@ class SabuflixTheme { )), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( - foregroundColor: p.accent, + foregroundColor: p.textPrimary, shape: shape, minimumSize: const Size(48, 48), textStyle: text, @@ -261,29 +264,30 @@ class SabuflixTheme { )), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: p.surface, + fillColor: p.surfaceLight, hintStyle: p.body(color: p.textMuted), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), border: OutlineInputBorder( - borderRadius: radiusMd, borderSide: BorderSide(color: p.border)), + borderRadius: radiusMd, borderSide: BorderSide.none), enabledBorder: OutlineInputBorder( - borderRadius: radiusMd, borderSide: BorderSide(color: p.border)), + borderRadius: radiusMd, borderSide: BorderSide.none), focusedBorder: OutlineInputBorder( borderRadius: radiusMd, - borderSide: BorderSide(color: p.accent, width: 2)), + borderSide: BorderSide(color: p.textPrimary, width: 1.5)), ), chipTheme: ChipThemeData( - backgroundColor: p.surface, - selectedColor: brandBlue, - labelStyle: WidgetStateTextStyle.resolveWith((states) => - text.copyWith( - fontSize: 13, - color: states.contains(WidgetState.selected) - ? Colors.white + backgroundColor: Colors.transparent, + selectedColor: p.textPrimary, + // Chips resolve only the label *colour* against their state. + labelStyle: text.copyWith( + fontSize: 13, + color: WidgetStateColor.resolveWith((states) => + states.contains(WidgetState.selected) + ? p.background : p.textPrimary)), - secondaryLabelStyle: text.copyWith(fontSize: 13, color: Colors.white), - checkmarkColor: Colors.white, + secondaryLabelStyle: text.copyWith(fontSize: 13, color: p.background), + checkmarkColor: p.background, side: BorderSide(color: p.border), shape: shape, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8)), @@ -304,12 +308,12 @@ class SabuflixTheme { textStyle: p.body(color: p.textPrimary), shape: shape), sliderTheme: SliderThemeData( - activeTrackColor: brandBlue, - thumbColor: brandBlue, + activeTrackColor: p.textPrimary, + thumbColor: p.textPrimary, inactiveTrackColor: p.border, trackHeight: 3), progressIndicatorTheme: ProgressIndicatorThemeData( - color: p.accent, linearTrackColor: p.border), + color: p.textPrimary, linearTrackColor: p.border), tooltipTheme: TooltipThemeData( decoration: BoxDecoration(color: p.elevated, borderRadius: radiusMd), textStyle: p.caption(color: p.textPrimary)), @@ -329,33 +333,33 @@ class SabuPalette { const SabuPalette.light() : isLight = true; const SabuPalette.dark() : isLight = false; Color get background => - isLight ? const Color(0xFFFAF9F6) : SabuflixTheme.background; + isLight ? const Color(0xFFFFFFFF) : SabuflixTheme.background; Color get surface => - isLight ? const Color(0xFFFFFFFF) : SabuflixTheme.surface; + isLight ? const Color(0xFFF7F7F7) : SabuflixTheme.surface; Color get surfaceLight => - isLight ? const Color(0xFFF0EFEB) : SabuflixTheme.surfaceLight; + isLight ? const Color(0xFFEEEEEE) : SabuflixTheme.surfaceLight; Color get elevated => isLight ? const Color(0xFFFFFFFF) : SabuflixTheme.elevated; - Color get border => isLight ? const Color(0xFFDDDED9) : SabuflixTheme.border; + Color get border => isLight ? const Color(0xFFE2E2E2) : SabuflixTheme.border; Color get borderStrong => - isLight ? const Color(0xFF868B91) : const Color(0xFF51565E); + isLight ? const Color(0xFF8A8A8A) : SabuflixTheme.borderStrong; Color get textPrimary => - isLight ? const Color(0xFF16181C) : SabuflixTheme.textPrimary; + isLight ? const Color(0xFF000000) : SabuflixTheme.textPrimary; Color get textSecondary => - isLight ? const Color(0xFF50565F) : SabuflixTheme.textSecondary; + isLight ? const Color(0xFF4A4A4A) : SabuflixTheme.textSecondary; Color get textMuted => - isLight ? const Color(0xFF656C76) : SabuflixTheme.textMuted; + isLight ? const Color(0xFF626262) : SabuflixTheme.textMuted; Color get accent => isLight ? SabuflixTheme.brandBlue : SabuflixTheme.accent; Color get accentHover => - isLight ? const Color(0xFF1236B0) : SabuflixTheme.accentHover; + isLight ? const Color(0xFF0016C0) : SabuflixTheme.accentHover; Color get accentMuted => SabuflixTheme.accentMuted; - Color get gold => isLight ? const Color(0xFF806000) : SabuflixTheme.gold; + Color get gold => isLight ? const Color(0xFF000000) : SabuflixTheme.gold; Color get success => isLight ? const Color(0xFF18703A) : SabuflixTheme.success; Color get error => isLight ? const Color(0xFFBA242B) : const Color(0xFFFF777D); Color get secondaryFill => - isLight ? const Color(0xFFF1F2F0) : const Color(0xFF222529); + isLight ? const Color(0xFFF2F2F2) : const Color(0xFF1A1A1A); TextStyle display({ double fontSize = 40, FontWeight fontWeight = FontWeight.w800, diff --git a/lib/utils/formatters.dart b/lib/utils/formatters.dart index 4d3e64c..e1e4a2a 100644 --- a/lib/utils/formatters.dart +++ b/lib/utils/formatters.dart @@ -39,3 +39,11 @@ String formatEpisodeTag(int? season, int? episode) { if (season == null || episode == null) return ''; return 'T$season E$episode'; } + +/// `12345` -> `12.345`, with the pt-BR thousands separator. +String formatCount(int value) { + final digits = value.abs().toString(); + final grouped = + digits.replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (_) => '.'); + return value < 0 ? '-$grouped' : grouped; +} diff --git a/lib/widgets/continue_watching_row.dart b/lib/widgets/continue_watching_row.dart index b55c227..405ff51 100644 --- a/lib/widgets/continue_watching_row.dart +++ b/lib/widgets/continue_watching_row.dart @@ -22,6 +22,7 @@ class ContinueWatchingRow extends StatelessWidget { if (entries.isEmpty) return SizedBox.shrink(); final isMobile = MediaQuery.of(context).size.width < 800; + final inset = isMobile ? 20.0 : 40.0; final cardWidth = isMobile ? 232.0 : 268.0; final rowHeight = cardWidth * 9 / 16 + 62; @@ -29,11 +30,13 @@ class ContinueWatchingRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: EdgeInsets.fromLTRB(24, 20, 24, 14), + padding: EdgeInsets.fromLTRB(inset, 20, inset, 16), child: Text( - 'Continuar Assistindo', - style: SabuflixTheme.of(context) - .title(fontSize: 19, fontWeight: FontWeight.w800), + 'CONTINUAR ASSISTINDO', + style: SabuflixTheme.of(context).title( + fontSize: isMobile ? 17 : 20, + fontWeight: FontWeight.w800, + letterSpacing: -.2), ), ), SizedBox( @@ -41,11 +44,11 @@ class ContinueWatchingRow extends StatelessWidget { child: ListView.builder( scrollDirection: Axis.horizontal, physics: BouncingScrollPhysics(), - padding: EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(horizontal: inset - 5), itemCount: entries.length, itemBuilder: (context, index) { return Padding( - padding: EdgeInsets.symmetric(horizontal: 8), + padding: EdgeInsets.symmetric(horizontal: 5), child: _ContinueCard(entry: entries[index], width: cardWidth), ); @@ -135,14 +138,14 @@ class _ContinueCard extends StatelessWidget { ), SizedBox(height: 6), ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(2)), + borderRadius: BorderRadius.zero, child: LinearProgressIndicator( value: entry.progress, - minHeight: 3, + minHeight: 2, backgroundColor: Colors.white.withValues(alpha: 0.3), - valueColor: AlwaysStoppedAnimation( - SabuflixTheme.of(context).accent), + valueColor: + AlwaysStoppedAnimation(Colors.white), ), ), ], @@ -162,12 +165,12 @@ class _ContinueCard extends StatelessWidget { ), SizedBox(height: 9), Text( - entry.media.title, + entry.media.title.toUpperCase(), maxLines: 1, overflow: TextOverflow.ellipsis, style: SabuflixTheme.of(context).caption( fontSize: 13, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: SabuflixTheme.of(context).textPrimary), ), SizedBox(height: 2), diff --git a/lib/widgets/hero_banner.dart b/lib/widgets/hero_banner.dart index 9a78eb9..38dc1f0 100644 --- a/lib/widgets/hero_banner.dart +++ b/lib/widgets/hero_banner.dart @@ -9,7 +9,10 @@ import '../screens/media_details_screen.dart'; class HeroBanner extends StatelessWidget { final MediaItem media; - const HeroBanner({super.key, required this.media}); + + /// Eyebrow above the title; defaults to the daily pick. + final String? label; + const HeroBanner({super.key, required this.media, this.label}); @override Widget build(BuildContext context) { final favorite = context.select( @@ -35,22 +38,24 @@ class HeroBanner extends StatelessWidget { alignment: desktop ? Alignment.centerRight : Alignment.topCenter, placeholder: (_, url) => - const ColoredBox(color: SabuflixTheme.surface), + const ColoredBox(color: Color(0xFF111111)), errorWidget: (_, url, error) => - const ColoredBox(color: SabuflixTheme.surface), + const ColoredBox(color: Color(0xFF111111)), ), ), - DecoratedBox( + // Stills keep their own colour; a plain black scrim holds the + // type, and the banner ends on a hard edge like a print. + const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - stops: const [0, .32, .72, 1], + stops: [0, .3, .62, 1], colors: [ - Colors.black26, + Color(0x59000000), Colors.transparent, - Colors.black.withValues(alpha: .75), - SabuflixTheme.background, + Color(0x99000000), + Color(0xE6000000), ], ), ), @@ -60,8 +65,8 @@ class HeroBanner extends StatelessWidget { decoration: BoxDecoration( gradient: LinearGradient( colors: [ - Colors.black.withValues(alpha: .55), - Colors.black.withValues(alpha: .22), + Colors.black.withValues(alpha: .6), + Colors.black.withValues(alpha: .2), Colors.transparent, ], stops: const [0, .45, 1], @@ -69,57 +74,76 @@ class HeroBanner extends StatelessWidget { ), ), Positioned( - left: desktop ? 40 : 20, + left: desktop ? 48 : 20, right: desktop ? constraints.maxWidth - (constraints.maxWidth * .55).clamp(520.0, 720.0) : 20, - bottom: 48, + bottom: desktop ? 56 : 32, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( - media.mediaType == 'tv' - ? 'SÉRIE EM DESTAQUE' - : 'FILME EM DESTAQUE', + label ?? + (media.mediaType == 'tv' + ? 'SÉRIE DO DIA' + : 'FILME DO DIA'), style: SabuflixTheme.label( - fontSize: 11, - color: Colors.white70, - letterSpacing: 1.8, + fontSize: 12, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: 2.2, ), ), - const SizedBox(height: 16), + const SizedBox(height: 14), Text( media.title.toUpperCase(), maxLines: 3, overflow: TextOverflow.ellipsis, style: SabuflixTheme.display( - fontSize: desktop ? 64 : 38, - height: 1.08, + fontSize: desktop ? 68 : 40, + fontWeight: FontWeight.w800, + color: Colors.white, + height: 1, + letterSpacing: desktop ? -2 : -1.2, ), ), - const SizedBox(height: 16), - Wrap( - spacing: 12, - runSpacing: 6, - children: [ - if (media.releaseDate?.isNotEmpty ?? false) - Text( - media.formattedYear, - style: SabuflixTheme.body(color: Colors.white70), - ), - if (media.voteCount > 0) - Text( - '★ ${media.formattedRating}', - style: SabuflixTheme.body(color: Colors.white70), - ), - if (media.genres?.isNotEmpty ?? false) - Text( - media.genres!.take(2).join(' · '), - style: SabuflixTheme.body(color: Colors.white70), + const SizedBox(height: 14), + if (media.directorLine != null) ...[ + Text( + media.directorLine!, + style: SabuflixTheme.body( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + ], + Text.rich( + TextSpan(children: [ + TextSpan( + text: [ + if (media.genres?.isNotEmpty ?? false) + media.genres!.take(2).join(', '), + if (media.originLine.isNotEmpty) media.originLine, + ].join(' · '), + ), + if (media.voteCount > 0) ...[ + const TextSpan(text: ' '), + const WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon(Icons.star_rounded, + size: 16, color: Colors.white), ), - ], + TextSpan(text: ' ${media.starRating}'), + ], + ]), + style: SabuflixTheme.body( + fontSize: 14, + color: const Color(0xFFD6D6D6), + ), ), if (media.overview?.isNotEmpty ?? false) ...[ const SizedBox(height: 14), @@ -129,7 +153,7 @@ class HeroBanner extends StatelessWidget { overflow: TextOverflow.ellipsis, style: SabuflixTheme.body( fontSize: 15, - color: const Color(0xFFE0E0E5), + color: const Color(0xFFE6E6E6), ), ), ], @@ -151,7 +175,7 @@ class HeroBanner extends StatelessWidget { ), ), icon: const Icon( - Icons.info_outline_rounded, + Icons.arrow_forward_rounded, size: 21, ), label: const Text('Ver detalhes'), @@ -185,10 +209,9 @@ class HeroBanner extends StatelessWidget { }, style: OutlinedButton.styleFrom( foregroundColor: Colors.white, - backgroundColor: - Colors.white.withValues(alpha: .08), - side: BorderSide( - color: Colors.white.withValues(alpha: .24)), + backgroundColor: Colors.transparent, + side: const BorderSide( + color: Colors.white, width: 1.2), minimumSize: const Size(150, 52), padding: const EdgeInsets.symmetric( horizontal: 20, diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 69efe90..1ab0299 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -133,6 +133,9 @@ class _MediaCardState extends State { color: Colors.transparent, child: InkWell( borderRadius: SabuflixTheme.radiusMd, + // Hover and focus are shown on the still itself. + hoverColor: Colors.transparent, + focusColor: Colors.transparent, onFocusChange: (focused) => setState(() => _isFocused = focused), onTap: () { Navigator.push( @@ -149,7 +152,7 @@ class _MediaCardState extends State { children: [ Expanded( child: AnimatedScale( - scale: (_isHovered || _isFocused) ? 1.025 : 1.0, + scale: (_isHovered || _isFocused) ? 1.015 : 1.0, duration: MediaQuery.disableAnimationsOf(context) ? Duration.zero : SabuflixTheme.durationFast, @@ -162,8 +165,8 @@ class _MediaCardState extends State { borderRadius: SabuflixTheme.radiusMd, border: _isFocused ? Border.all( - color: SabuflixTheme.of(context).accent, - width: 3) + color: SabuflixTheme.of(context).textPrimary, + width: 2) : null, boxShadow: [], ), @@ -188,6 +191,13 @@ class _MediaCardState extends State { ), ), ), + // Hover dims the still, as on a gallery wall. + AnimatedOpacity( + opacity: _isHovered ? 1 : 0, + duration: SabuflixTheme.durationFast, + child: + const ColoredBox(color: Color(0x33000000)), + ), if (watched) Positioned( top: 8, @@ -195,20 +205,13 @@ class _MediaCardState extends State { child: Container( width: 28, height: 28, - decoration: BoxDecoration( - color: Colors.black.withValues( - alpha: 0.68, - ), + decoration: const BoxDecoration( + color: Colors.white, shape: BoxShape.circle, - border: Border.all( - color: Colors.white.withValues( - alpha: 0.3, - ), - ), ), child: Icon( Icons.check_rounded, - color: Colors.white, + color: Colors.black, size: 17, ), ), @@ -222,30 +225,31 @@ class _MediaCardState extends State { if (!compact) ...[ SizedBox(height: 10), Text( - widget.landscape - ? widget.media.title.toUpperCase() - : widget.media.title, + widget.media.title.toUpperCase(), maxLines: 1, overflow: TextOverflow.ellipsis, style: SabuflixTheme.of(context).caption( fontSize: 13, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w800, + letterSpacing: .2, color: SabuflixTheme.of(context).textPrimary, ), ), if (widget.landscape) ...[ - const SizedBox(height: 5), + const SizedBox(height: 4), Text( - [ - if (widget.media.releaseDate?.isNotEmpty ?? false) - widget.media.formattedYear, - if (widget.media.genres?.isNotEmpty ?? false) - widget.media.genres!.take(2).join(' · '), - ].join(' · '), + widget.media.directorLine ?? + [ + if (widget.media.genres?.isNotEmpty ?? false) + widget.media.genres!.first, + if (widget.media.originLine.isNotEmpty) + widget.media.originLine, + ].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, - style: - SabuflixTheme.of(context).caption(fontSize: 11)), + style: SabuflixTheme.of(context).caption( + fontSize: 12, + color: SabuflixTheme.of(context).textMuted)), ], ], ], diff --git a/lib/widgets/media_row.dart b/lib/widgets/media_row.dart index 3de3ccb..fea21dd 100644 --- a/lib/widgets/media_row.dart +++ b/lib/widgets/media_row.dart @@ -44,23 +44,23 @@ class _MediaRowState extends State { ); final desktop = MediaQuery.sizeOf(context).width >= 800; final inset = desktop ? 40.0 : 20.0; - final width = desktop ? 340.0 : 270.0; + final width = desktop ? 360.0 : 280.0; final caption = - compact ? 0.0 : 15 + MediaQuery.textScalerOf(context).scale(32); + compact ? 0.0 : 16 + MediaQuery.textScalerOf(context).scale(34); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: EdgeInsets.fromLTRB(inset, 40, inset, 18), + padding: EdgeInsets.fromLTRB(inset, 36, inset, 16), child: Row( children: [ Expanded( child: Text( widget.title.toUpperCase(), - style: SabuflixTheme.of(context).label( - fontSize: desktop ? 15 : 13, - color: SabuflixTheme.of(context).textPrimary, - letterSpacing: 1.4, + style: SabuflixTheme.of(context).title( + fontSize: desktop ? 20 : 17, + fontWeight: FontWeight.w800, + letterSpacing: -.2, ), ), ), @@ -86,7 +86,7 @@ class _MediaRowState extends State { scrollDirection: Axis.horizontal, padding: EdgeInsets.symmetric(horizontal: inset, vertical: 4), itemCount: widget.mediaItems.length, - separatorBuilder: (_, index) => SizedBox(width: 14), + separatorBuilder: (_, index) => SizedBox(width: desktop ? 16 : 10), itemBuilder: (_, index) => MediaCard( media: widget.mediaItems[index], width: width, diff --git a/lib/widgets/now_showing_grid.dart b/lib/widgets/now_showing_grid.dart new file mode 100644 index 0000000..13597ce --- /dev/null +++ b/lib/widgets/now_showing_grid.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/media_item.dart'; +import '../providers/settings_provider.dart'; +import '../theme/sabuflix_theme.dart'; +import 'media_card.dart'; + +/// "Em Cartaz": the short, hand-picked programme right under the daily film — +/// a single column of large stills on phones, a gallery grid on desktop. +class NowShowingGrid extends StatelessWidget { + final List items; + + const NowShowingGrid({super.key, required this.items}); + + @override + Widget build(BuildContext context) { + if (items.isEmpty) return const SizedBox.shrink(); + final palette = SabuflixTheme.of(context); + final compact = + context.select((p) => p.compactPosters); + return LayoutBuilder(builder: (context, constraints) { + final desktop = constraints.maxWidth >= 800; + final inset = desktop ? 40.0 : 20.0; + final gap = desktop ? 24.0 : 28.0; + final columns = constraints.maxWidth >= 1100 + ? 3 + : constraints.maxWidth >= 600 + ? 2 + : 1; + final cardWidth = + (constraints.maxWidth - inset * 2 - gap * (columns - 1)) / columns; + final caption = + compact ? 0.0 : 16 + MediaQuery.textScalerOf(context).scale(34); + return Padding( + padding: EdgeInsets.fromLTRB(inset, desktop ? 48 : 36, inset, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('EM CARTAZ', + style: palette.title( + fontSize: desktop ? 20 : 17, + fontWeight: FontWeight.w800, + letterSpacing: -.2)), + const SizedBox(height: 6), + Text('Uma seleção feita a dedo. Novos títulos toda semana.', + style: palette.body(fontSize: 14, color: palette.textMuted)), + const SizedBox(height: 18), + Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final item in items) + SizedBox( + width: cardWidth, + height: cardWidth * 9 / 16 + caption, + child: MediaCard( + media: item, width: cardWidth, landscape: true), + ), + ], + ), + ], + ), + ); + }); + } +} diff --git a/lib/widgets/wordmark.dart b/lib/widgets/wordmark.dart index d09619f..81653e2 100644 --- a/lib/widgets/wordmark.dart +++ b/lib/widgets/wordmark.dart @@ -1,15 +1,57 @@ import 'package:flutter/material.dart'; import '../theme/sabuflix_theme.dart'; -/// The Sabuflix brand mark — plain type, nothing else. +/// The Sabuflix brand mark: a small dot grid beside heavy uppercase type. class SabuflixWordmark extends StatelessWidget { final double fontSize; + final Color? color; - const SabuflixWordmark({super.key, this.fontSize = 20}); + const SabuflixWordmark({super.key, this.fontSize = 20, this.color}); @override Widget build(BuildContext context) { - return Text('SABUFLIX', - style: SabuflixTheme.of(context).wordmark(fontSize: fontSize)); + final palette = SabuflixTheme.of(context); + final ink = color ?? palette.textPrimary; + final dot = fontSize * .26; + return Semantics( + label: 'Sabuflix', + child: ExcludeSemantics( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: dot * 5, + height: dot * 3, + child: CustomPaint(painter: _DotsPainter(ink)), + ), + SizedBox(width: fontSize * .4), + Text('SABUFLIX', + style: palette.wordmark(fontSize: fontSize, color: ink)), + ], + ), + ), + ); } } + +/// Two staggered rows of dots, drawn rather than shipped as an asset so the +/// mark stays crisp at any size and follows the theme colour. +class _DotsPainter extends CustomPainter { + final Color color; + const _DotsPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = color; + final r = size.height / 6; + for (var i = 0; i < 3; i++) { + canvas.drawCircle(Offset(r + i * 4 * r, r), r, paint); + } + for (var i = 0; i < 2; i++) { + canvas.drawCircle(Offset(3 * r + i * 4 * r, 5 * r), r, paint); + } + } + + @override + bool shouldRepaint(_DotsPainter old) => old.color != color; +} diff --git a/test/media_metadata_test.dart b/test/media_metadata_test.dart new file mode 100644 index 0000000..4619e10 --- /dev/null +++ b/test/media_metadata_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sabuflix/models/media_item.dart'; +import 'package:sabuflix/utils/formatters.dart'; + +void main() { + test('film credits come from TMDB details and survive local storage', () { + final film = MediaItem.fromJson({ + 'id': 1, + 'title': 'Retrato de uma Jovem em Chamas', + 'original_title': 'Portrait de la jeune fille en feu', + 'release_date': '2019-09-18', + 'vote_average': 8.1, + 'tagline': ' ', + 'production_countries': [ + {'iso_3166_1': 'FR', 'name': 'France'}, + {'iso_3166_1': 'XX', 'name': 'Elsewhere'}, + ], + 'credits': { + 'crew': [ + {'job': 'Director', 'name': 'Céline Sciamma'}, + {'job': 'Editor', 'name': 'Julien Lacheray'}, + ], + }, + }); + expect(film.directorLine, 'Dirigido por Céline Sciamma'); + expect(film.originLine, 'França, Elsewhere, 2019'); + expect(film.distinctOriginalTitle, 'Portrait de la jeune fille en feu'); + expect(film.starRating, '4,0'); + expect(film.tagline, isNull); + + final restored = MediaItem.fromJson(film.forStorage.toJson()); + expect(restored.directorLine, film.directorLine); + expect(restored.originLine, film.originLine); + expect(restored.distinctOriginalTitle, film.distinctOriginalTitle); + }); + + test('series credit their creators and list items stay minimal', () { + final series = MediaItem.fromJson({ + 'id': 2, + 'name': 'Dark', + 'original_name': 'Dark', + 'first_air_date': '2017-12-01', + 'origin_country': ['DE'], + 'created_by': [ + {'name': 'Baran bo Odar'}, + {'name': 'Jantje Friese'}, + ], + }); + expect(series.directorLine, 'Criado por Baran bo Odar e Jantje Friese'); + expect(series.originLine, 'Alemanha, 2017'); + expect(series.distinctOriginalTitle, isNull); + + final bare = MediaItem.fromJson({'id': 3, 'title': 'Sem dados'}); + expect(bare.directorLine, isNull); + expect(bare.originLine, isEmpty); + }); + + test('counts use the pt-BR thousands separator', () { + expect(formatCount(7), '7'); + expect(formatCount(2493), '2.493'); + expect(formatCount(1234567), '1.234.567'); + }); +} diff --git a/test/navigation_test.dart b/test/navigation_test.dart index fd24224..c748fda 100644 --- a/test/navigation_test.dart +++ b/test/navigation_test.dart @@ -76,7 +76,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(TextField), findsOneWidget); expect(tester.takeException(), isNull); - for (final label in ['Descobrir', 'Biblioteca']) { + for (final label in ['Explorar', 'Biblioteca']) { await tester.tap(find.text(label).first); await tester.pumpAndSettle(); expect(tester.takeException(), isNull);