From d7cbcc3f191a4ecc2711cb0cadba0298b7a164b1 Mon Sep 17 00:00:00 2001 From: RarDog Date: Sat, 5 Sep 2026 12:43:41 +0300 Subject: [PATCH] feat(artists): Liquid Glass redesign for Artists tab and bump version to v3.5.0 --- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 10 + .../artists/presentation/artists_screen.dart | 1478 +++++++++++------ pubspec.yaml | 2 +- 4 files changed, 984 insertions(+), 510 deletions(-) diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 238f516..cc68008 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '3.4.1'; -const appBuildNumber = 75; +const appDisplayVersion = '3.5.0'; +const appBuildNumber = 76; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 18e1530..48ab8c2 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,14 @@ const prismaChangelog = [ + PrismaChange( + version: '3.5.0', + title: 'Редизайн вкладки «Артисты» в стиле Liquid Glass', + bullets: [ + 'Дизайн-код Liquid Glass: карточки авторов и панель поиска переведены на эстетику матового стекла с эффектом размытия BackdropFilter и глянцевыми окантовками.', + 'Цветовое кодирование сервисов: для платформ Patreon, Fanbox, Fantia, Boosty и Discord добавлены фирменные неоновые фильтры и светящиеся бейджи.', + 'Интерактивные карточки: squircle-аватары с деликатным свечением, анимированная кнопка избранного с эффектом золотого сияния.', + 'Стеклянное модальное окно избранного: обновленный диалог любимых авторов с полупрозрачным блюром и стильной панелью Pawchive-синхронизации.', + ], + ), PrismaChange( version: '3.4.1', title: 'Полный ребрендинг интерфейса: заголовок ленты, окно «О приложении» и системные службы', diff --git a/lib/features/artists/presentation/artists_screen.dart b/lib/features/artists/presentation/artists_screen.dart index 2038159..398df92 100644 --- a/lib/features/artists/presentation/artists_screen.dart +++ b/lib/features/artists/presentation/artists_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:ui'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -259,7 +260,7 @@ class _ArtistsScreenState extends ConsumerState { crossAxisSpacing: 12, mainAxisSpacing: 12, mainAxisExtent: - Responsive.isMobile(context) ? 104 : 116, + Responsive.isMobile(context) ? 116 : 124, ), itemCount: displayedArtists.length + (_loading ? 1 : 0), @@ -350,10 +351,8 @@ class _ArtistsScreenState extends ConsumerState { context: context, isScrollControlled: true, useSafeArea: true, - backgroundColor: Theme.of(context).colorScheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), + backgroundColor: Colors.transparent, + barrierColor: Colors.black.withValues(alpha: 0.5), builder: (ctx) { return _FavoriteArtistsModal( favorites: favorites, @@ -398,6 +397,132 @@ class _ArtistsScreenState extends ConsumerState { } } +Color _serviceColor(String service) { + switch (service.toLowerCase()) { + case 'patreon': + return const Color(0xFFFF424D); + case 'fanbox': + return const Color(0xFF0096FA); + case 'fantia': + return const Color(0xFFFF2E74); + case 'boosty': + return const Color(0xFFF15F2C); + case 'discord': + return const Color(0xFF5865F2); + case 'subscribestar': + return const Color(0xFF009688); + case 'gumroad': + return const Color(0xFFFF90E8); + case 'afdian': + return const Color(0xFF946CE6); + default: + return const Color(0xFF7C4DFF); + } +} + +String _formatServiceName(String s) { + switch (s.toLowerCase()) { + case 'patreon': + return 'Patreon'; + case 'fanbox': + return 'Pixiv Fanbox'; + case 'fantia': + return 'Fantia'; + case 'boosty': + return 'Boosty'; + case 'discord': + return 'Discord'; + default: + return s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); + } +} + +class _ArtistsLiquidCard extends StatelessWidget { + const _ArtistsLiquidCard({ + required this.child, + this.padding, + this.glowColor, + this.onTap, + }); + + final Widget child; + final EdgeInsetsGeometry? padding; + final Color? glowColor; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final accent = glowColor ?? theme.colorScheme.primary; + final r = BorderRadius.circular(22); + + final cardContent = Container( + padding: padding ?? const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: r, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDark + ? [ + theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.62), + theme.colorScheme.surfaceContainerLow.withValues(alpha: 0.40), + ] + : [ + Colors.white.withValues(alpha: 0.88), + Colors.white.withValues(alpha: 0.72), + ], + ), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.14) + : Colors.white.withValues(alpha: 0.85), + width: 1.2, + ), + ), + child: child, + ); + + return Container( + decoration: BoxDecoration( + borderRadius: r, + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.32) + : accent.withValues(alpha: 0.08), + blurRadius: 16, + offset: const Offset(0, 5), + spreadRadius: -2, + ), + BoxShadow( + color: accent.withValues(alpha: isDark ? 0.06 : 0.03), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: r, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), + child: onTap != null + ? Material( + color: Colors.transparent, + child: InkWell( + borderRadius: r, + onTap: onTap, + child: cardContent, + ), + ) + : cardContent, + ), + ), + ); + } +} + class _ArtistsHeader extends StatelessWidget { const _ArtistsHeader({ required this.searchController, @@ -419,63 +544,94 @@ class _ArtistsHeader extends StatelessWidget { final VoidCallback onClearServices; final VoidCallback onSearch; - static const _availableServices = ['patreon', 'fanbox', 'fantia', 'boosty', 'discord']; - - String _formatServiceName(String s) { - switch (s.toLowerCase()) { - case 'patreon': - return 'Patreon'; - case 'fanbox': - return 'Pixiv Fanbox'; - case 'fantia': - return 'Fantia'; - case 'boosty': - return 'Boosty'; - case 'discord': - return 'Discord'; - default: - return s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); - } - } + static const _availableServices = [ + 'patreon', + 'fanbox', + 'fantia', + 'boosty', + 'discord', + ]; @override Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final scheme = theme.colorScheme; + return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 6), - child: DecoratedBox( - decoration: BoxDecoration( - color: scheme.surfaceContainerHighest.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(18), - border: Border.all(color: scheme.outlineVariant), - ), - child: Padding( - padding: const EdgeInsets.all(10), - child: Column( - children: [ - Row( + child: _ArtistsLiquidCard( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Glass search bar + Container( + decoration: BoxDecoration( + color: isDark + ? Colors.black.withValues(alpha: 0.22) + : Colors.white.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.10) + : scheme.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: Row( children: [ + const SizedBox(width: 12), + Icon( + Icons.search_rounded, + size: 20, + color: isDark ? Colors.white70 : scheme.primary, + ), + const SizedBox(width: 8), Expanded( child: TextField( controller: searchController, textInputAction: TextInputAction.search, decoration: const InputDecoration( - prefixIcon: Icon(Icons.search_rounded), - hintText: 'Search artists', + hintText: 'Поиск авторов...', border: InputBorder.none, - filled: false, + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 11), ), onSubmitted: (_) => onSearch(), ), ), - IconButton.filledTonal( - tooltip: 'Search', - onPressed: onSearch, - icon: const Icon(Icons.arrow_forward_rounded), + if (searchController.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.close_rounded, size: 18), + visualDensity: VisualDensity.compact, + onPressed: () { + searchController.clear(); + onSearch(); + }, + ), + Container( + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: scheme.primary.withValues(alpha: isDark ? 0.25 : 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: IconButton( + visualDensity: VisualDensity.compact, + tooltip: 'Найти', + onPressed: onSearch, + icon: Icon( + Icons.arrow_forward_rounded, + size: 18, + color: scheme.primary, + ), + ), ), ], ), - const SizedBox(height: 8), + ), + const SizedBox(height: 10), + // Providers horizontal selector + if (providers.isNotEmpty) SizedBox( height: 36, child: ListView.separated( @@ -484,40 +640,169 @@ class _ArtistsHeader extends StatelessWidget { separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (context, index) { final provider = providers[index]; - return ChoiceChip( - selected: provider.id == selectedProviderId, - avatar: const Icon(Icons.person_search_rounded, size: 16), - label: Text(provider.name), - onSelected: (_) => onProviderChanged(provider.id), + final isSelected = provider.id == selectedProviderId; + return InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () => onProviderChanged(provider.id), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isSelected + ? scheme.primary.withValues(alpha: isDark ? 0.28 : 0.18) + : (isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04)), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: isSelected + ? scheme.primary.withValues(alpha: 0.7) + : (isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06)), + width: 1.2, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.person_search_rounded, + size: 16, + color: isSelected ? scheme.primary : scheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + provider.name, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? scheme.primary : scheme.onSurface, + ), + ), + ], + ), + ), ); }, ), ), - const SizedBox(height: 8), - SizedBox( - height: 36, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ - FilterChip( - selected: selectedServices.isEmpty, - label: const Text('Все платформы'), - onSelected: (_) => onClearServices(), + const SizedBox(height: 8), + // Services horizontal filter pills + SizedBox( + height: 34, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onClearServices, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selectedServices.isEmpty + ? scheme.primary.withValues(alpha: isDark ? 0.28 : 0.18) + : (isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04)), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: selectedServices.isEmpty + ? scheme.primary.withValues(alpha: 0.7) + : (isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06)), + width: 1.2, + ), + ), + child: Center( + child: Text( + 'Все платформы', + style: TextStyle( + fontSize: 12, + fontWeight: selectedServices.isEmpty + ? FontWeight.w700 + : FontWeight.w500, + color: selectedServices.isEmpty + ? scheme.primary + : scheme.onSurface, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + for (final service in _availableServices) ...[ + Builder( + builder: (context) { + final sColor = _serviceColor(service); + final isSelected = selectedServices.contains(service); + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => onToggleService(service), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isSelected + ? sColor.withValues(alpha: isDark ? 0.25 : 0.16) + : (isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04)), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? sColor.withValues(alpha: 0.75) + : (isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06)), + width: 1.2, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: sColor, + shape: BoxShape.circle, + boxShadow: isSelected + ? [ + BoxShadow( + color: sColor.withValues(alpha: 0.6), + blurRadius: 6, + ), + ] + : null, + ), + ), + const SizedBox(width: 6), + Text( + _formatServiceName(service), + style: TextStyle( + fontSize: 12, + fontWeight: isSelected + ? FontWeight.w700 + : FontWeight.w500, + color: isSelected ? sColor : scheme.onSurface, + ), + ), + ], + ), + ), + ); + }, ), const SizedBox(width: 8), - for (final service in _availableServices) ...[ - FilterChip( - selected: selectedServices.contains(service), - label: Text(_formatServiceName(service)), - onSelected: (_) => onToggleService(service), - ), - const SizedBox(width: 8), - ], ], - ), + ], ), - ], - ), + ), + ], ), ), ); @@ -539,70 +824,114 @@ class _ArtistCard extends StatelessWidget { @override Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Material( - color: scheme.surfaceContainerHighest.withValues(alpha: 0.78), - borderRadius: BorderRadius.circular(18), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - _ArtistAvatar(artist: artist), - const SizedBox(width: 12), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final scheme = theme.colorScheme; + final sColor = _serviceColor(artist.service); + + return _ArtistsLiquidCard( + glowColor: sColor.withValues(alpha: 0.35), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + onTap: onTap, + child: Row( + children: [ + _ArtistAvatar(artist: artist, glowColor: sColor), + const SizedBox(width: 14), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + artist.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - Text( - artist.displayName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), + _LiquidMiniBadge( + label: artist.service.isEmpty ? 'Artist' : artist.service, + color: sColor, + hasDot: true, ), - const SizedBox(height: 6), - Wrap( - spacing: 6, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _MiniBadge(artist.service), - if (artist.postCount != null) - _MiniBadge('${artist.postCount} posts'), - ], - ), - if (artist.updatedAt != null) ...[ - const SizedBox(height: 5), - Text( - 'Updated ${artist.updatedAt!.toLocal().toString().split('.').first}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), + if (artist.postCount != null) + _LiquidMiniBadge( + label: '${artist.postCount} постов', + color: scheme.primary, ), - ], ], ), - ), - const SizedBox(width: 4), - IconButton( - tooltip: isFavorite ? 'В избранном' : 'В избранное', - onPressed: onToggleFavorite, - icon: Icon( + if (artist.updatedAt != null) ...[ + const SizedBox(height: 5), + Text( + 'Обновлено: ${artist.updatedAt!.toLocal().toString().split('.').first}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant.withValues(alpha: 0.8), + fontSize: 11, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 4), + // Interactive Favorite button with gold glow + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onToggleFavorite, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isFavorite + ? Colors.amber.withValues(alpha: isDark ? 0.22 : 0.15) + : (isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.03)), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isFavorite + ? Colors.amber.withValues(alpha: 0.6) + : (isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06)), + ), + boxShadow: isFavorite + ? [ + BoxShadow( + color: Colors.amber.withValues(alpha: 0.35), + blurRadius: 10, + ), + ] + : null, + ), + child: Icon( isFavorite ? Icons.star_rounded : Icons.star_outline_rounded, color: isFavorite ? Colors.amber : scheme.onSurfaceVariant, + size: 22, ), ), - const Icon(Icons.chevron_right_rounded), - ], + ), ), - ), + const SizedBox(width: 4), + Icon( + Icons.chevron_right_rounded, + color: scheme.onSurfaceVariant.withValues(alpha: 0.6), + size: 22, + ), + ], ), ); } @@ -638,7 +967,9 @@ class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> { @override Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final scheme = theme.colorScheme; final selectedFav = widget.favorites.firstWhere( (e) => e.key == _selectedKey, orElse: () => widget.favorites.isNotEmpty @@ -648,200 +979,230 @@ class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> { return DraggableScrollableSheet( expand: false, - initialChildSize: widget.favorites.isEmpty ? 0.35 : 0.65, + initialChildSize: widget.favorites.isEmpty ? 0.35 : 0.68, minChildSize: 0.25, maxChildSize: 0.92, builder: (context, scroll) { - return SingleChildScrollView( - controller: scroll, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: scheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Row( - children: [ - const Icon(Icons.star_rounded, - color: Colors.amber, size: 26), - const SizedBox(width: 8), - Text( - 'Любимые авторы', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - if (widget.favorites.isNotEmpty) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '${widget.favorites.length}', - style: - Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.amber.shade900, - fontWeight: FontWeight.w700, - ), - ), - ), + return Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDark + ? [ + scheme.surfaceContainerHigh.withValues(alpha: 0.92), + scheme.surfaceContainerLow.withValues(alpha: 0.84), + ] + : [ + Colors.white.withValues(alpha: 0.95), + Colors.white.withValues(alpha: 0.88), ], - const Spacer(), - IconButton( - icon: const Icon(Icons.close_rounded), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), + ), + border: Border( + top: BorderSide( + color: isDark ? Colors.white.withValues(alpha: 0.16) : Colors.white, + width: 1.5, ), - const Divider(height: 1), - Consumer( - builder: (context, ref, _) { - final settings = - ref.watch(appSettingsProvider).value ?? AppSettings.defaults; - final activeAcc = settings.activePawchiveAccount; - final accounts = settings.parsedPawchiveAccounts; - - return Container( - margin: const EdgeInsets.fromLTRB(16, 8, 16, 6), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: scheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: activeAcc != null - ? scheme.primary.withValues(alpha: 0.35) - : scheme.outlineVariant.withValues(alpha: 0.25), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.5 : 0.15), + blurRadius: 24, + offset: const Offset(0, -6), + ), + ], + ), + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: SingleChildScrollView( + controller: scroll, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: scheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), ), ), - child: Row( - children: [ - Icon( - Icons.cloud_sync_rounded, - size: 20, - color: activeAcc != null - ? scheme.primary - : scheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - activeAcc != null - ? 'Pawchive: @${activeAcc.username}' - : 'Pawchive не подключён', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 12, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 4), + child: Row( + children: [ + const Icon(Icons.star_rounded, color: Colors.amber, size: 26), + const SizedBox(width: 8), + Text( + 'Любимые авторы', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (widget.favorites.isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${widget.favorites.length}', + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.amber.shade900, + fontWeight: FontWeight.w700, ), ), + ), + ], + const Spacer(), + IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 1), + Consumer( + builder: (context, ref, _) { + final settings = ref.watch(appSettingsProvider).value ?? + AppSettings.defaults; + final activeAcc = settings.activePawchiveAccount; + final accounts = settings.parsedPawchiveAccounts; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 6), + child: _ArtistsLiquidCard( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 10), + glowColor: activeAcc != null ? scheme.primary : null, + child: Row( + children: [ + Icon( + Icons.cloud_sync_rounded, + size: 22, + color: activeAcc != null + ? scheme.primary + : scheme.onSurfaceVariant, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + activeAcc != null + ? 'Pawchive: @${activeAcc.username}' + : 'Pawchive не подключён', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + Text( + activeAcc != null + ? '${activeAcc.syncedArtistsCount} авторов в облаке' + : 'Синхронизируйте избранное с сервером', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 11, + ), + ), + ], + ), + ), + FilledButton.tonal( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + visualDensity: VisualDensity.compact, + ), + onPressed: () => + PawchiveAccountsSheet.show(context), + child: Text( + accounts.isEmpty ? 'Войти' : 'Аккаунты'), + ), + ], + ), + ), + ); + }, + ), + if (widget.favorites.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(24, 36, 24, 40), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.star_border_rounded, + size: 52, + color: scheme.onSurfaceVariant + .withValues(alpha: 0.4), + ), + const SizedBox(height: 16), Text( - activeAcc != null - ? '${activeAcc.syncedArtistsCount} авторов в облаке' - : 'Синхронизируйте избранное с сервером', - style: TextStyle( + 'Список пуст', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Нажмите на иконку звёздочки ★ на карточке любого автора, чтобы добавить его в избранное и быстро смотреть свежие работы.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( color: scheme.onSurfaceVariant, - fontSize: 11, ), ), ], ), ), - FilledButton.tonal( - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - visualDensity: VisualDensity.compact, - ), - onPressed: () => PawchiveAccountsSheet.show(context), - child: Text(accounts.isEmpty ? 'Войти' : 'Аккаунты'), - ), - ], - ), - ); - }, - ), - if (widget.favorites.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(24, 36, 24, 40), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.star_border_rounded, - size: 52, - color: scheme.onSurfaceVariant.withValues(alpha: 0.4), - ), - const SizedBox(height: 16), - Text( - 'Список пуст', - style: - Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Text( - 'Нажмите на иконку звёздочки ★ на карточке любого автора, чтобы добавить его в избранное и быстро смотреть свежие работы.', - textAlign: TextAlign.center, - style: - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ) - else ...[ - _FavoriteArtistsSection( - favorites: widget.favorites, - selectedKey: selectedFav.isEmpty ? null : selectedFav.key, - onSelect: (fav) { - setState(() => _selectedKey = fav.key); - widget.onSelect(fav); - }, - onOpenArtist: (fav) { - Navigator.of(context).pop(); - widget.onOpenArtist(fav); - }, - onRemoveFavorite: (fav) { - widget.onRemoveFavorite(fav); - if (_selectedKey == fav.key) { - setState(() { - _selectedKey = widget.favorites - .where((e) => e.key != fav.key) - .firstOrNull - ?.key; - }); - } - }, + ) + else ...[ + _FavoriteArtistsSection( + favorites: widget.favorites, + selectedKey: selectedFav.isEmpty ? null : selectedFav.key, + onSelect: (fav) { + setState(() => _selectedKey = fav.key); + widget.onSelect(fav); + }, + onOpenArtist: (fav) { + Navigator.of(context).pop(); + widget.onOpenArtist(fav); + }, + onRemoveFavorite: (fav) { + widget.onRemoveFavorite(fav); + if (_selectedKey == fav.key) { + setState(() { + _selectedKey = widget.favorites + .where((e) => e.key != fav.key) + .firstOrNull + ?.key; + }); + } + }, + ), + const SizedBox(height: 16), + ], + ], ), - const SizedBox(height: 16), - ], - ], + ), + ), ), ); }, @@ -867,35 +1228,24 @@ class _FavoriteArtistsSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { if (favorites.isEmpty) { - return Container( - margin: const EdgeInsets.fromLTRB(16, 4, 16, 8), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outlineVariant - .withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - const Icon(Icons.star_rounded, color: Colors.amber, size: 22), - const SizedBox(width: 10), - Expanded( - child: Text( - 'Нажмите звёздочку ★ на карточке автора, чтобы добавить его в любимые и смотреть его медиа здесь.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: _ArtistsLiquidCard( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + children: [ + const Icon(Icons.star_rounded, color: Colors.amber, size: 22), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Нажмите звёздочку ★ на карточке автора, чтобы добавить его в любимые.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), ), - ), - ], + ], + ), ), ); } @@ -905,27 +1255,14 @@ class _FavoriteArtistsSection extends ConsumerWidget { orElse: () => favorites.first, ); - return Container( - margin: const EdgeInsets.fromLTRB(16, 4, 16, 10), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outlineVariant - .withValues(alpha: 0.35), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), - child: Row( + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 10), + child: _ArtistsLiquidCard( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ const Icon(Icons.star_rounded, color: Colors.amber, size: 20), const SizedBox(width: 6), @@ -953,97 +1290,118 @@ class _FavoriteArtistsSection extends ConsumerWidget { ), ], ), - ), - SizedBox( - height: 84, - child: ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - scrollDirection: Axis.horizontal, - itemCount: favorites.length, - separatorBuilder: (_, __) => const SizedBox(width: 10), - itemBuilder: (context, index) { - final fav = favorites[index]; - final isSelected = fav.key == selected.key; - return InkWell( - borderRadius: BorderRadius.circular(14), - onTap: () => onSelect(fav), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: isSelected - ? Theme.of(context) - .colorScheme - .primaryContainer - .withValues(alpha: 0.6) - : Colors.transparent, - borderRadius: BorderRadius.circular(14), - border: Border.all( + const SizedBox(height: 8), + SizedBox( + height: 88, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + scrollDirection: Axis.horizontal, + itemCount: favorites.length, + separatorBuilder: (_, __) => const SizedBox(width: 10), + itemBuilder: (context, index) { + final fav = favorites[index]; + final isSelected = fav.key == selected.key; + final sColor = _serviceColor(fav.service); + + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => onSelect(fav), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( color: isSelected - ? Theme.of(context).colorScheme.primary + ? Theme.of(context) + .colorScheme + .primaryContainer + .withValues(alpha: 0.45) : Colors.transparent, - width: 1.5, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: 44, - height: 44, - child: ClipOval( - child: fav.avatarUrl != null && - fav.avatarUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: fav.avatarUrl!, - fit: BoxFit.cover, - placeholder: (_, __) => const CircleAvatar( - child: - Icon(Icons.person_rounded, size: 20), - ), - errorWidget: (_, __, ___) => - const CircleAvatar( - child: - Icon(Icons.person_rounded, size: 20), - ), - ) - : CircleAvatar( - child: Text( - fav.name.isNotEmpty - ? fav.name[0].toUpperCase() - : '?', - style: const TextStyle( - fontWeight: FontWeight.bold), - ), - ), - ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 1.5, ), - const SizedBox(height: 4), - SizedBox( - width: 68, - child: Text( - fav.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.normal, + boxShadow: isSelected + ? [ + BoxShadow( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.25), + blurRadius: 8, + ), + ] + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isSelected + ? Theme.of(context).colorScheme.primary + : sColor.withValues(alpha: 0.4), + width: 1.2, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(13), + child: fav.avatarUrl != null && + fav.avatarUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: fav.avatarUrl!, + fit: BoxFit.cover, + placeholder: (_, __) => const Icon( + Icons.person_rounded, + size: 20), + errorWidget: (_, __, ___) => const Icon( + Icons.person_rounded, + size: 20), + ) + : Center( + child: Text( + fav.name.isNotEmpty + ? fav.name[0].toUpperCase() + : '?', + style: const TextStyle( + fontWeight: FontWeight.bold), + ), + ), ), ), - ), - ], + const SizedBox(height: 4), + SizedBox( + width: 68, + child: Text( + fav.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + ], + ), ), - ), - ); - }, + ); + }, + ), ), - ), - _FavoriteArtistMediaStrip(artist: selected), - ], + const SizedBox(height: 6), + _FavoriteArtistMediaStrip(artist: selected), + ], + ), ), ); } @@ -1065,7 +1423,7 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget { final asyncPosts = ref.watch(artistPostsProvider(query)); return Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 10), + padding: const EdgeInsets.fromLTRB(4, 4, 4, 6), child: asyncPosts.when( loading: () => const SizedBox( height: 90, @@ -1098,7 +1456,7 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget { final post = posts[index]; final isVideo = MediaUrlSelector.isVideo(post); return InkWell( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), onTap: () => context.push( '/post/${post.providerId}/${post.id}', extra: PostNavigationContext( @@ -1106,47 +1464,56 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget { posts: posts, ), ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Stack( - children: [ - SizedBox( - width: 96, - height: 96, - child: CachedNetworkImage( - imageUrl: post.previewUrl.isNotEmpty - ? post.previewUrl - : post.sampleUrl, - fit: BoxFit.cover, - placeholder: (_, __) => ColoredBox( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - ), - errorWidget: (_, __, ___) => ColoredBox( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - child: const Icon(Icons.broken_image_rounded, - size: 24), - ), - ), - ), - if (isVideo) - Positioned( - bottom: 6, - right: 6, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - shape: BoxShape.circle, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Colors.white.withValues(alpha: 0.15), + width: 1.0, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(13), + child: Stack( + children: [ + SizedBox( + width: 96, + height: 96, + child: CachedNetworkImage( + imageUrl: post.previewUrl.isNotEmpty + ? post.previewUrl + : post.sampleUrl, + fit: BoxFit.cover, + placeholder: (_, __) => ColoredBox( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + ), + errorWidget: (_, __, ___) => ColoredBox( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + child: const Icon(Icons.broken_image_rounded, + size: 24), ), - child: const Icon(Icons.play_arrow_rounded, - color: Colors.white, size: 14), ), ), - ], + if (isVideo) + Positioned( + bottom: 6, + right: 6, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + shape: BoxShape.circle, + ), + child: const Icon(Icons.play_arrow_rounded, + color: Colors.white, size: 14), + ), + ), + ], + ), ), ), ); @@ -1160,40 +1527,66 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget { } class _ArtistAvatar extends StatelessWidget { - const _ArtistAvatar({required this.artist}); + const _ArtistAvatar({ + required this.artist, + this.glowColor, + }); final ArtistProfile artist; + final Color? glowColor; @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; final name = artist.displayName.trim(); final initials = name.isEmpty ? '?' : name.characters.first.toUpperCase(); + final accent = glowColor ?? theme.colorScheme.primary; + final fallback = DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( colors: [ - Theme.of(context).colorScheme.primaryContainer, - Theme.of(context).colorScheme.tertiaryContainer, + accent.withValues(alpha: 0.85), + theme.colorScheme.tertiaryContainer, ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), - shape: BoxShape.circle, ), child: Center( child: Text( initials, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w800, - ), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + color: Colors.white, + ), ), ), ); - return SizedBox( - width: 56, - height: 56, - child: ClipOval( - child: artist.avatarUrl == null + + return Container( + width: 54, + height: 54, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.20) + : Colors.white.withValues(alpha: 0.90), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: accent.withValues(alpha: 0.22), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: artist.avatarUrl == null || artist.avatarUrl!.isEmpty ? fallback : CachedNetworkImage( imageUrl: artist.avatarUrl!, @@ -1206,29 +1599,57 @@ class _ArtistAvatar extends StatelessWidget { } } -class _MiniBadge extends StatelessWidget { - const _MiniBadge(this.label); +class _LiquidMiniBadge extends StatelessWidget { + const _LiquidMiniBadge({ + required this.label, + required this.color, + this.hasDot = false, + }); final String label; + final Color color; + final bool hasDot; @override Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return DecoratedBox( + final isDark = Theme.of(context).brightness == Brightness.dark; + final displayLabel = + label.isEmpty ? '' : label[0].toUpperCase() + label.substring(1); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: scheme.surface.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: scheme.outlineVariant), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - child: Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w700, - ), + color: color.withValues(alpha: isDark ? 0.18 : 0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: color.withValues(alpha: isDark ? 0.35 : 0.25), + width: 1.0, ), ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasDot) ...[ + Container( + width: 5, + height: 5, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + ], + Text( + displayLabel, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ], + ), ); } } @@ -1238,13 +1659,56 @@ class _ArtistSkeletonCard extends StatelessWidget { @override Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return DecoratedBox( - decoration: BoxDecoration( - color: scheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(18), + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return _ArtistsLiquidCard( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Container( + width: 54, + height: 54, + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(16), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 140, + height: 16, + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.10) + : Colors.black.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(height: 8), + Container( + width: 80, + height: 12, + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ), + ), + ], ), - child: const Center(child: CircularProgressIndicator()), ); } } + diff --git a/pubspec.yaml b/pubspec.yaml index 66d0b42..d21015b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 3.4.1+75 +version: 3.5.0+76 environment: sdk: ">=3.3.0 <4.0.0"