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
@@ -0,0 +1,40 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
final postDetailsControllerProvider =
FutureProvider.family<Post?, PostDetailsArgs>((ref, args) async {
if (args.initialPost != null) return args.initialPost;
final cached = await ref
.watch(postRepositoryProvider)
.getCachedPost(args.postId, args.providerId);
if (cached is Success<Post?> && cached.data != null) return cached.data;
final remote = await ref
.watch(providerManagerProvider)
.getPost(args.providerId, args.postId);
return remote is Success<Post?> ? remote.data : null;
});
class PostDetailsArgs {
const PostDetailsArgs({
required this.providerId,
required this.postId,
this.initialPost,
});
final String providerId;
final String postId;
final Post? initialPost;
@override
bool operator ==(Object other) {
return other is PostDetailsArgs &&
other.providerId == providerId &&
other.postId == postId &&
other.initialPost == initialPost;
}
@override
int get hashCode => Object.hash(providerId, postId, initialPost);
}
@@ -0,0 +1,245 @@
import 'package:dio/dio.dart';
import 'package:file_selector/file_selector.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';
import 'package:path/path.dart' as p;
import 'package:url_launcher/url_launcher.dart';
import '../../../app/app.dart';
import '../../../app/responsive.dart';
import '../../../backend/backend.dart';
import '../../../core/utils/result.dart';
import '../../../shared/widgets/adaptive_scaffold.dart';
import '../../../shared/widgets/empty_view.dart';
import '../../../shared/widgets/error_view.dart';
import '../../../shared/widgets/post_card.dart';
import '../../../shared/widgets/rating_badge.dart';
import '../../collections/presentation/collection_form_dialog.dart';
import '../../feed/presentation/feed_controller.dart';
import 'post_details_controller.dart';
import 'widgets/post_action_bar.dart';
import 'widgets/post_media_viewer.dart';
import 'widgets/post_tags_panel.dart';
class PostDetailsScreen extends ConsumerWidget {
const PostDetailsScreen({
required this.providerId,
required this.postId,
this.initialPost,
super.key,
});
final String providerId;
final String postId;
final Post? initialPost;
@override
Widget build(BuildContext context, WidgetRef ref) {
final args = PostDetailsArgs(
providerId: providerId,
postId: postId,
initialPost: initialPost,
);
final post = ref.watch(postDetailsControllerProvider(args));
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final feedPosts =
ref.watch(feedControllerProvider).value?.posts ?? const <Post>[];
return AdaptiveScaffold(
title: 'Post',
actions: [
IconButton(
tooltip: 'Close',
onPressed: () => _close(context),
icon: const Icon(Icons.close_rounded),
),
],
body: post.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (post) {
if (post == null) return const EmptyView(title: 'Post not found');
final currentIndex = feedPosts.indexWhere(
(item) => item.providerId == post.providerId && item.id == post.id,
);
final previous =
currentIndex > 0 ? feedPosts[currentIndex - 1] : null;
final next = currentIndex >= 0 && currentIndex < feedPosts.length - 1
? feedPosts[currentIndex + 1]
: null;
if (Responsive.isMobile(context)) {
return _buildMobileDetails(context, ref, post, settings);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton.filledTonal(
tooltip: 'Previous',
onPressed: previous == null
? null
: () => _openPost(context, previous),
icon: const Icon(Icons.chevron_left_rounded),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 760),
child: PostMediaViewer(post: post),
),
),
),
IconButton.filledTonal(
tooltip: 'Next',
onPressed:
next == null ? null : () => _openPost(context, next),
icon: const Icon(Icons.chevron_right_rounded),
),
],
),
const SizedBox(height: 16),
PostActionBar(
onFavorite: () =>
ref.read(favoriteServiceProvider).addFavorite(post),
onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onCopy: () =>
Clipboard.setData(ClipboardData(text: post.fileUrl)),
onDownload: settings.allowDownloads
? () => _download(context, post)
: null,
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Chip(label: Text(post.providerName)),
RatingBadge(rating: post.rating),
Chip(label: Text('${post.width} x ${post.height}')),
if (post.source != null && post.source!.isNotEmpty)
ActionChip(
label: const Text('Source'),
onPressed: () => launchUrl(Uri.parse(post.source!)),
),
],
),
const SizedBox(height: 16),
Text('Tags', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
PostTagsPanel(post: post),
],
);
},
),
);
}
Widget _buildMobileDetails(
BuildContext context,
WidgetRef ref,
Post post,
AppSettings settings,
) {
return ListView(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.62,
),
child: Center(child: PostMediaViewer(post: post)),
),
const SizedBox(height: 12),
PostActionBar(
onFavorite: () => ref.read(favoriteServiceProvider).addFavorite(post),
onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
onCopy: () => Clipboard.setData(ClipboardData(text: post.fileUrl)),
onDownload:
settings.allowDownloads ? () => _download(context, post) : null,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text(post.providerName)),
RatingBadge(rating: post.rating),
Chip(label: Text('${post.width} x ${post.height}')),
if (post.source != null && post.source!.isNotEmpty)
ActionChip(
label: const Text('Source'),
onPressed: () => launchUrl(Uri.parse(post.source!)),
),
],
),
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
initiallyExpanded: false,
title: Text('Tags', style: Theme.of(context).textTheme.titleMedium),
children: [
Align(
alignment: Alignment.centerLeft,
child: PostTagsPanel(post: post),
),
],
),
],
);
}
Future<void> _addToCollection(
BuildContext context,
WidgetRef ref,
Post post,
) async {
final result = await ref.read(collectionServiceProvider).getCollections();
final collections =
result is Success<List<Collection>> ? result.data : <Collection>[];
if (!context.mounted) return;
await showAddToCollectionPicker(
context,
collections: collections,
onSelected: (collection) {
ref
.read(collectionServiceProvider)
.addPostToCollection(collection.id, post);
},
onCreate: () => showCollectionFormDialog(context, ref),
);
}
Future<void> _download(BuildContext context, Post post) async {
final filename = p.basename(Uri.parse(post.fileUrl).path);
final location = await getSaveLocation(suggestedName: filename);
if (location == null) return;
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Downloading...')),
);
await Dio().download(post.fileUrl, location.path);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Download complete')),
);
}
void _openPost(BuildContext context, Post post) {
context.go('/post/${post.providerId}/${post.id}', extra: post);
}
void _close(BuildContext context) {
if (Navigator.of(context).canPop()) {
context.pop();
return;
}
context.go('/');
}
}
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
class PostActionBar extends StatelessWidget {
const PostActionBar({
required this.onFavorite,
required this.onCollection,
required this.onOpen,
required this.onCopy,
this.onDownload,
super.key,
});
final VoidCallback onFavorite;
final VoidCallback onCollection;
final VoidCallback onOpen;
final VoidCallback onCopy;
final VoidCallback? onDownload;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: onFavorite,
icon: const Icon(Icons.favorite_rounded),
label: const Text('Favorite'),
),
FilledButton.tonalIcon(
onPressed: onCollection,
icon: const Icon(Icons.add_rounded),
label: const Text('Collection'),
),
IconButton.filledTonal(
tooltip: 'Open original',
onPressed: onOpen,
icon: const Icon(Icons.open_in_new_rounded),
),
IconButton.filledTonal(
tooltip: 'Copy link',
onPressed: onCopy,
icon: const Icon(Icons.copy_rounded),
),
if (onDownload != null)
IconButton.filledTonal(
tooltip: 'Download',
onPressed: onDownload,
icon: const Icon(Icons.download_rounded),
),
],
);
}
}
@@ -0,0 +1,323 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../../../../backend/backend.dart';
class PostMediaViewer extends StatefulWidget {
const PostMediaViewer({required this.post, super.key});
final Post post;
@override
State<PostMediaViewer> createState() => _PostMediaViewerState();
}
class _PostMediaViewerState extends State<PostMediaViewer> {
Player? _player;
VideoController? _controller;
late List<String> _imageUrls;
int _imageIndex = 0;
bool _controlsVisible = true;
bool _coverVideo = false;
bool _muted = false;
Timer? _hideTimer;
@override
void initState() {
super.initState();
_imageUrls = _buildImageUrls(widget.post);
if (widget.post.fileType == 'video') {
MediaKit.ensureInitialized();
_player = Player();
_controller = VideoController(_player!);
_player!.open(Media(widget.post.fileUrl), play: false);
_scheduleControlsHide();
}
}
@override
void dispose() {
_hideTimer?.cancel();
_player?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_controller != null) {
return _VideoSurface(
player: _player!,
controller: _controller!,
aspectRatio: widget.post.width > 0 && widget.post.height > 0
? widget.post.width / widget.post.height
: 16 / 9,
controlsVisible: _controlsVisible,
coverVideo: _coverVideo,
muted: _muted,
onInteract: _showControls,
onToggleFit: () {
setState(() => _coverVideo = !_coverVideo);
_showControls();
},
onToggleMute: () async {
final nextMuted = !_muted;
setState(() => _muted = nextMuted);
await _player!.setVolume(nextMuted ? 0 : 100);
_showControls();
},
);
}
final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex];
return InteractiveViewer(
child: CachedNetworkImage(
key: ValueKey(url),
imageUrl: url,
httpHeaders: _headersFor(widget.post),
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
errorWidget: (context, url, error) {
if (_imageIndex < _imageUrls.length - 1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _imageIndex++);
});
return const Center(child: CircularProgressIndicator());
}
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.broken_image_rounded, size: 48),
SizedBox(height: 8),
Text('Could not load image'),
],
),
);
},
),
);
}
List<String> _buildImageUrls(Post post) {
return {
post.sampleUrl,
post.fileUrl,
post.previewUrl,
}.where((url) => url.trim().isNotEmpty).toList();
}
Map<String, String> _headersFor(Post post) {
return {
'User-Agent': 'GelRuleApp/0.1 Flutter local booru browser',
if (post.providerName.toLowerCase().contains('gelbooru') ||
post.fileUrl.contains('gelbooru.com') ||
post.sampleUrl.contains('gelbooru.com') ||
post.previewUrl.contains('gelbooru.com'))
'Referer': 'https://gelbooru.com/',
};
}
void _showControls() {
if (!mounted) return;
setState(() => _controlsVisible = true);
_scheduleControlsHide();
}
void _scheduleControlsHide() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(seconds: 3), () {
if (mounted) setState(() => _controlsVisible = false);
});
}
}
class _VideoSurface extends StatelessWidget {
const _VideoSurface({
required this.player,
required this.controller,
required this.aspectRatio,
required this.controlsVisible,
required this.coverVideo,
required this.muted,
required this.onInteract,
required this.onToggleFit,
required this.onToggleMute,
});
final Player player;
final VideoController controller;
final double aspectRatio;
final bool controlsVisible;
final bool coverVideo;
final bool muted;
final VoidCallback onInteract;
final VoidCallback onToggleFit;
final VoidCallback onToggleMute;
@override
Widget build(BuildContext context) {
return MouseRegion(
onHover: (_) => onInteract(),
child: GestureDetector(
onTap: onInteract,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: AspectRatio(
aspectRatio: aspectRatio.clamp(0.35, 2.4),
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: Colors.black,
child: Video(
controller: controller,
fit: coverVideo ? BoxFit.cover : BoxFit.contain,
controls: null,
),
),
AnimatedOpacity(
opacity: controlsVisible ? 1 : 0,
duration: const Duration(milliseconds: 180),
child: _VideoControls(
player: player,
muted: muted,
coverVideo: coverVideo,
onToggleFit: onToggleFit,
onToggleMute: onToggleMute,
),
),
],
),
),
),
),
);
}
}
class _VideoControls extends StatelessWidget {
const _VideoControls({
required this.player,
required this.muted,
required this.coverVideo,
required this.onToggleFit,
required this.onToggleMute,
});
final Player player;
final bool muted;
final bool coverVideo;
final VoidCallback onToggleFit;
final VoidCallback onToggleMute;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.7),
Colors.black.withValues(alpha: 0.18),
Colors.transparent,
],
),
),
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.all(12),
child: StreamBuilder<bool>(
stream: player.stream.playing,
initialData: player.state.playing,
builder: (context, playingSnapshot) {
final playing = playingSnapshot.data ?? false;
return StreamBuilder<Duration>(
stream: player.stream.duration,
initialData: player.state.duration,
builder: (context, durationSnapshot) {
final duration = durationSnapshot.data ?? Duration.zero;
return StreamBuilder<Duration>(
stream: player.stream.position,
initialData: player.state.position,
builder: (context, positionSnapshot) {
final position = positionSnapshot.data ?? Duration.zero;
final maxMs =
duration.inMilliseconds.clamp(1, 1 << 31).toDouble();
final valueMs = position.inMilliseconds
.clamp(0, maxMs.toInt())
.toDouble();
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Slider(
value: valueMs,
max: maxMs,
onChanged: (value) => player.seek(
Duration(milliseconds: value.round()),
),
),
Row(
children: [
IconButton.filledTonal(
tooltip: playing ? 'Pause' : 'Play',
onPressed: player.playOrPause,
icon: Icon(
playing
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
),
const SizedBox(width: 8),
Text(
'${_format(position)} / ${_format(duration)}',
style: const TextStyle(color: Colors.white),
),
const Spacer(),
IconButton.filledTonal(
tooltip: muted ? 'Unmute' : 'Mute',
onPressed: onToggleMute,
icon: Icon(
muted
? Icons.volume_off_rounded
: Icons.volume_up_rounded,
),
),
const SizedBox(width: 6),
IconButton.filledTonal(
tooltip: coverVideo ? 'Fit' : 'Fill',
onPressed: onToggleFit,
icon: Icon(
coverVideo
? Icons.fit_screen_rounded
: Icons.crop_free_rounded,
),
),
],
),
],
);
},
);
},
);
},
),
),
),
);
}
String _format(Duration duration) {
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
final hours = duration.inHours;
if (hours > 0) return '$hours:$minutes:$seconds';
return '$minutes:$seconds';
}
}
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../backend/backend.dart';
import '../../../../shared/widgets/tag_chip.dart';
class PostTagsPanel extends StatelessWidget {
const PostTagsPanel({required this.post, super.key});
final Post post;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final tag in post.tags.take(80))
TagChip(
tag: tag,
onTap: () => context.go('/?q=${Uri.encodeQueryComponent(tag)}'),
),
],
);
}
}