Compare commits

...

2 Commits

Author SHA1 Message Date
42c3e5ff0a Shuffle mode swiper 2024-07-25 16:08:46 +08:00
7dc198f0a7 ♻️ Post list controller layer 2024-07-25 14:42:50 +08:00
9 changed files with 301 additions and 93 deletions

View File

@ -0,0 +1,128 @@
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/content/posts.dart';
class PostListController extends GetxController {
/// The polling source modifier.
/// - `0`: default recommendations
/// - `1`: shuffle mode
RxInt mode = 0.obs;
/// The paging controller for infinite loading.
/// Only available when mode is `0`.
PagingController<int, Post> pagingController =
PagingController(firstPageKey: 0);
PostListController() {
_initPagingController();
}
/// Initialize a compatibility layer to paging controller
void _initPagingController() {
pagingController.addPageRequestListener(_onPagingControllerRequest);
}
Future<void> _onPagingControllerRequest(int pageKey) async {
try {
final result = await loadMore();
if (result != null && hasMore.value) {
pagingController.appendPage(result, nextPageKey.value);
} else if (result != null) {
pagingController.appendLastPage(result);
}
} catch (e) {
pagingController.error = e;
}
}
void _resetPagingController() {
pagingController.removePageRequestListener(_onPagingControllerRequest);
pagingController.nextPageKey = nextPageKey.value;
pagingController.itemList?.clear();
}
RxBool isBusy = false.obs;
RxBool isPreparing = false.obs;
RxInt focusCursor = 0.obs;
Post get focusPost => postList[focusCursor.value];
RxInt postTotal = 0.obs;
RxList<Post> postList = RxList.empty(growable: true);
RxInt nextPageKey = 0.obs;
RxBool hasMore = true.obs;
Future<void> reloadAllOver() async {
isPreparing.value = true;
focusCursor.value = 0;
nextPageKey.value = 0;
postList.clear();
hasMore.value = true;
_resetPagingController();
final result = await loadMore();
if (result != null && hasMore.value) {
pagingController.appendPage(result, nextPageKey.value);
} else if (result != null) {
pagingController.appendLastPage(result);
}
_initPagingController();
isPreparing.value = false;
}
Future<List<Post>?> loadMore() async {
final result = await _loadPosts(nextPageKey.value);
if (result != null && result.length >= 10) {
postList.addAll(result);
nextPageKey.value += result.length;
hasMore.value = true;
} else if (result != null) {
postList.addAll(result);
nextPageKey.value += result.length;
hasMore.value = false;
}
final idx = <dynamic>{};
postList.retainWhere((x) => idx.add(x.id));
return result;
}
Future<List<Post>?> _loadPosts(int pageKey) async {
isBusy.value = true;
final PostProvider provider = Get.find();
Response resp;
try {
resp = await provider.listRecommendations(
pageKey,
channel: mode.value == 0 ? null : 'shuffle',
);
} catch (e) {
rethrow;
} finally {
isBusy.value = false;
}
final PaginationResult result = PaginationResult.fromJson(resp.body);
final out = result.data?.map((e) => Post.fromJson(e)).toList();
postTotal.value = result.count;
return out;
}
@override
void dispose() {
pagingController.dispose();
super.dispose();
}
}

View File

