Add similar posts, provider hints and app icon

This commit is contained in:
2026-05-24 16:53:22 +03:00
parent 73a991e3a8
commit 52f2c6669c
17 changed files with 769 additions and 295 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

+2 -2
View File
@@ -1,2 +1,2 @@
const appDisplayVersion = '0.4.0';
const appBuildNumber = 4;
const appDisplayVersion = '0.4.2';
const appBuildNumber = 6;
+9
View File
@@ -7,6 +7,7 @@ import '../features/collections/presentation/collections_screen.dart';
import '../features/favorites/presentation/favorites_screen.dart';
import '../features/feed/presentation/feed_screen.dart';
import '../features/post/presentation/post_details_screen.dart';
import '../features/post/presentation/similar_posts_screen.dart';
import '../features/providers/presentation/provider_check_screen.dart';
import '../features/providers/presentation/provider_form_screen.dart';
import '../features/providers/presentation/providers_screen.dart';
@@ -40,6 +41,14 @@ final appRouterProvider = Provider<GoRouter>((ref) {
initialPost: state.extra is Post ? state.extra! as Post : null,
),
),
GoRoute(
path: '/post/:providerId/:postId/similar',
builder: (context, state) => SimilarPostsScreen(
providerId: state.pathParameters['providerId']!,
postId: state.pathParameters['postId']!,
initialPost: state.extra is Post ? state.extra! as Post : null,
),
),
GoRoute(
path: '/favorites',
builder: (context, state) => const FavoritesScreen(),
+38 -4
View File
@@ -97,8 +97,8 @@ class ProviderManager {
providers.where((provider) => provider.id == providerId).toList();
}
final posts = <Post>[];
final seen = <String>{};
final providerResults = <List<Post>>[];
for (final provider in providers) {
try {
final providerPosts = await provider.searchPosts(
@@ -108,9 +108,7 @@ class ProviderManager {
rating: rating,
topPeriod: topPeriod,
);
for (final post in providerPosts) {
if (seen.add(post.cacheKey)) posts.add(post);
}
providerResults.add(providerPosts);
await _repository.saveDiagnostics(
ProviderDiagnostics(
providerId: provider.id,
@@ -138,9 +136,45 @@ class ProviderManager {
);
}
}
final posts = providerId == null
? _interleaveProviderResults(providerResults, seen)
: _flattenProviderResults(providerResults, seen);
return Success(posts);
}
List<Post> _flattenProviderResults(
List<List<Post>> results, Set<String> seen) {
final posts = <Post>[];
for (final providerPosts in results) {
for (final post in providerPosts) {
if (seen.add(post.cacheKey)) posts.add(post);
}
}
return posts;
}
List<Post> _interleaveProviderResults(
List<List<Post>> results,
Set<String> seen,
) {
final posts = <Post>[];
var index = 0;
while (true) {
var added = false;
for (final providerPosts in results) {
if (index >= providerPosts.length) continue;
final post = providerPosts[index];
if (seen.add(post.cacheKey)) {
posts.add(post);
added = true;
}
}
if (!added && results.every((items) => index >= items.length)) break;
index++;
}
return posts;
}
Future<Result<List<PostComment>>> getComments(
String providerId,
String postId,
@@ -177,7 +177,7 @@ class FeedController extends AsyncNotifier<FeedState> {
? <String?>[null]
: current.selectedProviderIds;
final posts = <Post>[];
final seen = <String>{};
final providerResults = <List<Post>>[];
for (final providerId in providerIds) {
final result = refresh
? await service.refresh(
@@ -193,10 +193,33 @@ class FeedController extends AsyncNotifier<FeedState> {
topPeriod: current.topPeriodFilter,
);
if (result is Success<List<Post>>) {
for (final post in result.data) {
providerResults.add(result.data);
}
}
final seen = <String>{};
if (providerResults.length <= 1) {
for (final group in providerResults) {
for (final post in group) {
if (seen.add(post.cacheKey)) posts.add(post);
}
}
return posts;
}
var index = 0;
while (true) {
var added = false;
for (final group in providerResults) {
if (index >= group.length) continue;
final post = group[index];
if (seen.add(post.cacheKey)) {
posts.add(post);
added = true;
}
}
if (!added && providerResults.every((group) => index >= group.length)) {
break;
}
index++;
}
return posts;
}
@@ -184,6 +184,7 @@ class PostDetailsScreen extends ConsumerWidget {
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 760),
child: PostMediaViewer(
key: ValueKey(post.cacheKey),
post: post,
qualityMode: qualityMode,
),
@@ -261,20 +262,36 @@ class PostDetailsScreen extends ConsumerWidget {
Set<String> favoriteKeys,
MediaQualityMode qualityMode,
) {
return ListView(
final isVideo = MediaUrlSelector.isVideo(post);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragEnd: (details) {
if ((details.primaryVelocity ?? 0) > 900) _close(context);
},
child: ListView(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
children: [
ConstrainedBox(
GestureDetector(
behavior: HitTestBehavior.deferToChild,
onDoubleTap:
isVideo ? null : () => _toggleFavorite(ref, post, favoriteKeys),
onLongPress: isVideo
? null
: () =>
_showMobileQuickActions(context, ref, post, favoriteKeys),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.62,
),
child: Center(
child: PostMediaViewer(
key: ValueKey(post.cacheKey),
post: post,
qualityMode: qualityMode,
),
),
),
),
const SizedBox(height: 12),
PostActionBar(
isFavorite: favoriteKeys.contains(post.cacheKey),
@@ -316,6 +333,7 @@ class PostDetailsScreen extends ConsumerWidget {
),
_CommentsSection(post: post),
],
),
);
}
@@ -349,6 +367,65 @@ class PostDetailsScreen extends ConsumerWidget {
);
}
Future<void> _showMobileQuickActions(
BuildContext context,
WidgetRef ref,
Post post,
Set<String> favoriteKeys,
) async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
favoriteKeys.contains(post.cacheKey)
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
),
title: Text(
favoriteKeys.contains(post.cacheKey)
? 'Remove favorite'
: 'Favorite',
),
onTap: () async {
Navigator.pop(sheetContext);
await _toggleFavorite(ref, post, favoriteKeys);
},
),
ListTile(
leading: const Icon(Icons.collections_bookmark_rounded),
title: const Text('Add to collection'),
onTap: () {
Navigator.pop(sheetContext);
_addToCollection(context, ref, post);
},
),
ListTile(
leading: const Icon(Icons.copy_rounded),
title: const Text('Copy link'),
onTap: () {
Clipboard.setData(ClipboardData(text: post.fileUrl));
Navigator.pop(sheetContext);
},
),
ListTile(
leading: const Icon(Icons.download_rounded),
title: const Text('Download'),
onTap: () {
Navigator.pop(sheetContext);
_download(context, ref, post);
},
),
],
),
),
);
}
Future<void> _toggleFavorite(
WidgetRef ref,
Post post,
@@ -370,10 +447,7 @@ class PostDetailsScreen extends ConsumerWidget {
}
void _openSimilar(BuildContext context, WidgetRef ref, Post post) {
final query = similarTagsFor(post).join(' ');
if (query.trim().isEmpty) return;
ref.read(feedControllerProvider.notifier).search(query);
context.go('/?q=${Uri.encodeQueryComponent(query)}');
context.push('/post/${post.providerId}/${post.id}/similar', extra: post);
}
void _replacePost(BuildContext context, Post post) {
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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_masonry_grid.dart';
import '../../favorites/presentation/favorites_controller.dart';
import '../../viewed/presentation/viewed_controller.dart';
import 'post_details_controller.dart';
final similarPostsProvider =
FutureProvider.family<List<Post>, PostDetailsArgs>((ref, args) async {
final source = args.initialPost ??
await ref.watch(postDetailsControllerProvider(args).future);
if (source == null) return const [];
final tags = similarTagsFor(source);
if (tags.isEmpty) return const [];
final result = await ref.watch(feedServiceProvider).refresh(
tags: tags,
limit: 60,
);
if (result is! Success<List<Post>>) return const [];
return result.data
.where((post) => post.cacheKey != source.cacheKey)
.toList(growable: false);
});
class SimilarPostsScreen extends ConsumerWidget {
const SimilarPostsScreen({
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 source = ref.watch(postDetailsControllerProvider(args));
final similar = ref.watch(similarPostsProvider(args));
final settings =
ref.watch(appSettingsProvider).value ?? AppSettings.defaults;
final favorites = ref.watch(favoriteKeysProvider).value ?? <String>{};
final viewed = ref.watch(viewedKeysProvider).value ?? <String>{};
final columns = Responsive.isMobile(context)
? settings.mobileColumns
: settings.desktopColumns;
return AdaptiveScaffold(
title: 'Similar',
body: source.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (post) {
final tags = post == null ? const <String>[] : similarTagsFor(post);
return similar.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
data: (posts) {
if (tags.isEmpty || posts.isEmpty) {
return const EmptyView(title: 'No similar posts yet');
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final tag in tags.take(8)) Chip(label: Text(tag)),
],
),
),
Expanded(
child: PostMasonryGrid(
posts: posts,
columns: columns,
blurExplicit: settings.blurExplicitContent,
showBadges: settings.showPostBadges,
nsfwEnabled: settings.nsfwEnabled,
favoriteKeys: favorites,
viewedKeys: viewed,
onOpen: (post) => context.push(
'/post/${post.providerId}/${post.id}',
extra: post,
),
onFavorite: (post) =>
_toggleFavorite(ref, post, favorites),
),
),
],
);
},
);
},
),
);
}
Future<void> _toggleFavorite(
WidgetRef ref,
Post post,
Set<String> favoriteKeys,
) async {
if (favoriteKeys.contains(post.cacheKey)) {
await ref
.read(favoriteServiceProvider)
.removeFavorite(post.id, post.providerId);
} else {
await ref.read(favoriteServiceProvider).addFavorite(post);
}
ref.invalidate(favoriteKeysProvider);
ref.invalidate(viewedControllerProvider);
}
}
@@ -44,7 +44,8 @@ class PostMediaViewer extends StatefulWidget {
State<PostMediaViewer> createState() => _PostMediaViewerState();
}
class _PostMediaViewerState extends State<PostMediaViewer> {
class _PostMediaViewerState extends State<PostMediaViewer>
with AutomaticKeepAliveClientMixin {
Player? _player;
VideoController? _controller;
late List<String> _imageUrls;
@@ -52,6 +53,7 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
int _imageIndex = 0;
int _videoIndex = 0;
bool _controlsVisible = true;
bool _inFullscreen = false;
late bool _coverVideo;
late bool _muted;
late bool _loopVideo;
@@ -98,8 +100,12 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
super.dispose();
}
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
if (_isSwf(widget.post)) {
return _SwfMediaViewer(
post: widget.post,
@@ -109,6 +115,19 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
}
if (_controller != null) {
if (_inFullscreen) {
return ColoredBox(
color: Colors.black,
child: AspectRatio(
aspectRatio: widget.post.width > 0 && widget.post.height > 0
? (widget.post.width / widget.post.height).clamp(0.35, 2.4)
: 16 / 9,
child: const Center(
child: Icon(Icons.fullscreen_rounded, color: Colors.white38),
),
),
);
}
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
@@ -347,22 +366,26 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
Future<void> _openFullscreen(BuildContext context) async {
final player = _player;
final position = player?.state.position ?? Duration.zero;
final wasPlaying = player?.state.playing ?? false;
if (wasPlaying) await player?.pause();
final controller = _controller;
if (player == null || controller == null) return;
setState(() => _inFullscreen = true);
if (!context.mounted) return;
final result = await Navigator.of(context, rootNavigator: true)
.push<VideoPlaybackSnapshot>(
PageRouteBuilder(
opaque: true,
pageBuilder: (_, __, ___) => _FullscreenVideoPage(
post: widget.post,
startAt: position,
autoplay: wasPlaying,
player: player,
controller: controller,
aspectRatio: widget.post.width > 0 && widget.post.height > 0
? widget.post.width / widget.post.height
: 16 / 9,
loopVideo: _loopVideo,
muted: _muted,
halfVolume: _halfVolume,
coverVideo: _coverVideo,
errorMessage: _videoError,
onRetry: _retryVideo,
),
transitionsBuilder: (_, animation, __, child) => FadeTransition(
opacity: animation,
@@ -370,7 +393,8 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
),
),
);
if (result != null && player != null) {
if (mounted) setState(() => _inFullscreen = false);
if (result != null) {
_playbackMemory[widget.post.cacheKey] = result;
setState(() {
_muted = result.muted;
@@ -382,11 +406,8 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
await player.setPlaylistMode(
_loopVideo ? PlaylistMode.single : PlaylistMode.none,
);
await player.seek(result.position);
if (result.playing) await player.play();
return;
}
if (wasPlaying) await player?.play();
}
VideoPlaybackSnapshot _snapshot() {
@@ -712,51 +733,58 @@ class VideoPlaybackSnapshot {
class _FullscreenVideoPage extends StatefulWidget {
const _FullscreenVideoPage({
required this.post,
required this.startAt,
required this.autoplay,
required this.player,
required this.controller,
required this.aspectRatio,
required this.loopVideo,
required this.muted,
required this.halfVolume,
required this.coverVideo,
required this.errorMessage,
required this.onRetry,
});
final Post post;
final Duration startAt;
final bool autoplay;
final Player player;
final VideoController controller;
final double aspectRatio;
final bool loopVideo;
final bool muted;
final bool halfVolume;
final bool coverVideo;
final String? errorMessage;
final VoidCallback onRetry;
@override
State<_FullscreenVideoPage> createState() => _FullscreenVideoPageState();
}
class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
late VideoPlaybackSnapshot _snapshot;
bool _controlsVisible = true;
late bool _coverVideo;
late bool _muted;
late bool _loopVideo;
late bool _halfVolume;
Timer? _hideTimer;
@override
void initState() {
super.initState();
_snapshot = VideoPlaybackSnapshot(
position: widget.startAt,
playing: widget.autoplay,
muted: widget.muted,
halfVolume: widget.halfVolume,
loopVideo: widget.loopVideo,
coverVideo: widget.coverVideo,
);
_coverVideo = widget.coverVideo;
_muted = widget.muted;
_loopVideo = widget.loopVideo;
_halfVolume = widget.halfVolume;
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
]);
_scheduleControlsHide();
}
@override
void dispose() {
_hideTimer?.cancel();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
super.dispose();
@@ -768,24 +796,77 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
Navigator.of(context, rootNavigator: true).pop(_snapshot);
Navigator.of(context, rootNavigator: true).pop(_snapshot());
},
child: Scaffold(
backgroundColor: Colors.black,
body: Stack(
body: Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseVideoIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const _TogglePlayIntent(),
},
child: Actions(
actions: {
_CloseVideoIntent: CallbackAction<_CloseVideoIntent>(
onInvoke: (_) {
Navigator.of(context, rootNavigator: true).pop(_snapshot());
return null;
},
),
_TogglePlayIntent: CallbackAction<_TogglePlayIntent>(
onInvoke: (_) {
widget.player.playOrPause();
_showControls();
return null;
},
),
},
child: Focus(
autofocus: true,
child: Stack(
children: [
Positioned.fill(
child: PostMediaViewer(
post: widget.post,
child: _VideoSurface(
player: widget.player,
controller: widget.controller,
aspectRatio: widget.aspectRatio,
controlsVisible: _controlsVisible,
coverVideo: _coverVideo,
muted: _muted,
loopVideo: _loopVideo,
halfVolume: _halfVolume,
fullscreen: true,
initialPosition: widget.startAt,
autoplay: widget.autoplay,
initialLoop: widget.loopVideo,
initialMuted: widget.muted,
initialHalfVolume: widget.halfVolume,
initialCoverVideo: widget.coverVideo,
qualityMode: MediaQualityMode.highQuality,
onPlaybackSnapshot: (snapshot) => _snapshot = snapshot,
errorMessage: widget.errorMessage,
onInteract: _showControls,
onRetry: widget.onRetry,
onToggleFit: () {
setState(() => _coverVideo = !_coverVideo);
_showControls();
},
onToggleMute: () async {
setState(() => _muted = !_muted);
await _applyVolume();
_showControls();
},
onToggleHalfVolume: () async {
setState(() {
_halfVolume = !_halfVolume;
if (_halfVolume) _muted = false;
});
await _applyVolume();
_showControls();
},
onToggleLoop: () async {
setState(() => _loopVideo = !_loopVideo);
await widget.player.setPlaylistMode(
_loopVideo ? PlaylistMode.single : PlaylistMode.none,
);
_showControls();
},
onFullscreen: () => Navigator.of(
context,
rootNavigator: true,
).pop(_snapshot()),
),
),
Positioned(
@@ -794,8 +875,10 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
child: SafeArea(
child: IconButton.filledTonal(
tooltip: 'Close fullscreen',
onPressed: () =>
Navigator.of(context, rootNavigator: true).pop(_snapshot),
onPressed: () => Navigator.of(
context,
rootNavigator: true,
).pop(_snapshot()),
icon: const Icon(Icons.close_rounded),
),
),
@@ -803,8 +886,49 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
],
),
),
),
),
),
);
}
void _showControls() {
if (!mounted) return;
setState(() => _controlsVisible = true);
_scheduleControlsHide();
}
void _scheduleControlsHide() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(seconds: 3), () {
if (mounted && widget.errorMessage == null) {
setState(() => _controlsVisible = false);
}
});
}
Future<void> _applyVolume() async {
await widget.player.setVolume(_muted
? 0
: _halfVolume
? 50
: 100);
}
VideoPlaybackSnapshot _snapshot() {
return VideoPlaybackSnapshot(
position: widget.player.state.position,
playing: widget.player.state.playing,
muted: _muted,
halfVolume: _halfVolume,
loopVideo: _loopVideo,
coverVideo: _coverVideo,
);
}
}
class _CloseVideoIntent extends Intent {
const _CloseVideoIntent();
}
class _VideoSurface extends StatelessWidget {
@@ -1039,17 +1163,17 @@ class _RoundControlButton extends StatelessWidget {
message: tooltip,
child: InkResponse(
onTap: onPressed,
radius: emphasized ? 30 : 24,
radius: emphasized ? 23 : 21,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
width: emphasized ? 48 : 40,
height: emphasized ? 48 : 40,
width: emphasized ? 38 : 34,
height: emphasized ? 38 : 34,
decoration: BoxDecoration(
color: background,
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),
child: Icon(icon, color: foreground),
child: Icon(icon, color: foreground, size: emphasized ? 24 : 20),
),
),
);
@@ -1086,26 +1210,26 @@ class _VideoControls extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return DecoratedBox(
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.78),
Colors.black.withValues(alpha: 0.22),
Colors.black.withValues(alpha: 0.72),
Colors.black.withValues(alpha: 0.12),
Colors.transparent,
],
),
),
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.fromLTRB(
fullscreen ? 22 : 12,
12,
fullscreen ? 22 : 12,
fullscreen ? 22 : 12,
child: SafeArea(
minimum: EdgeInsets.fromLTRB(
fullscreen ? 14 : 8,
0,
fullscreen ? 14 : 8,
fullscreen ? 10 : 8,
),
child: StreamBuilder<bool>(
stream: player.stream.playing,
@@ -1129,43 +1253,22 @@ class _VideoControls extends StatelessWidget {
.toDouble();
return LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 980;
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.58),
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: Colors.white.withValues(alpha: 0.12),
),
boxShadow: const [
BoxShadow(
color: Colors.black54,
blurRadius: 26,
offset: Offset(0, 12),
),
],
),
child: Padding(
padding: EdgeInsets.fromLTRB(
compact ? 10 : 14,
8,
compact ? 10 : 14,
compact ? 10 : 12,
),
child: Column(
final compact = constraints.maxWidth < 560;
final tiny = constraints.maxWidth < 390;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: scheme.primary,
inactiveTrackColor:
Colors.white.withValues(alpha: 0.22),
trackHeight: 5,
Colors.white.withValues(alpha: 0.24),
trackHeight: 3,
thumbColor: scheme.primary,
overlayColor:
scheme.primary.withValues(alpha: 0.2),
scheme.primary.withValues(alpha: 0.18),
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 7,
enabledThumbRadius: 5,
),
),
child: Slider(
@@ -1190,19 +1293,15 @@ class _VideoControls extends StatelessWidget {
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
const SizedBox(width: 10),
const SizedBox(width: 8),
Text(
_format(position),
tiny
? _format(position)
: '${_format(position)} / ${_format(duration)}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
Text(
' / ${_format(duration)}',
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w600,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const Spacer(),
@@ -1218,7 +1317,7 @@ class _VideoControls extends StatelessWidget {
),
icon: Icons.replay_10_rounded,
),
const SizedBox(width: 6),
const SizedBox(width: 5),
_RoundControlButton(
tooltip: 'Forward 10s',
onPressed: () => player.seek(
@@ -1230,7 +1329,7 @@ class _VideoControls extends StatelessWidget {
),
icon: Icons.forward_10_rounded,
),
const SizedBox(width: 6),
const SizedBox(width: 5),
],
_RoundControlButton(
tooltip: muted ? 'Unmute' : 'Mute',
@@ -1239,7 +1338,7 @@ class _VideoControls extends StatelessWidget {
? Icons.volume_off_rounded
: Icons.volume_up_rounded,
),
const SizedBox(width: 6),
const SizedBox(width: 5),
_RoundControlButton(
tooltip: halfVolume
? 'Normal app volume'
@@ -1248,7 +1347,7 @@ class _VideoControls extends StatelessWidget {
onPressed: onToggleHalfVolume,
icon: Icons.volume_down_rounded,
),
const SizedBox(width: 6),
const SizedBox(width: 5),
_RoundControlButton(
tooltip: loopVideo
? 'Disable repeat'
@@ -1259,7 +1358,7 @@ class _VideoControls extends StatelessWidget {
? Icons.repeat_one_on_rounded
: Icons.repeat_one_rounded,
),
const SizedBox(width: 6),
const SizedBox(width: 5),
if (!compact) ...[
_RoundControlButton(
tooltip: coverVideo ? 'Fit' : 'Fill',
@@ -1269,7 +1368,7 @@ class _VideoControls extends StatelessWidget {
? Icons.fit_screen_rounded
: Icons.crop_free_rounded,
),
const SizedBox(width: 6),
const SizedBox(width: 5),
],
_RoundControlButton(
tooltip: fullscreen
@@ -1283,8 +1382,6 @@ class _VideoControls extends StatelessWidget {
],
),
],
),
),
);
},
);
@@ -19,12 +19,43 @@ class ProviderHealthTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hint = ProviderQualityHint.from(
config: config,
health: health,
diagnostics: diagnostics,
);
return Card(
child: ListTile(
child: ExpansionTile(
leading: ProviderStatusBadge(health: health),
title: Text(config.name),
subtitle: Text(
subtitle: Wrap(
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Chip(
avatar: Icon(hint.icon, size: 16),
label: Text(hint.label),
visualDensity: VisualDensity.compact,
backgroundColor: hint.color(context).withValues(alpha: 0.16),
side:
BorderSide(color: hint.color(context).withValues(alpha: 0.4)),
),
Text('${config.apiType} | priority ${config.priority}'),
],
),
trailing: IconButton(
tooltip: 'Check',
onPressed: onCheck,
icon: const Icon(Icons.network_check_rounded),
),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
[
'${config.apiType} | ${config.enabled ? 'enabled' : 'disabled'} | priority ${config.priority}',
'${config.enabled ? 'Enabled' : 'Disabled'} | ${config.baseUrl}',
if (health != null) 'Ping ${health!.pingMs} ms',
if (health?.apiVersion != null) 'API ${health!.apiVersion}',
if (health?.lastCheckedAt != null)
@@ -35,16 +66,68 @@ class ProviderHealthTile extends StatelessWidget {
diagnostics!.lastErrorMessage!,
if (health?.errorMessage != null) health!.errorMessage!,
].join('\n'),
maxLines: 7,
overflow: TextOverflow.ellipsis,
),
leading: ProviderStatusBadge(health: health),
trailing: IconButton(
tooltip: 'Check',
onPressed: onCheck,
icon: const Icon(Icons.network_check_rounded),
),
],
),
);
}
}
class ProviderQualityHint {
const ProviderQualityHint._(this.label, this.icon, this._color);
final String label;
final IconData icon;
final Color Function(ColorScheme scheme) _color;
Color color(BuildContext context) => _color(Theme.of(context).colorScheme);
static ProviderQualityHint from({
required ContentProviderConfig config,
required ProviderHealth? health,
required ProviderDiagnostics? diagnostics,
}) {
if (!config.enabled) {
return ProviderQualityHint._(
'Disabled',
Icons.pause_circle_outline_rounded,
(scheme) => scheme.outline,
);
}
if (diagnostics?.lastErrorMessage != null ||
health?.status == ProviderStatus.offline) {
return ProviderQualityHint._(
'Search failing',
Icons.error_outline_rounded,
(scheme) => scheme.error,
);
}
if (diagnostics != null && diagnostics.lastResultCount == 0) {
return ProviderQualityHint._(
'No recent results',
Icons.search_off_rounded,
(scheme) => scheme.tertiary,
);
}
if (health != null && health.pingMs > 2500) {
return ProviderQualityHint._(
'Slow',
Icons.speed_rounded,
(scheme) => scheme.secondary,
);
}
if (health != null || diagnostics != null) {
return ProviderQualityHint._(
'Healthy',
Icons.check_circle_outline_rounded,
(scheme) => scheme.primary,
);
}
return ProviderQualityHint._(
'Not checked',
Icons.help_outline_rounded,
(scheme) => scheme.outline,
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
version: 0.4.0+4
version: 0.4.2+6
environment:
sdk: ">=3.3.0 <4.0.0"
+23
View File
@@ -191,6 +191,29 @@ void main() {
expect(repository.diagnostics['c']?.lastErrorMessage, 'Search failed');
});
test('all providers results are interleaved by provider priority', () async {
final repository = FakeProviderRepository()
..configs['a'] = config('a', 0)
..configs['b'] = config('b', 1)
..configs['c'] = config('c', 2);
final manager = ProviderManager(
repository,
FakeProviderFactory({
'a': FakeProvider('a', 'a', [post('a', '1'), post('a', '2')]),
'b': FakeProvider('b', 'b', [post('b', '1'), post('b', '2')]),
'c': FakeProvider('c', 'c', [post('c', '1')]),
}),
);
final result = await manager.searchAcrossProviders(tags: [], page: 0)
as Success<List<Post>>;
expect(
result.data.map((item) => item.cacheKey),
['a:1', 'b:1', 'c:1', 'a:2', 'b:2'],
);
});
test('enable disable provider persists config', () async {
final repository = FakeProviderRepository()..configs['a'] = config('a', 0);
final manager = ProviderManager(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 120 KiB