Merge pull request #218 from krawieck/feature/more-settings

This commit is contained in:
Filip Krawczyk 2021-04-21 23:03:13 +02:00 committed by GitHub
commit af8e88702d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 366 additions and 147 deletions

View File

@ -2,6 +2,10 @@
### Added
- Show avatars setting toggle
- Show scores setting toggle
- 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
- Editing comments

View File

@ -180,7 +180,10 @@ class CommunitiesTab extends HookWidget {
onTap: () => goToInstance(context,
accountsStore.loggedInInstances.elementAt(i)),
onLongPress: () => toggleCollapse(i),
leading: Avatar(url: instances[i].icon),
leading: Avatar(
url: instances[i].icon,
alwaysShow: true,
),
title: Text(
instances[i].name,
style: theme.textTheme.headline6,
@ -211,6 +214,7 @@ class CommunitiesTab extends HookWidget {
Avatar(
radius: 15,
url: comm.community.icon,
alwaysShow: true,
),
const SizedBox(width: 10),
Text(comm.community.originDisplayName),

View File

@ -257,6 +257,7 @@ class _CommunityOverview extends StatelessWidget {
child: Avatar(
url: community.community.icon,
radius: 83 / 2,
alwaysShow: true,
),
),
],

View File

@ -25,10 +25,13 @@ class HomeTab extends HookWidget {
@override
Widget build(BuildContext context) {
final accStore = useAccountsStore();
final defaultListingType =
useConfigStoreSelect((configStore) => configStore.defaultListingType);
final selectedList = useState(_SelectedList(
listingType: accStore.hasNoAccount
listingType: accStore.hasNoAccount &&
defaultListingType == PostListingType.subscribed
? PostListingType.all
: PostListingType.subscribed));
: defaultListingType));
final isc = useInfiniteScrollController();
final theme = Theme.of(context);
final instancesIcons = useMemoFuture(() async {
@ -54,9 +57,10 @@ class HomeTab extends HookWidget {
selectedList.value.listingType == PostListingType.subscribed ||
!accStore.instances.contains(selectedList.value.instanceHost)) {
selectedList.value = _SelectedList(
listingType: accStore.hasNoAccount
listingType: accStore.hasNoAccount &&
defaultListingType == PostListingType.subscribed
? PostListingType.all
: PostListingType.subscribed,
: defaultListingType,
);
}

View File

@ -4,15 +4,17 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lemmy_api_client/pictrs.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:url_launcher/url_launcher.dart' as ul;
import '../hooks/delayed_loading.dart';
import '../hooks/image_picker.dart';
import '../hooks/ref.dart';
import '../hooks/stores.dart';
import '../l10n/l10n.dart';
import '../util/more_icon.dart';
import '../util/pictrs.dart';
import '../widgets/bottom_modal.dart';
import '../widgets/bottom_safe.dart';
import '../widgets/radio_picker.dart';
/// Page for managing things like username, email, avatar etc
/// This page will assume the manage account is logged in and
@ -34,9 +36,39 @@ class ManageAccountPage extends HookWidget {
return site.myUser!;
});
void _openMoreMenu() {
showBottomModal(
context: context,
builder: (context) => Column(
children: [
ListTile(
leading: const Icon(Icons.open_in_browser),
title: const Text('Open in browser'),
onTap: () async {
final userProfileUrl =
await userFuture.then((e) => e.person.actorId);
if (await ul.canLaunch(userProfileUrl)) {
await ul.launch(userProfileUrl);
Navigator.of(context).pop();
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("can't open in browser")),
);
}
},
),
],
),
);
}
return Scaffold(
appBar: AppBar(
title: Text('$username@$instanceHost'),
actions: [
IconButton(icon: Icon(moreIcon), onPressed: _openMoreMenu),
],
),
body: FutureBuilder<LocalUserSettingsView>(
future: userFuture,
@ -76,12 +108,8 @@ class _ManageAccount extends HookWidget {
useTextEditingController(text: user.person.matrixUserId);
final avatar = useRef(user.person.avatar);
final banner = useRef(user.person.banner);
final showAvatars = useState(user.localUser.showAvatars);
final showNsfw = useState(user.localUser.showNsfw);
final sendNotificationsToEmail =
useState(user.localUser.sendNotificationsToEmail);
final defaultListingType = useState(user.localUser.defaultListingType);
final defaultSortType = useState(user.localUser.defaultSortType);
final newPasswordController = useTextEditingController();
final newPasswordVerifyController = useTextEditingController();
final oldPasswordController = useTextEditingController();
@ -106,12 +134,12 @@ class _ManageAccount extends HookWidget {
try {
await LemmyApiV3(user.instanceHost).run(SaveUserSettings(
showNsfw: showNsfw.value,
showNsfw: user.localUser.showNsfw,
theme: user.localUser.theme,
defaultSortType: defaultSortType.value,
defaultListingType: defaultListingType.value,
defaultSortType: user.localUser.defaultSortType,
defaultListingType: user.localUser.defaultListingType,
lang: user.localUser.lang,
showAvatars: showAvatars.value,
showAvatars: user.localUser.showAvatars,
sendNotificationsToEmail: sendNotificationsToEmail.value,
auth: token.raw,
avatar: avatar.current,
@ -290,78 +318,10 @@ class _ManageAccount extends HookWidget {
obscureText: true,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(L10n.of(context)!.type),
const Text(
'This has currently no effect on lemmur',
style: TextStyle(fontSize: 10),
)
],
),
RadioPicker<PostListingType>(
values: const [
PostListingType.all,
PostListingType.local,
PostListingType.subscribed,
],
groupValue: defaultListingType.value,
onChanged: (value) => defaultListingType.value = value,
mapValueToString: (value) => value.value,
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(L10n.of(context)!.sort_type),
const Text(
'This has currently no effect on lemmur',
style: TextStyle(fontSize: 10),
)
],
),
RadioPicker<SortType>(
values: SortType.values,
groupValue: defaultSortType.value,
onChanged: (value) => defaultSortType.value = value,
mapValueToString: (value) => value.value,
),
],
),
const SizedBox(height: 8),
CheckboxListTile(
value: showAvatars.value,
onChanged: (checked) {
if (checked != null) showAvatars.value = checked;
},
title: Text(L10n.of(context)!.show_avatars),
subtitle: const Text('This has currently no effect on lemmur'),
dense: true,
),
const SizedBox(height: 8),
CheckboxListTile(
value: showNsfw.value,
onChanged: (checked) {
if (checked != null) showNsfw.value = checked;
},
title: Text(L10n.of(context)!.show_nsfw),
subtitle: const Text('This has currently no effect on lemmur'),
dense: true,
),
const SizedBox(height: 8),
CheckboxListTile(
SwitchListTile.adaptive(
value: sendNotificationsToEmail.value,
onChanged: (checked) {
if (checked != null) sendNotificationsToEmail.value = checked;
sendNotificationsToEmail.value = checked;
},
title: Text(L10n.of(context)!.send_notifications_to_email),
dense: true,

View File

@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
import 'package:lemmy_api_client/v3.dart';
import '../hooks/stores.dart';
import '../l10n/l10n.dart';
@ -25,6 +26,13 @@ class SettingsPage extends StatelessWidget {
),
body: ListView(
children: [
ListTile(
leading: const Icon(Icons.settings),
title: const Text('General'),
onTap: () {
goTo(context, (_) => const GeneralConfigPage());
},
),
ListTile(
leading: const Icon(Icons.person),
title: const Text('Accounts'),
@ -54,9 +62,7 @@ class AppearanceConfigPage extends HookWidget {
final configStore = useConfigStore();
return Scaffold(
appBar: AppBar(
title: const Text('Appearance'),
),
appBar: AppBar(title: const Text('Appearance')),
body: ListView(
children: [
const _SectionHeading('Theme'),
@ -69,7 +75,7 @@ class AppearanceConfigPage extends HookWidget {
if (selected != null) configStore.theme = selected;
},
),
SwitchListTile(
SwitchListTile.adaptive(
title: const Text('AMOLED dark mode'),
value: configStore.amoledDarkMode,
onChanged: (checked) {
@ -77,7 +83,67 @@ class AppearanceConfigPage extends HookWidget {
},
),
const SizedBox(height: 12),
const _SectionHeading('General'),
const _SectionHeading('Other'),
SwitchListTile.adaptive(
title: Text(L10n.of(context)!.show_avatars),
value: configStore.showAvatars,
onChanged: (checked) {
configStore.showAvatars = checked;
},
),
SwitchListTile.adaptive(
title: const Text('Show scores'),
value: configStore.showScores,
onChanged: (checked) {
configStore.showScores = checked;
},
),
],
),
);
}
}
/// General settings
class GeneralConfigPage extends HookWidget {
const GeneralConfigPage();
@override
Widget build(BuildContext context) {
final configStore = useConfigStore();
return Scaffold(
appBar: AppBar(title: const Text('General')),
body: ListView(
children: [
ListTile(
title: Text(L10n.of(context)!.sort_type),
trailing: SizedBox(
width: 120,
child: RadioPicker<SortType>(
values: SortType.values,
groupValue: configStore.defaultSortType,
onChanged: (value) => configStore.defaultSortType = value,
mapValueToString: (value) => value.value,
),
),
),
ListTile(
title: Text(L10n.of(context)!.type),
trailing: SizedBox(
width: 120,
child: RadioPicker<PostListingType>(
values: const [
PostListingType.all,
PostListingType.local,
PostListingType.subscribed,
],
groupValue: configStore.defaultListingType,
onChanged: (value) => configStore.defaultListingType = value,
mapValueToString: (value) => value.value,
),
),
),
ListTile(
title: Text(L10n.of(context)!.language),
trailing: SizedBox(
@ -93,12 +159,110 @@ class AppearanceConfigPage extends HookWidget {
),
),
),
SwitchListTile.adaptive(
title: Text(L10n.of(context)!.show_nsfw),
value: configStore.showNsfw,
onChanged: (checked) {
configStore.showNsfw = checked;
},
),
],
),
);
}
}
/// Popup for an account
class _AccountOptions extends HookWidget {
final String instanceHost;
final String username;
const _AccountOptions({
Key? key,
required this.instanceHost,
required this.username,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final accountsStore = useAccountsStore();
final configStore = useConfigStore();
final importLoading = useState(false);
Future<void> removeUserDialog(String instanceHost, String username) async {
if (await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove user?'),
content: Text(
'Are you sure you want to remove $username@$instanceHost?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(L10n.of(context)!.no),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(L10n.of(context)!.yes),
),
],
),
) ??
false) {
await accountsStore.removeAccount(instanceHost, username);
Navigator.of(context).pop();
}
}
return Column(
children: [
if (accountsStore.defaultUsernameFor(instanceHost) != username)
ListTile(
leading: const Icon(Icons.check_circle_outline),
title: const Text('Set as default'),
onTap: () {
accountsStore.setDefaultAccountFor(instanceHost, username);
Navigator.of(context).pop();
},
),
ListTile(
leading: const Icon(Icons.delete),
title: const Text('Remove account'),
onTap: () => removeUserDialog(instanceHost, username),
),
ListTile(
leading: importLoading.value
? const SizedBox(
height: 25,
width: 25,
child: CircularProgressIndicator(),
)
: const Icon(Icons.cloud_download),
title: const Text('Import settings to lemmur'),
onTap: () async {
importLoading.value = true;
try {
await configStore.importLemmyUserSettings(
accountsStore.userDataFor(instanceHost, username)!.jwt,
);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Import successful'),
));
} on Exception catch (err) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(err.toString()),
));
} finally {
Navigator.of(context).pop();
importLoading.value = false;
}
}),
],
);
}
}
/// Settings for managing accounts
class AccountsConfigPage extends HookWidget {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
@ -132,51 +296,12 @@ class AccountsConfigPage extends HookWidget {
}
}
Future<void> removeUserDialog(String instanceHost, String username) async {
if (await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove user?'),
content: Text(
'Are you sure you want to remove $username@$instanceHost?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(L10n.of(context)!.no),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(L10n.of(context)!.yes),
),
],
),
) ??
false) {
await accountsStore.removeAccount(instanceHost, username);
Navigator.of(context).pop();
}
}
void accountActions(String instanceHost, String username) {
showBottomModal(
context: context,
builder: (context) => Column(
children: [
if (accountsStore.defaultUsernameFor(instanceHost) != username)
ListTile(
leading: const Icon(Icons.check_circle_outline),
title: const Text('Set as default'),
onTap: () {
accountsStore.setDefaultAccountFor(instanceHost, username);
Navigator.of(context).pop();
},
),
ListTile(
leading: const Icon(Icons.delete),
title: const Text('Remove account'),
onTap: () => removeUserDialog(instanceHost, username),
),
],
builder: (context) => _AccountOptions(
instanceHost: instanceHost,
username: username,
),
);
}

View File

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../l10n/l10n.dart';
@ -44,6 +45,90 @@ class ConfigStore extends ChangeNotifier {
save();
}
late bool _showAvatars;
@JsonKey(defaultValue: true)
bool get showAvatars => _showAvatars;
set showAvatars(bool showAvatars) {
_showAvatars = showAvatars;
notifyListeners();
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;
set showScores(bool showScores) {
_showScores = showScores;
notifyListeners();
save();
}
late SortType _defaultSortType;
// default is set in fromJson
@JsonKey(fromJson: _sortTypeFromJson)
SortType get defaultSortType => _defaultSortType;
set defaultSortType(SortType defaultSortType) {
_defaultSortType = defaultSortType;
notifyListeners();
save();
}
late PostListingType _defaultListingType;
// default is set in fromJson
@JsonKey(fromJson: _postListingTypeFromJson)
PostListingType get defaultListingType => _defaultListingType;
set defaultListingType(PostListingType defaultListingType) {
_defaultListingType = defaultListingType;
notifyListeners();
save();
}
/// 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',
// };
_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;
// TODO: add when it is released
// _showScores = localUserSettings.showScores;
_defaultSortType = localUserSettings.defaultSortType;
_defaultListingType = localUserSettings.defaultListingType;
notifyListeners();
save();
}
/// Fetches [LocalUserSettings] and imports them with [.copyLemmyUserSettings]
Future<void> importLemmyUserSettings(Jwt token) async {
final site =
await LemmyApiV3(token.payload.iss).run(GetSite(auth: token.raw));
copyLemmyUserSettings(site.myUser!.localUser);
}
static Future<ConfigStore> load() async {
final prefs = await _prefs;
@ -58,3 +143,8 @@ class ConfigStore extends ChangeNotifier {
await prefs.setString(prefsKey, jsonEncode(_$ConfigStoreToJson(this)));
}
}
SortType _sortTypeFromJson(String? json) =>
json != null ? SortType.fromJson(json) : SortType.hot;
PostListingType _postListingTypeFromJson(String? json) =>
json != null ? PostListingType.fromJson(json) : PostListingType.all;

View File

@ -11,7 +11,13 @@ ConfigStore _$ConfigStoreFromJson(Map<String, dynamic> json) {
..theme = _$enumDecodeNullable(_$ThemeModeEnumMap, json['theme']) ??
ThemeMode.system
..amoledDarkMode = json['amoledDarkMode'] as bool? ?? false
..locale = LocaleSerde.fromJson(json['locale'] as String?);
..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 =
_postListingTypeFromJson(json['defaultListingType'] as String?);
}
Map<String, dynamic> _$ConfigStoreToJson(ConfigStore instance) =>
@ -19,6 +25,11 @@ Map<String, dynamic> _$ConfigStoreToJson(ConfigStore instance) =>
'theme': _$ThemeModeEnumMap[instance.theme],
'amoledDarkMode': instance.amoledDarkMode,
'locale': LocaleSerde.toJson(instance.locale),
'showAvatars': instance.showAvatars,
'showNsfw': instance.showNsfw,
'showScores': instance.showScores,
'defaultSortType': instance.defaultSortType,
'defaultListingType': instance.defaultListingType,
};
K _$enumDecode<K, V>(

View File

@ -1,23 +1,34 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// User's avatar.
import '../hooks/stores.dart';
/// User's avatar. Respects the `showAvatars` setting from configStore
/// If passed url is null, a blank box is displayed to prevent weird indents
/// Can be disabled with `noBlank`
class Avatar extends StatelessWidget {
class Avatar extends HookWidget {
const Avatar({
Key? key,
required this.url,
this.radius = 25,
this.noBlank = false,
this.alwaysShow = false,
}) : super(key: key);
final String? url;
final double radius;
final bool noBlank;
/// Overrides the `showAvatars` setting
final bool alwaysShow;
@override
Widget build(BuildContext context) {
final showAvatars =
useConfigStoreSelect((configStore) => configStore.showAvatars) ||
alwaysShow;
final blankWidget = () {
if (noBlank) return const SizedBox.shrink();
@ -29,7 +40,7 @@ class Avatar extends StatelessWidget {
final imageUrl = url;
if (imageUrl == null) {
if (imageUrl == null || !showAvatars) {
return blankWidget;
}

View File

@ -93,6 +93,8 @@ class CommentWidget extends HookWidget {
final theme = Theme.of(context);
final accStore = useAccountsStore();
final showScores =
useConfigStoreSelect((configStore) => configStore.showScores);
final isMine = commentTree.comment.comment.creatorId ==
accStore.defaultUserDataFor(commentTree.comment.instanceHost)?.userId;
@ -412,10 +414,13 @@ class CommentWidget extends HookWidget {
SizedBox.fromSize(
size: const Size.square(16),
child: const CircularProgressIndicator())
else
else if (showScores)
Text(compactNumber(comment.counts.score +
(wasVoted ? 0 : myVote.value.value))),
const Text(' · '),
if (showScores)
const Text(' · ')
else
const SizedBox(width: 4),
Text(comment.comment.published.fancy),
],
),

View File

@ -10,6 +10,7 @@ import 'package:url_launcher/url_launcher.dart' as ul;
import '../hooks/delayed_loading.dart';
import '../hooks/logged_in_action.dart';
import '../hooks/stores.dart';
import '../l10n/l10n.dart';
import '../pages/create_post.dart';
import '../pages/full_post.dart';
@ -536,6 +537,8 @@ class _Voting extends HookWidget {
final theme = Theme.of(context);
final myVote = useState(post.myVote ?? VoteType.none);
final loading = useDelayedLoading();
final showScores =
useConfigStoreSelect((configStore) => configStore.showScores);
final loggedInAction = useLoggedInAction(post.instanceHost);
vote(VoteType vote, Jwt token) async {
@ -558,20 +561,21 @@ class _Voting extends HookWidget {
return Row(
children: [
IconButton(
icon: Icon(
Icons.arrow_upward,
color: myVote.value == VoteType.up ? theme.accentColor : null,
icon: Icon(
Icons.arrow_upward,
color: myVote.value == VoteType.up ? theme.accentColor : null,
),
onPressed: loggedInAction(
(token) => vote(
myVote.value == VoteType.up ? VoteType.none : VoteType.up,
token,
),
onPressed: loggedInAction(
(token) => vote(
myVote.value == VoteType.up ? VoteType.none : VoteType.up,
token,
),
)),
),
),
if (loading.loading)
const SizedBox(
width: 20, height: 20, child: CircularProgressIndicator())
else
else if (showScores)
Text(NumberFormat.compact()
.format(post.counts.score + (wasVoted ? 0 : myVote.value.value))),
IconButton(