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