@ -1,10 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:solian/controllers/post_list_controller.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart'; import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
@ -12,6 +9,7 @@ import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/feed/feed_list.dart'; import 'package:solian/widgets/feed/feed_list.dart';
import 'package:solian/widgets/posts/post_shuffle_swiper.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@ -22,48 +20,22 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
final PagingController<int, Post> _pagingController = late final PostListController _postController;
PagingController(firstPageKey: 0);
late final TabController _tabController; late final TabController _tabController;
int mode = 0;
getPosts(int pageKey) async {
final PostProvider provider = Get.find();
Response resp;
try {
resp = await provider.listRecommendations(
pageKey,
channel: mode == 0 ? null : 'shuffle',
);
} catch (e) {
_pagingController.error = e;
return;
}
final PaginationResult result = PaginationResult.fromJson(resp.body);
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
if (parsed != null && parsed.length >= 10) {
_pagingController.appendPage(parsed, pageKey + parsed.length);
} else if (parsed != null) {
_pagingController.appendLastPage(parsed);
}
}
@override @override
void initState() { void initState() {
Get.lazyPut(() => PostListController());
super.initState(); super.initState();
_pagingController.addPageRequestListener(getPosts); _postController = Get.find();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
switch (_tabController.index) { switch (_tabController.index) {
case 0: case 0:
case 1: case 1:
if (mode == _tabController.index) break; if (_postController.mode.value == _tabController.index) return;
mode = _tabController.index; _postController.mode.value = _tabController.index;
_pagingController.refresh(); _postController.reloadAllOver();
} }
}); });
} }
@ -72,21 +44,21 @@ class _HomeScreenState extends State<HomeScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: SafeArea(
floatingActionButton: FloatingActionButton( bottom: false,
child: const Icon(Icons.add), child: Scaffold(
onPressed: () { floatingActionButton: FloatingActionButton(
showModalBottomSheet( child: const Icon(Icons.add),
useRootNavigator: true, onPressed: () {
isScrollControlled: true, showModalBottomSheet(
context: context, useRootNavigator: true,
builder: (context) => const PostCreatePopup(), isScrollControlled: true,
); context: context,
}, builder: (context) => const PostCreatePopup(),
), );
body: RefreshIndicator( },
onRefresh: () => Future.sync(() => _pagingController.refresh()), ),
child: NestedScrollView( body: NestedScrollView(
headerSliverBuilder: headerSliverBuilder:
(BuildContext context, bool innerBoxIsScrolled) { (BuildContext context, bool innerBoxIsScrolled) {
return [ return [
@ -113,17 +85,28 @@ class _HomeScreenState extends State<HomeScreen>
) )
]; ];
}, },
body: TabBarView( body: Obx(() {
controller: _tabController, if (_postController.isPreparing.isTrue) {
children: [ return const Center(
CustomScrollView(slivers: [ child: CircularProgressIndicator(),
FeedListWidget(controller: _pagingController), );
]), }
CustomScrollView(slivers: [
FeedListWidget(controller: _pagingController), return TabBarView(
]), physics: const NeverScrollableScrollPhysics(),
], controller: _tabController,
), children: [
RefreshIndicator(
onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [
FeedListWidget(
controller: _postController.pagingController),
]),
),
PostShuffleSwiper(controller: _postController),
],
);
}),
), ),
), ),
), ),
@ -132,7 +115,7 @@ class _HomeScreenState extends State<HomeScreen>
@override @override
void dispose() { void dispose() {
_pagingController.dispose(); _postController.dispose();
super.dispose(); super.dispose();
} }
} }

View File

@ -105,7 +105,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
void syncWidget() { void syncWidget() {
if (widget.edit != null) { if (widget.edit != null) {
_contentController.text = widget.edit!.body['content']; _contentController.text = widget.edit!.body['content'];
_attachments = widget.edit!.body['attachments'] ?? List.empty(); _attachments = widget.edit!.body['attachments']?.cast<int>() ?? List.empty();
_isDraft = widget.edit!.isDraft ?? false; _isDraft = widget.edit!.isDraft ?? false;
} }
} }

View File

