diff --git a/lib/api/danbooru/danbooru_api.dart b/lib/api/danbooru/danbooru_api.dart index 69f4ece68..436b88a9d 100644 --- a/lib/api/danbooru/danbooru_api.dart +++ b/lib/api/danbooru/danbooru_api.dart @@ -300,4 +300,20 @@ abstract class DanbooruApi { Future deleteFavoriteGroup( @Path() int groupId, ); + + @GET('/forum_topics.json') + Future getForumTopics({ + @Query('page') int? page, + @Query('search[order]') String? order, + @Query('limit') int? limit, + @Query('only') String? only, + }); + + @GET('/forum_posts.json') + Future getForumPosts({ + @Query('page') String? page, + @Query('search[topic_id]') int? topicId, + @Query('limit') int? limit, + @Query('only') String? only, + }); } diff --git a/lib/boorus/core/feats/dtext/bbcode.dart b/lib/boorus/core/feats/dtext/bbcode.dart new file mode 100644 index 000000000..ff5a57637 --- /dev/null +++ b/lib/boorus/core/feats/dtext/bbcode.dart @@ -0,0 +1,52 @@ +part of 'dtext_grammar.dart'; + +Parser taggedElement(String tag) => (string('[$tag') & + ref0(attribute).optional() & + string(']') & + (string('[/$tag]').not() & any()).star().flatten() & + string('[/$tag]')) + .map((value) => BBCode(tag, value[3], value[1])); + +Parser attribute() => (char('=') & (char(']').not() & any()).star().flatten()) + .map((value) => value[1]); + +Parser bbcode() => + ref1(taggedElement, 'b') | + ref1(taggedElement, 'i') | + ref1(taggedElement, 'u') | + ref1(taggedElement, 's') | + ref1(taggedElement, 'spoiler') | + ref1(taggedElement, 'expand') | + ref1(taggedElement, 'quote'); + +enum BBCodeTagType { + bold, + italic, + underline, + strikethrough, + spoiler, + expand, + quote, +} + +class BBCode { + BBCode( + this.tag, + this.text, + this.attributes, + ); + final String tag; + final String text; + final String? attributes; + + BBCodeTagType get type => switch (tag) { + 'b' => BBCodeTagType.bold, + 'i' => BBCodeTagType.italic, + 'u' => BBCodeTagType.underline, + 's' => BBCodeTagType.strikethrough, + 'spoiler' => BBCodeTagType.spoiler, + 'expand' => BBCodeTagType.expand, + 'quote' => BBCodeTagType.quote, + _ => throw Exception('Unknown tag type: $tag') + }; +} diff --git a/lib/boorus/core/feats/dtext/common.dart b/lib/boorus/core/feats/dtext/common.dart new file mode 100644 index 000000000..5c63a27e5 --- /dev/null +++ b/lib/boorus/core/feats/dtext/common.dart @@ -0,0 +1,7 @@ +part of 'dtext_grammar.dart'; + +Parser url() => + char('<').optional() & + (string('http://') | string('https://')) & + pattern('a-zA-Z0-9./-_,?=&;%:+').plus().flatten() & + char('>').optional(); diff --git a/lib/boorus/core/feats/dtext/dtext_grammar.dart b/lib/boorus/core/feats/dtext/dtext_grammar.dart new file mode 100644 index 000000000..72146373a --- /dev/null +++ b/lib/boorus/core/feats/dtext/dtext_grammar.dart @@ -0,0 +1,33 @@ +// Package imports: +import 'package:petitparser/petitparser.dart'; + +part 'common.dart'; +part 'link.dart'; +part 'bbcode.dart'; +part 'line_break.dart'; + +class DTextGrammarDefinition extends GrammarDefinition { + DTextGrammarDefinition({ + required this.tagSearchUrl, + }); + + final String tagSearchUrl; + + @override + Parser start() => ref0(document); + + Parser document() => (ref0(bbcode) | + ref0(link) | + ref1(internalLink, tagSearchUrl) | + ref0(lineBreak) | + ref0(normalText)) + .star(); + + Parser normalText() => (ref0(bbcode).not() & + ref0(lineBreak).not() & + ref1(internalLink, tagSearchUrl).not() & + ref0(link).not() & + any()) + .plus() + .flatten(); +} diff --git a/lib/boorus/core/feats/dtext/html_converter.dart b/lib/boorus/core/feats/dtext/html_converter.dart new file mode 100644 index 000000000..2dad7495d --- /dev/null +++ b/lib/boorus/core/feats/dtext/html_converter.dart @@ -0,0 +1,46 @@ +// Project imports: +import 'package:boorusama/boorus/core/feats/boorus/boorus.dart'; +import 'dtext_grammar.dart'; + +String dtext( + String value, { + required Booru booru, +}) { + final tagSearchUrl = '${booru.url}/posts?tags='; + final result = + DTextGrammarDefinition(tagSearchUrl: tagSearchUrl).build().parse(value); + return result.isSuccess ? grammarToHtmlString(result.value) : value; +} + +String grammarToHtmlString(List value) { + final buffer = StringBuffer(); + for (final element in value) { + final data = mapDataToString(element); + buffer.write(data); + } + return buffer.toString(); +} + +String mapDataToString(dynamic data) => switch (data) { + BBCode c => parseBBcodeToHtml(c), + LineBreakElement => '
', + UrlElement url => parseUrl(url), + String => data, + _ => data.toString(), + }; + +String parseUrl(UrlElement url) { + final displayText = url.displayText ?? url.url; + return '$displayText'; +} + +String parseBBcodeToHtml(BBCode text) => switch (text.tag) { + 'b' => '${text.text}', + 'i' => '${text.text}', + 'u' => '${text.text}', + 's' => '${text.text}', + 'expand' => + '
${text.attributes ?? 'Show'}${text.text}
', + 'quote' => '
${text.text}
', + _ => text.text + }; diff --git a/lib/boorus/core/feats/dtext/line_break.dart b/lib/boorus/core/feats/dtext/line_break.dart new file mode 100644 index 000000000..d08dec7ca --- /dev/null +++ b/lib/boorus/core/feats/dtext/line_break.dart @@ -0,0 +1,7 @@ +part of 'dtext_grammar.dart'; + +Parser lineBreak() => string(r'\r\n').map((value) => const LineBreakElement()); + +class LineBreakElement { + const LineBreakElement(); +} diff --git a/lib/boorus/core/feats/dtext/link.dart b/lib/boorus/core/feats/dtext/link.dart new file mode 100644 index 000000000..ca102f1bc --- /dev/null +++ b/lib/boorus/core/feats/dtext/link.dart @@ -0,0 +1,45 @@ +part of 'dtext_grammar.dart'; + +Parser link() => + ref0(urlRawLink) | ref0(customTextLink) | ref0(markdownStyleLink); + +Parser internalLink(String baseUrl) => + ref1(tagSearchLink, baseUrl) | ref1(wikiLink, baseUrl); + +Parser urlRawLink() => url().flatten().map((value) => UrlElement(value)); +Parser markdownStyleLink() => (char('[') & + pattern('^]').star().flatten() & + char(']') & + char('(') & + ref0(url).flatten() & + char(')')) + .map((value) => UrlElement(value[1], displayText: value[3])); + +Parser customTextLink() => (char('"') & + pattern('^"').star().flatten() & + char('"') & + char(':') & + char('[') & + ref0(url).flatten() & + char(']')) + .map((value) => UrlElement(value[5], displayText: value[1])); + +Parser tagSearchLink(String searchUrl) => (string('{{') & + pattern('^}').star().flatten() & + string('}}')) + .map((value) => UrlElement('$searchUrl${value[1]}', displayText: value[1])); + +Parser wikiLink(String wikiUrl) => (string('[[') & + pattern('^]').star().flatten() & + string(']]')) + .map((value) => UrlElement('$wikiUrl${value[1]}', displayText: value[1])); + +class UrlElement { + final String url; + final String? displayText; + + UrlElement(this.url, {this.displayText}); + + @override + String toString() => 'UrlElement{url: $url, displayText: $displayText}'; +} diff --git a/lib/boorus/core/widgets/posts/information_section.dart b/lib/boorus/core/widgets/posts/information_section.dart index a02ca2de8..05db0e31e 100644 --- a/lib/boorus/core/widgets/posts/information_section.dart +++ b/lib/boorus/core/widgets/posts/information_section.dart @@ -12,6 +12,7 @@ import 'package:boorusama/dart.dart'; import 'package:boorusama/flutter.dart'; import 'package:boorusama/foundation/theme/theme_mode.dart'; import 'package:boorusama/functional.dart'; +import 'package:boorusama/widgets/compact_chip.dart'; import 'package:boorusama/widgets/widgets.dart'; class InformationSection extends StatelessWidget { @@ -79,29 +80,14 @@ class InformationSection extends StatelessWidget { Flexible( child: artistTags.firstOrNull.toOption().fold( () => const SizedBox.shrink(), - (artist) => Material( - borderRadius: BorderRadius.circular(6), - color: getTagColor( + (artist) => CompactChip( + label: artist.removeUnderscoreWithSpace(), + onTap: () => + onArtistTagTap?.call(context, artist), + backgroundColor: getTagColor( TagCategory.artist, ThemeMode.amoledDark, ), - child: InkWell( - borderRadius: BorderRadius.circular(6), - onTap: () => - onArtistTagTap?.call(context, artist), - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 2, - horizontal: 4, - ), - child: Text( - artist.removeUnderscoreWithSpace(), - style: const TextStyle( - fontWeight: FontWeight.w600, - ), - ), - ), - ), ), ), ), diff --git a/lib/boorus/danbooru/feats/favorites/data/favorite_group_dto.dart b/lib/boorus/danbooru/feats/favorites/data/favorite_group_dto.dart index 979c53bba..24253875e 100644 --- a/lib/boorus/danbooru/feats/favorites/data/favorite_group_dto.dart +++ b/lib/boorus/danbooru/feats/favorites/data/favorite_group_dto.dart @@ -1,5 +1,6 @@ // Project imports: import 'package:boorusama/boorus/danbooru/feats/favorites/favorites.dart'; +import 'package:boorusama/boorus/danbooru/feats/users/users.dart'; class FavoriteGroupDto { FavoriteGroupDto({ diff --git a/lib/boorus/danbooru/feats/favorites/favorites.dart b/lib/boorus/danbooru/feats/favorites/favorites.dart index 8926c7227..eb2d2e8b0 100644 --- a/lib/boorus/danbooru/feats/favorites/favorites.dart +++ b/lib/boorus/danbooru/feats/favorites/favorites.dart @@ -4,12 +4,10 @@ export 'app/favorite_groups_provider.dart'; export 'app/favorites_notifier.dart'; export 'app/favorites_provider.dart'; export 'app/favorite_utils.dart'; -export 'data/creator_dto.dart'; export 'data/favorite_dto.dart'; export 'data/favorite_group_dto.dart'; export 'data/favorite_group_repository.dart'; export 'data/favorite_post_repository_api.dart'; -export 'models/creator.dart'; export 'models/favorite.dart'; export 'models/favorite_group.dart'; export 'models/favorite_group_repository.dart'; diff --git a/lib/boorus/danbooru/feats/favorites/models/favorite_group.dart b/lib/boorus/danbooru/feats/favorites/models/favorite_group.dart index ee87654f5..a92482d54 100644 --- a/lib/boorus/danbooru/feats/favorites/models/favorite_group.dart +++ b/lib/boorus/danbooru/feats/favorites/models/favorite_group.dart @@ -2,7 +2,7 @@ import 'package:equatable/equatable.dart'; // Project imports: -import 'package:boorusama/boorus/danbooru/feats/favorites/models/creator.dart'; +import 'package:boorusama/boorus/danbooru/feats/users/creator.dart'; class FavoriteGroup extends Equatable { const FavoriteGroup({ diff --git a/lib/boorus/danbooru/feats/forums/danbooru_forum_provider.dart b/lib/boorus/danbooru/feats/forums/danbooru_forum_provider.dart new file mode 100644 index 000000000..e7aad3173 --- /dev/null +++ b/lib/boorus/danbooru/feats/forums/danbooru_forum_provider.dart @@ -0,0 +1,40 @@ +// Package imports: +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_infinite_scroll/riverpod_infinite_scroll.dart'; + +// Project imports: +import 'package:boorusama/boorus/danbooru/danbooru_provider.dart'; +import 'package:boorusama/boorus/danbooru/feats/forums/forums.dart'; +import 'package:boorusama/boorus/danbooru/feats/forums/posts/danbooru_forum_post_repository.dart'; +import 'package:boorusama/boorus/danbooru/feats/forums/posts/danbooru_forum_posts_notifier.dart'; + +final danbooruForumTopicRepoProvider = + Provider((ref) { + return DanbooruForumTopicRepositoryApi( + api: ref.watch(danbooruApiProvider), + ); +}); + +final danbooruForumTopicsProvider = StateNotifierProvider.autoDispose< + DanbooruForumTopicsNotifier, PagedState>((ref) { + return DanbooruForumTopicsNotifier( + repo: ref.watch(danbooruForumTopicRepoProvider), + ); +}); + +final danbooruForumPostRepoProvider = + Provider((ref) { + return DanbooruForumPostRepositoryApi( + api: ref.watch(danbooruApiProvider), + ); +}); + +final danbooruForumPostsProvider = StateNotifierProvider.autoDispose.family< + DanbooruForumPostsNotifier, + PagedState, + int>((ref, topicId) { + return DanbooruForumPostsNotifier( + topicId: topicId, + repo: ref.watch(danbooruForumPostRepoProvider), + ); +}); diff --git a/lib/boorus/danbooru/feats/forums/forums.dart b/lib/boorus/danbooru/feats/forums/forums.dart new file mode 100644 index 000000000..9d8975edb --- /dev/null +++ b/lib/boorus/danbooru/feats/forums/forums.dart @@ -0,0 +1,7 @@ +export 'danbooru_forum_provider.dart'; +export 'posts/danbooru_forum_post.dart'; +export 'posts/danbooru_forum_post_dto.dart'; +export 'topics/danbooru_forum_topic.dart'; +export 'topics/danbooru_forum_topic_dto.dart'; +export 'topics/danbooru_forum_topic_repository.dart'; +export 'topics/danbooru_forum_topics_notifier.dart'; diff --git a/lib/boorus/danbooru/feats/forums/posts/danbooru_forum_post.dart b/lib/boorus/danbooru/feats/forums/posts/danbooru_forum_post.dart new file mode 100644 index 000000000..7f9843a69 --- /dev/null +++ b/lib/boorus/danbooru/feats/forums/posts/danbooru_forum_post.dart @@ -0,0 +1,67 @@ +// Package imports: +import 'package:equatable/equatable.dart'; + +// Project imports: +import 'package:boorusama/boorus/danbooru/feats/users/users.dart'; +import 'danbooru_forum_post_dto.dart'; + +class DanbooruForumPost extends Equatable { + const DanbooruForumPost({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.body, + required this.isDeleted, + required this.topicId, + required this.creator, + required this.updater, + }); + + factory DanbooruForumPost.empty() => DanbooruForumPost( + id: -1, + createdAt: DateTime(1), + updatedAt: DateTime(1), + body: '', + isDeleted: false, + topicId: -1, + creator: Creator.empty(), + updater: Creator.empty(), + ); + + final int id; + final DateTime createdAt; + final DateTime updatedAt; + final String body; + final bool isDeleted; + final int topicId; + final Creator creator; + final Creator updater; + + @override + List get props => [ + id, + createdAt, + updatedAt, + body, + isDeleted, + topicId, + creator, + updater, + ]; +} + +DanbooruForumPost danbooruForumPostDtoToDanbooruForumPost( + DanbooruForumPostDto dto) { + return DanbooruForumPost( + id: dto.id ?? -1, + createdAt: + dto.createdAt != null ? DateTime.parse(dto.createdAt!) : DateTime.now(), + updatedAt: + dto.updatedAt != null ? DateTime.parse(dto.updatedAt!) : DateTime.now(), + body: dto.body ?? 'No Body', + isDeleted: dto.isDeleted ?? false, + topicId: dto.topicId ?? -1, + creator: creatorDtoToCreator(dto.creator), + updater: creatorDtoToCreator(dto.updater), + ); +} diff --git a/lib/boorus/danbooru/feats/forums/posts/danbooru_forum_post_dto.dart b/lib/boorus/danbooru/feats/forums/posts/danbooru_forum_post_dto.dart new file mode 100644 index 000000000..a81914ffa --- /dev/null +++ b/lib/boorus/danbooru/feats/forums/posts/danbooru_forum_post_dto.dart @@ -0,0 +1,40 @@ +// Project imports: +import 'package:boorusama/boorus/danbooru/feats/users/users.dart'; + +class DanbooruForumPostDto { + DanbooruForumPostDto({ + this.id, + this.createdAt, + this.updatedAt, + this.body, + this.isDeleted, + this.topicId, + this.creator, + this.updater, + }); + + factory DanbooruForumPostDto.fromJson(Map json) => + DanbooruForumPostDto( + id: json['id'], + createdAt: json['created_at'], + updatedAt: json['updated_at'], + body: json['body'], + isDeleted: json['is_deleted'], + topicId: json['topic_id'], + creator: json['creator'] != null + ? CreatorDto.fromJson(json['creator']) + : null, + updater: json['updater'] != null + ? CreatorDto.fromJson(json['updater']) + : null, + ); + + final int? id; + final String? createdAt; + final String? updatedAt; + final String? body; + final bool? isDeleted; + final int? topicId; + final CreatorDto? creator; + final CreatorDto? updater; +} diff --git a/lib/boorus/danbooru/feats/forums/posts/danbooru_forum_post_repository.dart b/lib/boorus/danbooru/feats/forums/posts/danbooru_forum_post_repository.dart new file mode 100644 index 000000000..8b069eadc --- /dev/null +++ b/lib/boorus/danbooru/feats/forums/posts/danbooru_forum_post_repository.dart @@ -0,0 +1,58 @@ +// Project imports: +import 'package:boorusama/api/danbooru/danbooru_api.dart'; +import 'package:boorusama/boorus/danbooru/feats/forums/forums.dart'; +import 'package:boorusama/foundation/error.dart'; +import 'package:boorusama/foundation/http/http.dart'; +import 'package:boorusama/functional.dart'; + +typedef DanbooruForumPostsOrError + = TaskEither>; + +abstract interface class DanbooruForumPostRepository { + DanbooruForumPostsOrError getForumPosts(int topicId, int lastForumPostId); +} + +extension DanbooruForumPostRepositoryX on DanbooruForumPostRepository { + Future> getForumPostsOrEmpty( + int topicId, + int lastForumPostId, + ) => + getForumPosts(topicId, lastForumPostId) + .run() + .then((value) => value.getOrElse((e) => [])); +} + +const _forumPostParams = + 'id,creator,updater,topic_id,body,created_at,updated_at,is_deleted'; + +class DanbooruForumPostRepositoryApi implements DanbooruForumPostRepository { + DanbooruForumPostRepositoryApi({ + required this.api, + }); + + final DanbooruApi api; + final limit = 20; + + @override + DanbooruForumPostsOrError getForumPosts(int topicId, int lastForumPostId) => + TaskEither.Do(($) async { + var response = await $(tryParseResponse( + fetcher: () => api.getForumPosts( + topicId: topicId, + page: + 'a${lastForumPostId - 1}', // offset by one to account for the last post + only: _forumPostParams, + limit: limit, + ), + )); + + var data = parseResponse( + value: response, + converter: (item) => DanbooruForumPostDto.fromJson(item), + ).map(danbooruForumPostDtoToDanbooruForumPost).toList(); + + data.sort((a, b) => a.id.compareTo(b.id)); + + return data; + }); +} diff --git a/lib/boorus/danbooru/feats/forums/posts/danbooru_forum_posts_notifier.dart b/lib/boorus/danbooru/feats/forums/posts/danbooru_forum_posts_notifier.dart new file mode 100644 index 000000000..3204fd436 --- /dev/null +++ b/lib/boorus/danbooru/feats/forums/posts/danbooru_forum_posts_notifier.dart @@ -0,0 +1,20 @@ +// Package imports: +import 'package:riverpod_infinite_scroll/riverpod_infinite_scroll.dart'; + +// Project imports: +import 'package:boorusama/boorus/danbooru/feats/forums/forums.dart'; +import 'package:boorusama/boorus/danbooru/feats/forums/posts/danbooru_forum_post_repository.dart'; + +class DanbooruForumPostsNotifier extends PagedNotifier { + DanbooruForumPostsNotifier({ + required int topicId, + required DanbooruForumPostRepository repo, + }) : super( + load: (key, limit) => repo.getForumPostsOrEmpty(topicId, key), + nextPageKeyBuilder: (lastItems, page, limit) => (lastItems == null || + lastItems.isEmpty || + lastItems.length < limit) + ? null + : lastItems.last.id, + ); +} diff --git a/lib/boorus/danbooru/feats/forums/topics/danbooru_forum_topic.dart b/lib/boorus/danbooru/feats/forums/topics/danbooru_forum_topic.dart new file mode 100644 index 000000000..001f0d794 --- /dev/null +++ b/lib/boorus/danbooru/feats/forums/topics/danbooru_forum_topic.dart @@ -0,0 +1,71 @@ +// Package imports: +import 'package:equatable/equatable.dart'; + +// Project imports: +import 'package:boorusama/boorus/danbooru/feats/forums/forums.dart'; +import 'package:boorusama/boorus/danbooru/feats/users/creator.dart'; + +enum DanbooruTopicCategory { + general, + tags, + bugsAndFeatures, +} + +class DanbooruForumTopic extends Equatable { + const DanbooruForumTopic({ + required this.id, + required this.creator, + required this.updater, + required this.title, + required this.responseCount, + required this.isSticky, + required this.isLocked, + required this.createdAt, + required this.updatedAt, + required this.isDeleted, + required this.category, + required this.originalPost, + }); + + final int id; + final Creator creator; + final Creator updater; + final String title; + final int responseCount; + final bool isSticky; + final bool isLocked; + final DateTime createdAt; + final DateTime updatedAt; + final bool isDeleted; + final DanbooruTopicCategory category; + + final DanbooruForumPost originalPost; + + @override + List get props => [ + id, + creator, + updater, + title, + responseCount, + isSticky, + isLocked, + createdAt, + updatedAt, + isDeleted, + category, + originalPost, + ]; +} + +DanbooruTopicCategory intToDanbooruTopicCategory(int value) => switch (value) { + 1 => DanbooruTopicCategory.tags, + 2 => DanbooruTopicCategory.bugsAndFeatures, + _ => DanbooruTopicCategory.general + }; + +int danbooruTopicCategoryToInt(DanbooruTopicCategory value) => switch (value) { + DanbooruTopicCategory.general => 0, + DanbooruTopicCategory.tags => 1, + DanbooruTopicCategory.bugsAndFeatures => 2, + }; diff --git a/lib/boorus/danbooru/feats/forums/topics/danbooru_forum_topic_dto.dart b/lib/boorus/danbooru/feats/forums/topics/danbooru_forum_topic_dto.dart new file mode 100644 index 000000000..396a37c6e --- /dev/null +++ b/lib/boorus/danbooru/feats/forums/topics/danbooru_forum_topic_dto.dart @@ -0,0 +1,58 @@ +// Project imports: +import 'package:boorusama/boorus/danbooru/feats/forums/forums.dart'; +import 'package:boorusama/boorus/danbooru/feats/users/users.dart'; + +class DanbooruForumTopicDto { + final int? id; + final CreatorDto? creator; + final CreatorDto? updater; + final String? title; + final int? responseCount; + final bool? isSticky; + final bool? isLocked; + final String? createdAt; + final String? updatedAt; + final bool? isDeleted; + final int? categoryId; + final int? minLevel; + + final DanbooruForumPostDto? originalPost; + + DanbooruForumTopicDto({ + this.id, + this.creator, + this.updater, + this.title, + this.responseCount, + this.isSticky, + this.isLocked, + this.createdAt, + this.updatedAt, + this.isDeleted, + this.categoryId, + this.minLevel, + this.originalPost, + }); + + factory DanbooruForumTopicDto.fromJson(Map json) { + return DanbooruForumTopicDto( + id: json['id'], + creator: + json['creator'] != null ? CreatorDto.fromJson(json['creator']) : null, + updater: + json['updater'] != null ? CreatorDto.fromJson(json['updater']) : null, + title: json['title'], + responseCount: json['response_count'], + isSticky: json['is_sticky'], + isLocked: json['is_locked'], + createdAt: json['created_at'], + updatedAt: json['updated_at'], + isDeleted: json['is_deleted'], + categoryId: json['category_id'], + minLevel: json['min_level'], + originalPost: json['original_post'] != null + ? DanbooruForumPostDto.fromJson(json['original_post']) + : null, + ); + } +} diff --git a/lib/boorus/danbooru/feats/forums/topics/danbooru_forum_topic_repository.dart b/lib/boorus/danbooru/feats/forums/topics/danbooru_forum_topic_repository.dart new file mode 100644 index 000000000..34c819958 --- /dev/null +++ b/lib/boorus/danbooru/feats/forums/topics/danbooru_forum_topic_repository.dart @@ -0,0 +1,78 @@ +// Project imports: +import 'package:boorusama/api/danbooru/danbooru_api.dart'; +import 'package:boorusama/boorus/danbooru/feats/forums/forums.dart'; +import 'package:boorusama/boorus/danbooru/feats/users/users.dart'; +import 'package:boorusama/foundation/error.dart'; +import 'package:boorusama/foundation/http/http.dart'; +import 'package:boorusama/functional.dart'; + +typedef DanbooruForumTopicsOrError + = TaskEither>; + +abstract interface class DanbooruForumTopicRepository { + DanbooruForumTopicsOrError getForumTopics(int page); +} + +extension DanbooruForumTopicRepositoryX on DanbooruForumTopicRepository { + Future> getForumTopicsOrEmpty(int page) => + getForumTopics(page) + .run() + .then((value) => value.getOrElse((e) => [].lock)); +} + +const _forumParams = + 'id,creator,updater,title,response_count,is_sticky,is_locked,created_at,updated_at,is_deleted,category_id,category_id,min_level,original_post'; + +class DanbooruForumTopicRepositoryApi implements DanbooruForumTopicRepository { + DanbooruForumTopicRepositoryApi({ + required this.api, + }); + + final DanbooruApi api; + + @override + DanbooruForumTopicsOrError getForumTopics(int page) => + TaskEither.Do(($) async { + var response = await $(tryParseResponse( + fetcher: () => api.getForumTopics( + order: 'sticky', + page: page, + limit: 50, + only: _forumParams, + ), + )); + + var data = parseResponse( + value: response, + converter: (item) => DanbooruForumTopicDto.fromJson(item), + ).map(danbooruForumTopicDtoToDanbooruForumTopic).toIList(); + + return data; + }); +} + +DanbooruForumTopic danbooruForumTopicDtoToDanbooruForumTopic( + DanbooruForumTopicDto dto, +) => + DanbooruForumTopic( + id: dto.id ?? 0, + creator: creatorDtoToCreator(dto.creator), + updater: creatorDtoToCreator(dto.updater), + title: dto.title ?? '', + responseCount: dto.responseCount ?? 0, + isSticky: dto.isSticky ?? false, + isLocked: dto.isLocked ?? false, + createdAt: dto.createdAt != null + ? DateTime.parse(dto.createdAt!) + : DateTime.now(), + updatedAt: dto.updatedAt != null + ? DateTime.parse(dto.updatedAt!) + : DateTime.now(), + isDeleted: dto.isDeleted ?? false, + category: dto.categoryId != null + ? intToDanbooruTopicCategory(dto.categoryId!) + : DanbooruTopicCategory.general, + originalPost: dto.originalPost != null + ? danbooruForumPostDtoToDanbooruForumPost(dto.originalPost!) + : DanbooruForumPost.empty(), + ); diff --git a/lib/boorus/danbooru/feats/forums/topics/danbooru_forum_topics_notifier.dart b/lib/boorus/danbooru/feats/forums/topics/danbooru_forum_topics_notifier.dart new file mode 100644 index 000000000..6d426776a --- /dev/null +++ b/lib/boorus/danbooru/feats/forums/topics/danbooru_forum_topics_notifier.dart @@ -0,0 +1,16 @@ +// Package imports: +import 'package:riverpod_infinite_scroll/riverpod_infinite_scroll.dart'; + +// Project imports: +import 'package:boorusama/boorus/danbooru/feats/forums/forums.dart'; + +class DanbooruForumTopicsNotifier + extends PagedNotifier { + DanbooruForumTopicsNotifier({ + required DanbooruForumTopicRepository repo, + }) : super( + load: (key, limit) => + repo.getForumTopicsOrEmpty(key).then((value) => value.unlock), + nextPageKeyBuilder: NextPageKeyBuilderDefault.mysqlPagination, + ); +} diff --git a/lib/boorus/danbooru/feats/favorites/models/creator.dart b/lib/boorus/danbooru/feats/users/creator.dart similarity index 100% rename from lib/boorus/danbooru/feats/favorites/models/creator.dart rename to lib/boorus/danbooru/feats/users/creator.dart diff --git a/lib/boorus/danbooru/feats/favorites/data/creator_dto.dart b/lib/boorus/danbooru/feats/users/creator_dto.dart similarity index 85% rename from lib/boorus/danbooru/feats/favorites/data/creator_dto.dart rename to lib/boorus/danbooru/feats/users/creator_dto.dart index 1abf7f384..c60da25f0 100644 --- a/lib/boorus/danbooru/feats/favorites/data/creator_dto.dart +++ b/lib/boorus/danbooru/feats/users/creator_dto.dart @@ -1,5 +1,4 @@ // Project imports: -import 'package:boorusama/boorus/danbooru/feats/favorites/favorites.dart'; import 'package:boorusama/boorus/danbooru/feats/users/users.dart'; class CreatorDto { @@ -49,10 +48,10 @@ class CreatorDto { final String? levelString; } -Creator creatorDtoToCreator(CreatorDto d) { - return Creator( - id: d.id!, - name: d.name ?? '', - level: d.level == null ? UserLevel.member : intToUserLevel(d.level!), - ); -} +Creator creatorDtoToCreator(CreatorDto? d) => d != null + ? Creator( + id: d.id!, + name: d.name ?? '', + level: d.level == null ? UserLevel.member : intToUserLevel(d.level!), + ) + : Creator.empty(); diff --git a/lib/boorus/danbooru/feats/users/users.dart b/lib/boorus/danbooru/feats/users/users.dart index 0b1911f2b..aead4d1b5 100644 --- a/lib/boorus/danbooru/feats/users/users.dart +++ b/lib/boorus/danbooru/feats/users/users.dart @@ -1,6 +1,8 @@ export 'app/current_user_notifier.dart'; export 'app/users_notifier.dart'; export 'app/users_provider.dart'; +export 'creator.dart'; +export 'creator_dto.dart'; export 'data/user_dto.dart'; export 'data/user_repository.dart'; export 'data/user_self_dto.dart'; diff --git a/lib/boorus/danbooru/pages/forums/danbooru_forum_page.dart b/lib/boorus/danbooru/pages/forums/danbooru_forum_page.dart new file mode 100644 index 000000000..9219092ce --- /dev/null +++ b/lib/boorus/danbooru/pages/forums/danbooru_forum_page.dart @@ -0,0 +1,58 @@ +// Flutter imports: +import 'package:flutter/material.dart'; + +// Package imports: +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:riverpod_infinite_scroll/riverpod_infinite_scroll.dart'; + +// Project imports: +import 'package:boorusama/boorus/core/feats/user_level_colors.dart'; +import 'package:boorusama/boorus/danbooru/feats/forums/forums.dart'; +import 'package:boorusama/boorus/danbooru/router.dart'; +import 'package:boorusama/flutter.dart'; +import 'package:boorusama/foundation/i18n.dart'; +import 'danbooru_forum_posts_page.dart'; +import 'forum_card.dart'; + +class DanbooruForumPage extends ConsumerWidget { + const DanbooruForumPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar( + title: const Text('forum.forum').tr(), + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: RiverPagedBuilder.autoDispose( + firstPageProgressIndicatorBuilder: (context, controller) => + const Center( + child: CircularProgressIndicator.adaptive(), + ), + pullToRefresh: false, + firstPageKey: 1, + provider: danbooruForumTopicsProvider, + itemBuilder: (context, topic, index) => ForumCard( + title: topic.title, + responseCount: topic.responseCount, + createdAt: topic.createdAt, + creatorName: topic.creator.name, + creatorColor: topic.creator.level.toColor(), + onCreatorTap: () => + goToUserDetailsPage(ref, context, uid: topic.creator.id), + onTap: () => context.navigator.push(MaterialPageRoute( + builder: (_) => DanbooruForumPostsPage( + topicId: topic.id, + originalPostId: topic.originalPost.id, + ))), + ), + pagedBuilder: (controller, builder) => PagedListView( + pagingController: controller, + builderDelegate: builder, + ), + ), + ); + } +} diff --git a/lib/boorus/danbooru/pages/forums/danbooru_forum_posts_page.dart b/lib/boorus/danbooru/pages/forums/danbooru_forum_posts_page.dart new file mode 100644 index 000000000..56507309f --- /dev/null +++ b/lib/boorus/danbooru/pages/forums/danbooru_forum_posts_page.dart @@ -0,0 +1,85 @@ +// Flutter imports: +import 'package:flutter/material.dart'; + +// Package imports: +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:riverpod_infinite_scroll/riverpod_infinite_scroll.dart'; + +// Project imports: +import 'package:boorusama/boorus/core/feats/boorus/providers.dart'; +import 'package:boorusama/boorus/core/feats/dtext/html_converter.dart'; +import 'package:boorusama/boorus/core/utils.dart'; +import 'package:boorusama/boorus/danbooru/feats/forums/forums.dart'; +import 'package:boorusama/boorus/danbooru/pages/forums/forum_post_header.dart'; +import 'package:boorusama/boorus/danbooru/router.dart'; + +class DanbooruForumPostsPage extends ConsumerWidget { + const DanbooruForumPostsPage({ + super.key, + required this.topicId, + required this.originalPostId, + }); + + final int topicId; + final int originalPostId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final booru = ref.watch(currentBooruProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Forum Posts'), + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: RiverPagedBuilder.autoDispose( + firstPageProgressIndicatorBuilder: (context, controller) => + const Center( + child: CircularProgressIndicator.adaptive(), + ), + pullToRefresh: false, + firstPageKey: originalPostId, + provider: danbooruForumPostsProvider(topicId), + itemBuilder: (context, post, index) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ForumPostHeader( + authorName: post.creator.name, + createdAt: post.createdAt, + authorLevel: post.creator.level, + onTap: () => + goToUserDetailsPage(ref, context, uid: post.creator.id), + ), + Html( + onLinkTap: (url, context, attributes, element) => + url != null ? launchExternalUrlString(url) : null, + style: { + 'body': Style( + margin: + const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + ), + 'blockquote': Style( + padding: const EdgeInsets.only(left: 8), + margin: const EdgeInsets.only(left: 4, bottom: 16), + border: const Border( + left: BorderSide(color: Colors.grey, width: 3)), + ) + }, + data: dtext(post.body, booru: booru), + ), + ], + ), + ), + pagedBuilder: (controller, builder) => PagedListView( + pagingController: controller, + builderDelegate: builder, + ), + ), + ); + } +} diff --git a/lib/boorus/danbooru/pages/forums/forum_card.dart b/lib/boorus/danbooru/pages/forums/forum_card.dart new file mode 100644 index 000000000..84801274b --- /dev/null +++ b/lib/boorus/danbooru/pages/forums/forum_card.dart @@ -0,0 +1,89 @@ +// Flutter imports: +import 'package:flutter/material.dart'; + +// Project imports: +import 'package:boorusama/dart.dart'; +import 'package:boorusama/foundation/i18n.dart'; +import 'package:boorusama/widgets/compact_chip.dart'; + +class ForumCard extends StatelessWidget { + const ForumCard({ + super.key, + required this.title, + required this.responseCount, + required this.createdAt, + required this.creatorName, + required this.creatorColor, + this.isSticky = false, + this.isLocked = false, + this.onTap, + this.onCreatorTap, + }); + + final bool isSticky; + final bool isLocked; + final int responseCount; + final String title; + final DateTime createdAt; + final String creatorName; + final Color creatorColor; + final VoidCallback? onTap; + final VoidCallback? onCreatorTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isSticky) + Icon( + Icons.push_pin_outlined, + size: 20, + color: Theme.of(context).hintColor, + ), + if (isLocked) + Icon( + Icons.lock_outline, + size: 20, + color: Theme.of(context).hintColor, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + ), + )), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + CompactChip( + label: creatorName.replaceAll('_', ' '), + backgroundColor: creatorColor, + onTap: onCreatorTap, + ), + const SizedBox(width: 8), + Text('Replies: $responseCount | '), + Expanded( + child: Text(createdAt.fuzzify(locale: context.locale))), + ], + ) + ], + ), + ), + ), + ); + } +} diff --git a/lib/boorus/danbooru/pages/forums/forum_post_header.dart b/lib/boorus/danbooru/pages/forums/forum_post_header.dart new file mode 100644 index 000000000..5e45dc00f --- /dev/null +++ b/lib/boorus/danbooru/pages/forums/forum_post_header.dart @@ -0,0 +1,52 @@ +// Flutter imports: +import 'package:flutter/material.dart'; + +// Project imports: +import 'package:boorusama/boorus/core/feats/user_level_colors.dart'; +import 'package:boorusama/boorus/danbooru/feats/users/users.dart'; +import 'package:boorusama/foundation/i18n.dart'; + +class ForumPostHeader extends StatelessWidget { + const ForumPostHeader({ + super.key, + required this.authorName, + this.authorLevel, + required this.createdAt, + this.onTap, + }); + + final String authorName; + final UserLevel? authorLevel; + final DateTime createdAt; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Wrap( + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + InkWell( + onTap: onTap, + child: Text( + authorName.replaceAll('_', ' '), + style: TextStyle( + color: authorLevel != null + ? Color(getUserHexColor(authorLevel!)) + : null, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox( + width: 6, + ), + Text( + DateFormat('MMM d, yyyy hh:mm a').format(createdAt.toLocal()), + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ], + ); + } +} diff --git a/lib/boorus/danbooru/pages/home/other_features_page.dart b/lib/boorus/danbooru/pages/home/other_features_page.dart index 6cad0fa7d..c2e4851e2 100644 --- a/lib/boorus/danbooru/pages/home/other_features_page.dart +++ b/lib/boorus/danbooru/pages/home/other_features_page.dart @@ -1,4 +1,5 @@ // Flutter imports: +import 'package:boorusama/boorus/core/provider.dart'; import 'package:flutter/material.dart'; // Package imports: @@ -18,6 +19,8 @@ class OtherFeaturesPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final booruConfig = ref.watch(currentBooruConfigProvider); final authState = ref.watch(authenticationProvider); + // Only used to force rebuild when language changes + ref.watch(settingsProvider.select((value) => value.language)); return Scaffold( body: SafeArea( @@ -31,6 +34,13 @@ class OtherFeaturesPage extends ConsumerWidget { goToPoolPage(context, ref); }, ), + ListTile( + leading: const Icon(Icons.forum_outlined), + title: const Text('forum.forum').tr(), + onTap: () { + goToForumPage(context); + }, + ), if (authState.isAuthenticated) ...[ ListTile( leading: const Icon(Icons.favorite_outline), @@ -41,14 +51,14 @@ class OtherFeaturesPage extends ConsumerWidget { ), ListTile( leading: const Icon(Icons.collections), - title: const Text('Favorite groups'), + title: const Text('favorite_groups.favorite_groups').tr(), onTap: () { goToFavoriteGroupPage(context); }, ), ListTile( leading: const Icon(Icons.search), - title: const Text('Saved search'), + title: const Text('saved_search.saved_search').tr(), onTap: () { goToSavedSearchPage(context, booruConfig.login); }, diff --git a/lib/boorus/danbooru/router.dart b/lib/boorus/danbooru/router.dart index a13fe5126..47ac62e94 100644 --- a/lib/boorus/danbooru/router.dart +++ b/lib/boorus/danbooru/router.dart @@ -36,6 +36,7 @@ import 'package:boorusama/boorus/danbooru/pages/favorites/create_favorite_group_ import 'package:boorusama/boorus/danbooru/pages/favorites/favorite_group_details_page.dart'; import 'package:boorusama/boorus/danbooru/pages/favorites/favorite_groups_page.dart'; import 'package:boorusama/boorus/danbooru/pages/favorites/favorites_page.dart'; +import 'package:boorusama/boorus/danbooru/pages/forums/danbooru_forum_page.dart'; import 'package:boorusama/boorus/danbooru/pages/pool/pool_detail_page.dart'; import 'package:boorusama/boorus/danbooru/pages/pool/pool_page.dart'; import 'package:boorusama/boorus/danbooru/pages/pool/pool_search_page.dart'; @@ -526,3 +527,11 @@ Future goToAddToBlacklistPage( ), ); } + +void goToForumPage(BuildContext context) { + context.navigator.push(MaterialPageRoute( + builder: (_) => DanbooruProvider( + builder: (_) => const DanbooruForumPage(), + ), + )); +} diff --git a/lib/widgets/compact_chip.dart b/lib/widgets/compact_chip.dart new file mode 100644 index 000000000..412c1101c --- /dev/null +++ b/lib/widgets/compact_chip.dart @@ -0,0 +1,41 @@ +// Flutter imports: +import 'package:flutter/material.dart'; + +class CompactChip extends StatelessWidget { + const CompactChip({ + super.key, + required this.label, + this.onTap, + this.backgroundColor, + this.borderRadius, + }); + + final void Function()? onTap; + final String label; + final Color? backgroundColor; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + return Material( + borderRadius: borderRadius ?? BorderRadius.circular(6), + color: backgroundColor, + child: InkWell( + borderRadius: borderRadius ?? BorderRadius.circular(6), + onTap: () => onTap?.call(), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 2, + horizontal: 4, + ), + child: Text( + label, + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index a2136007d..f5bec7af8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1220,7 +1220,7 @@ packages: source: hosted version: "0.1.2" petitparser: - dependency: transitive + dependency: "direct main" description: name: petitparser sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 diff --git a/pubspec.yaml b/pubspec.yaml index cfc7c6766..ffd8757bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -82,6 +82,7 @@ dependencies: path_provider: ^2.0.11 percent_indicator: ^4.2.2 permission_handler: ^10.2.0 + petitparser: ^5.4.0 photo_view: ^0.14.0 pull_to_refresh: git: