Add QoL feed, download and quality improvements
This commit is contained in:
@@ -36,11 +36,121 @@ class GelRuleApp extends ConsumerWidget {
|
||||
orElse: () => ThemeMode.dark,
|
||||
),
|
||||
routerConfig: router,
|
||||
builder: (context, child) => _DownloadOverlay(child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DownloadOverlay extends ConsumerWidget {
|
||||
const _DownloadOverlay({required this.child});
|
||||
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tasks = ref.watch(downloadTasksProvider).value ?? const [];
|
||||
final visible = tasks
|
||||
.where((task) =>
|
||||
task.status == DownloadTaskStatus.running ||
|
||||
task.status == DownloadTaskStatus.queued ||
|
||||
task.status == DownloadTaskStatus.failed ||
|
||||
task.status == DownloadTaskStatus.completed)
|
||||
.toList();
|
||||
return Stack(
|
||||
children: [
|
||||
if (child != null) child!,
|
||||
if (visible.isNotEmpty)
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: SafeArea(
|
||||
child: _DownloadPanel(tasks: visible.take(3).toList()),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DownloadPanel extends ConsumerWidget {
|
||||
const _DownloadPanel({required this.tasks});
|
||||
|
||||
final List<DownloadTask> tasks;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: scheme.surface,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final task in tasks) ...[
|
||||
Row(
|
||||
children: [
|
||||
Icon(_icon(task.status), size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
task.fileName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (task.status == DownloadTaskStatus.failed)
|
||||
IconButton(
|
||||
tooltip: 'Retry',
|
||||
onPressed: () => ref
|
||||
.read(downloadManagerServiceProvider)
|
||||
.retry(task.id),
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
LinearProgressIndicator(
|
||||
value: task.status == DownloadTaskStatus.running
|
||||
? task.progress.clamp(0, 1)
|
||||
: task.status == DownloadTaskStatus.completed
|
||||
? 1
|
||||
: null,
|
||||
),
|
||||
if (task.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
task.error!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.error),
|
||||
),
|
||||
),
|
||||
if (task != tasks.last) const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _icon(DownloadTaskStatus status) {
|
||||
return switch (status) {
|
||||
DownloadTaskStatus.completed => Icons.download_done_rounded,
|
||||
DownloadTaskStatus.failed => Icons.error_outline_rounded,
|
||||
DownloadTaskStatus.canceled => Icons.cancel_rounded,
|
||||
_ => Icons.download_rounded,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final appSettingsProvider = FutureProvider<AppSettings>((ref) {
|
||||
return ref.watch(settingsServiceProvider).getSettings().then(
|
||||
(result) => result.fold(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export 'di/backend_providers.dart';
|
||||
export 'models/collection.dart';
|
||||
export 'models/content_provider_config.dart';
|
||||
export 'models/download_task.dart';
|
||||
export 'models/favorite.dart';
|
||||
export 'models/post.dart';
|
||||
export 'models/post_comment.dart';
|
||||
@@ -14,6 +15,7 @@ export 'providers/content_provider.dart';
|
||||
export 'providers/provider_manager.dart';
|
||||
export 'services/collection_service.dart';
|
||||
export 'services/download_service.dart';
|
||||
export 'services/download_manager_service.dart';
|
||||
export 'services/favorite_service.dart';
|
||||
export 'services/feed_service.dart';
|
||||
export 'services/provider_check_service.dart';
|
||||
@@ -21,3 +23,4 @@ export 'services/search_service.dart';
|
||||
export 'services/settings_service.dart';
|
||||
export 'services/viewed_history_service.dart';
|
||||
export 'utils/smart_blacklist.dart';
|
||||
export 'utils/media_quality.dart';
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../../core/http/network_info.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../providers/provider_factory.dart';
|
||||
import '../providers/provider_manager.dart';
|
||||
import '../models/download_task.dart';
|
||||
import '../repositories/collection_repository.dart';
|
||||
import '../repositories/favorite_repository.dart';
|
||||
import '../repositories/post_repository.dart';
|
||||
@@ -17,6 +18,7 @@ import '../repositories/search_repository.dart';
|
||||
import '../repositories/viewed_post_repository.dart';
|
||||
import '../services/collection_service.dart';
|
||||
import '../services/download_service.dart';
|
||||
import '../services/download_manager_service.dart';
|
||||
import '../services/favorite_service.dart';
|
||||
import '../services/feed_service.dart';
|
||||
import '../services/provider_check_service.dart';
|
||||
@@ -147,3 +149,12 @@ final settingsServiceProvider = Provider<SettingsService>((ref) {
|
||||
final downloadServiceProvider = Provider<DownloadService>((ref) {
|
||||
return DownloadService();
|
||||
});
|
||||
|
||||
final downloadManagerServiceProvider = Provider<DownloadManagerService>((ref) {
|
||||
return DownloadManagerService(ref.watch(downloadServiceProvider));
|
||||
});
|
||||
|
||||
final downloadTasksProvider = StreamProvider<List<DownloadTask>>((ref) {
|
||||
final manager = ref.watch(downloadManagerServiceProvider);
|
||||
return manager.stream;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'post.dart';
|
||||
|
||||
enum DownloadTaskStatus {
|
||||
queued,
|
||||
running,
|
||||
completed,
|
||||
failed,
|
||||
canceled,
|
||||
}
|
||||
|
||||
class DownloadTask {
|
||||
const DownloadTask({
|
||||
required this.id,
|
||||
required this.post,
|
||||
required this.fileName,
|
||||
required this.progress,
|
||||
required this.status,
|
||||
this.savedPath,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final Post post;
|
||||
final String fileName;
|
||||
final double progress;
|
||||
final DownloadTaskStatus status;
|
||||
final String? savedPath;
|
||||
final String? error;
|
||||
|
||||
DownloadTask copyWith({
|
||||
double? progress,
|
||||
DownloadTaskStatus? status,
|
||||
String? savedPath,
|
||||
String? error,
|
||||
}) {
|
||||
return DownloadTask(
|
||||
id: id,
|
||||
post: post,
|
||||
fileName: fileName,
|
||||
progress: progress ?? this.progress,
|
||||
status: status ?? this.status,
|
||||
savedPath: savedPath ?? this.savedPath,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -147,10 +147,24 @@ class DanbooruProvider implements ContentProvider, CommentProvider {
|
||||
}
|
||||
|
||||
List<String> _topTags(TopPeriodFilter period) {
|
||||
final now = DateTime.now();
|
||||
return switch (period) {
|
||||
TopPeriodFilter.none => const [],
|
||||
TopPeriodFilter.month || TopPeriodFilter.year => const ['order:rank'],
|
||||
TopPeriodFilter.month => [
|
||||
'order:score',
|
||||
'date:>${_date(now.subtract(const Duration(days: 31)))}',
|
||||
],
|
||||
TopPeriodFilter.year => [
|
||||
'order:score',
|
||||
'date:>${_date(DateTime(now.year - 1, now.month, now.day))}',
|
||||
],
|
||||
TopPeriodFilter.allTime => const ['order:score'],
|
||||
};
|
||||
}
|
||||
|
||||
String _date(DateTime value) {
|
||||
return '${value.year.toString().padLeft(4, '0')}-'
|
||||
'${value.month.toString().padLeft(2, '0')}-'
|
||||
'${value.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,13 +168,27 @@ class E621Provider
|
||||
}
|
||||
|
||||
List<String> _topTags(TopPeriodFilter period) {
|
||||
final now = DateTime.now();
|
||||
return switch (period) {
|
||||
TopPeriodFilter.none => const [],
|
||||
TopPeriodFilter.month => const ['order:rank'],
|
||||
TopPeriodFilter.year || TopPeriodFilter.allTime => const ['order:score'],
|
||||
TopPeriodFilter.month => [
|
||||
'order:score',
|
||||
'date:>${_date(now.subtract(const Duration(days: 31)))}',
|
||||
],
|
||||
TopPeriodFilter.year => [
|
||||
'order:score',
|
||||
'date:>${_date(DateTime(now.year - 1, now.month, now.day))}',
|
||||
],
|
||||
TopPeriodFilter.allTime => const ['order:score'],
|
||||
};
|
||||
}
|
||||
|
||||
String _date(DateTime value) {
|
||||
return '${value.year.toString().padLeft(4, '0')}-'
|
||||
'${value.month.toString().padLeft(2, '0')}-'
|
||||
'${value.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
int _int(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
|
||||
@@ -140,10 +140,8 @@ class GelbooruProvider implements ContentProvider, CommentProvider {
|
||||
List<String> _topTags(TopPeriodFilter period) {
|
||||
return switch (period) {
|
||||
TopPeriodFilter.none => const [],
|
||||
TopPeriodFilter.month ||
|
||||
TopPeriodFilter.year ||
|
||||
TopPeriodFilter.allTime =>
|
||||
const ['sort:score:desc'],
|
||||
TopPeriodFilter.month || TopPeriodFilter.year => const [],
|
||||
TopPeriodFilter.allTime => const ['sort:score:desc'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -166,10 +166,8 @@ class MoebooruProvider
|
||||
List<String> _topTags(TopPeriodFilter period) {
|
||||
return switch (period) {
|
||||
TopPeriodFilter.none => const [],
|
||||
TopPeriodFilter.month ||
|
||||
TopPeriodFilter.year ||
|
||||
TopPeriodFilter.allTime =>
|
||||
const ['order:score'],
|
||||
TopPeriodFilter.month || TopPeriodFilter.year => const [],
|
||||
TopPeriodFilter.allTime => const ['order:score'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,10 +22,8 @@ class Rule34Provider extends GelbooruProvider {
|
||||
}) async {
|
||||
final topTags = switch (topPeriod) {
|
||||
TopPeriodFilter.none => const <String>[],
|
||||
TopPeriodFilter.month ||
|
||||
TopPeriodFilter.year ||
|
||||
TopPeriodFilter.allTime =>
|
||||
const ['sort:score:desc'],
|
||||
TopPeriodFilter.month || TopPeriodFilter.year => const <String>[],
|
||||
TopPeriodFilter.allTime => const ['sort:score:desc'],
|
||||
};
|
||||
final response = await dio.get<dynamic>(
|
||||
'/index.php',
|
||||
|
||||
@@ -70,6 +70,23 @@ class CollectionRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> addPosts(String collectionId, List<Post> posts) async {
|
||||
if (posts.isEmpty) return const Success(null);
|
||||
await _postRepository.cachePosts(posts);
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
final now = DateTime.now();
|
||||
await isar.collectionPostEntitys.putAll([
|
||||
for (final post in posts)
|
||||
CollectionPostEntity()
|
||||
..linkKey = '$collectionId:${post.providerId}:${post.id}'
|
||||
..collectionId = collectionId
|
||||
..postId = post.id
|
||||
..providerId = post.providerId
|
||||
..addedAt = now,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> removePost(
|
||||
String collectionId,
|
||||
String postId,
|
||||
|
||||
@@ -56,6 +56,13 @@ class CollectionService {
|
||||
return _repository.addPost(collectionId, post);
|
||||
}
|
||||
|
||||
Future<Result<void>> addPostsToCollection(
|
||||
String collectionId,
|
||||
List<Post> posts,
|
||||
) {
|
||||
return _repository.addPosts(collectionId, posts);
|
||||
}
|
||||
|
||||
Future<Result<void>> removePostFromCollection(
|
||||
String collectionId,
|
||||
String postId,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../models/download_task.dart';
|
||||
import '../models/post.dart';
|
||||
import '../utils/media_quality.dart';
|
||||
import 'download_service.dart';
|
||||
|
||||
class DownloadManagerService {
|
||||
DownloadManagerService(this._downloadService);
|
||||
|
||||
final DownloadService _downloadService;
|
||||
final _controller = StreamController<List<DownloadTask>>.broadcast();
|
||||
final Map<String, DownloadTask> _tasks = {};
|
||||
|
||||
Stream<List<DownloadTask>> get stream => _controller.stream;
|
||||
List<DownloadTask> get tasks => List.unmodifiable(_tasks.values);
|
||||
|
||||
Future<DownloadTask> start(Post post) async {
|
||||
final id = '${post.cacheKey}:${DateTime.now().microsecondsSinceEpoch}';
|
||||
final task = DownloadTask(
|
||||
id: id,
|
||||
post: post,
|
||||
fileName: _suggestedFileName(post),
|
||||
progress: 0,
|
||||
status: DownloadTaskStatus.queued,
|
||||
);
|
||||
_set(task);
|
||||
unawaited(_run(task));
|
||||
return task;
|
||||
}
|
||||
|
||||
Future<void> retry(String taskId) async {
|
||||
final existing = _tasks[taskId];
|
||||
if (existing == null) return;
|
||||
final task = existing.copyWith(
|
||||
progress: 0,
|
||||
status: DownloadTaskStatus.queued,
|
||||
error: null,
|
||||
);
|
||||
_set(task);
|
||||
await _run(task);
|
||||
}
|
||||
|
||||
void cancel(String taskId) {
|
||||
final existing = _tasks[taskId];
|
||||
if (existing == null) return;
|
||||
_set(existing.copyWith(status: DownloadTaskStatus.canceled));
|
||||
}
|
||||
|
||||
Future<void> _run(DownloadTask task) async {
|
||||
if (_tasks[task.id]?.status == DownloadTaskStatus.canceled) return;
|
||||
_set(task.copyWith(status: DownloadTaskStatus.running));
|
||||
try {
|
||||
final saved = await _downloadService.downloadPost(
|
||||
task.post,
|
||||
onProgress: (received, total) {
|
||||
if (total <= 0) return;
|
||||
final current = _tasks[task.id];
|
||||
if (current == null ||
|
||||
current.status == DownloadTaskStatus.canceled) {
|
||||
return;
|
||||
}
|
||||
_set(current.copyWith(progress: received / total));
|
||||
},
|
||||
);
|
||||
final current = _tasks[task.id];
|
||||
if (current == null || current.status == DownloadTaskStatus.canceled) {
|
||||
return;
|
||||
}
|
||||
_set(
|
||||
current.copyWith(
|
||||
progress: 1,
|
||||
status: DownloadTaskStatus.completed,
|
||||
savedPath: saved,
|
||||
),
|
||||
);
|
||||
_scheduleAutoRemove(task.id, const Duration(seconds: 6));
|
||||
} catch (error) {
|
||||
final current = _tasks[task.id];
|
||||
if (current == null || current.status == DownloadTaskStatus.canceled) {
|
||||
return;
|
||||
}
|
||||
_set(
|
||||
current.copyWith(
|
||||
status: DownloadTaskStatus.failed,
|
||||
error: error.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _set(DownloadTask task) {
|
||||
_tasks[task.id] = task;
|
||||
_controller.add(tasks);
|
||||
}
|
||||
|
||||
void _scheduleAutoRemove(String taskId, Duration delay) {
|
||||
Future<void>.delayed(delay, () {
|
||||
final current = _tasks[taskId];
|
||||
if (current?.status != DownloadTaskStatus.completed) return;
|
||||
_tasks.remove(taskId);
|
||||
_controller.add(tasks);
|
||||
});
|
||||
}
|
||||
|
||||
String _suggestedFileName(Post post) {
|
||||
final url = MediaUrlSelector.download(post) ?? post.fileUrl;
|
||||
final parsed = Uri.tryParse(url);
|
||||
final last =
|
||||
parsed?.pathSegments.isEmpty ?? true ? '' : parsed!.pathSegments.last;
|
||||
if (last.contains('.')) return last;
|
||||
return '${post.providerId}_${post.id}';
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../models/post.dart';
|
||||
import '../utils/media_quality.dart';
|
||||
|
||||
class DownloadService {
|
||||
DownloadService({Dio? dio}) : _dio = dio ?? Dio();
|
||||
@@ -43,10 +44,7 @@ class DownloadService {
|
||||
}
|
||||
|
||||
String? _downloadUrl(Post post) {
|
||||
for (final url in [post.sampleUrl, post.fileUrl, post.previewUrl]) {
|
||||
if (url.trim().isNotEmpty) return url;
|
||||
}
|
||||
return null;
|
||||
return MediaUrlSelector.download(post);
|
||||
}
|
||||
|
||||
String _fileName(Post post, String url) {
|
||||
|
||||
@@ -78,7 +78,7 @@ class FeedService {
|
||||
final viewedKeys = viewedKeysResult is Success<Set<String>>
|
||||
? viewedKeysResult.data
|
||||
: <String>{};
|
||||
final filteredPosts = posts
|
||||
final filteredPosts = _applyTopPeriod(posts, topPeriod)
|
||||
.where((post) => postMatchesRequestedTags(post, tags))
|
||||
.where((post) => postPassesTagFilters(post, settings))
|
||||
.where((post) =>
|
||||
@@ -90,6 +90,25 @@ class FeedService {
|
||||
);
|
||||
return Success(filteredPosts);
|
||||
}
|
||||
|
||||
List<Post> _applyTopPeriod(List<Post> posts, TopPeriodFilter period) {
|
||||
if (period == TopPeriodFilter.none) return posts;
|
||||
|
||||
final sorted = [...posts]..sort((a, b) => b.score.compareTo(a.score));
|
||||
if (period == TopPeriodFilter.allTime) return sorted;
|
||||
|
||||
final now = DateTime.now();
|
||||
final minDate = period == TopPeriodFilter.month
|
||||
? now.subtract(const Duration(days: 31))
|
||||
: DateTime(now.year - 1, now.month, now.day);
|
||||
final inPeriod = sorted
|
||||
.where((post) =>
|
||||
post.createdAt.isAfter(minDate) &&
|
||||
post.createdAt.isBefore(now.add(const Duration(days: 1))))
|
||||
.toList(growable: false);
|
||||
|
||||
return inPeriod.isEmpty ? sorted : inPeriod;
|
||||
}
|
||||
}
|
||||
|
||||
bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
|
||||
|
||||
@@ -28,7 +28,13 @@ class AppSettings {
|
||||
required this.whitelistedTags,
|
||||
required this.smartBlacklistRules,
|
||||
required this.hideViewedPosts,
|
||||
required this.mediaQualityMode,
|
||||
required this.lastFeedTags,
|
||||
required this.lastFeedProviderIds,
|
||||
required this.lastFeedTopPeriod,
|
||||
required this.lastFeedScrollOffset,
|
||||
this.defaultRatingFilter,
|
||||
this.lastFeedRating,
|
||||
});
|
||||
|
||||
final List<String> enabledProviderIds;
|
||||
@@ -48,7 +54,13 @@ class AppSettings {
|
||||
final List<String> whitelistedTags;
|
||||
final List<String> smartBlacklistRules;
|
||||
final bool hideViewedPosts;
|
||||
final String mediaQualityMode;
|
||||
final List<String> lastFeedTags;
|
||||
final List<String> lastFeedProviderIds;
|
||||
final String lastFeedTopPeriod;
|
||||
final double lastFeedScrollOffset;
|
||||
final String? defaultRatingFilter;
|
||||
final String? lastFeedRating;
|
||||
|
||||
static const defaults = AppSettings(
|
||||
enabledProviderIds: ['gelbooru', 'rule34', 'safebooru'],
|
||||
@@ -68,7 +80,13 @@ class AppSettings {
|
||||
whitelistedTags: [],
|
||||
smartBlacklistRules: [],
|
||||
hideViewedPosts: false,
|
||||
mediaQualityMode: 'auto',
|
||||
lastFeedTags: [],
|
||||
lastFeedProviderIds: [],
|
||||
lastFeedTopPeriod: 'none',
|
||||
lastFeedScrollOffset: 0,
|
||||
defaultRatingFilter: null,
|
||||
lastFeedRating: null,
|
||||
);
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -89,7 +107,14 @@ class AppSettings {
|
||||
List<String>? whitelistedTags,
|
||||
List<String>? smartBlacklistRules,
|
||||
bool? hideViewedPosts,
|
||||
String? mediaQualityMode,
|
||||
List<String>? lastFeedTags,
|
||||
List<String>? lastFeedProviderIds,
|
||||
String? lastFeedTopPeriod,
|
||||
double? lastFeedScrollOffset,
|
||||
String? defaultRatingFilter,
|
||||
String? lastFeedRating,
|
||||
bool clearLastFeedRating = false,
|
||||
}) {
|
||||
return AppSettings(
|
||||
enabledProviderIds: enabledProviderIds ?? this.enabledProviderIds,
|
||||
@@ -111,7 +136,14 @@ class AppSettings {
|
||||
whitelistedTags: whitelistedTags ?? this.whitelistedTags,
|
||||
smartBlacklistRules: smartBlacklistRules ?? this.smartBlacklistRules,
|
||||
hideViewedPosts: hideViewedPosts ?? this.hideViewedPosts,
|
||||
mediaQualityMode: mediaQualityMode ?? this.mediaQualityMode,
|
||||
lastFeedTags: lastFeedTags ?? this.lastFeedTags,
|
||||
lastFeedProviderIds: lastFeedProviderIds ?? this.lastFeedProviderIds,
|
||||
lastFeedTopPeriod: lastFeedTopPeriod ?? this.lastFeedTopPeriod,
|
||||
lastFeedScrollOffset: lastFeedScrollOffset ?? this.lastFeedScrollOffset,
|
||||
defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter,
|
||||
lastFeedRating:
|
||||
clearLastFeedRating ? null : lastFeedRating ?? this.lastFeedRating,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,7 +165,13 @@ class AppSettings {
|
||||
'whitelistedTags': whitelistedTags,
|
||||
'smartBlacklistRules': smartBlacklistRules,
|
||||
'hideViewedPosts': hideViewedPosts,
|
||||
'mediaQualityMode': mediaQualityMode,
|
||||
'lastFeedTags': lastFeedTags,
|
||||
'lastFeedProviderIds': lastFeedProviderIds,
|
||||
'lastFeedTopPeriod': lastFeedTopPeriod,
|
||||
'lastFeedScrollOffset': lastFeedScrollOffset,
|
||||
'defaultRatingFilter': defaultRatingFilter,
|
||||
'lastFeedRating': lastFeedRating,
|
||||
};
|
||||
|
||||
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
|
||||
@@ -178,7 +216,24 @@ class AppSettings {
|
||||
),
|
||||
hideViewedPosts:
|
||||
(json['hideViewedPosts'] as bool?) ?? defaults.hideViewedPosts,
|
||||
mediaQualityMode:
|
||||
(json['mediaQualityMode'] as String?) ?? defaults.mediaQualityMode,
|
||||
lastFeedTags: List<String>.from(
|
||||
(json['lastFeedTags'] as List?) ?? defaults.lastFeedTags,
|
||||
),
|
||||
lastFeedProviderIds: List<String>.from(
|
||||
(json['lastFeedProviderIds'] as List?) ??
|
||||
(json['selectedFeedProviderIds'] as List?) ??
|
||||
defaults.lastFeedProviderIds,
|
||||
),
|
||||
lastFeedTopPeriod: (json['lastFeedTopPeriod'] as String?) ??
|
||||
(json['defaultTopPeriodFilter'] as String?) ??
|
||||
defaults.lastFeedTopPeriod,
|
||||
lastFeedScrollOffset:
|
||||
(json['lastFeedScrollOffset'] as num?)?.toDouble() ??
|
||||
defaults.lastFeedScrollOffset,
|
||||
defaultRatingFilter: json['defaultRatingFilter'] as String?,
|
||||
lastFeedRating: json['lastFeedRating'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import '../models/post.dart';
|
||||
|
||||
enum MediaQualityMode {
|
||||
auto,
|
||||
dataSaver,
|
||||
highQuality;
|
||||
|
||||
static MediaQualityMode fromName(String value) {
|
||||
return MediaQualityMode.values.firstWhere(
|
||||
(mode) => mode.name == value,
|
||||
orElse: () => MediaQualityMode.auto,
|
||||
);
|
||||
}
|
||||
|
||||
String get label {
|
||||
return switch (this) {
|
||||
MediaQualityMode.auto => 'Auto',
|
||||
MediaQualityMode.dataSaver => 'Data saver',
|
||||
MediaQualityMode.highQuality => 'High quality',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MediaUrlSelector {
|
||||
const MediaUrlSelector._();
|
||||
|
||||
static List<String> feed(Post post) {
|
||||
return _compact([post.previewUrl, post.sampleUrl]);
|
||||
}
|
||||
|
||||
static List<String> preview(Post post) {
|
||||
return _compact([post.sampleUrl, post.previewUrl, post.fileUrl]);
|
||||
}
|
||||
|
||||
static List<String> details(
|
||||
Post post, {
|
||||
MediaQualityMode mode = MediaQualityMode.auto,
|
||||
}) {
|
||||
if (_isVideo(post)) {
|
||||
return _compact([post.fileUrl, post.sampleUrl, post.previewUrl]);
|
||||
}
|
||||
if (_isGif(post)) {
|
||||
return switch (mode) {
|
||||
MediaQualityMode.dataSaver =>
|
||||
_compact([post.sampleUrl, post.previewUrl, post.fileUrl]),
|
||||
_ => _compact([post.fileUrl, post.sampleUrl, post.previewUrl]),
|
||||
};
|
||||
}
|
||||
return switch (mode) {
|
||||
MediaQualityMode.dataSaver =>
|
||||
_compact([post.sampleUrl, post.previewUrl, post.fileUrl]),
|
||||
MediaQualityMode.highQuality =>
|
||||
_compact([post.fileUrl, post.sampleUrl, post.previewUrl]),
|
||||
MediaQualityMode.auto =>
|
||||
_compact([post.sampleUrl, post.fileUrl, post.previewUrl]),
|
||||
};
|
||||
}
|
||||
|
||||
static String? download(Post post) {
|
||||
return _compact([post.fileUrl, post.sampleUrl, post.previewUrl])
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
static List<String> video(Post post) {
|
||||
return _compact([
|
||||
post.fileUrl,
|
||||
if (_looksLikeVideoUrl(post.sampleUrl)) post.sampleUrl,
|
||||
if (_looksLikeVideoUrl(post.previewUrl)) post.previewUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
static bool isVideo(Post post) => _isVideo(post);
|
||||
static bool isGif(Post post) => _isGif(post);
|
||||
|
||||
static List<String> _compact(Iterable<String> urls) {
|
||||
final seen = <String>{};
|
||||
return urls
|
||||
.map((url) => url.trim())
|
||||
.where((url) => url.isNotEmpty && seen.add(url))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
static bool _isVideo(Post post) {
|
||||
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
|
||||
return value.contains('video') ||
|
||||
value.contains('.webm') ||
|
||||
value.contains('.mp4') ||
|
||||
value.contains('.mov');
|
||||
}
|
||||
|
||||
static bool _isGif(Post post) {
|
||||
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
|
||||
return value.contains('gif') || value.contains('.gif');
|
||||
}
|
||||
|
||||
static bool _looksLikeVideoUrl(String url) {
|
||||
final value = url.toLowerCase();
|
||||
return value.contains('.webm') ||
|
||||
value.contains('.mp4') ||
|
||||
value.contains('.mov');
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,14 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
final providerIds = providers.map((provider) => provider.id).toSet();
|
||||
final initial = FeedState(
|
||||
providers: providers,
|
||||
ratingFilter: settings.defaultRatingFilter,
|
||||
topPeriodFilter: TopPeriodFilter.values.firstWhere(
|
||||
(value) => value.name == settings.defaultTopPeriodFilter,
|
||||
(value) => value.name == settings.lastFeedTopPeriod,
|
||||
orElse: () => TopPeriodFilter.none,
|
||||
),
|
||||
selectedTags: settings.lastFeedTags,
|
||||
selectedProviderIds:
|
||||
settings.selectedFeedProviderIds.where(providerIds.contains).toList(),
|
||||
settings.lastFeedProviderIds.where(providerIds.contains).toList(),
|
||||
ratingFilter: settings.lastFeedRating ?? settings.defaultRatingFilter,
|
||||
);
|
||||
state = AsyncData(initial);
|
||||
await loadInitial();
|
||||
@@ -66,6 +67,7 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
state = AsyncData(
|
||||
current.copyWith(selectedTags: tags, posts: [], tagSuggestions: []),
|
||||
);
|
||||
await saveSession(scrollOffset: 0);
|
||||
await ref.read(searchServiceProvider).saveSearch(query, 0);
|
||||
await refresh();
|
||||
final count = state.value?.posts.length ?? 0;
|
||||
@@ -99,7 +101,11 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
current.copyWith(selectedProviderIds: providerIds, posts: []));
|
||||
final settings = await _settings();
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(selectedFeedProviderIds: providerIds));
|
||||
settings.copyWith(
|
||||
selectedFeedProviderIds: providerIds,
|
||||
lastFeedProviderIds: providerIds,
|
||||
),
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
@@ -110,6 +116,7 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(defaultTopPeriodFilter: period.name),
|
||||
);
|
||||
await saveSession(scrollOffset: 0);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
@@ -120,6 +127,7 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
ratingFilter: rating, clearRating: rating == null, posts: []),
|
||||
);
|
||||
await refresh();
|
||||
await saveSession(scrollOffset: 0);
|
||||
}
|
||||
|
||||
Future<void> clearFilters() async {
|
||||
@@ -134,12 +142,32 @@ class FeedController extends AsyncNotifier<FeedState> {
|
||||
),
|
||||
);
|
||||
final settings = await _settings();
|
||||
await ref
|
||||
.read(settingsServiceProvider)
|
||||
.updateSettings(settings.copyWith(selectedFeedProviderIds: []));
|
||||
await ref.read(settingsServiceProvider).updateSettings(settings.copyWith(
|
||||
selectedFeedProviderIds: [],
|
||||
lastFeedTags: [],
|
||||
lastFeedProviderIds: [],
|
||||
clearLastFeedRating: true,
|
||||
lastFeedTopPeriod: TopPeriodFilter.none.name,
|
||||
lastFeedScrollOffset: 0,
|
||||
));
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> saveSession({double? scrollOffset}) async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final settings = await _settings();
|
||||
await ref.read(settingsServiceProvider).updateSettings(
|
||||
settings.copyWith(
|
||||
lastFeedTags: current.selectedTags,
|
||||
lastFeedProviderIds: current.selectedProviderIds,
|
||||
lastFeedRating: current.ratingFilter,
|
||||
lastFeedTopPeriod: current.topPeriodFilter.name,
|
||||
lastFeedScrollOffset: scrollOffset ?? settings.lastFeedScrollOffset,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Post>> _load({
|
||||
required bool refresh,
|
||||
required FeedState current,
|
||||
|
||||
@@ -2,6 +2,8 @@ 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 'dart:async';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
import '../../../app/app.dart';
|
||||
import '../../../app/responsive.dart';
|
||||
@@ -28,7 +30,10 @@ class FeedScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
final _scrollController = ScrollController();
|
||||
final Set<String> _selectedKeys = {};
|
||||
bool _selectionMode = false;
|
||||
bool _usedInitialQuery = false;
|
||||
Timer? _scrollSaveDebounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -37,11 +42,30 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
if (_scrollController.position.extentAfter < 800) {
|
||||
ref.read(feedControllerProvider.notifier).loadNextPage();
|
||||
}
|
||||
_scrollSaveDebounce?.cancel();
|
||||
_scrollSaveDebounce = Timer(const Duration(milliseconds: 600), () {
|
||||
ref
|
||||
.read(feedControllerProvider.notifier)
|
||||
.saveSession(scrollOffset: _scrollController.offset);
|
||||
});
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final settings =
|
||||
ref.read(appSettingsProvider).value ?? AppSettings.defaults;
|
||||
if (settings.lastFeedScrollOffset > 0 && _scrollController.hasClients) {
|
||||
_scrollController.jumpTo(
|
||||
settings.lastFeedScrollOffset.clamp(
|
||||
0.0,
|
||||
_scrollController.position.maxScrollExtent,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollSaveDebounce?.cancel();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -64,6 +88,10 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
return Shortcuts(
|
||||
shortcuts: {
|
||||
LogicalKeySet(LogicalKeyboardKey.keyR): const _RefreshIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): const _ClearSelectionIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.delete): const _ClearSelectionIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA):
|
||||
const _SelectVisibleIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: {
|
||||
@@ -73,6 +101,26 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_ClearSelectionIntent: CallbackAction<_ClearSelectionIntent>(
|
||||
onInvoke: (_) {
|
||||
_clearSelection();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_SelectVisibleIntent: CallbackAction<_SelectVisibleIntent>(
|
||||
onInvoke: (_) {
|
||||
final state = feed.value;
|
||||
if (state != null) {
|
||||
setState(() {
|
||||
_selectionMode = true;
|
||||
_selectedKeys
|
||||
..clear()
|
||||
..addAll(state.posts.map((post) => post.cacheKey));
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: AdaptiveScaffold(
|
||||
title: 'Feed',
|
||||
@@ -106,6 +154,13 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
ref.read(feedControllerProvider.notifier).refresh(),
|
||||
onClearFilters: () =>
|
||||
ref.read(feedControllerProvider.notifier).clearFilters(),
|
||||
selectionMode: _selectionMode,
|
||||
onToggleSelectionMode: () {
|
||||
setState(() {
|
||||
_selectionMode = !_selectionMode;
|
||||
if (!_selectionMode) _selectedKeys.clear();
|
||||
});
|
||||
},
|
||||
onQuickProviderToggle: (providerId) {
|
||||
if (providerId == '__all__') {
|
||||
ref
|
||||
@@ -167,10 +222,14 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
loading: state.isLoadingMore,
|
||||
favoriteKeys: favoriteKeys,
|
||||
viewedKeys: viewedKeys,
|
||||
selectionMode: _selectionMode,
|
||||
selectedKeys: _selectedKeys,
|
||||
onOpen: (post) => context.push(
|
||||
'/post/${post.providerId}/${post.id}',
|
||||
extra: post,
|
||||
),
|
||||
onPreview: (post) => _showPreview(context, post),
|
||||
onToggleSelected: (post) => _toggleSelected(post),
|
||||
onFavorite: (post) =>
|
||||
_toggleFavorite(ref, post, favoriteKeys),
|
||||
onAddToCollection: (post) =>
|
||||
@@ -178,6 +237,21 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_selectionMode && _selectedKeys.isNotEmpty)
|
||||
_BatchActionBar(
|
||||
count: _selectedKeys.length,
|
||||
onFavorite: () => _favoriteSelected(
|
||||
ref,
|
||||
state.posts,
|
||||
favoriteKeys,
|
||||
),
|
||||
onCollection: () => _addSelectedToCollection(
|
||||
context,
|
||||
ref,
|
||||
state.posts,
|
||||
),
|
||||
onClear: _clearSelection,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -186,6 +260,95 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleSelected(Post post) {
|
||||
setState(() {
|
||||
_selectionMode = true;
|
||||
if (!_selectedKeys.add(post.cacheKey)) {
|
||||
_selectedKeys.remove(post.cacheKey);
|
||||
}
|
||||
if (_selectedKeys.isEmpty) _selectionMode = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
if (!_selectionMode && _selectedKeys.isEmpty) return;
|
||||
setState(() {
|
||||
_selectionMode = false;
|
||||
_selectedKeys.clear();
|
||||
});
|
||||
}
|
||||
|
||||
List<Post> _selectedPosts(List<Post> posts) {
|
||||
return posts
|
||||
.where((post) => _selectedKeys.contains(post.cacheKey))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _favoriteSelected(
|
||||
WidgetRef ref,
|
||||
List<Post> posts,
|
||||
Set<String> favoriteKeys,
|
||||
) async {
|
||||
for (final post in _selectedPosts(posts)) {
|
||||
if (!favoriteKeys.contains(post.cacheKey)) {
|
||||
await ref.read(favoriteServiceProvider).addFavorite(post);
|
||||
}
|
||||
}
|
||||
ref.invalidate(favoriteKeysProvider);
|
||||
ref.invalidate(favoritesControllerProvider);
|
||||
_clearSelection();
|
||||
}
|
||||
|
||||
Future<void> _addSelectedToCollection(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<Post> posts,
|
||||
) async {
|
||||
final selectedPosts = _selectedPosts(posts);
|
||||
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) async {
|
||||
await ref
|
||||
.read(collectionServiceProvider)
|
||||
.addPostsToCollection(collection.id, selectedPosts);
|
||||
_clearSelection();
|
||||
},
|
||||
onCreate: () => showCollectionFormDialog(context, ref),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showPreview(BuildContext context, Post post) {
|
||||
final imageUrl = MediaUrlSelector.preview(post).firstOrNull;
|
||||
final child = imageUrl == null
|
||||
? const Center(child: Icon(Icons.broken_image_rounded, size: 48))
|
||||
: CachedNetworkImage(imageUrl: imageUrl, fit: BoxFit.contain);
|
||||
if (Responsive.isMobile(context)) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height * 0.72,
|
||||
child: Padding(padding: const EdgeInsets.all(8), child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 900, maxHeight: 760),
|
||||
child: Padding(padding: const EdgeInsets.all(8), child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addToCollection(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -227,3 +390,70 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||
class _RefreshIntent extends Intent {
|
||||
const _RefreshIntent();
|
||||
}
|
||||
|
||||
class _ClearSelectionIntent extends Intent {
|
||||
const _ClearSelectionIntent();
|
||||
}
|
||||
|
||||
class _SelectVisibleIntent extends Intent {
|
||||
const _SelectVisibleIntent();
|
||||
}
|
||||
|
||||
class _BatchActionBar extends StatelessWidget {
|
||||
const _BatchActionBar({
|
||||
required this.count,
|
||||
required this.onFavorite,
|
||||
required this.onCollection,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
final int count;
|
||||
final VoidCallback onFavorite;
|
||||
final VoidCallback onCollection;
|
||||
final VoidCallback onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 10),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 16),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('$count selected',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const Spacer(),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Favorite selected',
|
||||
onPressed: onFavorite,
|
||||
icon: const Icon(Icons.favorite_rounded),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onCollection,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('Collection'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear',
|
||||
onPressed: onClear,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ class FeedToolbar extends StatefulWidget {
|
||||
required this.onSearchChanged,
|
||||
required this.onSuggestionTap,
|
||||
required this.onTopPeriodChanged,
|
||||
this.onToggleSelectionMode,
|
||||
this.selectionMode = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -40,6 +42,8 @@ class FeedToolbar extends StatefulWidget {
|
||||
final ValueChanged<String> onSearchChanged;
|
||||
final ValueChanged<String> onSuggestionTap;
|
||||
final ValueChanged<TopPeriodFilter> onTopPeriodChanged;
|
||||
final VoidCallback? onToggleSelectionMode;
|
||||
final bool selectionMode;
|
||||
|
||||
@override
|
||||
State<FeedToolbar> createState() => _FeedToolbarState();
|
||||
@@ -91,6 +95,13 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
tooltip: widget.selectionMode ? 'Exit select' : 'Select posts',
|
||||
onPressed: widget.onToggleSelectionMode,
|
||||
icon: Icon(widget.selectionMode
|
||||
? Icons.check_box_rounded
|
||||
: Icons.check_box_outline_blank_rounded),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Providers',
|
||||
onPressed: widget.onProviderFilter,
|
||||
@@ -184,9 +195,12 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
primary: false,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Providers',
|
||||
onPressed: widget.onProviderFilter,
|
||||
icon: const Icon(Icons.hub_rounded),
|
||||
tooltip:
|
||||
widget.selectionMode ? 'Exit select' : 'Select posts',
|
||||
onPressed: widget.onToggleSelectionMode,
|
||||
icon: Icon(widget.selectionMode
|
||||
? Icons.check_box_rounded
|
||||
: Icons.check_box_outline_blank_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton.filledTonal(
|
||||
|
||||
@@ -84,6 +84,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
final next = currentIndex >= 0 && currentIndex < feedPosts.length - 1
|
||||
? feedPosts[currentIndex + 1]
|
||||
: null;
|
||||
final qualityMode =
|
||||
MediaQualityMode.fromName(settings.mediaQualityMode);
|
||||
if (Responsive.isMobile(context)) {
|
||||
if (currentIndex >= 0 && feedPosts.length > 1) {
|
||||
return _MobilePostPager(
|
||||
@@ -95,6 +97,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
post,
|
||||
settings,
|
||||
favoriteKeys,
|
||||
qualityMode,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -104,6 +107,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
post,
|
||||
settings,
|
||||
favoriteKeys,
|
||||
qualityMode,
|
||||
);
|
||||
}
|
||||
return Shortcuts(
|
||||
@@ -116,6 +120,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
const _ToggleFavoriteIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.keyC):
|
||||
const _AddCollectionIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.keyS): const _DownloadIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): const _CloseIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
@@ -150,6 +155,14 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_DownloadIntent: CallbackAction<_DownloadIntent>(
|
||||
onInvoke: (_) {
|
||||
if (settings.allowDownloads) {
|
||||
_download(context, ref, post);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
@@ -170,7 +183,10 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 760),
|
||||
child: PostMediaViewer(post: post),
|
||||
child: PostMediaViewer(
|
||||
post: post,
|
||||
qualityMode: qualityMode,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -243,6 +259,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
Post post,
|
||||
AppSettings settings,
|
||||
Set<String> favoriteKeys,
|
||||
MediaQualityMode qualityMode,
|
||||
) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
|
||||
@@ -251,7 +268,12 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.62,
|
||||
),
|
||||
child: Center(child: PostMediaViewer(post: post)),
|
||||
child: Center(
|
||||
child: PostMediaViewer(
|
||||
post: post,
|
||||
qualityMode: qualityMode,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PostActionBar(
|
||||
@@ -320,22 +342,12 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
|
||||
Future<void> _download(BuildContext context, WidgetRef ref, Post post) async {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Downloading...')),
|
||||
);
|
||||
try {
|
||||
final saved = await ref.read(downloadServiceProvider).downloadPost(post);
|
||||
if (!context.mounted || saved == null) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Download complete: $saved')),
|
||||
);
|
||||
} catch (error) {
|
||||
await ref.read(downloadManagerServiceProvider).start(post);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Download failed: $error')),
|
||||
const SnackBar(content: Text('Download started')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(
|
||||
WidgetRef ref,
|
||||
@@ -583,6 +595,10 @@ class _CloseIntent extends Intent {
|
||||
const _CloseIntent();
|
||||
}
|
||||
|
||||
class _DownloadIntent extends Intent {
|
||||
const _DownloadIntent();
|
||||
}
|
||||
|
||||
class _CommentsSection extends ConsumerStatefulWidget {
|
||||
const _CommentsSection({required this.post});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class PostMediaViewer extends StatefulWidget {
|
||||
this.initialMuted = false,
|
||||
this.initialCoverVideo = false,
|
||||
this.initialHalfVolume = false,
|
||||
this.qualityMode = MediaQualityMode.auto,
|
||||
this.onPlaybackSnapshot,
|
||||
super.key,
|
||||
});
|
||||
@@ -36,6 +37,7 @@ class PostMediaViewer extends StatefulWidget {
|
||||
final bool initialMuted;
|
||||
final bool initialCoverVideo;
|
||||
final bool initialHalfVolume;
|
||||
final MediaQualityMode qualityMode;
|
||||
final ValueChanged<VideoPlaybackSnapshot>? onPlaybackSnapshot;
|
||||
|
||||
@override
|
||||
@@ -281,18 +283,11 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
|
||||
}
|
||||
|
||||
List<String> _buildImageUrls(Post post) {
|
||||
final urls = post.fileType.toLowerCase().contains('gif')
|
||||
? [post.fileUrl, post.sampleUrl, post.previewUrl]
|
||||
: [post.sampleUrl, post.fileUrl, post.previewUrl];
|
||||
return urls.where((url) => url.trim().isNotEmpty).toSet().toList();
|
||||
return MediaUrlSelector.details(post, mode: widget.qualityMode);
|
||||
}
|
||||
|
||||
List<String> _buildVideoUrls(Post post) {
|
||||
return {
|
||||
post.fileUrl,
|
||||
if (_looksLikeVideoUrl(post.sampleUrl)) post.sampleUrl,
|
||||
if (_looksLikeVideoUrl(post.previewUrl)) post.previewUrl,
|
||||
}.where((url) => url.trim().isNotEmpty).toList();
|
||||
return MediaUrlSelector.video(post);
|
||||
}
|
||||
|
||||
bool _isVideo(Post post) {
|
||||
@@ -318,13 +313,6 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
|
||||
return post.fileUrl.trim();
|
||||
}
|
||||
|
||||
bool _looksLikeVideoUrl(String url) {
|
||||
final value = url.toLowerCase();
|
||||
return value.contains('.webm') ||
|
||||
value.contains('.mp4') ||
|
||||
value.contains('.mov');
|
||||
}
|
||||
|
||||
Map<String, String> _headersFor(Post post) {
|
||||
return {
|
||||
'User-Agent': 'RuleGel/0.2 Flutter local booru browser',
|
||||
@@ -796,6 +784,7 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
|
||||
initialMuted: widget.muted,
|
||||
initialHalfVolume: widget.halfVolume,
|
||||
initialCoverVideo: widget.coverVideo,
|
||||
qualityMode: MediaQualityMode.highQuality,
|
||||
onPlaybackSnapshot: (snapshot) => _snapshot = snapshot,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 '../../../backend/backend.dart';
|
||||
import '../../../shared/widgets/adaptive_scaffold.dart';
|
||||
@@ -57,6 +58,30 @@ class SettingsScreen extends ConsumerWidget {
|
||||
onChanged: (value) =>
|
||||
_update(ref, settings.copyWith(allowDownloads: value)),
|
||||
),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: settings.mediaQualityMode,
|
||||
decoration: const InputDecoration(labelText: 'Media quality'),
|
||||
items: [
|
||||
for (final mode in MediaQualityMode.values)
|
||||
DropdownMenuItem(value: mode.name, child: Text(mode.label)),
|
||||
],
|
||||
onChanged: (value) => _update(
|
||||
ref,
|
||||
settings.copyWith(mediaQualityMode: value ?? 'auto'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => context.go('/providers'),
|
||||
icon: const Icon(Icons.hub_rounded),
|
||||
label: const Text('Providers'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => context.go('/providers/check'),
|
||||
icon: const Icon(Icons.network_check_rounded),
|
||||
label: const Text('Provider diagnostics'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.hideViewedPosts,
|
||||
title: const Text('Hide viewed posts'),
|
||||
|
||||
@@ -27,6 +27,19 @@ class AppShell extends StatelessWidget {
|
||||
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF):
|
||||
const _NavigateIntent('/search'),
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(),
|
||||
LogicalKeySet(LogicalKeyboardKey.digit1): const _NavigateIntent('/'),
|
||||
LogicalKeySet(LogicalKeyboardKey.digit2):
|
||||
const _NavigateIntent('/search'),
|
||||
LogicalKeySet(LogicalKeyboardKey.digit3):
|
||||
const _NavigateIntent('/favorites'),
|
||||
LogicalKeySet(LogicalKeyboardKey.digit4):
|
||||
const _NavigateIntent('/viewed'),
|
||||
LogicalKeySet(LogicalKeyboardKey.digit5):
|
||||
const _NavigateIntent('/collections'),
|
||||
LogicalKeySet(LogicalKeyboardKey.digit6):
|
||||
const _NavigateIntent('/providers'),
|
||||
LogicalKeySet(LogicalKeyboardKey.digit7):
|
||||
const _NavigateIntent('/settings'),
|
||||
},
|
||||
child: Actions(
|
||||
actions: {
|
||||
|
||||
@@ -16,6 +16,10 @@ class PostCard extends StatefulWidget {
|
||||
required this.onOpen,
|
||||
required this.onFavorite,
|
||||
this.onAddToCollection,
|
||||
this.onPreview,
|
||||
this.onToggleSelected,
|
||||
this.selectionMode = false,
|
||||
this.selected = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -27,6 +31,10 @@ class PostCard extends StatefulWidget {
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onFavorite;
|
||||
final VoidCallback? onAddToCollection;
|
||||
final VoidCallback? onPreview;
|
||||
final VoidCallback? onToggleSelected;
|
||||
final bool selectionMode;
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
State<PostCard> createState() => _PostCardState();
|
||||
@@ -46,7 +54,24 @@ class _PostCardState extends State<PostCard> {
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onOpen,
|
||||
onTap: () {
|
||||
if (widget.selectionMode) {
|
||||
widget.onToggleSelected?.call();
|
||||
return;
|
||||
}
|
||||
widget.onOpen();
|
||||
},
|
||||
onLongPress: () {
|
||||
if (widget.selectionMode && widget.onToggleSelected != null) {
|
||||
widget.onToggleSelected!();
|
||||
return;
|
||||
}
|
||||
if (widget.onPreview != null) {
|
||||
widget.onPreview!();
|
||||
return;
|
||||
}
|
||||
widget.onToggleSelected?.call();
|
||||
},
|
||||
onSecondaryTapDown: (details) {
|
||||
showMenu<void>(
|
||||
context: context,
|
||||
@@ -119,6 +144,29 @@ class _PostCardState extends State<PostCard> {
|
||||
child: _SeenBadge(),
|
||||
),
|
||||
],
|
||||
if (widget.selected)
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.24),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Icon(Icons.check_circle_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (mobile)
|
||||
Positioned(
|
||||
right: 8,
|
||||
|
||||
@@ -16,6 +16,10 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
required this.onOpen,
|
||||
required this.onFavorite,
|
||||
this.onAddToCollection,
|
||||
this.onPreview,
|
||||
this.onToggleSelected,
|
||||
this.selectionMode = false,
|
||||
this.selectedKeys = const {},
|
||||
this.favoriteKeys = const {},
|
||||
this.viewedKeys = const {},
|
||||
this.loading = false,
|
||||
@@ -31,6 +35,10 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
final ValueChanged<Post> onOpen;
|
||||
final ValueChanged<Post> onFavorite;
|
||||
final ValueChanged<Post>? onAddToCollection;
|
||||
final ValueChanged<Post>? onPreview;
|
||||
final ValueChanged<Post>? onToggleSelected;
|
||||
final bool selectionMode;
|
||||
final Set<String> selectedKeys;
|
||||
final Set<String> favoriteKeys;
|
||||
final Set<String> viewedKeys;
|
||||
final bool loading;
|
||||
@@ -57,6 +65,11 @@ class PostMasonryGrid extends StatelessWidget {
|
||||
isViewed: viewedKeys.contains(post.cacheKey),
|
||||
onOpen: () => onOpen(post),
|
||||
onFavorite: () => onFavorite(post),
|
||||
onPreview: onPreview == null ? null : () => onPreview!(post),
|
||||
selectionMode: selectionMode,
|
||||
selected: selectedKeys.contains(post.cacheKey),
|
||||
onToggleSelected:
|
||||
onToggleSelected == null ? null : () => onToggleSelected!(post),
|
||||
onAddToCollection:
|
||||
onAddToCollection == null ? null : () => onAddToCollection!(post),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user