@ -95,7 +95,7 @@ const messagesEnglish = {
'postEdited': 'Edited at @date', 'postEdited': 'Edited at @date',
'postNewCreated': 'Created at @date', 'postNewCreated': 'Created at @date',
'postAttachmentTip': '@count attachment(s)', 'postAttachmentTip': '@count attachment(s)',
'postInRealm': 'In realm @realm', 'postInRealm': 'In @realm',
'postDetail': 'Post', 'postDetail': 'Post',
'postReplies': 'Replies', 'postReplies': 'Replies',
'postPublish': 'Post a post', 'postPublish': 'Post a post',

View File

@ -86,7 +86,7 @@ class _PostItemState extends State<PostItem> {
} }
if (widget.item.realm != null) { if (widget.item.realm != null) {
labels.add('postInRealm'.trParams({ labels.add('postInRealm'.trParams({
'realm': '#${widget.item.realm!.id}', 'realm': widget.item.realm!.alias,
})); }));
} }
@ -182,7 +182,10 @@ class _PostItemState extends State<PostItem> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasAttachment = item.body['attachments']?.isNotEmpty ?? false; final List<int> attachments = item.body['attachments'] is List
? item.body['attachments']?.cast<int>()
: List.empty();
final hasAttachment = attachments.isNotEmpty;
if (widget.isCompact) { if (widget.isCompact) {
return Column( return Column(
@ -199,7 +202,7 @@ class _PostItemState extends State<PostItem> {
bottom: hasAttachment ? 4 : 0, bottom: hasAttachment ? 4 : 0,
), ),
buildFooter().paddingOnly(left: 16), buildFooter().paddingOnly(left: 16),
if (item.body['attachments']?.isNotEmpty ?? false) if (attachments.isNotEmpty)
Row( Row(
children: [ children: [
Icon( Icon(
@ -209,7 +212,7 @@ class _PostItemState extends State<PostItem> {
).paddingOnly(right: 6), ).paddingOnly(right: 6),
Text( Text(
'postAttachmentTip'.trParams( 'postAttachmentTip'.trParams(
{'count': item.body['attachments']!.length.toString()}, {'count': attachments.length.toString()},
), ),
style: TextStyle(color: _unFocusColor), style: TextStyle(color: _unFocusColor),
) )
@ -293,8 +296,7 @@ class _PostItemState extends State<PostItem> {
), ),
child: AttachmentList( child: AttachmentList(
parentId: widget.item.id.toString(), parentId: widget.item.id.toString(),
attachmentsId: attachmentsId: attachments,
item.body['attachments']?.cast<int>() ?? List.empty(),
divided: true, divided: true,
), ),
), ),

View File

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:flutter_card_swiper/flutter_card_swiper.dart';
import 'package:get/get.dart';
import 'package:solian/controllers/post_list_controller.dart';
import 'package:solian/widgets/posts/post_single_display.dart';
class PostShuffleSwiper extends StatefulWidget {
final PostListController controller;
const PostShuffleSwiper({super.key, required this.controller});
@override
State<PostShuffleSwiper> createState() => _PostShuffleSwiperState();
}
class _PostShuffleSwiperState extends State<PostShuffleSwiper> {
final CardSwiperController _swiperController = CardSwiperController();
@override
Widget build(BuildContext context) {
return Obx(
() => CardSwiper(
initialIndex: 0,
isLoop: false,
controller: _swiperController,
cardsCount: widget.controller.postTotal.value,
numberOfCardsDisplayed: 2,
allowedSwipeDirection: const AllowedSwipeDirection.symmetric(
horizontal: true,
),
cardBuilder: (context, index, percentThresholdX, percentThresholdY) {
if (widget.controller.postList.length <= index) {
return Card(
child: const Center(
child: CircularProgressIndicator(),
).paddingAll(24),
);
}
final element = widget.controller.postList[index];
return PostSingleDisplay(
key: Key('p${element.id}'),
item: element,
);
},
padding: const EdgeInsets.all(24),
onSwipe: (prevIndex, currIndex, dir) {
if (prevIndex + 2 >= widget.controller.postList.length) {
// Automatically load more
widget.controller.loadMore();
}
return true;
},
).paddingOnly(bottom: MediaQuery.of(context).padding.bottom),
);
}
@override
void dispose() {
_swiperController.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/models/post.dart';
import 'package:solian/widgets/posts/post_item.dart';
class PostSingleDisplay extends StatelessWidget {
final Post item;
const PostSingleDisplay({super.key, required this.item});
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.center,
child: Card(
child: SingleChildScrollView(
child: PostItem(
item: item,
).paddingSymmetric(horizontal: 10, vertical: 16),
),
),
);
}
}

View File

@ -301,10 +301,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: dev_build name: dev_build
sha256: cd2d110dc7ca372cc45d4eba79e4be9eb54ceda673687305b82be367aad8c652 sha256: "5600100e28f7424ed53728e8e7aa6bc0e0506ec04bb49a82616f62112c1822c3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0+7" version: "1.0.0+8"
device_info_plus: device_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@ -389,10 +389,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: file_selector_windows name: file_selector_windows
sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 sha256: "2ad726953f6e8affbc4df8dc78b77c3b4a060967a291e528ef72ae846c60fb69"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.3+1" version: "0.9.3+2"
firebase_core: firebase_core:
dependency: "direct main" dependency: "direct main"
description: description:
@ -506,10 +506,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_cache_manager name: flutter_cache_manager
sha256: "395d6b7831f21f3b989ebedbb785545932adb9afe2622c1ffacf7f4b53a7e544" sha256: ceff65d74d907b1b772e22cf04daad60fb472461638977d9fae8b00a63e01e3d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.2" version: "3.3.3"
flutter_card_swiper:
dependency: "direct main"
description:
name: flutter_card_swiper
sha256: "880ad669017154d6d1f8c3abd861db08af97b3b7b0f7d7d5cbde690a9253811d"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter_launcher_icons: flutter_launcher_icons:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -620,10 +628,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_webrtc name: flutter_webrtc
sha256: "3f115def06fb80df7c2e9f97b7d73c1b43a973211fc56df69638a663529f56c6" sha256: d305793e6737c59a81c45b18484e1f985710827704eeb9092573387efcbae272
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.4" version: "0.11.5"
font_awesome_flutter: font_awesome_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@ -716,10 +724,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_android name: image_picker_android
sha256: cea2bd5b9fcff039a4901d3b13c67fe747f940be9ba76bde1bcd218d168eeb7f sha256: a26dc9a03fe042440c1e4be554fb0fceae2bf6d887d7467fc48c688fa4a81889
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.12+6" version: "0.8.12+7"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
@ -852,10 +860,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: livekit_client name: livekit_client
sha256: "0c369b25e56910650184049cc713bb38a920cbe2e0d10db83fac400a3c393e2b" sha256: e6b1e8a3cdcae95f7e62c0371590648444bac245fce3a1bcfb4ec05889ad82f3
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.1" version: "2.2.2"
logging: logging:
dependency: transitive dependency: transitive
description: description:
@ -1188,10 +1196,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: process_run name: process_run
sha256: "6052115540ad88715d6bcee60656970f70c68c85846d1948b92e435f0382899e" sha256: "6dc6f83198b876431c9823a1f93a4c147470e7f239403c37caca702cf35e146f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0+1" version: "1.0.0+3"
protobuf: protobuf:
dependency: transitive dependency: transitive
description: description:
@ -1268,10 +1276,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: rxdart name: rxdart
sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.27.7" version: "0.28.0"
safe_local_storage: safe_local_storage:
dependency: transitive dependency: transitive
description: description:
@ -1340,18 +1348,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sentry name: sentry
sha256: cbc29cbdd8a047aab3df42f826daf07e58dfb2e1d550895d1021a6d4e618b00d sha256: "60756499f09c3ed944640d7993ac527a89f7c3033f13ec12ae145706b269b5c8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.4.0" version: "8.5.0"
sentry_flutter: sentry_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
name: sentry_flutter name: sentry_flutter
sha256: "96ce085e1be6c9963d93d42d6ba5c67484c076c59d25c94a7ba906549dc6c635" sha256: "26cfe89cb08a60d9bc0c4e748a0508e223ae378265aec8ed2a2b48f0d2c936b9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.4.0" version: "8.5.0"
shelf: shelf:
dependency: transitive dependency: transitive
description: description:
@ -1633,10 +1641,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_windows name: url_launcher_windows
sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.1" version: "3.1.2"
uuid: uuid:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -54,6 +54,7 @@ dependencies:
pasteboard: ^0.2.0 pasteboard: ^0.2.0
desktop_drop: ^0.4.4 desktop_drop: ^0.4.4
badges: ^3.1.2 badges: ^3.1.2
flutter_card_swiper: ^7.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: