Merge pull request #224 from krawieck/fix/deduplication-infinite-scroll

This commit is contained in:
Filip Krawczyk 2021-04-28 15:15:07 +02:00 committed by GitHub
commit 772cb5e575
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 134 additions and 81 deletions

View File

@ -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

View File

@ -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(

View File

@ -36,6 +36,7 @@ class CommunitiesListPage extends StatelessWidget {
)
],
),
uniqueProp: (item) => item.community.actorId,
),
);
}

View File

@ -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,
),
],
),

View File

@ -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) {

View File

@ -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;
}
},
);
}
}

View File

@ -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;
},
),
],
),
);

View File

@ -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;

View File

@ -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,

View File

@ -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);
}

View File

@ -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,
);
}