Merge pull request #224 from krawieck/fix/deduplication-infinite-scroll
This commit is contained in:
commit
772cb5e575
|
@ -4,6 +4,7 @@
|
|||
|
||||
- Show avatars setting toggle
|
||||
- Show scores setting toggle
|
||||
- Default sort type setting
|
||||
- Default listing type for the home tab setting
|
||||
- Import Lemmy settings: long press an account in account settings then choose the import option
|
||||
- Editing posts
|
||||
|
@ -12,6 +13,7 @@
|
|||
### Fixed
|
||||
|
||||
- Fixed bug where creating post would crash after uploading a picture
|
||||
- Added deduplication in infinite scrolls
|
||||
|
||||
## v0.4.2 - 2021-04-12
|
||||
|
||||
|
|
|
@ -3,11 +3,13 @@ import 'package:flutter/cupertino.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:lemmy_api_client/v3.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart' as ul;
|
||||
|
||||
import '../hooks/delayed_loading.dart';
|
||||
import '../hooks/stores.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../stores/config_store.dart';
|
||||
import '../widgets/fullscreenable_image.dart';
|
||||
import '../widgets/radio_picker.dart';
|
||||
import 'add_instance.dart';
|
||||
|
@ -40,12 +42,27 @@ class AddAccountPage extends HookWidget {
|
|||
|
||||
handleOnAdd() async {
|
||||
try {
|
||||
final isFirstAccount = accountsStore.hasNoAccount;
|
||||
|
||||
loading.start();
|
||||
await accountsStore.addAccount(
|
||||
selectedInstance.value,
|
||||
usernameController.text,
|
||||
passwordController.text,
|
||||
);
|
||||
|
||||
// if first account try to import settings
|
||||
if (isFirstAccount) {
|
||||
try {
|
||||
await context.read<ConfigStore>().importLemmyUserSettings(
|
||||
accountsStore
|
||||
.userDataFor(
|
||||
selectedInstance.value, usernameController.text)!
|
||||
.jwt);
|
||||
// ignore: avoid_catches_without_on_clauses, empty_catches
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
Navigator.of(context).pop();
|
||||
} on Exception catch (err) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
|
|
|
@ -36,6 +36,7 @@ class CommunitiesListPage extends StatelessWidget {
|
|||
)
|
||||
],
|
||||
),
|
||||
uniqueProp: (item) => item.community.actorId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -120,6 +120,7 @@ class InboxPage extends HookWidget {
|
|||
canBeMarkedAsRead: true,
|
||||
hideOnRead: unreadOnly.value,
|
||||
),
|
||||
uniqueProp: (item) => item.comment.apId,
|
||||
),
|
||||
SortableInfiniteList<PersonMentionView>(
|
||||
noItems: const Text('no mentions'),
|
||||
|
@ -137,6 +138,7 @@ class InboxPage extends HookWidget {
|
|||
umv,
|
||||
hideOnRead: unreadOnly.value,
|
||||
),
|
||||
uniqueProp: (item) => item.personMention.id,
|
||||
),
|
||||
InfiniteScroll<PrivateMessageView>(
|
||||
noItems: const Padding(
|
||||
|
@ -156,6 +158,7 @@ class InboxPage extends HookWidget {
|
|||
privateMessageView: mv,
|
||||
hideOnRead: unreadOnly.value,
|
||||
),
|
||||
uniqueProp: (item) => item.privateMessage.apId,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -108,6 +108,7 @@ class _ManageAccount extends HookWidget {
|
|||
useTextEditingController(text: user.person.matrixUserId);
|
||||
final avatar = useRef(user.person.avatar);
|
||||
final banner = useRef(user.person.banner);
|
||||
final showNsfw = useState(user.localUser.showNsfw);
|
||||
final sendNotificationsToEmail =
|
||||
useState(user.localUser.sendNotificationsToEmail);
|
||||
final newPasswordController = useTextEditingController();
|
||||
|
@ -134,7 +135,7 @@ class _ManageAccount extends HookWidget {
|
|||
|
||||
try {
|
||||
await LemmyApiV3(user.instanceHost).run(SaveUserSettings(
|
||||
showNsfw: user.localUser.showNsfw,
|
||||
showNsfw: showNsfw.value,
|
||||
theme: user.localUser.theme,
|
||||
defaultSortType: user.localUser.defaultSortType,
|
||||
defaultListingType: user.localUser.defaultListingType,
|
||||
|
@ -318,6 +319,15 @@ class _ManageAccount extends HookWidget {
|
|||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile.adaptive(
|
||||
value: showNsfw.value,
|
||||
onChanged: (checked) {
|
||||
showNsfw.value = checked;
|
||||
},
|
||||
title: Text(L10n.of(context)!.show_nsfw),
|
||||
dense: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile.adaptive(
|
||||
value: sendNotificationsToEmail.value,
|
||||
onChanged: (checked) {
|
||||
|
|
|
@ -116,6 +116,20 @@ class _SearchResultsList extends HookWidget {
|
|||
throw UnimplementedError();
|
||||
}
|
||||
},
|
||||
uniqueProp: (item) {
|
||||
switch (type) {
|
||||
case SearchType.comments:
|
||||
return (item as CommentView).comment.apId;
|
||||
case SearchType.communities:
|
||||
return (item as CommunityView).community.actorId;
|
||||
case SearchType.posts:
|
||||
return (item as PostView).post.apId;
|
||||
case SearchType.users:
|
||||
return (item as PersonViewSafe).person.actorId;
|
||||
default:
|
||||
return item;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -159,13 +159,6 @@ class GeneralConfigPage extends HookWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
title: Text(L10n.of(context)!.show_nsfw),
|
||||
value: configStore.showNsfw,
|
||||
onChanged: (checked) {
|
||||
configStore.showNsfw = checked;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -54,15 +54,6 @@ class ConfigStore extends ChangeNotifier {
|
|||
save();
|
||||
}
|
||||
|
||||
late bool _showNsfw;
|
||||
@JsonKey(defaultValue: false)
|
||||
bool get showNsfw => _showNsfw;
|
||||
set showNsfw(bool showNsfw) {
|
||||
_showNsfw = showNsfw;
|
||||
notifyListeners();
|
||||
save();
|
||||
}
|
||||
|
||||
late bool _showScores;
|
||||
@JsonKey(defaultValue: true)
|
||||
bool get showScores => _showScores;
|
||||
|
@ -95,24 +86,27 @@ class ConfigStore extends ChangeNotifier {
|
|||
/// Copies over settings from lemmy to [ConfigStore]
|
||||
void copyLemmyUserSettings(LocalUserSettings localUserSettings) {
|
||||
// themes from lemmy-ui that are dark mode
|
||||
// const darkModeLemmyUiThemes = {
|
||||
// 'solar',
|
||||
// 'cyborg',
|
||||
// 'darkly',
|
||||
// 'vaporwave-dark',
|
||||
// // TODO: is it dark theme?
|
||||
// 'i386',
|
||||
// };
|
||||
const darkModeLemmyUiThemes = {
|
||||
'solar',
|
||||
'cyborg',
|
||||
'darkly',
|
||||
'vaporwave-dark',
|
||||
'i386',
|
||||
};
|
||||
|
||||
_showAvatars = localUserSettings.showAvatars;
|
||||
_showNsfw = localUserSettings.showNsfw;
|
||||
// TODO: should these also be imported? If so, how?
|
||||
// _theme = darkModeLemmyUiThemes.contains(localUserSettings.theme)
|
||||
// ? ThemeMode.dark
|
||||
// : ThemeMode.light;
|
||||
// _locale = L10n.supportedLocales.contains(Locale(localUserSettings.lang))
|
||||
// ? Locale(localUserSettings.lang)
|
||||
// : _locale;
|
||||
_theme = () {
|
||||
if (localUserSettings.theme == 'browser') return ThemeMode.system;
|
||||
|
||||
if (darkModeLemmyUiThemes.contains(localUserSettings.theme)) {
|
||||
return ThemeMode.dark;
|
||||
}
|
||||
|
||||
return ThemeMode.light;
|
||||
}();
|
||||
_locale = L10n.supportedLocales.contains(Locale(localUserSettings.lang))
|
||||
? Locale(localUserSettings.lang)
|
||||
: _locale;
|
||||
// TODO: add when it is released
|
||||
// _showScores = localUserSettings.showScores;
|
||||
_defaultSortType = localUserSettings.defaultSortType;
|
||||
|
|
|
@ -13,7 +13,6 @@ ConfigStore _$ConfigStoreFromJson(Map<String, dynamic> json) {
|
|||
..amoledDarkMode = json['amoledDarkMode'] as bool? ?? false
|
||||
..locale = LocaleSerde.fromJson(json['locale'] as String?)
|
||||
..showAvatars = json['showAvatars'] as bool? ?? true
|
||||
..showNsfw = json['showNsfw'] as bool? ?? false
|
||||
..showScores = json['showScores'] as bool? ?? true
|
||||
..defaultSortType = _sortTypeFromJson(json['defaultSortType'] as String?)
|
||||
..defaultListingType =
|
||||
|
@ -26,7 +25,6 @@ Map<String, dynamic> _$ConfigStoreToJson(ConfigStore instance) =>
|
|||
'amoledDarkMode': instance.amoledDarkMode,
|
||||
'locale': LocaleSerde.toJson(instance.locale),
|
||||
'showAvatars': instance.showAvatars,
|
||||
'showNsfw': instance.showNsfw,
|
||||
'showScores': instance.showScores,
|
||||
'defaultSortType': instance.defaultSortType,
|
||||
'defaultListingType': instance.defaultListingType,
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
@ -43,6 +45,10 @@ class InfiniteScroll<T> extends HookWidget {
|
|||
/// Widget that will be displayed if there are no items
|
||||
final Widget noItems;
|
||||
|
||||
/// Maps an item to its unique property that will allow to detect possible
|
||||
/// duplicates thus perfoming deduplication
|
||||
final Object Function(T item)? uniqueProp;
|
||||
|
||||
const InfiniteScroll({
|
||||
this.batchSize = 10,
|
||||
this.leading = const SizedBox.shrink(),
|
||||
|
@ -53,31 +59,39 @@ class InfiniteScroll<T> extends HookWidget {
|
|||
required this.fetcher,
|
||||
this.controller,
|
||||
this.noItems = const SizedBox.shrink(),
|
||||
this.uniqueProp,
|
||||
}) : assert(batchSize > 0);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = useState<List<T>>([]);
|
||||
// holds unique props of the data
|
||||
final dataSet = useRef(HashSet<Object>());
|
||||
final hasMore = useRef(true);
|
||||
final page = useRef(1);
|
||||
final isFetching = useRef(false);
|
||||
|
||||
final uniquePropFunc = uniqueProp ?? (e) => e as Object;
|
||||
|
||||
useEffect(() {
|
||||
if (controller != null) {
|
||||
controller?.clear = () {
|
||||
data.value = [];
|
||||
hasMore.current = true;
|
||||
page.current = 1;
|
||||
dataSet.current.clear();
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
final page = data.value.length ~/ batchSize + 1;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
data.value = [];
|
||||
hasMore.current = true;
|
||||
page.current = 1;
|
||||
dataSet.current.clear();
|
||||
|
||||
await HapticFeedback.mediumImpact();
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
@ -107,13 +121,20 @@ class InfiniteScroll<T> extends HookWidget {
|
|||
// if it's already fetching more, skip
|
||||
if (!isFetching.current) {
|
||||
isFetching.current = true;
|
||||
fetcher(page, batchSize).then((newData) {
|
||||
fetcher(page.current, batchSize).then((incoming) {
|
||||
// if got less than the batchSize, mark the list as done
|
||||
if (newData.length < batchSize) {
|
||||
if (incoming.length < batchSize) {
|
||||
hasMore.current = false;
|
||||
}
|
||||
|
||||
final newData = incoming.where(
|
||||
(e) => !dataSet.current.contains(uniquePropFunc(e)),
|
||||
);
|
||||
|
||||
// append new data
|
||||
data.value = [...data.value, ...newData];
|
||||
dataSet.current.addAll(newData.map(uniquePropFunc));
|
||||
page.current += 1;
|
||||
}).whenComplete(() => isFetching.current = false);
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:lemmy_api_client/v3.dart';
|
|||
|
||||
import '../comment_tree.dart';
|
||||
import '../hooks/infinite_scroll.dart';
|
||||
import '../hooks/stores.dart';
|
||||
import 'comment.dart';
|
||||
import 'infinite_scroll.dart';
|
||||
import 'post.dart';
|
||||
|
@ -19,7 +20,11 @@ class SortableInfiniteList<T> extends HookWidget {
|
|||
final InfiniteScrollController? controller;
|
||||
final Function? onStyleChange;
|
||||
final Widget noItems;
|
||||
final SortType defaultSort;
|
||||
|
||||
/// if no defaultSort is provided, the defaultSortType
|
||||
/// from the configStore will be used
|
||||
final SortType? defaultSort;
|
||||
final Object Function(T item)? uniqueProp;
|
||||
|
||||
const SortableInfiniteList({
|
||||
required this.fetcher,
|
||||
|
@ -27,15 +32,18 @@ class SortableInfiniteList<T> extends HookWidget {
|
|||
this.controller,
|
||||
this.onStyleChange,
|
||||
this.noItems = const SizedBox.shrink(),
|
||||
this.defaultSort = SortType.active,
|
||||
this.defaultSort,
|
||||
this.uniqueProp,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final defaultSortType =
|
||||
useConfigStoreSelect((store) => store.defaultSortType);
|
||||
final defaultController = useInfiniteScrollController();
|
||||
final isc = controller ?? defaultController;
|
||||
|
||||
final sort = useState(defaultSort);
|
||||
final sort = useState(defaultSort ?? defaultSortType);
|
||||
|
||||
void changeSorting(SortType newSort) {
|
||||
sort.value = newSort;
|
||||
|
@ -54,49 +62,41 @@ class SortableInfiniteList<T> extends HookWidget {
|
|||
controller: isc,
|
||||
batchSize: 20,
|
||||
noItems: noItems,
|
||||
uniqueProp: uniqueProp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InfinitePostList extends StatelessWidget {
|
||||
final FetcherWithSorting<PostView> fetcher;
|
||||
final InfiniteScrollController? controller;
|
||||
|
||||
const InfinitePostList({
|
||||
required this.fetcher,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
Widget build(BuildContext context) => SortableInfiniteList<PostView>(
|
||||
onStyleChange: () {},
|
||||
itemBuilder: (post) => Column(
|
||||
children: [
|
||||
PostWidget(post),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
fetcher: fetcher,
|
||||
controller: controller,
|
||||
noItems: const Text('there are no posts'),
|
||||
);
|
||||
class InfinitePostList extends SortableInfiniteList<PostView> {
|
||||
InfinitePostList({
|
||||
required FetcherWithSorting<PostView> fetcher,
|
||||
InfiniteScrollController? controller,
|
||||
}) : super(
|
||||
itemBuilder: (post) => Column(
|
||||
children: [
|
||||
PostWidget(post),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
fetcher: fetcher,
|
||||
controller: controller,
|
||||
noItems: const Text('there are no posts'),
|
||||
uniqueProp: (item) => item.post.apId,
|
||||
);
|
||||
}
|
||||
|
||||
class InfiniteCommentList extends StatelessWidget {
|
||||
final FetcherWithSorting<CommentView> fetcher;
|
||||
final InfiniteScrollController? controller;
|
||||
|
||||
const InfiniteCommentList({
|
||||
required this.fetcher,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
Widget build(BuildContext context) => SortableInfiniteList<CommentView>(
|
||||
itemBuilder: (comment) => CommentWidget(
|
||||
CommentTree(comment),
|
||||
detached: true,
|
||||
),
|
||||
fetcher: fetcher,
|
||||
controller: controller,
|
||||
noItems: const Text('there are no comments'),
|
||||
);
|
||||
class InfiniteCommentList extends SortableInfiniteList<CommentView> {
|
||||
InfiniteCommentList({
|
||||
required FetcherWithSorting<CommentView> fetcher,
|
||||
InfiniteScrollController? controller,
|
||||
}) : super(
|
||||
itemBuilder: (comment) => CommentWidget(
|
||||
CommentTree(comment),
|
||||
detached: true,
|
||||
),
|
||||
fetcher: fetcher,
|
||||
controller: controller,
|
||||
noItems: const Text('there are no comments'),
|
||||
uniqueProp: (item) => item.comment.apId,
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue