Initial Flutter booru app
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user