diff --git a/lib/components/grid.dart b/lib/components/grid.dart new file mode 100644 index 0000000..071fad5 --- /dev/null +++ b/lib/components/grid.dart @@ -0,0 +1,36 @@ +import 'package:flutter/widgets.dart'; + +class SliverGridViewWithFixedItemHeight extends StatelessWidget { + const SliverGridViewWithFixedItemHeight( + {required this.delegate, + required this.maxCrossAxisExtent, + required this.itemHeight, + super.key}); + + final SliverChildDelegate delegate; + + final double maxCrossAxisExtent; + + final double itemHeight; + + @override + Widget build(BuildContext context) { + return SliverLayoutBuilder( + builder: ((context, constraints) => SliverGrid( + delegate: delegate, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: maxCrossAxisExtent, + childAspectRatio: + calcChildAspectRatio(constraints.crossAxisExtent)), + ))); + } + + double calcChildAspectRatio(double width) { + var crossItems = width ~/ maxCrossAxisExtent; + if (width % maxCrossAxisExtent != 0) { + crossItems += 1; + } + final itemWidth = width / crossItems; + return itemWidth / itemHeight; + } +} \ No newline at end of file diff --git a/lib/components/user_preview.dart b/lib/components/user_preview.dart new file mode 100644 index 0000000..c9ea8ea --- /dev/null +++ b/lib/components/user_preview.dart @@ -0,0 +1,104 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:pixes/components/animated_image.dart'; +import 'package:pixes/components/color_scheme.dart'; +import 'package:pixes/foundation/app.dart'; +import 'package:pixes/foundation/image_provider.dart'; +import 'package:pixes/pages/user_info_page.dart'; +import 'package:pixes/utils/translation.dart'; + +import '../network/network.dart'; + +class UserPreviewWidget extends StatefulWidget { + const UserPreviewWidget(this.user, {super.key}); + + final UserPreview user; + + @override + State createState() => _UserPreviewWidgetState(); +} + +class _UserPreviewWidgetState extends State { + bool isFollowing = false; + + void follow() async{ + if(isFollowing) return; + setState(() { + isFollowing = true; + }); + var method = widget.user.isFollowed ? "delete" : "add"; + var res = await Network().follow(widget.user.id.toString(), method); + if(res.error) { + if(mounted) { + context.showToast(message: "Network Error"); + } + } else { + widget.user.isFollowed = !widget.user.isFollowed; + } + setState(() { + isFollowing = false; + }); + } + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Row( + children: [ + SizedBox( + width: 64, + height: 64, + child: ClipRRect( + borderRadius: BorderRadius.circular(64), + child: ColoredBox( + color: ColorScheme.of(context).secondaryContainer, + child: AnimatedImage( + image: CachedImageProvider(widget.user.avatar), + fit: BoxFit.cover, + filterQuality: FilterQuality.medium, + ), + ), + ), + ), + const SizedBox(width: 12,), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.user.name, maxLines: 1, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const Spacer(), + Row( + children: [ + Button( + onPressed: () => context.to(() => UserInfoPage(widget.user.id.toString())), + child: Text("View".tl,), + ), + const SizedBox(width: 8,), + if(isFollowing) + Button(onPressed: follow, child: const SizedBox( + width: 42, + height: 24, + child: Center( + child: SizedBox.square( + dimension: 18, + child: ProgressRing(strokeWidth: 2,), + ), + ), + )) + else if (!widget.user.isFollowed) + Button(onPressed: follow, child: Text("Follow".tl)) + else + Button( + onPressed: follow, + child: Text("Unfollow".tl, style: TextStyle(color: ColorScheme.of(context).errorColor),), + ), + ], + ) + ], + ).paddingVertical(8), + ) + ], + ), + ); + } +} diff --git a/lib/network/models.dart b/lib/network/models.dart index 4d01e0b..d36adda 100644 --- a/lib/network/models.dart +++ b/lib/network/models.dart @@ -309,3 +309,36 @@ class SearchOptions { DateTime? endTime; AgeLimit ageLimit = AgeLimit.unlimited; } + +/* +json: +{ + "id": 20542044, + "name": "vocaloidhm01", + "account": "vocaloidhm01", + "profile_image_urls": { + "medium": "https://i.pximg.net/user-profile/img/2023/04/28/00/21/54/24348957_c74a61e78ddccb467417be7c37b5d463_170.jpg" + }, + "is_followed": false, + "is_access_blocking_user": false +} + */ +class UserPreview { + final int id; + final String name; + final String account; + final String avatar; + bool isFollowed; + final bool isBlocking; + + UserPreview(this.id, this.name, this.account, this.avatar, this.isFollowed, + this.isBlocking); + + UserPreview.fromJson(Map json) + : id = json['id'], + name = json['name'], + account = json['account'], + avatar = json['profile_image_urls']['medium'], + isFollowed = json['is_followed'], + isBlocking = json['is_access_blocking_user']; +} diff --git a/lib/network/network.dart b/lib/network/network.dart index 68fc1a1..4497fe6 100644 --- a/lib/network/network.dart +++ b/lib/network/network.dart @@ -302,12 +302,12 @@ class Network { } } - Future>> searchUsers(String keyword, [String? nextUrl]) async{ + Future>> searchUsers(String keyword, [String? nextUrl]) async{ var path = nextUrl ?? "/v1/search/user?filter=for_android&word=${Uri.encodeComponent(keyword)}"; var res = await apiGet(path); if (res.success) { return Res( - (res.data["user_previews"] as List).map((e) => User.fromJson(e)).toList(), + (res.data["user_previews"] as List).map((e) => UserPreview.fromJson(e["user"])).toList(), subData: res.data["next_url"]); } else { return Res.error(res.errorMessage); diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart index c890be3..394419c 100644 --- a/lib/pages/search_page.dart +++ b/lib/pages/search_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:pixes/appdata.dart'; import 'package:pixes/components/loading.dart'; import 'package:pixes/components/page_route.dart'; +import 'package:pixes/components/user_preview.dart'; import 'package:pixes/foundation/app.dart'; import 'package:pixes/network/network.dart'; import 'package:pixes/pages/user_info_page.dart'; @@ -10,6 +11,7 @@ import 'package:pixes/utils/translation.dart'; import '../components/animated_image.dart'; import '../components/color_scheme.dart'; +import '../components/grid.dart'; import '../components/illust_widget.dart'; import '../foundation/image_provider.dart'; @@ -41,7 +43,7 @@ class _SearchPageState extends State { case 1: // TODO: novel search case 2: - // TODO: user search + context.to(() => SearchUserResultPage(text)); case 3: // TODO: artwork id throw UnimplementedError(); @@ -87,6 +89,7 @@ class _SearchPageState extends State { child: TextBox( placeholder: searchTypes[searchType].tl, onChanged: (s) => text = s, + onSubmitted: (s) => search(), foregroundDecoration: BoxDecoration( border: Border.all( color: ColorScheme.of(context) @@ -361,20 +364,29 @@ class SearchResultPage extends StatefulWidget { class _SearchResultPageState extends MultiPageLoadingState { @override Widget buildContent(BuildContext context, final List data) { - return LayoutBuilder(builder: (context, constrains){ - return MasonryGridView.builder( - gridDelegate: const SliverSimpleGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 240, + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Text("${"Search".tl}: ${widget.keyword}", + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),) + .paddingVertical(12).paddingHorizontal(16), ), - itemCount: data.length, - itemBuilder: (context, index) { - if(index == data.length - 1){ - nextPage(); - } - return IllustWidget(data[index]); - }, - ); - }); + SliverMasonryGrid( + gridDelegate: const SliverSimpleGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 240, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + if(index == data.length - 1){ + nextPage(); + } + return IllustWidget(data[index]); + }, + childCount: data.length, + ), + ).sliverPaddingHorizontal(8) + ], + ); } String? nextUrl; @@ -395,3 +407,55 @@ class _SearchResultPageState extends MultiPageLoadingState createState() => _SearchUserResultPageState(); +} + +class _SearchUserResultPageState extends MultiPageLoadingState { + @override + Widget buildContent(BuildContext context, final List data) { + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Text("${"Search".tl}: ${widget.keyword}", + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),) + .paddingVertical(12).paddingHorizontal(16), + ), + SliverGridViewWithFixedItemHeight( + delegate: SliverChildBuilderDelegate( + (context, index) { + if(index == data.length - 1){ + nextPage(); + } + return UserPreviewWidget(data[index]); + }, + childCount: data.length + ), + maxCrossAxisExtent: 520, + itemHeight: 114, + ).sliverPaddingHorizontal(8) + ], + ); + } + + String? nextUrl; + + @override + Future>> loadData(page) async{ + if(nextUrl == "end") { + return Res.error("No more data"); + } + var res = await Network().searchUsers(widget.keyword, nextUrl); + if(!res.error) { + nextUrl = res.subData; + nextUrl ??= "end"; + } + return res; + } +} +