diff --git a/README.md b/README.md index 301e2f1..65f0734 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 3.0.9 + Version 3.1.0 Flutter Platforms Wiki diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index f059ecf..174819c 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '3.0.9'; -const appBuildNumber = 60; +const appDisplayVersion = '3.1.0'; +const appBuildNumber = 61; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index ab9dcba..d199127 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,14 @@ const lunarisChangelog = [ + LunarisChange( + version: '3.1.0', + title: 'iPhone Liquid Glass — Поиск, Избранное, История и исправление доступности', + bullets: [ + 'Liquid Glass редизайн разделов «Поиск», «Избранное» и «История»: матовые стеклянные карточки (BackdropFilter blur), полупрозрачные градиенты, зеркальные контуры и сквиркл-бейджи иконок в стиле iOS.', + 'Исправление шпаргалки по поиску: увеличен нижний отступ прокрутки во всех списках и сетках до 140px + safe area, благодаря чему блок операторов и шпаргалка по поиску больше не перекрываются парящей навигационной панелью и легко нажимаются.', + 'Исправление модального окна проверки обновлений: панель с выбором источников (GitHub и Gitea) открывается через корневой навигатор поверх всей навигации с безопасным отступом снизу, обеспечивая беспрепятственный доступ к обеим кнопкам.', + 'Тактильный отклик и стеклянные пилюли: быстрые операторы, категории, недавние запросы, фильтры медиа и переключатели разделов получили матовое оформление и вибрационный отклик Haptic Feedback.', + ], + ), LunarisChange( version: '3.0.9', title: 'Коррекция позиции кнопки настроек и отображения кнопок JSON и бэкапа', diff --git a/lib/features/favorites/presentation/favorites_screen.dart b/lib/features/favorites/presentation/favorites_screen.dart index de08eb2..fc7cce5 100644 --- a/lib/features/favorites/presentation/favorites_screen.dart +++ b/lib/features/favorites/presentation/favorites_screen.dart @@ -1,5 +1,8 @@ +import 'dart:ui'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -176,82 +179,154 @@ class _FavoritesScreenState extends ConsumerState { // Search input bar if active if (_isSearching) Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), - child: TextField( - controller: _searchController, - autofocus: true, - decoration: InputDecoration( - hintText: isRu - ? 'Поиск по тегам и авторам...' - : 'Search by tags and artists...', - prefixIcon: const Icon(Icons.search_rounded), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear_rounded), - onPressed: () { - _searchController.clear(); - setState(() => _searchQuery = ''); - }, - ) - : null, - isDense: true, - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.surfaceContainerHigh + .withValues(alpha: 0.55) + : Colors.white.withValues(alpha: 0.80), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withValues(alpha: 0.14) + : Colors.white.withValues(alpha: 0.85), + width: 1.1, + ), + ), + child: TextField( + controller: _searchController, + autofocus: true, + decoration: InputDecoration( + hintText: isRu + ? 'Поиск по тегам и авторам...' + : 'Search by tags and artists...', + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear_rounded), + onPressed: () { + HapticFeedback.lightImpact(); + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + isDense: true, + filled: false, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + border: InputBorder.none, + ), + onChanged: (val) => + setState(() => _searchQuery = val.trim()), + ), ), ), - onChanged: (val) => setState(() => _searchQuery = val.trim()), ), ), - // Navigation Tabs + // Navigation Tabs with Liquid Glass container Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 6), - child: SizedBox( - width: double.infinity, - child: SegmentedButton( - showSelectedIcon: false, - segments: [ - ButtonSegment( - value: 0, - icon: const Icon(Icons.favorite_rounded, size: 18), - label: Text( - '${isRu ? 'Все' : 'All'} (${allPosts.length})', - style: const TextStyle(fontSize: 12), - overflow: TextOverflow.ellipsis, - ), - ), - ButtonSegment( - value: 1, - icon: const Icon(Icons.offline_pin_rounded, size: 18), - label: Text( - '${isRu ? 'Офлайн' : 'Offline'} (${offlinePosts.length})', - style: const TextStyle(fontSize: 12), - overflow: TextOverflow.ellipsis, - ), - ), - ButtonSegment( - value: 2, - icon: const Icon(Icons.groups_rounded, size: 18), - label: Text( - '${isRu ? 'Авторы' : 'Artists'} (${artistGroups.length})', - style: const TextStyle(fontSize: 12), - overflow: TextOverflow.ellipsis, + padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues( + alpha: Theme.of(context).brightness == Brightness.dark + ? 0.20 + : 0.04, ), + blurRadius: 10, + offset: const Offset(0, 2), ), ], - selected: {_tabIndex}, - onSelectionChanged: (value) { - setState(() { - _tabIndex = value.first; - _selectedArtist = null; - }); - }, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(22), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + color: Theme.of(context).brightness == Brightness.dark + ? Theme.of(context) + .colorScheme + .surfaceContainerHigh + .withValues(alpha: 0.50) + : Colors.white.withValues(alpha: 0.72), + border: Border.all( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withValues(alpha: 0.12) + : Colors.white.withValues(alpha: 0.85), + width: 1.1, + ), + ), + child: SizedBox( + width: double.infinity, + child: SegmentedButton( + showSelectedIcon: false, + style: ButtonStyle( + visualDensity: VisualDensity.compact, + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + segments: [ + ButtonSegment( + value: 0, + icon: const Icon(Icons.favorite_rounded, size: 18), + label: Text( + '${isRu ? 'Все' : 'All'} (${allPosts.length})', + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + ButtonSegment( + value: 1, + icon: const Icon(Icons.offline_pin_rounded, + size: 18), + label: Text( + '${isRu ? 'Офлайн' : 'Offline'} (${offlinePosts.length})', + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + ButtonSegment( + value: 2, + icon: const Icon(Icons.groups_rounded, size: 18), + label: Text( + '${isRu ? 'Авторы' : 'Artists'} (${artistGroups.length})', + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + ], + selected: {_tabIndex}, + onSelectionChanged: (value) { + HapticFeedback.selectionClick(); + setState(() { + _tabIndex = value.first; + _selectedArtist = null; + }); + }, + ), + ), + ), + ), ), ), ), @@ -265,9 +340,12 @@ class _FavoritesScreenState extends ConsumerState { FilterChip( label: Text(isRu ? 'Все' : 'All'), selected: _mediaFilter == FavoriteMediaType.all, - onSelected: (_) => setState( - () => _mediaFilter = FavoriteMediaType.all, - ), + onSelected: (_) { + HapticFeedback.selectionClick(); + setState( + () => _mediaFilter = FavoriteMediaType.all, + ); + }, visualDensity: VisualDensity.compact, ), const SizedBox(width: 8), @@ -275,9 +353,12 @@ class _FavoritesScreenState extends ConsumerState { avatar: const Icon(Icons.videocam_rounded, size: 16), label: Text(isRu ? 'Видео' : 'Videos'), selected: _mediaFilter == FavoriteMediaType.video, - onSelected: (_) => setState( - () => _mediaFilter = FavoriteMediaType.video, - ), + onSelected: (_) { + HapticFeedback.selectionClick(); + setState( + () => _mediaFilter = FavoriteMediaType.video, + ); + }, visualDensity: VisualDensity.compact, ), const SizedBox(width: 8), @@ -285,9 +366,12 @@ class _FavoritesScreenState extends ConsumerState { avatar: const Icon(Icons.image_rounded, size: 16), label: Text(isRu ? 'Фото' : 'Images'), selected: _mediaFilter == FavoriteMediaType.image, - onSelected: (_) => setState( - () => _mediaFilter = FavoriteMediaType.image, - ), + onSelected: (_) { + HapticFeedback.selectionClick(); + setState( + () => _mediaFilter = FavoriteMediaType.image, + ); + }, visualDensity: VisualDensity.compact, ), ], @@ -490,31 +574,40 @@ class _FavoritesScreenState extends ConsumerState { }) { final scheme = Theme.of(context).colorScheme; - return Container( + return _FavoritesLiquidCard( margin: const EdgeInsets.fromLTRB(16, 4, 16, 8), padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: scheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(18), - border: Border.all( - color: scheme.outlineVariant.withValues(alpha: 0.35), - ), - ), + glowColor: scheme.primary, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( - padding: const EdgeInsets.all(10), + width: 40, + height: 40, decoration: BoxDecoration( - color: scheme.primaryContainer.withValues(alpha: 0.7), + gradient: LinearGradient( + colors: [ + scheme.primary, + scheme.primary.withValues(alpha: 0.85), + ], + ), borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: scheme.primary.withValues(alpha: 0.35), + blurRadius: 7, + offset: const Offset(0, 2.5), + ), + ], ), - child: Icon( - Icons.offline_pin_rounded, - color: scheme.onPrimaryContainer, - size: 22, + child: const Center( + child: Icon( + Icons.offline_pin_rounded, + color: Colors.white, + size: 22, + ), ), ), const SizedBox(width: 12), @@ -526,6 +619,7 @@ class _FavoritesScreenState extends ConsumerState { '$downloadedCount ${isRu ? 'из' : 'of'} $totalCount ${isRu ? 'офлайн' : 'saved'}', style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, + letterSpacing: -0.2, ), ), const SizedBox(height: 2), @@ -553,21 +647,22 @@ class _FavoritesScreenState extends ConsumerState { if (pendingCount > 0) FilledButton.tonalIcon( onPressed: () async { + HapticFeedback.mediumImpact(); final dm = ref.read(downloadManagerServiceProvider); for (final p in pendingPosts) { await dm.start(p); } if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - isRu - ? 'В очередь загрузки добавлено: $pendingCount' - : 'Queued $pendingCount downloads', - ), - behavior: SnackBarBehavior.floating, + SnackBar( + content: Text( + isRu + ? 'В очередь загрузки добавлено: $pendingCount' + : 'Queued $pendingCount downloads', ), - ); + behavior: SnackBarBehavior.floating, + ), + ); }, icon: const Icon(Icons.download_rounded, size: 16), label: Text( @@ -594,7 +689,7 @@ class _FavoritesScreenState extends ConsumerState { vertical: 6, ), decoration: BoxDecoration( - color: scheme.secondaryContainer.withValues(alpha: 0.5), + color: scheme.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(12), ), child: Row( @@ -610,8 +705,8 @@ class _FavoritesScreenState extends ConsumerState { isRu ? 'Всё готово' : 'Synced', style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: scheme.onSecondaryContainer, - fontWeight: FontWeight.w600, + color: scheme.primary, + fontWeight: FontWeight.w700, ), ), ], @@ -666,27 +761,36 @@ class _FavoritesScreenState extends ConsumerState { ChoiceChip( label: Text(isRu ? 'По дате' : 'Date'), selected: _offlineSort == OfflineSortOption.dateAddedDesc, - onSelected: (_) => setState( - () => _offlineSort = OfflineSortOption.dateAddedDesc, - ), + onSelected: (_) { + HapticFeedback.selectionClick(); + setState( + () => _offlineSort = OfflineSortOption.dateAddedDesc, + ); + }, visualDensity: VisualDensity.compact, ), const SizedBox(width: 6), ChoiceChip( label: Text(isRu ? 'По размеру' : 'Size'), selected: _offlineSort == OfflineSortOption.sizeDesc, - onSelected: (_) => setState( - () => _offlineSort = OfflineSortOption.sizeDesc, - ), + onSelected: (_) { + HapticFeedback.selectionClick(); + setState( + () => _offlineSort = OfflineSortOption.sizeDesc, + ); + }, visualDensity: VisualDensity.compact, ), const SizedBox(width: 6), ChoiceChip( label: Text(isRu ? 'По автору' : 'Artist'), selected: _offlineSort == OfflineSortOption.artist, - onSelected: (_) => setState( - () => _offlineSort = OfflineSortOption.artist, - ), + onSelected: (_) { + HapticFeedback.selectionClick(); + setState( + () => _offlineSort = OfflineSortOption.artist, + ); + }, visualDensity: VisualDensity.compact, ), ], @@ -712,38 +816,50 @@ class _FavoritesScreenState extends ConsumerState { return Column( children: [ - // Artist Header Banner - Container( + // Artist Header Banner (Liquid Glass) + _FavoritesLiquidCard( margin: const EdgeInsets.fromLTRB(16, 4, 16, 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerLow, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outlineVariant - .withValues(alpha: 0.3), - ), - ), child: Row( children: [ IconButton( - onPressed: () => setState(() => _selectedArtist = null), + onPressed: () { + HapticFeedback.selectionClick(); + setState(() => _selectedArtist = null); + }, icon: const Icon(Icons.arrow_back_rounded), tooltip: isRu ? 'Все авторы' : 'All artists', ), const SizedBox(width: 8), - CircleAvatar( - radius: 18, - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - child: Text( - selected.isNotEmpty ? selected[0].toUpperCase() : '?', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onPrimaryContainer, + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.tertiary, + ], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.35), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Center( + child: Text( + selected.isNotEmpty ? selected[0].toUpperCase() : '?', + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), ), ), @@ -756,6 +872,7 @@ class _FavoritesScreenState extends ConsumerState { selected, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, + letterSpacing: -0.2, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -776,6 +893,7 @@ class _FavoritesScreenState extends ConsumerState { visualDensity: VisualDensity.compact, ), onPressed: () { + HapticFeedback.selectionClick(); context.push('/search?q=${Uri.encodeComponent('artist:$selected')}'); }, icon: const Icon(Icons.search_rounded, size: 16), @@ -808,7 +926,9 @@ class _FavoritesScreenState extends ConsumerState { } return ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + // Extended bottom padding so the last artist cards scroll comfortably above the floating dock + padding: EdgeInsets.fromLTRB( + 16, 8, 16, 140 + MediaQuery.paddingOf(context).bottom), itemCount: entries.length, separatorBuilder: (_, __) => const SizedBox(height: 10), itemBuilder: (context, index) { @@ -863,6 +983,83 @@ class _FavoritesScreenState extends ConsumerState { } } +class _FavoritesLiquidCard extends StatelessWidget { + const _FavoritesLiquidCard({ + required this.child, + this.padding, + this.glowColor, + this.margin, + }); + + final Widget child; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final Color? glowColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final accent = glowColor ?? theme.colorScheme.primary; + + return Container( + margin: margin, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.30) + : accent.withValues(alpha: 0.07), + blurRadius: 16, + offset: const Offset(0, 5), + spreadRadius: -2, + ), + BoxShadow( + color: accent.withValues(alpha: isDark ? 0.05 : 0.02), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(22), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), + child: Container( + padding: padding ?? const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDark + ? [ + theme.colorScheme.surfaceContainerHigh + .withValues(alpha: 0.60), + theme.colorScheme.surfaceContainerLow + .withValues(alpha: 0.38), + ] + : [ + 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, + ), + ), + ), + ); + } +} + class _ArtistCard extends StatelessWidget { const _ArtistCard({ required this.artistName, @@ -881,123 +1078,182 @@ class _ArtistCard extends StatelessWidget { @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; - return Card( - elevation: 0, - color: scheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide( - color: scheme.outlineVariant.withValues(alpha: 0.3), - ), + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.28) + : scheme.primary.withValues(alpha: 0.06), + blurRadius: 14, + offset: const Offset(0, 4), + spreadRadius: -2, + ), + ], ), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - // Artist Avatar - Container( - width: 48, - height: 48, + child: ClipRRect( + borderRadius: BorderRadius.circular(22), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(22), + onTap: () { + HapticFeedback.selectionClick(); + onTap(); + }, + child: Container( + padding: const EdgeInsets.all(12), decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), gradient: LinearGradient( - colors: [ - scheme.primaryContainer, - scheme.tertiaryContainer, - ], begin: Alignment.topLeft, end: Alignment.bottomRight, + colors: isDark + ? [ + scheme.surfaceContainerHigh + .withValues(alpha: 0.58), + scheme.surfaceContainerLow + .withValues(alpha: 0.36), + ] + : [ + Colors.white.withValues(alpha: 0.88), + Colors.white.withValues(alpha: 0.72), + ], ), - borderRadius: BorderRadius.circular(14), - ), - child: Center( - child: Text( - artistName.isNotEmpty ? artistName[0].toUpperCase() : '?', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: scheme.onPrimaryContainer, - ), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.13) + : Colors.white.withValues(alpha: 0.85), + width: 1.1, ), ), - ), - const SizedBox(width: 12), - // Artist Name & Count - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - artistName, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), + // Artist Squircle Avatar Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), + width: 48, + height: 48, decoration: BoxDecoration( - color: scheme.secondaryContainer.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(8), + gradient: LinearGradient( + colors: [ + scheme.primary, + scheme.tertiary, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: scheme.primary.withValues(alpha: 0.35), + blurRadius: 8, + offset: const Offset(0, 2.5), + ), + ], ), - child: Text( - '$postsCount ${isRu ? 'работ' : 'posts'}', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: scheme.onSecondaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - // Thumbnails preview - if (previewUrls.isNotEmpty) ...[ - const SizedBox(width: 8), - Row( - children: [ - for (final url in previewUrls) - Padding( - padding: const EdgeInsets.only(left: 4), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: SizedBox( - width: 44, - height: 44, - child: CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - placeholder: (_, __) => Container( - color: scheme.surfaceContainerHighest, - ), - errorWidget: (_, __, ___) => Container( - color: scheme.surfaceContainerHighest, - child: const Icon( - Icons.image_not_supported_rounded, - size: 16, - ), - ), - ), + child: Center( + child: Text( + artistName.isNotEmpty ? artistName[0].toUpperCase() : '?', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, ), ), ), + ), + const SizedBox(width: 12), + // Artist Name & Count + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + artistName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: -0.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 3), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: scheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$postsCount ${isRu ? 'работ' : 'posts'}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + // Thumbnails preview + if (previewUrls.isNotEmpty) ...[ + const SizedBox(width: 8), + Row( + children: [ + for (final url in previewUrls) + Padding( + padding: const EdgeInsets.only(left: 5), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.10) + : scheme.outlineVariant.withValues(alpha: 0.25), + ), + borderRadius: BorderRadius.circular(10), + ), + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: scheme.surfaceContainerHighest, + ), + errorWidget: (_, __, ___) => Container( + color: scheme.surfaceContainerHighest, + child: const Icon( + Icons.image_not_supported_rounded, + size: 16, + ), + ), + ), + ), + ), + ), + ], + ), + ], + const SizedBox(width: 4), + Icon( + Icons.chevron_right_rounded, + size: 20, + color: scheme.onSurfaceVariant.withValues(alpha: 0.5), + ), ], ), - ], - const SizedBox(width: 6), - Icon( - Icons.chevron_right_rounded, - color: scheme.onSurfaceVariant.withValues(alpha: 0.6), ), - ], + ), ), ), ), diff --git a/lib/features/search/presentation/search_screen.dart b/lib/features/search/presentation/search_screen.dart index 9021df4..908e71e 100644 --- a/lib/features/search/presentation/search_screen.dart +++ b/lib/features/search/presentation/search_screen.dart @@ -1,4 +1,7 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -47,6 +50,7 @@ class _SearchScreenState extends ConsumerState { final strings = AppStrings(settings.languageCode); final isRu = strings.ru; final theme = Theme.of(context); + final bottomInset = MediaQuery.paddingOf(context).bottom; return AdaptiveScaffold( title: strings.search, @@ -78,19 +82,12 @@ class _SearchScreenState extends ConsumerState { child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 860), child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + // Extended bottom padding so the cheat sheet card is never obscured by the floating dock + padding: EdgeInsets.fromLTRB(16, 14, 16, 140 + bottomInset), children: [ // 1. Search Bar with Quick Operators - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: theme.colorScheme.outlineVariant - .withValues(alpha: 0.3), - ), - ), + _SearchLiquidCard( + glowColor: theme.colorScheme.primary, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -171,56 +168,124 @@ class _SearchScreenState extends ConsumerState { const SizedBox(height: 16), // 2. Recent Searches Card - _SearchSectionCard( - icon: Icons.history_rounded, - accentColor: const Color(0xFF6366F1), - title: isRu ? 'Недавние запросы' : 'Recent searches', - badgeCount: data.recent - .map((e) => e.query.trim().toLowerCase()) - .where((q) => q.isNotEmpty) - .toSet() - .length, - trailing: data.recent.isEmpty - ? null - : TextButton( - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 4), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + _SearchLiquidCard( + glowColor: const Color(0xFF6366F1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const _SearchIconBadge( + icon: Icons.history_rounded, + color: Color(0xFF6366F1), ), - onPressed: () => ref - .read(searchControllerProvider.notifier) - .clearHistory(), - child: Text( - isRu ? 'Очистить' : 'Clear', - style: TextStyle( - color: theme.colorScheme.primary, - fontSize: 13, - fontWeight: FontWeight.w600, + const SizedBox(width: 12), + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + isRu ? 'Недавние запросы' : 'Recent searches', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 15, + letterSpacing: -0.2, + ), + ), + ), + if (data.recent.isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF6366F1) + .withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${data.recent.map((e) => e.query.trim().toLowerCase()).where((q) => q.isNotEmpty).toSet().length}', + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: const Color(0xFF6366F1), + ), + ), + ), + ], + ], ), ), - ), - child: RecentSearches( - items: data.recent, - onTap: (query) => - context.go('/?q=${Uri.encodeQueryComponent(query)}'), - onDelete: (id) => ref - .read(searchControllerProvider.notifier) - .deleteHistory(id), + if (data.recent.isNotEmpty) + TextButton( + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () { + HapticFeedback.selectionClick(); + ref + .read(searchControllerProvider.notifier) + .clearHistory(); + }, + child: Text( + isRu ? 'Очистить' : 'Clear', + style: TextStyle( + color: theme.colorScheme.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + RecentSearches( + items: data.recent, + onTap: (query) => + context.go('/?q=${Uri.encodeQueryComponent(query)}'), + onDelete: (id) => ref + .read(searchControllerProvider.notifier) + .deleteHistory(id), + ), + ], ), ), const SizedBox(height: 16), // 3. Explore Popular Categories & Tags - _SearchSectionCard( - icon: Icons.explore_rounded, - accentColor: const Color(0xFF10B981), - title: isRu ? 'Популярные категории' : 'Explore categories', + _SearchLiquidCard( + glowColor: const Color(0xFF10B981), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row( + children: [ + const _SearchIconBadge( + icon: Icons.explore_rounded, + color: Color(0xFF10B981), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + isRu ? 'Популярные категории' : 'Explore categories', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 15, + letterSpacing: -0.2, + ), + ), + ), + ], + ), + const SizedBox(height: 16), _CategoryTagRow( categoryName: isRu ? 'Тематика' : 'Theme & Ambience', color: const Color(0xFF0EA5E9), @@ -234,7 +299,7 @@ class _SearchScreenState extends ConsumerState { ], onTap: (tag) => _onTagTap(tag, data.query), ), - const SizedBox(height: 14), + const SizedBox(height: 16), _CategoryTagRow( categoryName: isRu ? 'Персонажи и детали' : 'Characters & Attire', @@ -250,7 +315,7 @@ class _SearchScreenState extends ConsumerState { ], onTap: (tag) => _onTagTap(tag, data.query), ), - const SizedBox(height: 14), + const SizedBox(height: 16), _CategoryTagRow( categoryName: isRu ? 'Провайдеры' : 'Providers', color: const Color(0xFF8B5CF6), @@ -268,84 +333,78 @@ class _SearchScreenState extends ConsumerState { ), const SizedBox(height: 16), - // 4. Search Tips / Cheat Sheet Card - Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: theme.colorScheme.outlineVariant - .withValues(alpha: 0.3), - ), - ), - child: ExpansionTile( - shape: const Border(), - collapsedShape: const Border(), - leading: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: const Color(0xFFF59E0B).withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.lightbulb_outline_rounded, - size: 18, + // 4. Search Tips / Cheat Sheet Card (Full Liquid Glass Container) + _SearchLiquidCard( + padding: EdgeInsets.zero, + glowColor: const Color(0xFFF59E0B), + child: Theme( + data: theme.copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + shape: const Border(), + collapsedShape: const Border(), + tilePadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 4), + leading: const _SearchIconBadge( + icon: Icons.lightbulb_rounded, color: Color(0xFFF59E0B), ), - ), - title: Text( - isRu ? 'Шпаргалка по поиску' : 'Search tips & operators', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - initiallyExpanded: _showTips, - onExpansionChanged: (exp) => setState(() => _showTips = exp), - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _TipRow( - code: 'tag_a and tag_b', - description: isRu - ? 'Независимый опрос обоих тегов с чередованием постов в ленте.' - : 'Interleaves results from both queries independently.', - ), - const SizedBox(height: 8), - _TipRow( - code: 'type:video / type:gif', - description: isRu - ? 'Показывает только видео или анимированные GIF.' - : 'Filters results to only videos or animated GIFs.', - ), - const SizedBox(height: 8), - _TipRow( - code: 'rating:safe / rating:explicit', - description: isRu - ? 'Фильтр по возрастному рейтингу медиа.' - : 'Filter by age rating classification.', - ), - const SizedBox(height: 8), - _TipRow( - code: 'score:>50 / score:>100', - description: isRu - ? 'Посты с оценкой пользователей выше указанной.' - : 'Posts with community score greater than threshold.', - ), - const SizedBox(height: 8), - _TipRow( - code: 'provider:gelbooru', - description: isRu - ? 'Поиск исключительно в указанном источнике.' - : 'Search specifically on the given booru provider.', - ), - ], + title: Text( + isRu ? 'Шпаргалка по поиску' : 'Search tips & operators', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.2, ), ), - ], + initiallyExpanded: _showTips, + onExpansionChanged: (exp) { + HapticFeedback.selectionClick(); + setState(() => _showTips = exp); + }, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TipRow( + code: 'tag_a and tag_b', + description: isRu + ? 'Независимый опрос обоих тегов с чередованием постов в ленте.' + : 'Interleaves results from both queries independently.', + ), + const SizedBox(height: 8), + _TipRow( + code: 'type:video / type:gif', + description: isRu + ? 'Показывает только видео или анимированные GIF.' + : 'Filters results to only videos or animated GIFs.', + ), + const SizedBox(height: 8), + _TipRow( + code: 'rating:safe / rating:explicit', + description: isRu + ? 'Фильтр по возрастному рейтингу медиа.' + : 'Filter by age rating classification.', + ), + const SizedBox(height: 8), + _TipRow( + code: 'score:>50 / score:>100', + description: isRu + ? 'Посты с оценкой пользователей выше указанной.' + : 'Posts with community score greater than threshold.', + ), + const SizedBox(height: 8), + _TipRow( + code: 'provider:gelbooru', + description: isRu + ? 'Поиск исключительно в указанном источнике.' + : 'Search specifically on the given booru provider.', + ), + ], + ), + ), + ], + ), ), ), const SizedBox(height: 24), @@ -358,93 +417,117 @@ class _SearchScreenState extends ConsumerState { } } -class _SearchSectionCard extends StatelessWidget { - const _SearchSectionCard({ - required this.icon, - required this.accentColor, - required this.title, +class _SearchLiquidCard extends StatelessWidget { + const _SearchLiquidCard({ required this.child, - this.badgeCount, - this.trailing, + this.padding, + this.glowColor, }); - final IconData icon; - final Color accentColor; - final String title; final Widget child; - final int? badgeCount; - final Widget? trailing; + final EdgeInsetsGeometry? padding; + final Color? glowColor; @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final accent = glowColor ?? theme.colorScheme.primary; + return Container( decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: theme.colorScheme.outlineVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.35) + : accent.withValues(alpha: 0.08), + blurRadius: 18, + offset: const Offset(0, 6), + spreadRadius: -2, + ), + BoxShadow( + color: accent.withValues(alpha: isDark ? 0.06 : 0.03), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), + child: Container( + padding: padding ?? const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDark + ? [ + theme.colorScheme.surfaceContainerHigh + .withValues(alpha: 0.60), + theme.colorScheme.surfaceContainerLow + .withValues(alpha: 0.38), + ] + : [ + 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, + ), ), ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: accentColor.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, size: 18, color: accentColor), - ), - const SizedBox(width: 10), - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - fontSize: 15, - ), - ), - ), - if (badgeCount != null && badgeCount! > 0) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: accentColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '$badgeCount', - style: theme.textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.bold, - color: accentColor, - ), - ), - ), - ], - ], - ), - ), - if (trailing != null) trailing!, - ], + ); + } +} + +class _SearchIconBadge extends StatelessWidget { + const _SearchIconBadge({ + required this.icon, + required this.color, + }); + + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + const size = 34.0; + const iconSize = 18.0; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + color, + color.withValues(alpha: 0.86), + ], + ), + borderRadius: BorderRadius.circular(size * 0.28), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.35), + blurRadius: 7, + offset: const Offset(0, 2.5), ), - const SizedBox(height: 14), - child, ], ), + child: Center( + child: Icon(icon, size: iconSize, color: Colors.white), + ), ); } } @@ -465,19 +548,33 @@ class _QuickOperatorChip extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + return Material( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), + color: Colors.transparent, + borderRadius: BorderRadius.circular(14), child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: onTap, + borderRadius: BorderRadius.circular(14), + onTap: () { + HapticFeedback.selectionClick(); + onTap(); + }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), + color: color.withValues(alpha: isDark ? 0.16 : 0.10), + borderRadius: BorderRadius.circular(14), border: Border.all( - color: color.withValues(alpha: 0.35), + color: color.withValues(alpha: isDark ? 0.40 : 0.32), + width: 1.1, ), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: isDark ? 0.14 : 0.06), + blurRadius: 6, + offset: const Offset(0, 1.5), + ), + ], ), child: Row( mainAxisSize: MainAxisSize.min, @@ -490,6 +587,7 @@ class _QuickOperatorChip extends StatelessWidget { fontWeight: FontWeight.w700, color: color, fontFamily: 'monospace', + letterSpacing: -0.2, ), ), ], @@ -516,44 +614,76 @@ class _CategoryTagRow extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - categoryName, - style: theme.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w600, - color: theme.colorScheme.onSurfaceVariant, - ), + Row( + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.6), + blurRadius: 4, + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + categoryName, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + letterSpacing: -0.2, + ), + ), + ], ), - const SizedBox(height: 8), + const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, children: [ for (final tag in tags) Material( - color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(12), + color: Colors.transparent, + borderRadius: BorderRadius.circular(14), child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => onTap(tag), + borderRadius: BorderRadius.circular(14), + onTap: () { + HapticFeedback.selectionClick(); + onTap(tag); + }, child: Container( padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), + horizontal: 11, vertical: 6), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), + color: isDark + ? theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.45) + : Colors.white.withValues(alpha: 0.60), + borderRadius: BorderRadius.circular(14), border: Border.all( - color: theme.colorScheme.outlineVariant - .withValues(alpha: 0.3), + color: isDark + ? Colors.white.withValues(alpha: 0.10) + : theme.colorScheme.outlineVariant + .withValues(alpha: 0.35), + width: 1.0, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( - width: 6, - height: 6, + width: 5, + height: 5, decoration: BoxDecoration( color: color, shape: BoxShape.circle, @@ -563,7 +693,8 @@ class _CategoryTagRow extends StatelessWidget { Text( tag, style: theme.textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, + letterSpacing: -0.1, ), ), ], @@ -587,14 +718,23 @@ class _TipRow extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(6), + color: isDark + ? theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6) + : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : theme.colorScheme.outlineVariant.withValues(alpha: 0.25), + ), ), child: Text( code, @@ -608,10 +748,14 @@ class _TipRow extends StatelessWidget { ), const SizedBox(width: 10), Expanded( - child: Text( - description, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.35, + ), ), ), ), diff --git a/lib/features/search/presentation/widgets/recent_searches.dart b/lib/features/search/presentation/widgets/recent_searches.dart index 91f6302..abd98e0 100644 --- a/lib/features/search/presentation/widgets/recent_searches.dart +++ b/lib/features/search/presentation/widgets/recent_searches.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../../backend/backend.dart'; @@ -95,20 +96,40 @@ class _RecentSearchesState extends State { children: [ for (final item in visibleItems) Material( - color: theme.colorScheme.surfaceContainer, + color: Colors.transparent, borderRadius: BorderRadius.circular(16), child: InkWell( borderRadius: BorderRadius.circular(16), - onTap: () => widget.onTap(item.query), + onTap: () { + HapticFeedback.selectionClick(); + widget.onTap(item.query); + }, child: Container( padding: const EdgeInsets.only(left: 10, right: 6, top: 6, bottom: 6), decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark + ? theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.45) + : Colors.white.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(16), border: Border.all( - color: theme.colorScheme.outlineVariant - .withValues(alpha: 0.35), + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withValues(alpha: 0.12) + : theme.colorScheme.outlineVariant + .withValues(alpha: 0.35), + width: 1.0, ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues( + alpha: Theme.of(context).brightness == Brightness.dark + ? 0.15 + : 0.04), + blurRadius: 6, + offset: const Offset(0, 1.5), + ), + ], ), child: Row( mainAxisSize: MainAxisSize.min, @@ -140,7 +161,7 @@ class _RecentSearchesState extends State { padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.12), + color: theme.colorScheme.primary.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(8), ), child: Text( @@ -161,7 +182,10 @@ class _RecentSearchesState extends State { clipBehavior: Clip.antiAlias, child: InkWell( customBorder: const CircleBorder(), - onTap: () => widget.onDelete!(item.id), + onTap: () { + HapticFeedback.lightImpact(); + widget.onDelete!(item.id); + }, child: Padding( padding: const EdgeInsets.all(4), child: Icon( diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index f769148..9b88500 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -976,191 +976,275 @@ class _SettingsContentState extends ConsumerState<_SettingsContent> { final isRu = settings.languageCode == 'ru'; final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final selectedSource = await showModalBottomSheet( context: context, + useRootNavigator: true, + isScrollControlled: true, backgroundColor: Colors.transparent, builder: (sheetContext) { - return Container( - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), - ), - padding: const EdgeInsets.fromLTRB(20, 12, 20, 28), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Container( - width: 36, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 24, sigmaY: 24), + child: Container( + decoration: BoxDecoration( + color: isDark + ? theme.colorScheme.surface.withValues(alpha: 0.88) + : Colors.white.withValues(alpha: 0.94), + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + border: Border( + top: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.18) + : Colors.white.withValues(alpha: 0.85), + width: 1.2, ), ), ), - Text( - isRu ? 'Источник обновлений' : 'Update Source', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 6), - Text( - isRu - ? 'Выберите сервер для загрузки обновлений' - : 'Choose a server to download updates from', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 20), - - // Option 1: GitHub (Recommended for speed) - Material( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(16), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () => Navigator.pop(sheetContext, UpdateSource.github), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - width: 44, - height: 44, + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + 20, + 14, + 20, + 24 + MediaQuery.paddingOf(sheetContext).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 38, + height: 4.5, + margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.bolt_rounded, - color: theme.colorScheme.primary, - size: 24, + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(3), ), ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'GitHub', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - isRu ? 'Рекомендуется для скорости' : 'Recommended for speed', - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.green, - ), - ), - ), - ], - ), - const SizedBox(height: 3), - Text( - isRu - ? 'Прямая быстрая загрузка без ограничений' - : 'Direct fast download without limits', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), + ), + Text( + isRu ? 'Источник обновлений' : 'Update Source', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.3, ), - Icon( - Icons.arrow_forward_ios_rounded, - size: 14, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + isRu + ? 'Выберите сервер для быстрой загрузки новой версии' + : 'Choose a server to download updates from', + style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), - ], - ), - ), - ), - ), - const SizedBox(height: 12), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), - // Option 2: Gitea (Muted / neutral grey) - Material( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.25), - borderRadius: BorderRadius.circular(16), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () => Navigator.pop(sheetContext, UpdateSource.gitea), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: theme.colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.dns_rounded, - color: theme.colorScheme.onSurfaceVariant, - size: 22, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Gitea', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, + // Option 1: GitHub (Recommended for speed) + Material( + color: isDark + ? theme.colorScheme.primaryContainer.withValues(alpha: 0.35) + : theme.colorScheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(18), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () { + HapticFeedback.selectionClick(); + Navigator.pop(sheetContext, UpdateSource.github); + }, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.15) + : theme.colorScheme.primary.withValues(alpha: 0.35), + width: 1.2, + ), + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF6366F1), Color(0xFF4F46E5)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(13), + boxShadow: [ + BoxShadow( + color: const Color(0xFF6366F1).withValues(alpha: 0.40), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: const Center( + child: Icon( + Icons.bolt_rounded, + color: Colors.white, + size: 24, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'GitHub', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 2.5, + ), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.20), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + isRu ? 'Рекомендуется' : 'Recommended', + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: Colors.green, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + isRu + ? 'Прямая быстрая загрузка релизов без задержек' + : 'Direct fast download without limits', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios_rounded, + size: 15, color: theme.colorScheme.onSurfaceVariant, ), - ), - const SizedBox(height: 3), - Text( - isRu - ? 'Сервер релизов' - : 'Release server', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ], + ], + ), ), ), - Icon( - Icons.arrow_forward_ios_rounded, - size: 14, - color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: 12), + + // Option 2: Gitea + Material( + color: isDark + ? theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35) + : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.40), + borderRadius: BorderRadius.circular(18), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () { + HapticFeedback.selectionClick(); + Navigator.pop(sheetContext, UpdateSource.gitea); + }, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.12) + : theme.colorScheme.outlineVariant.withValues(alpha: 0.35), + width: 1.2, + ), + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0EA5E9), Color(0xFF0284C7)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(13), + boxShadow: [ + BoxShadow( + color: const Color(0xFF0EA5E9).withValues(alpha: 0.35), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: const Center( + child: Icon( + Icons.dns_rounded, + color: Colors.white, + size: 22, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Gitea (Synapse)', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + isRu + ? 'Альтернативный независимый сервер релизов' + : 'Alternative independent release mirror', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios_rounded, + size: 15, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), ), - ], - ), + ), + ], ), ), ), - ], + ), ), ); }, diff --git a/lib/features/viewed/presentation/viewed_screen.dart b/lib/features/viewed/presentation/viewed_screen.dart index 0e52cfc..55ddc00 100644 --- a/lib/features/viewed/presentation/viewed_screen.dart +++ b/lib/features/viewed/presentation/viewed_screen.dart @@ -1,5 +1,8 @@ +import 'dart:ui'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -156,28 +159,56 @@ class _ViewedScreenState extends ConsumerState { ); } + final bottomInset = MediaQuery.paddingOf(context).bottom; + return RefreshIndicator( onRefresh: () => ref.read(viewedControllerProvider.notifier).refresh(), child: ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + padding: EdgeInsets.fromLTRB(16, 8, 16, 140 + bottomInset), children: [ for (final group in groups) ...[ Padding( - padding: const EdgeInsets.fromLTRB(4, 16, 4, 10), + padding: const EdgeInsets.fromLTRB(2, 16, 2, 10), child: Row( children: [ - Icon( - Icons.calendar_today_rounded, - size: 16, - color: Theme.of(context).colorScheme.primary, + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.primary.withValues(alpha: 0.82), + ], + ), + borderRadius: BorderRadius.circular(9), + boxShadow: [ + BoxShadow( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.32), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: const Center( + child: Icon( + Icons.calendar_today_rounded, + size: 14, + color: Colors.white, + ), + ), ), - const SizedBox(width: 8), + const SizedBox(width: 10), Text( _localizedGroupLabel(group.label, isRu), style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, + letterSpacing: -0.2, ), ), const SizedBox(width: 8), @@ -187,10 +218,24 @@ class _ViewedScreenState extends ConsumerState { vertical: 2, ), decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, + color: Theme.of(context).brightness == Brightness.dark + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.5) + : Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withValues(alpha: 0.08) + : Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.20), + ), ), child: Text( '${group.items.length}', @@ -198,7 +243,10 @@ class _ViewedScreenState extends ConsumerState { .textTheme .labelSmall ?.copyWith( - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, + color: Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.primary, ), ), ), @@ -299,184 +347,251 @@ class _ViewedPostTile extends StatelessWidget { @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; final image = MediaUrlSelector.preview(post).firstOrNull; final timeStr = '${viewedAt.hour.toString().padLeft(2, '0')}:${viewedAt.minute.toString().padLeft(2, '0')}'; - return Card( - elevation: 0, - color: scheme.surfaceContainerLow, - margin: const EdgeInsets.only(bottom: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide( - color: scheme.outlineVariant.withValues(alpha: 0.3), - ), + return Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.28 : 0.05), + blurRadius: 14, + offset: const Offset(0, 4), + spreadRadius: -2, + ), + ], ), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onOpen, - child: Padding( - padding: const EdgeInsets.all(10), - child: Row( - children: [ - // Squircle Thumbnail - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SizedBox( - width: 68, - height: 68, - child: Stack( - fit: StackFit.expand, - children: [ - image == null - ? Container( - color: scheme.surfaceContainerHighest, - child: const Icon(Icons.image_not_supported_rounded), - ) - : CachedNetworkImage( - imageUrl: image, - fit: BoxFit.cover, - placeholder: (_, __) => Container( - color: scheme.surfaceContainerHighest, - ), - errorWidget: (_, __, ___) => Container( - color: scheme.surfaceContainerHighest, - child: const Icon( - Icons.broken_image_rounded, - size: 20, - ), - ), - ), - if (_isVideo) - Positioned( - right: 4, - bottom: 4, - child: Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.65), - borderRadius: BorderRadius.circular(4), - ), - child: const Icon( - Icons.play_arrow_rounded, - size: 14, - color: Colors.white, - ), - ), - ), - ], + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + HapticFeedback.selectionClick(); + onOpen(); + }, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDark + ? [ + scheme.surfaceContainerHigh + .withValues(alpha: 0.58), + scheme.surfaceContainerLow + .withValues(alpha: 0.36), + ] + : [ + Colors.white.withValues(alpha: 0.88), + Colors.white.withValues(alpha: 0.72), + ], + ), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.13) + : Colors.white.withValues(alpha: 0.85), + width: 1.1, ), ), - ), - const SizedBox(width: 12), - // Post details - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Row( - children: [ - Flexible( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: - scheme.primaryContainer.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - post.providerName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - fontWeight: FontWeight.w600, - color: scheme.onPrimaryContainer, + // Squircle Thumbnail + ClipRRect( + borderRadius: BorderRadius.circular(13), + child: Container( + width: 68, + height: 68, + decoration: BoxDecoration( + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.10) + : scheme.outlineVariant.withValues(alpha: 0.30), + ), + borderRadius: BorderRadius.circular(13), + ), + child: Stack( + fit: StackFit.expand, + children: [ + image == null + ? Container( + color: scheme.surfaceContainerHighest, + child: const Icon( + Icons.image_not_supported_rounded), + ) + : CachedNetworkImage( + imageUrl: image, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: scheme.surfaceContainerHighest, + ), + errorWidget: (_, __, ___) => Container( + color: scheme.surfaceContainerHighest, + child: const Icon( + Icons.broken_image_rounded, + size: 20, + ), + ), ), - ), - ), - ), - const SizedBox(width: 6), - Text( - timeStr, - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: scheme.onSurfaceVariant, + if (_isVideo) + Positioned( + right: 4, + bottom: 4, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(5), + ), + child: const Icon( + Icons.play_arrow_rounded, + size: 14, + color: Colors.white, + ), + ), ), + ], ), - ], + ), ), - const SizedBox(height: 4), - Text( - post.tags.isEmpty ? '#${post.id}' : post.tags.take(4).join(' '), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - if (post.score != 0) ...[ - const SizedBox(height: 2), - Row( + const SizedBox(width: 12), + // Post details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.thumb_up_rounded, - size: 12, - color: scheme.outline, + Row( + children: [ + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 2, + ), + decoration: BoxDecoration( + color: scheme.primary + .withValues(alpha: isDark ? 0.20 : 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: scheme.primary.withValues( + alpha: isDark ? 0.30 : 0.20), + ), + ), + child: Text( + post.providerName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + fontWeight: FontWeight.w700, + color: scheme.primary, + ), + ), + ), + ), + const SizedBox(width: 6), + Text( + timeStr, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ], ), - const SizedBox(width: 4), + const SizedBox(height: 4), Text( - '${post.score}', - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: scheme.outline, + post.tags.isEmpty ? '#${post.id}' : post.tags.take(4).join(' '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: -0.1, ), ), + if (post.score != 0) ...[ + const SizedBox(height: 2), + Row( + children: [ + Icon( + Icons.thumb_up_rounded, + size: 12, + color: scheme.outline, + ), + const SizedBox(width: 4), + Text( + '${post.score}', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: scheme.outline, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], ], ), - ], + ), + // Action buttons + IconButton( + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 36, minHeight: 36), + tooltip: isFavorite + ? (isRu ? 'В избранном' : 'In favorites') + : (isRu ? 'В избранное' : 'Add to favorites'), + onPressed: () { + HapticFeedback.selectionClick(); + onFavorite(); + }, + icon: Icon( + isFavorite + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + color: isFavorite + ? Colors.redAccent + : scheme.onSurfaceVariant, + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 36, minHeight: 36), + tooltip: + isRu ? 'Удалить из истории' : 'Delete from history', + onPressed: () { + HapticFeedback.lightImpact(); + onDelete(); + }, + icon: Icon( + Icons.close_rounded, + size: 20, + color: scheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), ], ), ), - // Action buttons - IconButton( - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - tooltip: isFavorite - ? (isRu ? 'В избранном' : 'In favorites') - : (isRu ? 'В избранное' : 'Add to favorites'), - onPressed: onFavorite, - icon: Icon( - isFavorite - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - color: isFavorite ? Colors.redAccent : scheme.onSurfaceVariant, - ), - ), - IconButton( - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - tooltip: isRu ? 'Удалить из истории' : 'Delete from history', - onPressed: onDelete, - icon: Icon( - Icons.close_rounded, - size: 20, - color: scheme.onSurfaceVariant.withValues(alpha: 0.6), - ), - ), - ], + ), ), ), ), diff --git a/lib/shared/widgets/post_masonry_grid.dart b/lib/shared/widgets/post_masonry_grid.dart index 81ff957..3f140e3 100644 --- a/lib/shared/widgets/post_masonry_grid.dart +++ b/lib/shared/widgets/post_masonry_grid.dart @@ -28,6 +28,7 @@ class PostMasonryGrid extends StatelessWidget { this.loading = false, this.controller, this.gridMode = 'masonry', + this.padding, super.key, }); @@ -51,11 +52,19 @@ class PostMasonryGrid extends StatelessWidget { final bool loading; final ScrollController? controller; final String gridMode; + final EdgeInsetsGeometry? padding; @override Widget build(BuildContext context) { final mobile = Responsive.isMobile(context); - final pad = EdgeInsets.all(mobile ? 8 : 16); + final bottomInset = MediaQuery.paddingOf(context).bottom; + final pad = padding ?? + EdgeInsets.fromLTRB( + mobile ? 8 : 16, + mobile ? 8 : 16, + mobile ? 8 : 16, + (mobile ? 8 : 16) + (mobile ? (120.0 + bottomInset) : 0.0), + ); final spacing = mobile ? 8.0 : 12.0; Widget buildCard(Post post) { diff --git a/pubspec.yaml b/pubspec.yaml index 600608d..23be272 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.0.9+60 +version: 3.1.0+61 environment: sdk: ">=3.3.0 <4.0.0"