Initial Flutter booru app

This commit is contained in:
2026-05-23 11:46:52 +03:00
commit 63da741295
187 changed files with 24113 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import '../../app/responsive.dart';
class AdaptiveScaffold extends StatelessWidget {
const AdaptiveScaffold({
required this.title,
required this.body,
this.actions = const [],
this.floatingActionButton,
super.key,
});
final String title;
final Widget body;
final List<Widget> actions;
final Widget? floatingActionButton;
@override
Widget build(BuildContext context) {
if (Responsive.isMobile(context)) {
return Scaffold(
appBar: AppBar(
title: Text(title),
actions: actions,
toolbarHeight: 52,
titleTextStyle: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
body: SafeArea(top: false, bottom: false, child: body),
floatingActionButton: floatingActionButton,
);
}
return Scaffold(
appBar: AppBar(title: Text(title), actions: actions),
body: body,
floatingActionButton: floatingActionButton,
);
}
}
+76
View File
@@ -0,0 +1,76 @@
import 'dart:async';
import 'package:flutter/material.dart';
class AppSearchBar extends StatefulWidget {
const AppSearchBar({
required this.onSubmitted,
this.onChanged,
this.initialValue,
this.hintText = 'Search tags',
super.key,
});
final ValueChanged<String> onSubmitted;
final ValueChanged<String>? onChanged;
final String? initialValue;
final String hintText;
@override
State<AppSearchBar> createState() => _AppSearchBarState();
}
class _AppSearchBarState extends State<AppSearchBar> {
late final TextEditingController _controller;
Timer? _debounce;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
}
@override
void didUpdateWidget(covariant AppSearchBar oldWidget) {
super.didUpdateWidget(oldWidget);
final next = widget.initialValue ?? '';
if (oldWidget.initialValue != widget.initialValue &&
_controller.text != next) {
_controller.text = next;
}
}
@override
void dispose() {
_debounce?.cancel();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
textInputAction: TextInputAction.search,
onSubmitted: widget.onSubmitted,
onChanged: (value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 250), () {
widget.onChanged?.call(value);
});
},
decoration: InputDecoration(
hintText: widget.hintText,
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: IconButton(
tooltip: 'Clear',
icon: const Icon(Icons.close_rounded),
onPressed: () {
_controller.clear();
widget.onChanged?.call('');
},
),
),
);
}
}
+248
View File
@@ -0,0 +1,248 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import '../../app/responsive.dart';
class AppShell extends StatelessWidget {
const AppShell({required this.child, super.key});
final Widget child;
static const destinations = [
_Destination('Feed', Icons.dashboard_rounded, '/'),
_Destination('Search', Icons.search_rounded, '/search'),
_Destination('Favorites', Icons.favorite_rounded, '/favorites'),
_Destination(
'Collections', Icons.collections_bookmark_rounded, '/collections'),
_Destination('Providers', Icons.hub_rounded, '/providers'),
_Destination('Settings', Icons.settings_rounded, '/settings'),
];
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF):
const _NavigateIntent('/search'),
LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(),
},
child: Actions(
actions: {
_NavigateIntent: CallbackAction<_NavigateIntent>(
onInvoke: (intent) {
context.go(intent.location);
return null;
},
),
_BackIntent: CallbackAction<_BackIntent>(
onInvoke: (_) {
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
return null;
},
),
},
child: Focus(
autofocus: true,
child: Responsive.isDesktop(context)
? _DesktopShell(child: child)
: _MobileShell(child: child),
),
),
);
}
}
class _DesktopShell extends StatefulWidget {
const _DesktopShell({required this.child});
final Widget child;
@override
State<_DesktopShell> createState() => _DesktopShellState();
}
class _DesktopShellState extends State<_DesktopShell> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.path;
final selected = AppShell.destinations.indexWhere(
(item) => item.location == '/'
? location == '/'
: location.startsWith(item.location),
);
return Scaffold(
body: Row(
children: [
MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
width: _hovered ? 256 : 72,
color: Theme.of(context).navigationRailTheme.backgroundColor,
child: SafeArea(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: Column(
children: [
for (var index = 0;
index < AppShell.destinations.length;
index++)
_RailButton(
destination: AppShell.destinations[index],
selected: (selected < 0 ? 0 : selected) == index,
expanded: _hovered,
onTap: () {
context.go(AppShell.destinations[index].location);
},
),
const Spacer(),
],
),
),
),
),
),
const VerticalDivider(width: 1),
Expanded(child: widget.child),
],
),
);
}
}
class _RailButton extends StatelessWidget {
const _RailButton({
required this.destination,
required this.selected,
required this.expanded,
required this.onTap,
});
final _Destination destination;
final bool selected;
final bool expanded;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Tooltip(
message: expanded ? '' : destination.label,
waitDuration: const Duration(milliseconds: 450),
child: Material(
color: selected ? colors.primaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(28),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 48,
child: Row(
children: [
SizedBox(
width: 56,
child: Icon(
destination.icon,
color: selected
? colors.onPrimaryContainer
: colors.onSurfaceVariant,
),
),
if (expanded)
Expanded(
child: ClipRect(
child: AnimatedOpacity(
opacity: 1,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.clip,
softWrap: false,
style: TextStyle(
fontWeight: selected
? FontWeight.w700
: FontWeight.w600,
color: selected
? colors.onPrimaryContainer
: colors.onSurface,
),
),
),
),
),
)
else
const SizedBox.shrink(),
if (expanded) const SizedBox(width: 12),
],
),
),
),
),
),
);
}
}
class _MobileShell extends StatelessWidget {
const _MobileShell({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final items = AppShell.destinations.take(5).toList();
final location = GoRouterState.of(context).uri.path;
final selected = items.indexWhere(
(item) => item.location == '/'
? location == '/'
: location.startsWith(item.location),
);
return Scaffold(
body: child,
floatingActionButton: location == '/settings'
? null
: FloatingActionButton.small(
heroTag: 'mobile-settings',
tooltip: 'Settings',
onPressed: () => context.go('/settings'),
child: const Icon(Icons.settings_rounded),
),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
bottomNavigationBar: NavigationBar(
selectedIndex: selected < 0 ? 0 : selected,
onDestinationSelected: (index) => context.go(items[index].location),
destinations: [
for (final item in items)
NavigationDestination(icon: Icon(item.icon), label: item.label),
],
),
);
}
}
class _Destination {
const _Destination(this.label, this.icon, this.location);
final String label;
final IconData icon;
final String location;
}
class _NavigateIntent extends Intent {
const _NavigateIntent(this.location);
final String location;
}
class _BackIntent extends Intent {
const _BackIntent();
}
+34
View File
@@ -0,0 +1,34 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class BlurContent extends StatelessWidget {
const BlurContent({
required this.enabled,
required this.child,
super.key,
});
final bool enabled;
final Widget child;
@override
Widget build(BuildContext context) {
if (!enabled) return child;
return Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
child: child,
),
ColoredBox(
color: Colors.black.withValues(alpha: 0.12),
child: const Center(
child: Icon(Icons.visibility_off_rounded, color: Colors.white),
),
),
],
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
Future<bool> showConfirmDialog(
BuildContext context, {
required String title,
required String message,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Confirm'),
),
],
),
);
return result ?? false;
}
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
class EmptyView extends StatelessWidget {
const EmptyView({required this.title, this.message, super.key});
final String title;
final String? message;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.inbox_rounded, size: 42),
const SizedBox(height: 12),
Text(title, style: Theme.of(context).textTheme.titleMedium),
if (message != null) ...[
const SizedBox(height: 8),
Text(message!, textAlign: TextAlign.center),
],
],
),
),
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
class ErrorView extends StatelessWidget {
const ErrorView({required this.message, this.onRetry, super.key});
final String message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline_rounded, size: 42),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
if (onRetry != null) ...[
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
],
),
),
);
}
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
class LoadingSkeleton extends StatelessWidget {
const LoadingSkeleton({this.height = 220, super.key});
final double height;
@override
Widget build(BuildContext context) {
final base = Theme.of(context).colorScheme.surfaceContainerHighest;
final highlight = Theme.of(context).colorScheme.surface;
return Shimmer.fromColors(
baseColor: base,
highlightColor: highlight,
child: Container(
height: height,
decoration: BoxDecoration(
color: base,
borderRadius: BorderRadius.circular(8),
),
),
);
}
}
+273
View File
@@ -0,0 +1,273 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../../backend/backend.dart';
import 'blur_content.dart';
import 'loading_skeleton.dart';
import 'rating_badge.dart';
class PostCard extends StatefulWidget {
const PostCard({
required this.post,
required this.blurExplicit,
required this.showBadges,
required this.onOpen,
required this.onFavorite,
this.onAddToCollection,
super.key,
});
final Post post;
final bool blurExplicit;
final bool showBadges;
final VoidCallback onOpen;
final VoidCallback onFavorite;
final VoidCallback? onAddToCollection;
@override
State<PostCard> createState() => _PostCardState();
}
class _PostCardState extends State<PostCard> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final post = widget.post;
final sensitive = _isSensitive(post.rating);
final aspect =
post.width > 0 && post.height > 0 ? post.width / post.height : 0.72;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onOpen,
onSecondaryTapDown: (details) {
showMenu<void>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
details.globalPosition.dy,
),
items: [
PopupMenuItem(
onTap: widget.onOpen,
child: const Text('Open'),
),
PopupMenuItem(
onTap: widget.onFavorite,
child: const Text('Favorite'),
),
if (widget.onAddToCollection != null)
PopupMenuItem(
onTap: widget.onAddToCollection,
child: const Text('Add to collection'),
),
],
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
AspectRatio(
aspectRatio: aspect.clamp(0.45, 1.6),
child: BlurContent(
enabled: widget.blurExplicit && sensitive,
child: CachedNetworkImage(
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
fit: BoxFit.cover,
placeholder: (context, url) => const LoadingSkeleton(),
errorWidget: (context, url, error) => ColoredBox(
color:
Theme.of(context).colorScheme.surfaceContainerHighest,
child:
const Center(child: Icon(Icons.broken_image_rounded)),
),
),
),
),
if (widget.showBadges) ...[
Positioned(
left: 8,
top: 8,
child: _ProviderBadge(name: post.providerName),
),
Positioned(
right: 8,
top: 8,
child: RatingBadge(rating: post.rating),
),
Positioned(
left: 8,
bottom: 8,
child: _MediaBadge(fileType: post.fileType),
),
],
Positioned.fill(
child: IgnorePointer(
ignoring: !_hovered,
child: AnimatedOpacity(
opacity: _hovered ? 1 : 0,
duration: const Duration(milliseconds: 140),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.55),
Colors.transparent,
],
),
),
child: Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: 'Favorite',
onPressed: widget.onFavorite,
icon: const Icon(Icons.favorite_rounded),
),
const SizedBox(width: 6),
IconButton.filledTonal(
tooltip: 'Add to collection',
onPressed: widget.onAddToCollection,
icon: const Icon(Icons.add_rounded),
),
],
),
),
),
),
),
),
),
],
),
),
),
);
}
bool _isSensitive(String rating) {
final normalized = rating.toLowerCase();
return normalized.startsWith('e') ||
normalized.startsWith('q') ||
normalized.contains('explicit') ||
normalized.contains('questionable');
}
}
class _ProviderBadge extends StatelessWidget {
const _ProviderBadge({required this.name});
final String name;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
child: Text(
name,
style: const TextStyle(fontSize: 11, color: Colors.white),
),
),
);
}
}
class _MediaBadge extends StatelessWidget {
const _MediaBadge({required this.fileType});
final String fileType;
@override
Widget build(BuildContext context) {
final type = _type(fileType);
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_icon(type), size: 12, color: Colors.white),
const SizedBox(width: 4),
Text(
type,
style: const TextStyle(fontSize: 11, color: Colors.white),
),
],
),
),
);
}
String _type(String raw) {
final value = raw.toLowerCase();
if (value.contains('video') ||
value.contains('webm') ||
value.contains('mp4')) {
return 'video';
}
if (value.contains('gif')) return 'gif';
return 'photo';
}
IconData _icon(String type) => switch (type) {
'video' => Icons.play_arrow_rounded,
'gif' => Icons.gif_box_rounded,
_ => Icons.image_rounded,
};
}
Future<void> showAddToCollectionPicker(
BuildContext context, {
required List<Collection> collections,
required void Function(Collection collection) onSelected,
required VoidCallback onCreate,
}) {
return showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => ListView(
padding: const EdgeInsets.all(16),
children: [
ListTile(
leading: const Icon(Icons.add_rounded),
title: const Text('Create collection'),
onTap: () {
Navigator.pop(context);
onCreate();
},
),
for (final collection in collections)
ListTile(
title: Text(collection.name),
subtitle: Text(collection.description ?? ''),
onTap: () {
Navigator.pop(context);
onSelected(collection);
},
),
],
),
);
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import '../../app/responsive.dart';
import '../../backend/backend.dart';
import 'loading_skeleton.dart';
import 'post_card.dart';
class PostMasonryGrid extends StatelessWidget {
const PostMasonryGrid({
required this.posts,
required this.columns,
required this.blurExplicit,
required this.showBadges,
required this.nsfwEnabled,
required this.onOpen,
required this.onFavorite,
this.onAddToCollection,
this.loading = false,
this.controller,
super.key,
});
final List<Post> posts;
final int columns;
final bool blurExplicit;
final bool showBadges;
final bool nsfwEnabled;
final ValueChanged<Post> onOpen;
final ValueChanged<Post> onFavorite;
final ValueChanged<Post>? onAddToCollection;
final bool loading;
final ScrollController? controller;
@override
Widget build(BuildContext context) {
final mobile = Responsive.isMobile(context);
return MasonryGridView.count(
controller: controller,
padding: EdgeInsets.all(mobile ? 8 : 16),
crossAxisCount: columns,
mainAxisSpacing: mobile ? 8 : 12,
crossAxisSpacing: mobile ? 8 : 12,
itemCount: posts.length + (loading ? columns : 0),
itemBuilder: (context, index) {
if (index >= posts.length) return const LoadingSkeleton();
final post = posts[index];
return PostCard(
post: post,
blurExplicit: blurExplicit && !nsfwEnabled,
showBadges: showBadges,
onOpen: () => onOpen(post),
onFavorite: () => onFavorite(post),
onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post),
);
},
);
}
}
@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import '../../backend/backend.dart';
class ProviderStatusBadge extends StatelessWidget {
const ProviderStatusBadge({required this.health, super.key});
final ProviderHealth? health;
@override
Widget build(BuildContext context) {
final status = health?.status ?? ProviderStatus.unknown;
final color = switch (status) {
ProviderStatus.online => Colors.green,
ProviderStatus.offline => Colors.red,
ProviderStatus.unknown => Colors.grey,
};
return Chip(
avatar: Icon(Icons.circle, color: color, size: 10),
label: Text(status.name),
visualDensity: VisualDensity.compact,
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
class RatingBadge extends StatelessWidget {
const RatingBadge({required this.rating, super.key});
final String rating;
@override
Widget build(BuildContext context) {
final normalized = rating.toLowerCase();
final color = switch (normalized) {
'safe' || 's' => Colors.green,
'questionable' || 'q' => Colors.orange,
'explicit' || 'e' => Colors.red,
_ => Colors.blueGrey,
};
return DecoratedBox(
decoration: BoxDecoration(
color: color.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
child: Text(
rating.isEmpty ? 'unknown' : rating,
style: const TextStyle(fontSize: 11, color: Colors.white),
),
),
);
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
class TagChip extends StatelessWidget {
const TagChip({required this.tag, this.onTap, super.key});
final String tag;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return ActionChip(
onPressed: onTap,
label: Text(tag),
visualDensity: VisualDensity.compact,
);
}
}