Merge branch 'master' into fix/deduplication-infinite-scroll

This commit is contained in:
shilangyu 2021-04-22 19:26:39 +02:00
commit 329bf46921
72 changed files with 1964 additions and 1386 deletions

View File

@ -28,6 +28,6 @@
},
"L10n string": {
"prefix": "l10n",
"body": ["L10n.of(context).$0"]
"body": ["L10n.of(context)!.$0"]
}
}

View File

@ -1,13 +1,38 @@
## Unreleased
### 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
### Fixed
- Fixed bug where creating post would crash after uploading a picture
## v0.4.2 - 2021-04-12
### Changed
- Disable commenting on locked posts
- Enhanced keyboard experience
- appropriate keyboard types are opened
- correct capitalization
- added text input hints for things like password managers
- Account actions in settings are more obvious to access: long press an account/instance to see possible actions such as setting as default or removal
### Added
- When writing a comment, the parent text is now selectable
- Text of a post is now selectable
- Tapping outside of a text input hides the keyboard
### Fixed
- When writing a comment the parent text is now selectable
- Text of a post is now selectable
- Actually fixed the thing that v0.4.1 supposedly fixed
## v0.4.1 - 2021-04-06

View File

@ -6,7 +6,11 @@
# lemmur
A mobile client for [lemmy](https://github.com/LemmyNet/lemmy) - a federated reddit alternative
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/com.krawieck.lemmur)
[<img src="https://cdn.rawgit.com/steverichey/google-play-badge-svg/master/img/en_get.svg" height="80">](https://play.google.com/store/apps/details?id=com.krawieck.lemmur)
[<img src="https://raw.githubusercontent.com/andOTP/andOTP/master/assets/badges/get-it-on-github.png" height="80">](https://github.com/krawieck/lemmur/releases/latest)
A mobile client for [Lemmy](https://github.com/LemmyNet/lemmy) - a federated reddit alternative
<a href="https://www.buymeacoffee.com/lemmur" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
@ -18,6 +22,9 @@ A mobile client for [lemmy](https://github.com/LemmyNet/lemmy) - a federated red
- [Android](#android)
- [Linux](#linux)
- [Windows](#windows)
- [FAQ](#faq)
- [Version x.x.x was released, why is it not yet on F-droid?](#version-xxx-was-released-why-is-it-not-yet-on-f-droid)
- ["App not installed" - what to do?](#app-not-installed---what-to-do)
## Build from source
@ -36,10 +43,8 @@ The apk will be in `build/app/outputs/flutter-apk/app-release.apk`
### Linux
1. Make sure you have the additional [linux requirements](https://flutter.dev/desktop#additional-linux-requirements) (verify with `flutter doctor`)
2. Switch to dev channel of flutter:
2. Enable linux desktop:
```sh
flutter channel dev
flutter upgrade
flutter config --enable-linux-desktop
```
3. Build: `flutter build linux`
@ -49,12 +54,20 @@ The executable will be in `build/linux/release/bundle/lemmur` (be aware, however
### Windows
1. Make sure you have the additional [windows requirements](https://flutter.dev/desktop#additional-windows-requirements) (verify with `flutter doctor`)
2. Switch to dev channel of flutter:
2. Enable windows desktop:
```sh
flutter channel dev
flutter upgrade
flutter config --enable-windows-desktop
```
3. Build: `flutter build windows`
The executable will be in `build\windows\runner\Release\lemmur.exe` (be aware, however, that this executable is not standalone)
## FAQ
### Version x.x.x was released, why is it not yet on F-droid?
We have no control over F-droid's build process. This process is automatic and not always predictable in terms of time it takes. If a new version does not appear in F-droid a week after its release, then feel free to open an issue about it and we will look into it.
### "App not installed" - what to do?
When installing the APK directly you might get this message. This happens when you are trying to update lemmur from a different source than where you originally got it from. To fix it simply uninstall the previous version (you will lose all local data) and then install the new one. Always make sure to install lemmur APKs only from verified sources.

View File

@ -0,0 +1,18 @@
### Changed
- Disable commenting on locked posts
- Enhanced keyboard experience
- appropriate keyboard types are opened
- correct capitalization
- added text input hints for things like password managers
- Account actions in settings are more obvious to access: long press an account/instance to see possible actions such as setting as default or removal
### Added
- When writing a comment, the parent text is now selectable
- Text of a post is now selectable
- Tapping outside of a text input hides the keyboard
### Fixed
- Actually fixed the thing that v0.4.1 supposedly fixed

View File

@ -36,18 +36,14 @@ extension on CommentSortType {
return (b, a) =>
a.comment.counts.score.compareTo(b.comment.counts.score);
}
throw Exception('unreachable');
}
}
class CommentTree {
CommentView comment;
List<CommentTree> children;
List<CommentTree> children = [];
CommentTree(this.comment, [this.children]) {
children ??= [];
}
CommentTree(this.comment);
/// takes raw linear comments and turns them into a CommentTree
static List<CommentTree> fromList(List<CommentView> comments) {

View File

@ -18,20 +18,20 @@ class AssetGenImage extends AssetImage {
final String _assetName;
Image image({
Key key,
ImageFrameBuilder frameBuilder,
ImageLoadingBuilder loadingBuilder,
ImageErrorWidgetBuilder errorBuilder,
String semanticLabel,
Key? key,
ImageFrameBuilder? frameBuilder,
ImageLoadingBuilder? loadingBuilder,
ImageErrorWidgetBuilder? errorBuilder,
String? semanticLabel,
bool excludeFromSemantics = false,
double width,
double height,
Color color,
BlendMode colorBlendMode,
BoxFit fit,
double? width,
double? height,
Color? color,
BlendMode? colorBlendMode,
BoxFit? fit,
AlignmentGeometry alignment = Alignment.center,
ImageRepeat repeat = ImageRepeat.noRepeat,
Rect centerSlice,
Rect? centerSlice,
bool matchTextDirection = false,
bool gaplessPlayback = false,
bool isAntiAlias = false,

View File

@ -10,8 +10,8 @@ class Debounce {
final VoidCallback callback;
const Debounce({
@required this.loading,
@required this.callback,
required this.loading,
required this.callback,
});
void call() => callback();
@ -24,7 +24,7 @@ Debounce useDebounce(
Duration delayDuration = const Duration(seconds: 1),
]) {
final loading = useState(false);
final timerHandle = useRef<Timer>(null);
final timerHandle = useRef<Timer?>(null);
cancel() {
timerHandle.current?.cancel();

View File

@ -12,10 +12,10 @@ class DelayedLoading {
final VoidCallback cancel;
const DelayedLoading({
@required this.pending,
@required this.loading,
@required this.start,
@required this.cancel,
required this.pending,
required this.loading,
required this.start,
required this.cancel,
});
}
@ -26,7 +26,7 @@ DelayedLoading useDelayedLoading(
[Duration delayDuration = const Duration(milliseconds: 500)]) {
final loading = useState(false);
final pending = useState(false);
final timerHandle = useRef<Timer>(null);
final timerHandle = useRef<Timer?>(null);
return DelayedLoading(
loading: loading.value,

View File

@ -2,10 +2,5 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import '../widgets/infinite_scroll.dart';
InfiniteScrollController useInfiniteScrollController() {
final controller = useMemoized(() => InfiniteScrollController());
useEffect(() => controller.dispose, []);
return controller;
}
InfiniteScrollController useInfiniteScrollController() =>
useMemoized(() => InfiniteScrollController());

View File

@ -14,14 +14,13 @@ import 'stores.dart';
VoidCallback Function(
void Function(Jwt token) action, [
String message,
]) useLoggedInAction(String instanceHost, {bool any = false}) {
String? message,
]) useAnyLoggedInAction() {
final context = useContext();
final store = useAccountsStore();
return (action, [message]) {
if (any && store.hasNoAccount ||
!any && store.isAnonymousFor(instanceHost)) {
if (store.hasNoAccount) {
return () {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(message ?? 'you have to be logged in to do that'),
@ -31,7 +30,29 @@ VoidCallback Function(
));
};
}
final token = store.defaultTokenFor(instanceHost);
return () => action(store.defaultUserData!.jwt);
};
}
VoidCallback Function(
void Function(Jwt token) action, [
String? message,
]) useLoggedInAction(String instanceHost) {
final context = useContext();
final store = useAccountsStore();
return (action, [message]) {
if (store.isAnonymousFor(instanceHost)) {
return () {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(message ?? 'you have to be logged in to do that'),
action: SnackBarAction(
label: 'log in',
onPressed: () => goTo(context, (_) => AccountsConfigPage())),
));
};
}
final token = store.defaultUserDataFor(instanceHost)!.jwt;
return () => action(token);
};
}

View File

@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
/// creates an [AsyncSnapshot] from the Future returned from the valueBuilder.
/// [keys] can be used to rebuild the Future
AsyncSnapshot<T> useMemoFuture<T>(Future<T> Function() valueBuilder,
[List<Object> keys = const <dynamic>[]]) =>
AsyncSnapshot<T?> useMemoFuture<T>(Future<T> Function() valueBuilder,
[List<Object> keys = const <Object>[]]) =>
useFuture(useMemoized<Future<T>>(valueBuilder, keys),
preserveState: false, initialData: null);

View File

@ -5,9 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'memo_future.dart';
class Refreshable<T> {
const Refreshable({@required this.snapshot, @required this.refresh})
: assert(snapshot != null),
assert(refresh != null);
const Refreshable({required this.snapshot, required this.refresh});
final AsyncSnapshot<T> snapshot;
final AsyncCallback refresh;
@ -20,9 +18,9 @@ class Refreshable<T> {
///
/// `keys` will re-run the initial fetching thus yielding a
/// loading state in the AsyncSnapshot
Refreshable<T> useRefreshable<T>(AsyncValueGetter<T> fetcher,
[List<Object> keys = const <dynamic>[]]) {
final newData = useState<T>(null);
Refreshable<T?> useRefreshable<T>(AsyncValueGetter<T> fetcher,
[List<Object> keys = const <Object>[]]) {
final newData = useState<T?>(null);
final snapshot = useMemoFuture(() async {
newData.value = null;
return fetcher();

View File

@ -6,8 +6,8 @@ export 'l10n_api.dart';
export 'l10n_from_string.dart';
abstract class LocaleSerde {
static Locale fromJson(String json) {
if (json == null) return null;
static Locale fromJson(String? json) {
if (json == null) return const Locale('en');
final lang = json.split('-');

View File

@ -6,25 +6,25 @@ extension SortTypeL10n on SortType {
String tr(BuildContext context) {
switch (this) {
case SortType.hot:
return L10n.of(context).hot;
return L10n.of(context)!.hot;
case SortType.new_:
return L10n.of(context).new_;
return L10n.of(context)!.new_;
case SortType.topYear:
return L10n.of(context).top_year;
return L10n.of(context)!.top_year;
case SortType.topMonth:
return L10n.of(context).top_month;
return L10n.of(context)!.top_month;
case SortType.topWeek:
return L10n.of(context).top_week;
return L10n.of(context)!.top_week;
case SortType.topDay:
return L10n.of(context).top_day;
return L10n.of(context)!.top_day;
case SortType.topAll:
return L10n.of(context).top_all;
return L10n.of(context)!.top_all;
case SortType.newComments:
return L10n.of(context).new_comments;
return L10n.of(context)!.new_comments;
case SortType.active:
return L10n.of(context).active;
return L10n.of(context)!.active;
case SortType.mostComments:
return L10n.of(context).most_comments;
return L10n.of(context)!.most_comments;
default:
throw Exception('unreachable');
}
@ -35,13 +35,13 @@ extension PostListingTypeL10n on PostListingType {
String tr(BuildContext context) {
switch (this) {
case PostListingType.all:
return L10n.of(context).all;
return L10n.of(context)!.all;
case PostListingType.community:
return L10n.of(context).community;
return L10n.of(context)!.community;
case PostListingType.local:
return L10n.of(context).local;
return L10n.of(context)!.local;
case PostListingType.subscribed:
return L10n.of(context).subscribed;
return L10n.of(context)!.subscribed;
default:
throw Exception('unreachable');
}
@ -52,17 +52,17 @@ extension SearchTypeL10n on SearchType {
String tr(BuildContext context) {
switch (this) {
case SearchType.all:
return L10n.of(context).all;
return L10n.of(context)!.all;
case SearchType.comments:
return L10n.of(context).comments;
return L10n.of(context)!.comments;
case SearchType.communities:
return L10n.of(context).communities;
return L10n.of(context)!.communities;
case SearchType.posts:
return L10n.of(context).posts;
return L10n.of(context)!.posts;
case SearchType.url:
return L10n.of(context).url;
return L10n.of(context)!.url;
case SearchType.users:
return L10n.of(context).users;
return L10n.of(context)!.users;
default:
throw Exception('unreachable');
}

View File

@ -147,255 +147,255 @@ extension L10nFromString on String {
String tr(BuildContext context) {
switch (this) {
case L10nStrings.settings:
return L10n.of(context).settings;
return L10n.of(context)!.settings;
case L10nStrings.password:
return L10n.of(context).password;
return L10n.of(context)!.password;
case L10nStrings.email_or_username:
return L10n.of(context).email_or_username;
return L10n.of(context)!.email_or_username;
case L10nStrings.posts:
return L10n.of(context).posts;
return L10n.of(context)!.posts;
case L10nStrings.comments:
return L10n.of(context).comments;
return L10n.of(context)!.comments;
case L10nStrings.modlog:
return L10n.of(context).modlog;
return L10n.of(context)!.modlog;
case L10nStrings.community:
return L10n.of(context).community;
return L10n.of(context)!.community;
case L10nStrings.url:
return L10n.of(context).url;
return L10n.of(context)!.url;
case L10nStrings.title:
return L10n.of(context).title;
return L10n.of(context)!.title;
case L10nStrings.body:
return L10n.of(context).body;
return L10n.of(context)!.body;
case L10nStrings.nsfw:
return L10n.of(context).nsfw;
return L10n.of(context)!.nsfw;
case L10nStrings.post:
return L10n.of(context).post;
return L10n.of(context)!.post;
case L10nStrings.save:
return L10n.of(context).save;
return L10n.of(context)!.save;
case L10nStrings.subscribed:
return L10n.of(context).subscribed;
return L10n.of(context)!.subscribed;
case L10nStrings.local:
return L10n.of(context).local;
return L10n.of(context)!.local;
case L10nStrings.all:
return L10n.of(context).all;
return L10n.of(context)!.all;
case L10nStrings.replies:
return L10n.of(context).replies;
return L10n.of(context)!.replies;
case L10nStrings.mentions:
return L10n.of(context).mentions;
return L10n.of(context)!.mentions;
case L10nStrings.from:
return L10n.of(context).from;
return L10n.of(context)!.from;
case L10nStrings.to:
return L10n.of(context).to;
return L10n.of(context)!.to;
case L10nStrings.deleted_by_creator:
return L10n.of(context).deleted_by_creator;
return L10n.of(context)!.deleted_by_creator;
case L10nStrings.more:
return L10n.of(context).more;
return L10n.of(context)!.more;
case L10nStrings.mark_as_read:
return L10n.of(context).mark_as_read;
return L10n.of(context)!.mark_as_read;
case L10nStrings.mark_as_unread:
return L10n.of(context).mark_as_unread;
return L10n.of(context)!.mark_as_unread;
case L10nStrings.reply:
return L10n.of(context).reply;
return L10n.of(context)!.reply;
case L10nStrings.edit:
return L10n.of(context).edit;
return L10n.of(context)!.edit;
case L10nStrings.delete:
return L10n.of(context).delete;
return L10n.of(context)!.delete;
case L10nStrings.restore:
return L10n.of(context).restore;
return L10n.of(context)!.restore;
case L10nStrings.yes:
return L10n.of(context).yes;
return L10n.of(context)!.yes;
case L10nStrings.no:
return L10n.of(context).no;
return L10n.of(context)!.no;
case L10nStrings.avatar:
return L10n.of(context).avatar;
return L10n.of(context)!.avatar;
case L10nStrings.banner:
return L10n.of(context).banner;
return L10n.of(context)!.banner;
case L10nStrings.display_name:
return L10n.of(context).display_name;
return L10n.of(context)!.display_name;
case L10nStrings.bio:
return L10n.of(context).bio;
return L10n.of(context)!.bio;
case L10nStrings.email:
return L10n.of(context).email;
return L10n.of(context)!.email;
case L10nStrings.matrix_user:
return L10n.of(context).matrix_user;
return L10n.of(context)!.matrix_user;
case L10nStrings.sort_type:
return L10n.of(context).sort_type;
return L10n.of(context)!.sort_type;
case L10nStrings.type:
return L10n.of(context).type;
return L10n.of(context)!.type;
case L10nStrings.show_nsfw:
return L10n.of(context).show_nsfw;
return L10n.of(context)!.show_nsfw;
case L10nStrings.send_notifications_to_email:
return L10n.of(context).send_notifications_to_email;
return L10n.of(context)!.send_notifications_to_email;
case L10nStrings.delete_account:
return L10n.of(context).delete_account;
return L10n.of(context)!.delete_account;
case L10nStrings.saved:
return L10n.of(context).saved;
return L10n.of(context)!.saved;
case L10nStrings.communities:
return L10n.of(context).communities;
return L10n.of(context)!.communities;
case L10nStrings.users:
return L10n.of(context).users;
return L10n.of(context)!.users;
case L10nStrings.theme:
return L10n.of(context).theme;
return L10n.of(context)!.theme;
case L10nStrings.language:
return L10n.of(context).language;
return L10n.of(context)!.language;
case L10nStrings.hot:
return L10n.of(context).hot;
return L10n.of(context)!.hot;
case L10nStrings.new_:
return L10n.of(context).new_;
return L10n.of(context)!.new_;
case L10nStrings.old:
return L10n.of(context).old;
return L10n.of(context)!.old;
case L10nStrings.top:
return L10n.of(context).top;
return L10n.of(context)!.top;
case L10nStrings.chat:
return L10n.of(context).chat;
return L10n.of(context)!.chat;
case L10nStrings.admin:
return L10n.of(context).admin;
return L10n.of(context)!.admin;
case L10nStrings.by:
return L10n.of(context).by;
return L10n.of(context)!.by;
case L10nStrings.not_a_mod_or_admin:
return L10n.of(context).not_a_mod_or_admin;
return L10n.of(context)!.not_a_mod_or_admin;
case L10nStrings.not_an_admin:
return L10n.of(context).not_an_admin;
return L10n.of(context)!.not_an_admin;
case L10nStrings.couldnt_find_post:
return L10n.of(context).couldnt_find_post;
return L10n.of(context)!.couldnt_find_post;
case L10nStrings.not_logged_in:
return L10n.of(context).not_logged_in;
return L10n.of(context)!.not_logged_in;
case L10nStrings.site_ban:
return L10n.of(context).site_ban;
return L10n.of(context)!.site_ban;
case L10nStrings.community_ban:
return L10n.of(context).community_ban;
return L10n.of(context)!.community_ban;
case L10nStrings.downvotes_disabled:
return L10n.of(context).downvotes_disabled;
return L10n.of(context)!.downvotes_disabled;
case L10nStrings.invalid_url:
return L10n.of(context).invalid_url;
return L10n.of(context)!.invalid_url;
case L10nStrings.locked:
return L10n.of(context).locked;
return L10n.of(context)!.locked;
case L10nStrings.couldnt_create_comment:
return L10n.of(context).couldnt_create_comment;
return L10n.of(context)!.couldnt_create_comment;
case L10nStrings.couldnt_like_comment:
return L10n.of(context).couldnt_like_comment;
return L10n.of(context)!.couldnt_like_comment;
case L10nStrings.couldnt_update_comment:
return L10n.of(context).couldnt_update_comment;
return L10n.of(context)!.couldnt_update_comment;
case L10nStrings.no_comment_edit_allowed:
return L10n.of(context).no_comment_edit_allowed;
return L10n.of(context)!.no_comment_edit_allowed;
case L10nStrings.couldnt_save_comment:
return L10n.of(context).couldnt_save_comment;
return L10n.of(context)!.couldnt_save_comment;
case L10nStrings.couldnt_get_comments:
return L10n.of(context).couldnt_get_comments;
return L10n.of(context)!.couldnt_get_comments;
case L10nStrings.report_reason_required:
return L10n.of(context).report_reason_required;
return L10n.of(context)!.report_reason_required;
case L10nStrings.report_too_long:
return L10n.of(context).report_too_long;
return L10n.of(context)!.report_too_long;
case L10nStrings.couldnt_create_report:
return L10n.of(context).couldnt_create_report;
return L10n.of(context)!.couldnt_create_report;
case L10nStrings.couldnt_resolve_report:
return L10n.of(context).couldnt_resolve_report;
return L10n.of(context)!.couldnt_resolve_report;
case L10nStrings.invalid_post_title:
return L10n.of(context).invalid_post_title;
return L10n.of(context)!.invalid_post_title;
case L10nStrings.couldnt_create_post:
return L10n.of(context).couldnt_create_post;
return L10n.of(context)!.couldnt_create_post;
case L10nStrings.couldnt_like_post:
return L10n.of(context).couldnt_like_post;
return L10n.of(context)!.couldnt_like_post;
case L10nStrings.couldnt_find_community:
return L10n.of(context).couldnt_find_community;
return L10n.of(context)!.couldnt_find_community;
case L10nStrings.couldnt_get_posts:
return L10n.of(context).couldnt_get_posts;
return L10n.of(context)!.couldnt_get_posts;
case L10nStrings.no_post_edit_allowed:
return L10n.of(context).no_post_edit_allowed;
return L10n.of(context)!.no_post_edit_allowed;
case L10nStrings.couldnt_save_post:
return L10n.of(context).couldnt_save_post;
return L10n.of(context)!.couldnt_save_post;
case L10nStrings.site_already_exists:
return L10n.of(context).site_already_exists;
return L10n.of(context)!.site_already_exists;
case L10nStrings.couldnt_update_site:
return L10n.of(context).couldnt_update_site;
return L10n.of(context)!.couldnt_update_site;
case L10nStrings.invalid_community_name:
return L10n.of(context).invalid_community_name;
return L10n.of(context)!.invalid_community_name;
case L10nStrings.community_already_exists:
return L10n.of(context).community_already_exists;
return L10n.of(context)!.community_already_exists;
case L10nStrings.community_moderator_already_exists:
return L10n.of(context).community_moderator_already_exists;
return L10n.of(context)!.community_moderator_already_exists;
case L10nStrings.community_follower_already_exists:
return L10n.of(context).community_follower_already_exists;
return L10n.of(context)!.community_follower_already_exists;
case L10nStrings.not_a_moderator:
return L10n.of(context).not_a_moderator;
return L10n.of(context)!.not_a_moderator;
case L10nStrings.couldnt_update_community:
return L10n.of(context).couldnt_update_community;
return L10n.of(context)!.couldnt_update_community;
case L10nStrings.no_community_edit_allowed:
return L10n.of(context).no_community_edit_allowed;
return L10n.of(context)!.no_community_edit_allowed;
case L10nStrings.system_err_login:
return L10n.of(context).system_err_login;
return L10n.of(context)!.system_err_login;
case L10nStrings.community_user_already_banned:
return L10n.of(context).community_user_already_banned;
return L10n.of(context)!.community_user_already_banned;
case L10nStrings.couldnt_find_that_username_or_email:
return L10n.of(context).couldnt_find_that_username_or_email;
return L10n.of(context)!.couldnt_find_that_username_or_email;
case L10nStrings.password_incorrect:
return L10n.of(context).password_incorrect;
return L10n.of(context)!.password_incorrect;
case L10nStrings.registration_closed:
return L10n.of(context).registration_closed;
return L10n.of(context)!.registration_closed;
case L10nStrings.invalid_password:
return L10n.of(context).invalid_password;
return L10n.of(context)!.invalid_password;
case L10nStrings.passwords_dont_match:
return L10n.of(context).passwords_dont_match;
return L10n.of(context)!.passwords_dont_match;
case L10nStrings.captcha_incorrect:
return L10n.of(context).captcha_incorrect;
return L10n.of(context)!.captcha_incorrect;
case L10nStrings.invalid_username:
return L10n.of(context).invalid_username;
return L10n.of(context)!.invalid_username;
case L10nStrings.bio_length_overflow:
return L10n.of(context).bio_length_overflow;
return L10n.of(context)!.bio_length_overflow;
case L10nStrings.couldnt_update_user:
return L10n.of(context).couldnt_update_user;
return L10n.of(context)!.couldnt_update_user;
case L10nStrings.couldnt_update_private_message:
return L10n.of(context).couldnt_update_private_message;
return L10n.of(context)!.couldnt_update_private_message;
case L10nStrings.couldnt_update_post:
return L10n.of(context).couldnt_update_post;
return L10n.of(context)!.couldnt_update_post;
case L10nStrings.couldnt_create_private_message:
return L10n.of(context).couldnt_create_private_message;
return L10n.of(context)!.couldnt_create_private_message;
case L10nStrings.no_private_message_edit_allowed:
return L10n.of(context).no_private_message_edit_allowed;
return L10n.of(context)!.no_private_message_edit_allowed;
case L10nStrings.post_title_too_long:
return L10n.of(context).post_title_too_long;
return L10n.of(context)!.post_title_too_long;
case L10nStrings.email_already_exists:
return L10n.of(context).email_already_exists;
return L10n.of(context)!.email_already_exists;
case L10nStrings.user_already_exists:
return L10n.of(context).user_already_exists;
return L10n.of(context)!.user_already_exists;
case L10nStrings.unsubscribe:
return L10n.of(context).unsubscribe;
return L10n.of(context)!.unsubscribe;
case L10nStrings.subscribe:
return L10n.of(context).subscribe;
return L10n.of(context)!.subscribe;
case L10nStrings.messages:
return L10n.of(context).messages;
return L10n.of(context)!.messages;
case L10nStrings.banned_users:
return L10n.of(context).banned_users;
return L10n.of(context)!.banned_users;
case L10nStrings.delete_account_confirm:
return L10n.of(context).delete_account_confirm;
return L10n.of(context)!.delete_account_confirm;
case L10nStrings.new_password:
return L10n.of(context).new_password;
return L10n.of(context)!.new_password;
case L10nStrings.verify_password:
return L10n.of(context).verify_password;
return L10n.of(context)!.verify_password;
case L10nStrings.old_password:
return L10n.of(context).old_password;
return L10n.of(context)!.old_password;
case L10nStrings.show_avatars:
return L10n.of(context).show_avatars;
return L10n.of(context)!.show_avatars;
case L10nStrings.search:
return L10n.of(context).search;
return L10n.of(context)!.search;
case L10nStrings.send_message:
return L10n.of(context).send_message;
return L10n.of(context)!.send_message;
case L10nStrings.top_day:
return L10n.of(context).top_day;
return L10n.of(context)!.top_day;
case L10nStrings.top_week:
return L10n.of(context).top_week;
return L10n.of(context)!.top_week;
case L10nStrings.top_month:
return L10n.of(context).top_month;
return L10n.of(context)!.top_month;
case L10nStrings.top_year:
return L10n.of(context).top_year;
return L10n.of(context)!.top_year;
case L10nStrings.top_all:
return L10n.of(context).top_all;
return L10n.of(context)!.top_all;
case L10nStrings.most_comments:
return L10n.of(context).most_comments;
return L10n.of(context)!.most_comments;
case L10nStrings.new_comments:
return L10n.of(context).new_comments;
return L10n.of(context)!.new_comments;
case L10nStrings.active:
return L10n.of(context).active;
return L10n.of(context)!.active;
default:
return this;

View File

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:keyboard_dismisser/keyboard_dismisser.dart';
import 'package:provider/provider.dart';
import 'hooks/stores.dart';
@ -41,15 +42,17 @@ class MyApp extends HookWidget {
Widget build(BuildContext context) {
final configStore = useConfigStore();
return MaterialApp(
title: 'lemmur',
supportedLocales: L10n.supportedLocales,
localizationsDelegates: L10n.localizationsDelegates,
themeMode: configStore.theme,
darkTheme: configStore.amoledDarkMode ? amoledTheme : darkTheme,
locale: configStore.locale,
theme: lightTheme,
home: const MyHomePage(),
return KeyboardDismisser(
child: MaterialApp(
title: 'lemmur',
supportedLocales: L10n.supportedLocales,
localizationsDelegates: L10n.localizationsDelegates,
themeMode: configStore.theme,
darkTheme: configStore.amoledDarkMode ? amoledTheme : darkTheme,
locale: configStore.locale,
theme: lightTheme,
home: const MyHomePage(),
),
);
}
}

View File

@ -16,8 +16,7 @@ import 'add_instance.dart';
class AddAccountPage extends HookWidget {
final String instanceHost;
const AddAccountPage({@required this.instanceHost})
: assert(instanceHost != null);
const AddAccountPage({required this.instanceHost});
@override
Widget build(BuildContext context) {
@ -25,16 +24,17 @@ class AddAccountPage extends HookWidget {
final usernameController = useListenable(useTextEditingController());
final passwordController = useListenable(useTextEditingController());
final passwordFocusNode = useFocusNode();
final accountsStore = useAccountsStore();
final loading = useDelayedLoading();
final selectedInstance = useState(instanceHost);
final icon = useState<String>(null);
final icon = useState<String?>(null);
useEffect(() {
LemmyApiV3(selectedInstance.value)
.run(const GetSite())
.then((site) => icon.value = site.siteView.site.icon);
.then((site) => icon.value = site.siteView?.site.icon);
return null;
}, [selectedInstance.value]);
@ -55,96 +55,109 @@ class AddAccountPage extends HookWidget {
loading.cancel();
}
final handleSubmit =
usernameController.text.isEmpty || passwordController.text.isEmpty
? null
: loading.pending
? () {}
: handleOnAdd;
return Scaffold(
appBar: AppBar(
leading: const CloseButton(),
title: const Text('Add account'),
),
body: ListView(
padding: const EdgeInsets.all(15),
children: [
if (icon.value == null)
const SizedBox(height: 150)
else
SizedBox(
height: 150,
child: FullscreenableImage(
url: icon.value,
child: CachedNetworkImage(
imageUrl: icon.value,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
body: AutofillGroup(
child: ListView(
padding: const EdgeInsets.all(15),
children: [
if (icon.value == null)
const SizedBox(height: 150)
else
SizedBox(
height: 150,
child: FullscreenableImage(
url: icon.value!,
child: CachedNetworkImage(
imageUrl: icon.value!,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
),
),
RadioPicker<String>(
title: 'select instance',
values: accountsStore.instances.toList(),
groupValue: selectedInstance.value,
onChanged: (value) => selectedInstance.value = value,
buttonBuilder: (context, displayValue, onPressed) => TextButton(
onPressed: onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(displayValue),
const Icon(Icons.arrow_drop_down),
],
RadioPicker<String>(
title: 'select instance',
values: accountsStore.instances.toList(),
groupValue: selectedInstance.value,
onChanged: (value) => selectedInstance.value = value,
buttonBuilder: (context, displayValue, onPressed) => TextButton(
onPressed: onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(displayValue),
const Icon(Icons.arrow_drop_down),
],
),
),
trailing: ListTile(
leading: const Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.add),
),
title: const Text('Add instance'),
onTap: () async {
final value = await showCupertinoModalPopup<String>(
context: context,
builder: (context) => const AddInstancePage(),
);
Navigator.of(context).pop(value);
},
),
),
trailing: ListTile(
leading: const Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.add),
),
title: const Text('Add instance'),
onTap: () async {
final value = await showCupertinoModalPopup<String>(
context: context,
builder: (context) => const AddInstancePage(),
);
Navigator.of(context).pop(value);
},
TextField(
autofocus: true,
controller: usernameController,
autofillHints: const [
AutofillHints.email,
AutofillHints.username
],
onSubmitted: (_) => passwordFocusNode.requestFocus(),
decoration: InputDecoration(
labelText: L10n.of(context)!.email_or_username),
),
),
// TODO: add support for password managers
TextField(
autofocus: true,
controller: usernameController,
decoration:
InputDecoration(labelText: L10n.of(context).email_or_username),
),
const SizedBox(height: 5),
TextField(
controller: passwordController,
obscureText: true,
decoration: InputDecoration(labelText: L10n.of(context).password),
),
ElevatedButton(
onPressed: usernameController.text.isEmpty ||
passwordController.text.isEmpty
? null
: loading.pending
? () {}
: handleOnAdd,
child: !loading.loading
? const Text('Sign in')
: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
valueColor:
AlwaysStoppedAnimation<Color>(theme.canvasColor),
const SizedBox(height: 5),
TextField(
controller: passwordController,
obscureText: true,
focusNode: passwordFocusNode,
onSubmitted: (_) => handleSubmit?.call(),
autofillHints: const [AutofillHints.password],
keyboardType: TextInputType.visiblePassword,
decoration:
InputDecoration(labelText: L10n.of(context)!.password),
),
ElevatedButton(
onPressed: handleSubmit,
child: !loading.loading
? const Text('Sign in')
: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
valueColor:
AlwaysStoppedAnimation<Color>(theme.canvasColor),
),
),
),
),
TextButton(
onPressed: () {
// TODO: extract to LemmyUrls or something
ul.launch('https://${selectedInstance.value}/login');
},
child: const Text('Register'),
),
],
),
TextButton(
onPressed: () {
// TODO: extract to LemmyUrls or something
ul.launch('https://${selectedInstance.value}/login');
},
child: const Text('Register'),
),
],
),
),
);
}

View File

@ -19,8 +19,8 @@ class AddInstancePage extends HookWidget {
useValueListenable(instanceController);
final accountsStore = useAccountsStore();
final isSite = useState<bool>(null);
final icon = useState<String>(null);
final isSite = useState<bool?>(null);
final icon = useState<String?>(null);
final prevInput = usePrevious(instanceController.text);
final debounce = useDebounce(() async {
if (prevInput == instanceController.text) return;
@ -32,7 +32,7 @@ class AddInstancePage extends HookWidget {
}
try {
icon.value =
(await LemmyApiV3(inst).run(const GetSite())).siteView.site.icon;
(await LemmyApiV3(inst).run(const GetSite())).siteView?.site.icon;
isSite.value = true;
// ignore: avoid_catches_without_on_clauses
} catch (e) {
@ -47,7 +47,9 @@ class AddInstancePage extends HookWidget {
instanceController.removeListener(debounce);
};
}, []);
final inst = normalizeInstanceHost(instanceController.text);
handleOnAdd() async {
try {
await accountsStore.addInstance(inst, assumeValid: true);
@ -59,6 +61,8 @@ class AddInstancePage extends HookWidget {
}
}
final handleAdd = isSite.value == true ? handleOnAdd : null;
return Scaffold(
appBar: AppBar(
leading: const CloseButton(),
@ -70,9 +74,9 @@ class AddInstancePage extends HookWidget {
SizedBox(
height: 150,
child: FullscreenableImage(
url: icon.value,
url: icon.value!,
child: CachedNetworkImage(
imageUrl: icon.value,
imageUrl: icon.value!,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
))
@ -97,6 +101,9 @@ class AddInstancePage extends HookWidget {
child: TextField(
autofocus: true,
controller: instanceController,
autofillHints: const [AutofillHints.url],
keyboardType: TextInputType.url,
onSubmitted: (_) => handleAdd?.call(),
autocorrect: false,
decoration: const InputDecoration(labelText: 'instance url'),
),
@ -108,7 +115,7 @@ class AddInstancePage extends HookWidget {
child: SizedBox(
height: 40,
child: ElevatedButton(
onPressed: isSite.value == true ? handleOnAdd : null,
onPressed: handleAdd,
child: !debounce.loading
? const Text('Add')
: SizedBox(

View File

@ -15,10 +15,8 @@ class CommunitiesListPage extends StatelessWidget {
SortType sortType,
) fetcher;
const CommunitiesListPage({Key key, @required this.fetcher, this.title = ''})
: assert(fetcher != null),
assert(title != null),
super(key: key);
const CommunitiesListPage({Key? key, required this.fetcher, this.title = ''})
: super(key: key);
@override
Widget build(BuildContext context) {
@ -47,9 +45,8 @@ class CommunitiesListPage extends StatelessWidget {
class CommunitiesListItem extends StatelessWidget {
final CommunityView community;
const CommunitiesListItem({Key key, @required this.community})
: assert(community != null),
super(key: key);
const CommunitiesListItem({Key? key, required this.community})
: super(key: key);
@override
Widget build(BuildContext context) => ListTile(
@ -58,7 +55,7 @@ class CommunitiesListItem extends StatelessWidget {
? Opacity(
opacity: 0.7,
child: MarkdownText(
community.community.description,
community.community.description!,
instanceHost: community.instanceHost,
),
)

View File

@ -7,6 +7,7 @@ import 'package:fuzzy/fuzzy.dart';
import 'package:lemmy_api_client/v3.dart';
import '../hooks/delayed_loading.dart';
import '../hooks/logged_in_action.dart';
import '../hooks/refreshable.dart';
import '../hooks/stores.dart';
import '../util/extensions/api.dart';
@ -37,7 +38,7 @@ class CommunitiesTab extends HookWidget {
.map(
(instanceHost) => LemmyApiV3(instanceHost)
.run(const GetSite())
.then((e) => e.siteView.site),
.then((e) => e.siteView!.site),
)
.toList();
@ -52,8 +53,8 @@ class CommunitiesTab extends HookWidget {
sort: SortType.active,
savedOnly: false,
personId:
accountsStore.defaultTokenFor(instanceHost).payload.sub,
auth: accountsStore.defaultTokenFor(instanceHost).raw,
accountsStore.defaultUserDataFor(instanceHost)!.userId,
auth: accountsStore.defaultUserDataFor(instanceHost)!.jwt.raw,
))
.then((e) => e.follows),
)
@ -84,7 +85,7 @@ class CommunitiesTab extends HookWidget {
padding: const EdgeInsets.all(8),
child: Text(
communitiesRefreshable.snapshot.error?.toString() ??
instancesRefreshable.snapshot.error?.toString(),
instancesRefreshable.snapshot.error!.toString(),
),
)
],
@ -115,8 +116,8 @@ class CommunitiesTab extends HookWidget {
}
}
final instances = instancesRefreshable.snapshot.data;
final communities = communitiesRefreshable.snapshot.data
final instances = instancesRefreshable.snapshot.data!;
final communities = communitiesRefreshable.snapshot.data!
..forEach((e) =>
e.sort((a, b) => a.community.name.compareTo(b.community.name)));
@ -128,7 +129,7 @@ class CommunitiesTab extends HookWidget {
return IconButton(
onPressed: () {
filterController.clear();
primaryFocus.unfocus();
primaryFocus?.unfocus();
},
icon: const Icon(Icons.clear),
);
@ -179,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,
@ -210,6 +214,7 @@ class CommunitiesTab extends HookWidget {
Avatar(
radius: 15,
url: comm.community.icon,
alwaysShow: true,
),
const SizedBox(width: 10),
Text(comm.community.originDisplayName),
@ -236,26 +241,24 @@ class _CommunitySubscribeToggle extends HookWidget {
final String instanceHost;
const _CommunitySubscribeToggle(
{@required this.instanceHost, @required this.communityId, Key key})
: assert(instanceHost != null),
assert(communityId != null),
super(key: key);
{required this.instanceHost, required this.communityId, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final subbed = useState(true);
final delayed = useDelayedLoading();
final accountsStore = useAccountsStore();
final loggedInAction = useLoggedInAction(instanceHost);
handleTap() async {
handleTap(Jwt token) async {
delayed.start();
try {
await LemmyApiV3(instanceHost).run(FollowCommunity(
communityId: communityId,
follow: !subbed.value,
auth: accountsStore.defaultTokenFor(instanceHost).raw,
auth: token.raw,
));
subbed.value = !subbed.value;
} on Exception catch (err) {
@ -268,7 +271,7 @@ class _CommunitySubscribeToggle extends HookWidget {
}
return InkWell(
onTap: delayed.pending ? () {} : handleTap,
onTap: delayed.pending ? () {} : loggedInAction(handleTap),
child: Container(
decoration: delayed.loading
? null

View File

@ -28,26 +28,22 @@ import 'modlog_page.dart';
/// Displays posts, comments, and general info about the given community
class CommunityPage extends HookWidget {
final CommunityView _community;
final CommunityView? _community;
final String instanceHost;
final String communityName;
final int communityId;
final String? communityName;
final int? communityId;
const CommunityPage.fromName({
@required this.communityName,
@required this.instanceHost,
}) : assert(communityName != null),
assert(instanceHost != null),
communityId = null,
required String this.communityName,
required this.instanceHost,
}) : communityId = null,
_community = null;
const CommunityPage.fromId({
@required this.communityId,
@required this.instanceHost,
}) : assert(communityId != null),
assert(instanceHost != null),
communityName = null,
required int this.communityId,
required this.instanceHost,
}) : communityName = null,
_community = null;
CommunityPage.fromCommunityView(this._community)
CommunityPage.fromCommunityView(CommunityView this._community)
: instanceHost = _community.instanceHost,
communityId = _community.community.id,
communityName = _community.community.name;
@ -59,7 +55,7 @@ class CommunityPage extends HookWidget {
final scrollController = useScrollController();
final fullCommunitySnap = useMemoFuture(() {
final token = accountsStore.defaultTokenFor(instanceHost);
final token = accountsStore.defaultUserDataFor(instanceHost)?.jwt;
if (communityId != null) {
return LemmyApiV3(instanceHost).run(GetCommunity(
@ -76,7 +72,7 @@ class CommunityPage extends HookWidget {
final community = () {
if (fullCommunitySnap.hasData) {
return fullCommunitySnap.data.communityView;
return fullCommunitySnap.data!.communityView;
} else if (_community != null) {
return _community;
} else {
@ -173,8 +169,8 @@ class CommunityPage extends HookWidget {
color: theme.cardColor,
child: TabBar(
tabs: [
Tab(text: L10n.of(context).posts),
Tab(text: L10n.of(context).comments),
Tab(text: L10n.of(context)!.posts),
Tab(text: L10n.of(context)!.comments),
const Tab(text: 'About'),
],
),
@ -200,8 +196,9 @@ class CommunityPage extends HookWidget {
LemmyApiV3(community.instanceHost).run(GetComments(
communityId: community.community.id,
auth: accountsStore
.defaultTokenFor(community.instanceHost)
?.raw,
.defaultUserDataFor(community.instanceHost)
?.jwt
.raw,
type: CommentListingType.community,
sort: sortType,
limit: batchSize,
@ -224,14 +221,13 @@ class CommunityPage extends HookWidget {
class _CommunityOverview extends StatelessWidget {
final CommunityView community;
final String instanceHost;
final int onlineUsers;
final int? onlineUsers;
const _CommunityOverview({
@required this.community,
@required this.instanceHost,
@required this.onlineUsers,
}) : assert(instanceHost != null),
assert(goToInstance != null);
required this.community,
required this.instanceHost,
required this.onlineUsers,
});
@override
Widget build(BuildContext context) {
@ -257,10 +253,11 @@ class _CommunityOverview extends StatelessWidget {
),
),
FullscreenableImage(
url: community.community.icon,
url: community.community.icon!,
child: Avatar(
url: community.community.icon,
radius: 83 / 2,
alwaysShow: true,
),
),
],
@ -270,9 +267,9 @@ class _CommunityOverview extends StatelessWidget {
return Stack(children: [
if (community.community.banner != null)
FullscreenableImage(
url: community.community.banner,
url: community.community.banner!,
child: CachedNetworkImage(
imageUrl: community.community.banner,
imageUrl: community.community.banner!,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
@ -280,7 +277,7 @@ class _CommunityOverview extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.only(top: 45),
child: Column(children: [
if (community.community.icon != null) icon,
if (icon != null) icon,
// NAME
Center(
child: Padding(
@ -289,7 +286,7 @@ class _CommunityOverview extends StatelessWidget {
overflow: TextOverflow.ellipsis, // TODO: fix overflowing
text: TextSpan(
style:
theme.textTheme.subtitle1.copyWith(shadows: [shadow]),
theme.textTheme.subtitle1?.copyWith(shadows: [shadow]),
children: [
const TextSpan(
text: '!',
@ -346,7 +343,7 @@ class _CommunityOverview extends StatelessWidget {
),
Text(onlineUsers == null
? 'xx'
: compactNumber(onlineUsers)),
: compactNumber(onlineUsers!)),
const Spacer(),
],
),
@ -364,14 +361,14 @@ class _CommunityOverview extends StatelessWidget {
class _AboutTab extends StatelessWidget {
final CommunityView community;
final List<CommunityModeratorView> moderators;
final int onlineUsers;
final List<CommunityModeratorView>? moderators;
final int? onlineUsers;
const _AboutTab({
Key key,
@required this.community,
@required this.moderators,
@required this.onlineUsers,
Key? key,
required this.community,
required this.moderators,
required this.onlineUsers,
}) : super(key: key);
@override
@ -384,7 +381,7 @@ class _AboutTab extends StatelessWidget {
if (community.community.description != null) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: MarkdownText(community.community.description,
child: MarkdownText(community.community.description!,
instanceHost: community.instanceHost),
),
const _Divider(),
@ -396,10 +393,10 @@ class _AboutTab extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 15),
children: [
Chip(
label: Text(L10n.of(context)
label: Text(L10n.of(context)!
.number_of_users_online(onlineUsers ?? 0))),
Chip(
label: Text(L10n.of(context)
label: Text(L10n.of(context)!
.number_of_subscribers(community.counts.subscribers))),
Chip(
label: Text(
@ -422,16 +419,16 @@ class _AboutTab extends StatelessWidget {
communityName: community.community.name,
),
),
child: Text(L10n.of(context).modlog),
child: Text(L10n.of(context)!.modlog),
),
),
const _Divider(),
if (moderators != null && moderators.isNotEmpty) ...[
if (moderators != null && moderators!.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Text('Mods:', style: theme.textTheme.subtitle2),
),
for (final mod in moderators)
for (final mod in moderators!)
// TODO: add user picture, maybe make it into reusable component
ListTile(
title: Text(
@ -463,7 +460,7 @@ class _FollowButton extends HookWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isSubbed = useState(community.subscribed ?? false);
final isSubbed = useState(community.subscribed);
final delayed = useDelayedLoading(Duration.zero);
final loggedInAction = useLoggedInAction(community.instanceHost);
@ -493,7 +490,7 @@ class _FollowButton extends HookWidget {
return ElevatedButtonTheme(
data: ElevatedButtonThemeData(
style: theme.elevatedButtonTheme.style.copyWith(
style: theme.elevatedButtonTheme.style?.copyWith(
shape: MaterialStateProperty.all(const StadiumBorder()),
textStyle: MaterialStateProperty.all(theme.textTheme.subtitle1),
),
@ -518,8 +515,8 @@ class _FollowButton extends HookWidget {
? const Icon(Icons.remove, size: 18)
: const Icon(Icons.add, size: 18),
label: Text(isSubbed.value
? L10n.of(context).unsubscribe
: L10n.of(context).subscribe),
? L10n.of(context)!.unsubscribe
: L10n.of(context)!.subscribe),
),
),
),

View File

@ -15,53 +15,85 @@ import '../util/extensions/api.dart';
import '../util/extensions/spaced.dart';
import '../util/goto.dart';
import '../util/pictrs.dart';
import '../util/unawaited.dart';
import '../widgets/editor.dart';
import '../widgets/markdown_mode_icon.dart';
import '../widgets/markdown_text.dart';
import '../widgets/radio_picker.dart';
import 'full_post.dart';
/// Fab that triggers the [CreatePost] modal
/// After creation it will navigate to the newly created post
class CreatePostFab extends HookWidget {
final CommunityView community;
final CommunityView? community;
const CreatePostFab({this.community});
@override
Widget build(BuildContext context) {
final loggedInAction = useLoggedInAction(null, any: true);
final loggedInAction = useAnyLoggedInAction();
return FloatingActionButton(
onPressed: loggedInAction((_) => showCupertinoModalPopup(
onPressed: loggedInAction((_) async {
final postView = await showCupertinoModalPopup<PostView>(
context: context,
builder: (_) => CreatePostPage.toCommunity(community))),
builder: (_) => community == null
? const CreatePostPage()
: CreatePostPage.toCommunity(community!),
);
if (postView != null) {
await goTo(
context,
(_) => FullPostPage.fromPostView(postView),
);
}
}),
child: const Icon(Icons.add),
);
}
}
/// Modal for creating a post to some community in some instance
/// Pops the navigator stack with a [PostView]
class CreatePostPage extends HookWidget {
final CommunityView community;
final CommunityView? community;
const CreatePostPage() : community = null;
const CreatePostPage.toCommunity(this.community);
final bool _isEdit;
final Post? post;
const CreatePostPage()
: community = null,
_isEdit = false,
post = null;
const CreatePostPage.toCommunity(CommunityView this.community)
: _isEdit = false,
post = null;
const CreatePostPage.edit(this.post)
: _isEdit = true,
community = null;
@override
Widget build(BuildContext context) {
final urlController = useTextEditingController();
final titleController = useTextEditingController();
final bodyController = useTextEditingController();
final urlController =
useTextEditingController(text: _isEdit ? post?.url : null);
final titleController =
useTextEditingController(text: _isEdit ? post?.name : null);
final bodyController =
useTextEditingController(text: _isEdit ? post?.body : null);
final accStore = useAccountsStore();
final selectedInstance =
useState(community?.instanceHost ?? accStore.loggedInInstances.first);
final selectedInstance = useState(_isEdit
? post!.instanceHost
: community?.instanceHost ?? accStore.loggedInInstances.first);
final selectedCommunity = useState(community);
final showFancy = useState(false);
final nsfw = useState(false);
final nsfw = useState(_isEdit && post!.nsfw);
final delayed = useDelayedLoading();
final imagePicker = useImagePicker();
final imageUploadLoading = useState(false);
final pictrsDeleteToken = useState<PictrsUploadFile>(null);
final pictrsDeleteToken = useState<PictrsUploadFile?>(null);
final loggedInAction = useLoggedInAction(selectedInstance.value);
final titleFocusNode = useFocusNode();
final bodyFocusNode = useFocusNode();
final allCommunitiesSnap = useMemoFuture(
() => LemmyApiV3(selectedInstance.value)
@ -69,7 +101,7 @@ class CreatePostPage extends HookWidget {
type: PostListingType.all,
sort: SortType.hot,
limit: 9999,
auth: accStore.defaultTokenFor(selectedInstance.value).raw,
auth: accStore.defaultUserDataFor(selectedInstance.value)?.jwt.raw,
))
.then(
(value) {
@ -80,14 +112,13 @@ class CreatePostPage extends HookWidget {
[selectedInstance.value],
);
uploadPicture() async {
uploadPicture(Jwt token) async {
try {
final pic = await imagePicker.getImage(source: ImageSource.gallery);
// pic is null when the picker was cancelled
if (pic != null) {
imageUploadLoading.value = true;
final token = accStore.defaultTokenFor(selectedInstance.value);
final pictrs = PictrsApi(selectedInstance.value);
final upload =
await pictrs.upload(filePath: pic.path, auth: token.raw);
@ -95,7 +126,6 @@ class CreatePostPage extends HookWidget {
urlController.text =
pathToPictrs(selectedInstance.value, upload.files[0].file);
}
print(urlController.text);
// ignore: avoid_catches_without_on_clauses
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
@ -105,10 +135,8 @@ class CreatePostPage extends HookWidget {
}
}
removePicture() {
PictrsApi(selectedInstance.value)
.delete(pictrsDeleteToken.value)
.catchError((_) {});
removePicture(PictrsUploadFile deleteToken) {
PictrsApi(selectedInstance.value).delete(deleteToken).catchError((_) {});
pictrsDeleteToken.value = null;
urlController.text = '';
@ -117,7 +145,7 @@ class CreatePostPage extends HookWidget {
final instanceDropdown = RadioPicker<String>(
values: accStore.loggedInInstances.toList(),
groupValue: selectedInstance.value,
onChanged: (value) => selectedInstance.value = value,
onChanged: _isEdit ? null : (value) => selectedInstance.value = value,
buttonBuilder: (context, displayValue, onPressed) => TextButton(
onPressed: onPressed,
child: Row(
@ -140,10 +168,10 @@ class CreatePostPage extends HookWidget {
List<DropdownMenuItem<int>> communitiesList() {
if (allCommunitiesSnap.hasData) {
return allCommunitiesSnap.data.map(communityDropDownItem).toList();
return allCommunitiesSnap.data!.map(communityDropDownItem).toList();
} else {
if (selectedCommunity.value != null) {
return [communityDropDownItem(selectedCommunity.value)];
return [communityDropDownItem(selectedCommunity.value!)];
} else {
return const [
DropdownMenuItem(
@ -155,31 +183,85 @@ class CreatePostPage extends HookWidget {
}
}
handleSubmit(Jwt token) async {
if ((!_isEdit && selectedCommunity.value == null) ||
titleController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Choosing a community and a title is required'),
));
return;
}
final api = LemmyApiV3(selectedInstance.value);
delayed.start();
try {
final res = await () {
if (_isEdit) {
return api.run(EditPost(
url: urlController.text.isEmpty ? null : urlController.text,
body: bodyController.text.isEmpty ? null : bodyController.text,
nsfw: nsfw.value,
name: titleController.text,
postId: post!.id,
auth: token.raw,
));
} else {
return api.run(CreatePost(
url: urlController.text.isEmpty ? null : urlController.text,
body: bodyController.text.isEmpty ? null : bodyController.text,
nsfw: nsfw.value,
name: titleController.text,
communityId: selectedCommunity.value!.community.id,
auth: token.raw,
));
}
}();
Navigator.of(context).pop(res);
return;
// ignore: avoid_catches_without_on_clauses
} catch (e) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Failed to post')));
}
delayed.cancel();
}
// TODO: use lazy autocomplete
final communitiesDropdown = InputDecorator(
decoration: const InputDecoration(
contentPadding: EdgeInsets.symmetric(vertical: 1, horizontal: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10)))),
contentPadding: EdgeInsets.symmetric(vertical: 1, horizontal: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: selectedCommunity.value?.community?.id,
hint: Text(L10n.of(context).community),
onChanged: (communityId) => selectedCommunity.value =
allCommunitiesSnap.data
?.firstWhere((e) => e.community.id == communityId),
value: selectedCommunity.value?.community.id,
hint: Text(L10n.of(context)!.community),
onChanged: _isEdit
? null
: (communityId) {
selectedCommunity.value = allCommunitiesSnap.data
?.firstWhere((e) => e.community.id == communityId);
},
items: communitiesList(),
),
),
);
final enabledUrlField = pictrsDeleteToken.value == null;
final url = Row(children: [
Expanded(
child: TextField(
enabled: pictrsDeleteToken.value == null,
enabled: enabledUrlField,
controller: urlController,
autofillHints: enabledUrlField ? const [AutofillHints.url] : null,
keyboardType: TextInputType.url,
onSubmitted: (_) => titleFocusNode.requestFocus(),
decoration: InputDecoration(
labelText: L10n.of(context).url,
labelText: L10n.of(context)!.url,
suffixIcon: const Icon(Icons.link),
),
),
@ -191,8 +273,9 @@ class CreatePostPage extends HookWidget {
: Icon(pictrsDeleteToken.value == null
? Icons.add_photo_alternate
: Icons.close),
onPressed:
pictrsDeleteToken.value == null ? uploadPicture : removePicture,
onPressed: pictrsDeleteToken.value == null
? loggedInAction(uploadPicture)
: () => removePicture(pictrsDeleteToken.value!),
tooltip:
pictrsDeleteToken.value == null ? 'Add picture' : 'Delete picture',
)
@ -200,63 +283,25 @@ class CreatePostPage extends HookWidget {
final title = TextField(
controller: titleController,
focusNode: titleFocusNode,
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.sentences,
onSubmitted: (_) => bodyFocusNode.requestFocus(),
minLines: 1,
maxLines: 2,
decoration: InputDecoration(labelText: L10n.of(context).title),
decoration: InputDecoration(labelText: L10n.of(context)!.title),
);
final body = IndexedStack(
index: showFancy.value ? 1 : 0,
children: [
TextField(
controller: bodyController,
keyboardType: TextInputType.multiline,
maxLines: null,
minLines: 5,
decoration: InputDecoration(labelText: L10n.of(context).body),
),
Padding(
padding: const EdgeInsets.all(16),
child: MarkdownText(
bodyController.text,
instanceHost: selectedInstance.value,
),
),
],
final body = Editor(
controller: bodyController,
focusNode: bodyFocusNode,
onSubmitted: (_) =>
delayed.pending ? () {} : loggedInAction(handleSubmit),
labelText: L10n.of(context)!.body,
instanceHost: selectedInstance.value,
fancy: showFancy.value,
);
handleSubmit() async {
if (selectedCommunity.value == null || titleController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Choosing a community and a title is required'),
));
return;
}
final api = LemmyApiV3(selectedInstance.value);
final token = accStore.defaultTokenFor(selectedInstance.value);
delayed.start();
try {
final res = await api.run(CreatePost(
url: urlController.text.isEmpty ? null : urlController.text,
body: bodyController.text.isEmpty ? null : bodyController.text,
nsfw: nsfw.value,
name: titleController.text,
communityId: selectedCommunity.value.community.id,
auth: token.raw,
));
unawaited(goToReplace(context, (_) => FullPostPage.fromPostView(res)));
return;
// ignore: avoid_catches_without_on_clauses
} catch (e) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Failed to post')));
}
delayed.cancel();
}
return Scaffold(
appBar: AppBar(
leading: const CloseButton(),
@ -272,7 +317,7 @@ class CreatePostPage extends HookWidget {
padding: const EdgeInsets.all(5),
children: [
instanceDropdown,
communitiesDropdown,
if (!_isEdit) communitiesDropdown,
url,
title,
body,
@ -285,17 +330,22 @@ class CreatePostPage extends HookWidget {
children: [
Checkbox(
value: nsfw.value,
onChanged: (val) => nsfw.value = val,
onChanged: (val) {
if (val != null) nsfw.value = val;
},
),
Text(L10n.of(context).nsfw)
Text(L10n.of(context)!.nsfw)
],
),
),
TextButton(
onPressed: delayed.pending ? () {} : handleSubmit,
onPressed:
delayed.pending ? () {} : loggedInAction(handleSubmit),
child: delayed.loading
? const CircularProgressIndicator()
: Text(L10n.of(context).post),
: Text(_isEdit
? L10n.of(context)!.edit
: L10n.of(context)!.post),
)
],
),

View File

@ -20,13 +20,11 @@ import '../widgets/write_comment.dart';
class FullPostPage extends HookWidget {
final int id;
final String instanceHost;
final PostView post;
final PostView? post;
const FullPostPage({@required this.id, @required this.instanceHost})
: assert(id != null),
assert(instanceHost != null),
post = null;
FullPostPage.fromPostView(this.post)
const FullPostPage({required this.id, required this.instanceHost})
: post = null;
FullPostPage.fromPostView(PostView this.post)
: id = post.post.id,
instanceHost = post.instanceHost;
@ -38,7 +36,7 @@ class FullPostPage extends HookWidget {
final fullPostRefreshable =
useRefreshable(() => LemmyApiV3(instanceHost).run(GetPost(
id: id,
auth: accStore.defaultTokenFor(instanceHost)?.raw,
auth: accStore.defaultUserDataFor(instanceHost)?.jwt.raw,
)));
final loggedInAction = useLoggedInAction(instanceHost);
final newComments = useState(const <CommentView>[]);
@ -64,8 +62,8 @@ class FullPostPage extends HookWidget {
// VARIABLES
final post = fullPostRefreshable.snapshot.hasData
? fullPostRefreshable.snapshot.data.postView
: this.post;
? fullPostRefreshable.snapshot.data!.postView
: this.post!;
final fullPost = fullPostRefreshable.snapshot.data;
@ -111,8 +109,13 @@ class FullPostPage extends HookWidget {
IconButton(icon: const Icon(Icons.share), onPressed: sharePost),
SavePostButton(post),
IconButton(
icon: Icon(moreIcon),
onPressed: () => PostWidget.showMoreMenu(context, post)),
icon: Icon(moreIcon),
onPressed: () => PostWidget.showMoreMenu(
context: context,
post: post,
fullPost: true,
),
),
],
),
floatingActionButton: post.post.locked
@ -129,7 +132,7 @@ class FullPostPage extends HookWidget {
children: [
const SizedBox(height: 15),
PostWidget(post, fullPost: true),
if (fullPostRefreshable.snapshot.hasData)
if (fullPost != null)
CommentSection(
newComments.value.followedBy(fullPost.comments).toList(),
postCreatorId: fullPost.postView.creator.id)

View File

@ -25,20 +25,24 @@ 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 {
final instances = accStore.instances.toList(growable: false);
final sites = await Future.wait(instances.map(
(e) => LemmyApiV3(e).run(const GetSite()).catchError((e) => null)));
final sites = await Future.wait(accStore.instances.map((e) =>
LemmyApiV3(e)
.run<FullSiteView?>(const GetSite())
.catchError((e) => null)));
return {
for (var i = 0; i < sites.length; i++)
instances[i]: sites[i].siteView.site.icon
for (final site in sites)
if (site != null) site.instanceHost: site.siteView?.site.icon
};
});
@ -48,19 +52,22 @@ class HomeTab extends HookWidget {
// - listingType == subscribed on an instance that has no longer a logged in account
// - instanceHost of a removed instance
useEffect(() {
if (accStore.isAnonymousFor(selectedList.value.instanceHost) &&
if ((selectedList.value.instanceHost == null ||
accStore.isAnonymousFor(selectedList.value.instanceHost!)) &&
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,
);
}
return null;
}, [
accStore.isAnonymousFor(selectedList.value.instanceHost),
selectedList.value.instanceHost == null ||
accStore.isAnonymousFor(selectedList.value.instanceHost!),
accStore.hasNoAccount,
accStore.instances.length,
]);
@ -84,10 +91,10 @@ class HomeTab extends HookWidget {
),
ListTile(
title: Text(
L10n.of(context).subscribed,
L10n.of(context)!.subscribed,
style: TextStyle(
color: accStore.hasNoAccount
? theme.textTheme.bodyText1.color.withOpacity(0.4)
? theme.textTheme.bodyText1?.color?.withOpacity(0.4)
: null,
),
),
@ -119,7 +126,7 @@ class HomeTab extends HookWidget {
instance.toUpperCase(),
style: TextStyle(
color:
theme.textTheme.bodyText1.color.withOpacity(0.7)),
theme.textTheme.bodyText1?.color?.withOpacity(0.7)),
),
onTap: () => goToInstance(context, instance),
dense: true,
@ -127,14 +134,14 @@ class HomeTab extends HookWidget {
visualDensity: const VisualDensity(
vertical: VisualDensity.minimumDensity),
leading: (instancesIcons.hasData &&
instancesIcons.data[instance] != null)
instancesIcons.data![instance] != null)
? Padding(
padding: const EdgeInsets.only(left: 20),
child: SizedBox(
width: 25,
height: 25,
child: CachedNetworkImage(
imageUrl: instancesIcons.data[instance],
imageUrl: instancesIcons.data![instance]!,
height: 25,
width: 25,
),
@ -144,10 +151,10 @@ class HomeTab extends HookWidget {
),
ListTile(
title: Text(
L10n.of(context).subscribed,
L10n.of(context)!.subscribed,
style: TextStyle(
color: accStore.isAnonymousFor(instance)
? theme.textTheme.bodyText1.color.withOpacity(0.4)
? theme.textTheme.bodyText1?.color?.withOpacity(0.4)
: null),
),
onTap: accStore.isAnonymousFor(instance)
@ -162,7 +169,7 @@ class HomeTab extends HookWidget {
leading: const SizedBox(width: 20),
),
ListTile(
title: Text(L10n.of(context).local),
title: Text(L10n.of(context)!.local),
onTap: () => pop(_SelectedList(
listingType: PostListingType.local,
instanceHost: instance,
@ -170,7 +177,7 @@ class HomeTab extends HookWidget {
leading: const SizedBox(width: 20),
),
ListTile(
title: Text(L10n.of(context).all),
title: Text(L10n.of(context)!.all),
onTap: () => pop(_SelectedList(
listingType: PostListingType.all,
instanceHost: instance,
@ -229,7 +236,7 @@ class HomeTab extends HookWidget {
Flexible(
child: Text(
title,
style: theme.appBarTheme.textTheme.headline6,
style: theme.appBarTheme.textTheme?.headline6,
overflow: TextOverflow.fade,
softWrap: false,
),
@ -249,15 +256,13 @@ class HomeTab extends HookWidget {
/// Infinite list of posts
class InfiniteHomeList extends HookWidget {
final Function onStyleChange;
final InfiniteScrollController controller;
final _SelectedList selectedList;
const InfiniteHomeList({
@required this.selectedList,
this.onStyleChange,
this.controller,
}) : assert(selectedList != null);
required this.selectedList,
required this.controller,
});
@override
Widget build(BuildContext context) {
@ -292,7 +297,7 @@ class InfiniteHomeList extends HookWidget {
page: page,
limit: limit,
savedOnly: false,
auth: accStore.defaultTokenFor(instanceHost)?.raw,
auth: accStore.defaultUserDataFor(instanceHost)?.jwt.raw,
))
];
final instancePosts = await Future.wait(futures);
@ -315,7 +320,7 @@ class InfiniteHomeList extends HookWidget {
page: page,
limit: batchSize,
savedOnly: false,
auth: accStore.defaultTokenFor(instanceHost)?.raw,
auth: accStore.defaultUserDataFor(instanceHost)?.jwt.raw,
));
return InfinitePostList(
@ -323,7 +328,7 @@ class InfiniteHomeList extends HookWidget {
? (page, limit, sort) =>
generalFetcher(page, limit, sort, selectedList.listingType)
: fetcherFromInstance(
selectedList.instanceHost, selectedList.listingType),
selectedList.instanceHost!, selectedList.listingType),
controller: controller,
);
}
@ -331,13 +336,13 @@ class InfiniteHomeList extends HookWidget {
class _SelectedList {
/// when null it implies the 'EVERYTHING' mode
final String instanceHost;
final String? instanceHost;
final PostListingType listingType;
const _SelectedList({
@required this.listingType,
required this.listingType,
this.instanceHost,
}) : assert(listingType != null);
});
String toString() =>
'SelectedList(instanceHost: $instanceHost, listingType: $listingType)';

View File

@ -45,6 +45,8 @@ class InboxPage extends HookWidget {
);
}
final selectedInstance = selected.value!;
toggleUnreadOnly() {
unreadOnly.value = !unreadOnly.value;
isc.clear();
@ -60,7 +62,7 @@ class InboxPage extends HookWidget {
isc.clear();
},
title: 'select instance',
groupValue: selected.value,
groupValue: selectedInstance,
buttonBuilder: (context, displayString, onPressed) => TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 15),
@ -73,7 +75,7 @@ class InboxPage extends HookWidget {
Flexible(
child: Text(
displayString,
style: theme.appBarTheme.textTheme.headline6,
style: theme.appBarTheme.textTheme?.headline6,
overflow: TextOverflow.fade,
softWrap: false,
),
@ -93,9 +95,9 @@ class InboxPage extends HookWidget {
],
bottom: TabBar(
tabs: [
Tab(text: L10n.of(context).replies),
Tab(text: L10n.of(context).mentions),
Tab(text: L10n.of(context).messages),
Tab(text: L10n.of(context)!.replies),
Tab(text: L10n.of(context)!.mentions),
Tab(text: L10n.of(context)!.messages),
],
),
),
@ -106,8 +108,8 @@ class InboxPage extends HookWidget {
controller: isc,
defaultSort: SortType.new_,
fetcher: (page, batchSize, sortType) =>
LemmyApiV3(selected.value).run(GetReplies(
auth: accStore.defaultTokenFor(selected.value).raw,
LemmyApiV3(selectedInstance).run(GetReplies(
auth: accStore.defaultUserDataFor(selectedInstance)!.jwt.raw,
sort: sortType,
limit: batchSize,
page: page,
@ -125,8 +127,8 @@ class InboxPage extends HookWidget {
controller: isc,
defaultSort: SortType.new_,
fetcher: (page, batchSize, sortType) =>
LemmyApiV3(selected.value).run(GetPersonMentions(
auth: accStore.defaultTokenFor(selected.value).raw,
LemmyApiV3(selectedInstance).run(GetPersonMentions(
auth: accStore.defaultUserDataFor(selectedInstance)!.jwt.raw,
sort: sortType,
limit: batchSize,
page: page,
@ -144,9 +146,9 @@ class InboxPage extends HookWidget {
child: Text('no messages'),
),
controller: isc,
fetcher: (page, batchSize) => LemmyApiV3(selected.value).run(
fetcher: (page, batchSize) => LemmyApiV3(selectedInstance).run(
GetPrivateMessages(
auth: accStore.defaultTokenFor(selected.value).raw,
auth: accStore.defaultUserDataFor(selectedInstance)!.jwt.raw,
limit: batchSize,
page: page,
unreadOnly: unreadOnly.value,
@ -170,10 +172,9 @@ class PrivateMessageTile extends HookWidget {
final bool hideOnRead;
const PrivateMessageTile({
@required this.privateMessageView,
required this.privateMessageView,
this.hideOnRead = false,
}) : assert(privateMessageView != null),
assert(hideOnRead != null);
});
static const double _iconSize = 16;
@override
@ -192,7 +193,7 @@ class PrivateMessageTile extends HookWidget {
final toMe = useMemoized(() =>
pmv.value.recipient.originInstanceHost == pmv.value.instanceHost &&
pmv.value.recipient.id ==
accStore.defaultTokenFor(pmv.value.instanceHost)?.payload?.sub);
accStore.defaultUserDataFor(pmv.value.instanceHost)?.userId);
final otherSide =
useMemoized(() => toMe ? pmv.value.creator : pmv.value.recipient);
@ -242,7 +243,7 @@ class PrivateMessageTile extends HookWidget {
instanceHost: pmv.value.instanceHost,
query: DeletePrivateMessage(
privateMessageId: pmv.value.privateMessage.id,
auth: accStore.defaultTokenFor(pmv.value.instanceHost)?.raw,
auth: accStore.defaultUserDataFor(pmv.value.instanceHost)!.jwt.raw,
deleted: !deleted.value,
),
onSuccess: (val) => deleted.value = val.privateMessage.deleted,
@ -254,7 +255,7 @@ class PrivateMessageTile extends HookWidget {
instanceHost: pmv.value.instanceHost,
query: MarkPrivateMessageAsRead(
privateMessageId: pmv.value.privateMessage.id,
auth: accStore.defaultTokenFor(pmv.value.instanceHost)?.raw,
auth: accStore.defaultUserDataFor(pmv.value.instanceHost)!.jwt.raw,
read: !read.value,
),
// TODO: add notification for notifying parent list
@ -283,8 +284,8 @@ class PrivateMessageTile extends HookWidget {
Row(
children: [
Text(
'${toMe ? L10n.of(context).from : L10n.of(context).to} ',
style: TextStyle(color: theme.textTheme.caption.color),
'${toMe ? L10n.of(context)!.from : L10n.of(context)!.to} ',
style: TextStyle(color: theme.textTheme.caption?.color),
),
InkWell(
borderRadius: BorderRadius.circular(10),
@ -295,7 +296,7 @@ class PrivateMessageTile extends HookWidget {
Padding(
padding: const EdgeInsets.only(right: 5),
child: CachedNetworkImage(
imageUrl: otherSide.avatar,
imageUrl: otherSide.avatar!,
height: 20,
width: 20,
imageBuilder: (context, imageProvider) => Container(
@ -339,7 +340,7 @@ class PrivateMessageTile extends HookWidget {
const SizedBox(height: 5),
if (pmv.value.privateMessage.deleted)
Text(
L10n.of(context).deleted_by_creator,
L10n.of(context)!.deleted_by_creator,
style: const TextStyle(fontStyle: FontStyle.italic),
)
else
@ -349,19 +350,19 @@ class PrivateMessageTile extends HookWidget {
TileAction(
icon: moreIcon,
onPressed: showMoreMenu,
tooltip: L10n.of(context).more,
tooltip: L10n.of(context)!.more,
),
if (toMe) ...[
TileAction(
iconColor: read.value ? theme.accentColor : null,
icon: Icons.check,
tooltip: L10n.of(context).mark_as_read,
tooltip: L10n.of(context)!.mark_as_read,
onPressed: handleRead,
delayedLoading: readDelayed,
),
TileAction(
icon: Icons.reply,
tooltip: L10n.of(context).reply,
tooltip: L10n.of(context)!.reply,
onPressed: () {
showCupertinoModalPopup(
context: context,
@ -374,7 +375,7 @@ class PrivateMessageTile extends HookWidget {
] else ...[
TileAction(
icon: Icons.edit,
tooltip: L10n.of(context).edit,
tooltip: L10n.of(context)!.edit,
onPressed: () async {
final val = await showCupertinoModalPopup<PrivateMessageView>(
context: context,
@ -386,8 +387,8 @@ class PrivateMessageTile extends HookWidget {
delayedLoading: deleteDelayed,
icon: deleted.value ? Icons.restore : Icons.delete,
tooltip: deleted.value
? L10n.of(context).restore
: L10n.of(context).delete,
? L10n.of(context)!.restore
: L10n.of(context)!.delete,
onPressed: handleDelete,
),
]

View File

@ -30,9 +30,8 @@ class InstancePage extends HookWidget {
final Future<FullSiteView> siteFuture;
final Future<List<CommunityView>> communitiesFuture;
InstancePage({@required this.instanceHost})
: assert(instanceHost != null),
siteFuture = LemmyApiV3(instanceHost).run(const GetSite()),
InstancePage({required this.instanceHost})
: siteFuture = LemmyApiV3(instanceHost).run(const GetSite()),
communitiesFuture = LemmyApiV3(instanceHost).run(const ListCommunities(
type: PostListingType.local, sort: SortType.hot, limit: 6));
@ -44,7 +43,7 @@ class InstancePage extends HookWidget {
final accStore = useAccountsStore();
final scrollController = useScrollController();
if (!siteSnap.hasData) {
if (!siteSnap.hasData || siteSnap.data!.siteView == null) {
return Scaffold(
appBar: AppBar(),
body: Center(
@ -57,7 +56,9 @@ class InstancePage extends HookWidget {
padding: const EdgeInsets.all(8),
child: Text('ERROR: ${siteSnap.error}'),
)
] else
] else if (siteSnap.hasData && siteSnap.data!.siteView == null)
const Text('ERROR')
else
const CircularProgressIndicator(semanticsLabel: 'loading')
],
),
@ -65,11 +66,12 @@ class InstancePage extends HookWidget {
);
}
final site = siteSnap.data;
final site = siteSnap.data!;
final siteView = site.siteView!;
void _share() => share('https://$instanceHost', context: context);
void _openMoreMenu(BuildContext c) {
void _openMoreMenu() {
showBottomModal(
context: context,
builder: (context) => Column(
@ -110,23 +112,21 @@ class InstancePage extends HookWidget {
fade: true,
scrollController: scrollController,
child: Text(
site.siteView.site.name,
siteView.site.name,
style: TextStyle(color: colorOnCard),
),
),
actions: [
IconButton(icon: const Icon(Icons.share), onPressed: _share),
IconButton(
icon: Icon(moreIcon),
onPressed: () => _openMoreMenu(context)),
IconButton(icon: Icon(moreIcon), onPressed: _openMoreMenu),
],
flexibleSpace: FlexibleSpaceBar(
background: Stack(children: [
if (site.siteView.site.banner != null)
if (siteView.site.banner != null)
FullscreenableImage(
url: site.siteView.site.banner,
url: siteView.site.banner!,
child: CachedNetworkImage(
imageUrl: site.siteView.site.banner,
imageUrl: siteView.site.banner!,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
@ -136,20 +136,20 @@ class InstancePage extends HookWidget {
children: [
Padding(
padding: const EdgeInsets.only(top: 40),
child: site.siteView.site.icon == null
child: siteView.site.icon == null
? const SizedBox(height: 100, width: 100)
: FullscreenableImage(
url: site.siteView.site.icon,
url: siteView.site.icon!,
child: CachedNetworkImage(
width: 100,
height: 100,
imageUrl: site.siteView.site.icon,
imageUrl: siteView.site.icon!,
errorWidget: (_, __, ___) =>
const Icon(Icons.warning),
),
),
),
Text(site.siteView.site.name,
Text(siteView.site.name,
style: theme.textTheme.headline6),
Text(instanceHost, style: theme.textTheme.caption)
],
@ -164,8 +164,8 @@ class InstancePage extends HookWidget {
color: theme.cardColor,
child: TabBar(
tabs: [
Tab(text: L10n.of(context).posts),
Tab(text: L10n.of(context).comments),
Tab(text: L10n.of(context)!.posts),
Tab(text: L10n.of(context)!.comments),
const Tab(text: 'About'),
],
),
@ -184,7 +184,8 @@ class InstancePage extends HookWidget {
limit: batchSize,
page: page,
savedOnly: false,
auth: accStore.defaultTokenFor(instanceHost)?.raw,
auth:
accStore.defaultUserDataFor(instanceHost)?.jwt.raw,
))),
InfiniteCommentList(
fetcher: (page, batchSize, sort) =>
@ -194,7 +195,8 @@ class InstancePage extends HookWidget {
limit: batchSize,
page: page,
savedOnly: false,
auth: accStore.defaultTokenFor(instanceHost)?.raw,
auth:
accStore.defaultUserDataFor(instanceHost)?.jwt.raw,
))),
_AboutTab(site,
communitiesFuture: communitiesFuture,
@ -212,17 +214,18 @@ class _AboutTab extends HookWidget {
final Future<List<CommunityView>> communitiesFuture;
final String instanceHost;
const _AboutTab(this.site,
{@required this.communitiesFuture, @required this.instanceHost})
: assert(communitiesFuture != null),
assert(instanceHost != null);
const _AboutTab(
this.site, {
required this.communitiesFuture,
required this.instanceHost,
});
void goToBannedUsers(BuildContext context) {
goTo(
context,
(_) => UsersListPage(
users: site.banned.reversed.toList(),
title: L10n.of(context).banned_users,
title: L10n.of(context)!.banned_users,
),
);
}
@ -243,27 +246,41 @@ class _AboutTab extends HookWidget {
sort: sortType,
limit: batchSize,
page: page,
auth: accStore.defaultTokenFor(instanceHost)?.raw,
auth: accStore.defaultUserDataFor(instanceHost)?.jwt.raw,
),
),
title: 'Communities of ${site.siteView.site.name}',
title: 'Communities of ${site.siteView?.site.name}',
),
);
}
final siteView = site.siteView;
if (siteView == null) {
return const SingleChildScrollView(
child: Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('error'),
)));
}
return SingleChildScrollView(
child: SafeArea(
top: false,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 15),
child: MarkdownText(
site.siteView.site.description,
instanceHost: instanceHost,
if (siteView.site.description != null) ...[
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 15, vertical: 15),
child: MarkdownText(
siteView.site.description!,
instanceHost: instanceHost,
),
),
),
const _Divider(),
const _Divider(),
],
SizedBox(
height: 32,
child: ListView(
@ -271,17 +288,16 @@ class _AboutTab extends HookWidget {
padding: const EdgeInsets.symmetric(horizontal: 15),
children: [
Chip(
label: Text(L10n.of(context)
label: Text(L10n.of(context)!
.number_of_users_online(site.online))),
Chip(
label: Text(L10n.of(context)
.number_of_users(site.siteView.counts.users))),
label: Text(L10n.of(context)!
.number_of_users(siteView.counts.users))),
Chip(
label: Text(
'${site.siteView.counts.communities} communities')),
Chip(label: Text('${site.siteView.counts.posts} posts')),
Chip(
label: Text('${site.siteView.counts.comments} comments')),
label:
Text('${siteView.counts.communities} communities')),
Chip(label: Text('${siteView.counts.posts} posts')),
Chip(label: Text('${siteView.counts.comments} comments')),
].spaced(8),
),
),
@ -290,12 +306,12 @@ class _AboutTab extends HookWidget {
title: Center(
child: Text(
'Trending communities:',
style: theme.textTheme.headline6.copyWith(fontSize: 18),
style: theme.textTheme.headline6?.copyWith(fontSize: 18),
),
),
),
if (commSnap.hasData)
for (final c in commSnap.data)
for (final c in commSnap.data!)
ListTile(
onTap: () => goToCommunity.byId(
context, c.instanceHost, c.community.id),
@ -321,7 +337,7 @@ class _AboutTab extends HookWidget {
title: Center(
child: Text(
'Admins:',
style: theme.textTheme.headline6.copyWith(fontSize: 18),
style: theme.textTheme.headline6?.copyWith(fontSize: 18),
),
),
),
@ -329,18 +345,18 @@ class _AboutTab extends HookWidget {
ListTile(
title: Text(u.person.originDisplayName),
subtitle: u.person.bio != null
? MarkdownText(u.person.bio, instanceHost: instanceHost)
? MarkdownText(u.person.bio!, instanceHost: instanceHost)
: null,
onTap: () => goToUser.fromPersonSafe(context, u.person),
leading: Avatar(url: u.person.avatar),
),
const _Divider(),
ListTile(
title: Center(child: Text(L10n.of(context).banned_users)),
title: Center(child: Text(L10n.of(context)!.banned_users)),
onTap: () => goToBannedUsers(context),
),
ListTile(
title: Center(child: Text(L10n.of(context).modlog)),
title: Center(child: Text(L10n.of(context)!.modlog)),
onTap: () => goTo(
context,
(context) => ModlogPage.forInstance(instanceHost: instanceHost),

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
@ -21,25 +23,52 @@ class ManageAccountPage extends HookWidget {
final String instanceHost;
final String username;
const ManageAccountPage(
{@required this.instanceHost, @required this.username})
: assert(instanceHost != null),
assert(username != null);
const ManageAccountPage({required this.instanceHost, required this.username});
@override
Widget build(BuildContext context) {
final accountStore = useAccountsStore();
final userFuture = useMemoized(() async {
final site = await LemmyApiV3(instanceHost).run(
GetSite(auth: accountStore.tokenFor(instanceHost, username).raw));
final site = await LemmyApiV3(instanceHost).run(GetSite(
auth: accountStore.userDataFor(instanceHost, username)!.jwt.raw));
return site.myUser;
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,
@ -51,7 +80,7 @@ class ManageAccountPage extends HookWidget {
return const Center(child: CircularProgressIndicator());
}
return _ManageAccount(user: userSnap.data);
return _ManageAccount(user: userSnap.data!);
},
),
);
@ -59,9 +88,7 @@ class ManageAccountPage extends HookWidget {
}
class _ManageAccount extends HookWidget {
const _ManageAccount({Key key, @required this.user})
: assert(user != null),
super(key: key);
const _ManageAccount({Key? key, required this.user}) : super(key: key);
final LocalUserSettingsView user;
@ -81,34 +108,38 @@ 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();
final informAcceptedAvatarRef = useRef<VoidCallback>(null);
final informAcceptedBannerRef = useRef<VoidCallback>(null);
final informAcceptedAvatarRef = useRef<VoidCallback?>(null);
final informAcceptedBannerRef = useRef<VoidCallback?>(null);
final deleteAccountPasswordController = useTextEditingController();
final token = accountsStore.tokenFor(user.instanceHost, user.person.name);
final bioFocusNode = useFocusNode();
final emailFocusNode = useFocusNode();
final matrixUserFocusNode = useFocusNode();
final newPasswordFocusNode = useFocusNode();
final verifyPasswordFocusNode = useFocusNode();
final oldPasswordFocusNode = useFocusNode();
final token =
accountsStore.userDataFor(user.instanceHost, user.person.name)!.jwt;
handleSubmit() async {
saveDelayedLoading.start();
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,
@ -132,8 +163,8 @@ class _ManageAccount extends HookWidget {
email: emailController.text.isEmpty ? null : emailController.text,
));
informAcceptedAvatarRef.current();
informAcceptedBannerRef.current();
informAcceptedAvatarRef.current?.call();
informAcceptedBannerRef.current?.call();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('User settings saved'),
@ -152,28 +183,30 @@ class _ManageAccount extends HookWidget {
context: context,
builder: (context) => AlertDialog(
title: Text(
'${L10n.of(context).delete_account} @${user.instanceHost}@${user.person.name}'),
'${L10n.of(context)!.delete_account} @${user.instanceHost}@${user.person.name}'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(L10n.of(context).delete_account_confirm),
Text(L10n.of(context)!.delete_account_confirm),
const SizedBox(height: 10),
TextField(
controller: deleteAccountPasswordController,
autofillHints: const [AutofillHints.password],
keyboardType: TextInputType.visiblePassword,
obscureText: true,
decoration:
InputDecoration(hintText: L10n.of(context).password),
InputDecoration(hintText: L10n.of(context)!.password),
)
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(L10n.of(context).no),
child: Text(L10n.of(context)!.no),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(L10n.of(context).yes),
child: Text(L10n.of(context)!.yes),
),
],
),
@ -209,7 +242,7 @@ class _ManageAccount extends HookWidget {
children: [
_ImagePicker(
user: user,
name: L10n.of(context).avatar,
name: L10n.of(context)!.avatar,
initialUrl: avatar.current,
onChange: (value) => avatar.current = value,
informAcceptedRef: informAcceptedAvatarRef,
@ -217,115 +250,80 @@ class _ManageAccount extends HookWidget {
const SizedBox(height: 8),
_ImagePicker(
user: user,
name: L10n.of(context).banner,
name: L10n.of(context)!.banner,
initialUrl: banner.current,
onChange: (value) => banner.current = value,
informAcceptedRef: informAcceptedBannerRef,
),
const SizedBox(height: 8),
Text(L10n.of(context).display_name, style: theme.textTheme.headline6),
TextField(controller: displayNameController),
Text(L10n.of(context)!.display_name, style: theme.textTheme.headline6),
TextField(
controller: displayNameController,
onSubmitted: (_) => bioFocusNode.requestFocus(),
),
const SizedBox(height: 8),
Text(L10n.of(context).bio, style: theme.textTheme.headline6),
Text(L10n.of(context)!.bio, style: theme.textTheme.headline6),
TextField(
controller: bioController,
focusNode: bioFocusNode,
textCapitalization: TextCapitalization.sentences,
onSubmitted: (_) => emailFocusNode.requestFocus(),
minLines: 4,
maxLines: 10,
),
const SizedBox(height: 8),
Text(L10n.of(context).email, style: theme.textTheme.headline6),
TextField(controller: emailController),
const SizedBox(height: 8),
Text(L10n.of(context).matrix_user, style: theme.textTheme.headline6),
TextField(controller: matrixUserController),
const SizedBox(height: 8),
Text(L10n.of(context).new_password, style: theme.textTheme.headline6),
Text(L10n.of(context)!.email, style: theme.textTheme.headline6),
TextField(
controller: newPasswordController,
obscureText: true,
focusNode: emailFocusNode,
controller: emailController,
autofillHints: const [AutofillHints.email],
keyboardType: TextInputType.emailAddress,
onSubmitted: (_) => matrixUserFocusNode.requestFocus(),
),
const SizedBox(height: 8),
Text(L10n.of(context).verify_password,
Text(L10n.of(context)!.matrix_user, style: theme.textTheme.headline6),
TextField(
focusNode: matrixUserFocusNode,
controller: matrixUserController,
onSubmitted: (_) => newPasswordFocusNode.requestFocus(),
),
const SizedBox(height: 8),
Text(L10n.of(context)!.new_password, style: theme.textTheme.headline6),
TextField(
focusNode: newPasswordFocusNode,
controller: newPasswordController,
autofillHints: const [AutofillHints.newPassword],
keyboardType: TextInputType.visiblePassword,
obscureText: true,
onSubmitted: (_) => verifyPasswordFocusNode.requestFocus(),
),
const SizedBox(height: 8),
Text(L10n.of(context)!.verify_password,
style: theme.textTheme.headline6),
TextField(
focusNode: verifyPasswordFocusNode,
controller: newPasswordVerifyController,
autofillHints: const [AutofillHints.newPassword],
keyboardType: TextInputType.visiblePassword,
obscureText: true,
onSubmitted: (_) => oldPasswordFocusNode.requestFocus(),
),
const SizedBox(height: 8),
Text(L10n.of(context).old_password, style: theme.textTheme.headline6),
Text(L10n.of(context)!.old_password, style: theme.textTheme.headline6),
TextField(
focusNode: oldPasswordFocusNode,
controller: oldPasswordController,
autofillHints: const [AutofillHints.password],
keyboardType: TextInputType.visiblePassword,
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) => 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) => 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) => sendNotificationsToEmail.value = checked,
title: Text(L10n.of(context).send_notifications_to_email),
onChanged: (checked) {
sendNotificationsToEmail.value = checked;
},
title: Text(L10n.of(context)!.send_notifications_to_email),
dense: true,
),
const SizedBox(height: 8),
@ -337,7 +335,7 @@ class _ManageAccount extends HookWidget {
height: 20,
child: CircularProgressIndicator(),
)
: Text(L10n.of(context).save),
: Text(L10n.of(context)!.save),
),
const SizedBox(height: 8),
ElevatedButton(
@ -345,7 +343,7 @@ class _ManageAccount extends HookWidget {
style: ElevatedButton.styleFrom(
primary: Colors.red,
),
child: Text(L10n.of(context).delete_account.toUpperCase()),
child: Text(L10n.of(context)!.delete_account.toUpperCase()),
),
const BottomSafe(),
],
@ -356,25 +354,23 @@ class _ManageAccount extends HookWidget {
/// Picker and cleanuper for local images uploaded to pictrs
class _ImagePicker extends HookWidget {
final String name;
final String initialUrl;
final String? initialUrl;
final LocalUserSettingsView user;
final ValueChanged<String> onChange;
final ValueChanged<String?>? onChange;
/// _ImagePicker will set the ref to a callback that can inform _ImagePicker
/// that the current picture is accepted
/// and should no longer allow for deletion of it
final Ref<VoidCallback> informAcceptedRef;
final Ref<VoidCallback?> informAcceptedRef;
const _ImagePicker({
Key key,
@required this.initialUrl,
@required this.name,
@required this.user,
@required this.onChange,
@required this.informAcceptedRef,
}) : assert(name != null),
assert(user != null),
super(key: key);
Key? key,
required this.initialUrl,
required this.name,
required this.user,
required this.onChange,
required this.informAcceptedRef,
}) : super(key: key);
@override
Widget build(BuildContext context) {
@ -383,7 +379,7 @@ class _ImagePicker extends HookWidget {
final initialUrl = useRef(this.initialUrl);
final theme = Theme.of(context);
final url = useState(initialUrl.current);
final pictrsDeleteToken = useState<PictrsUploadFile>(null);
final pictrsDeleteToken = useState<PictrsUploadFile?>(null);
final imagePicker = useImagePicker();
final accountsStore = useAccountsStore();
@ -398,12 +394,14 @@ class _ImagePicker extends HookWidget {
final upload = await PictrsApi(user.instanceHost).upload(
filePath: pic.path,
auth:
accountsStore.tokenFor(user.instanceHost, user.person.name).raw,
auth: accountsStore
.userDataFor(user.instanceHost, user.person.name)!
.jwt
.raw,
);
pictrsDeleteToken.value = upload.files[0];
url.value =
pathToPictrs(user.instanceHost, pictrsDeleteToken.value.file);
pathToPictrs(user.instanceHost, pictrsDeleteToken.value!.file);
onChange?.call(url.value);
}
@ -415,10 +413,11 @@ class _ImagePicker extends HookWidget {
delayedLoading.cancel();
}
removePicture({bool updateState = true}) {
PictrsApi(user.instanceHost)
.delete(pictrsDeleteToken.value)
.catchError((_) {});
removePicture({
bool updateState = true,
required PictrsUploadFile pictrsToken,
}) {
PictrsApi(user.instanceHost).delete(pictrsToken).catchError((_) {});
if (updateState) {
pictrsDeleteToken.value = null;
@ -436,7 +435,10 @@ class _ImagePicker extends HookWidget {
return () {
// remove picture from pictrs when exiting
if (pictrsDeleteToken.value != null) {
removePicture(updateState: false);
removePicture(
updateState: false,
pictrsToken: pictrsDeleteToken.value!,
);
}
};
}, []);
@ -462,13 +464,14 @@ class _ImagePicker extends HookWidget {
else
IconButton(
icon: const Icon(Icons.close),
onPressed: removePicture,
onPressed: () =>
removePicture(pictrsToken: pictrsDeleteToken.value!),
)
],
),
if (url.value != null)
CachedNetworkImage(
imageUrl: url.value,
imageUrl: url.value!,
errorWidget: (_, __, ___) => const Icon(Icons.error),
),
],

View File

@ -26,7 +26,7 @@ class MediaViewPage extends HookWidget {
final isDragging = useState(false);
final offset = useState(Offset.zero);
final prevOffset = usePrevious(offset.value);
final prevOffset = usePrevious(offset.value) ?? Offset.zero;
notImplemented() {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(

View File

@ -12,22 +12,18 @@ import '../widgets/bottom_safe.dart';
class ModlogPage extends HookWidget {
final String instanceHost;
final String name;
final int communityId;
final int? communityId;
const ModlogPage.forInstance({
@required this.instanceHost,
}) : assert(instanceHost != null),
communityId = null,
required this.instanceHost,
}) : communityId = null,
name = instanceHost;
const ModlogPage.forCommunity({
@required this.instanceHost,
@required this.communityId,
@required String communityName,
}) : assert(instanceHost != null),
assert(communityId != null),
assert(communityName != null),
name = '!$communityName';
required this.instanceHost,
required int this.communityId,
required String communityName,
}) : name = '!$communityName';
@override
Widget build(BuildContext context) {
@ -65,7 +61,7 @@ class ModlogPage extends HookWidget {
return Center(
child: Text('Error: ${snapshot.error?.toString()}'));
}
final modlog = snapshot.data;
final modlog = snapshot.requireData;
if (modlog.added.length +
modlog.addedToCommunity.length +
@ -78,7 +74,7 @@ class ModlogPage extends HookWidget {
modlog.stickiedPosts.length ==
0) {
WidgetsBinding.instance
.addPostFrameCallback((_) => isDone.value = true);
?.addPostFrameCallback((_) => isDone.value = true);
return const Center(child: Text('no more logs to show'));
}
@ -118,9 +114,7 @@ class ModlogPage extends HookWidget {
}
class _ModlogTable extends StatelessWidget {
const _ModlogTable({Key key, @required this.modlog})
: assert(modlog != null),
super(key: key);
const _ModlogTable({Key? key, required this.modlog}) : super(key: key);
final Modlog modlog;
@ -179,7 +173,7 @@ class _ModlogTable extends StatelessWidget {
RichText(
text: TextSpan(
children: [
if (removedPost.modRemovePost.removed)
if (removedPost.modRemovePost.removed ?? false)
const TextSpan(text: 'removed')
else
const TextSpan(text: 'restored'),
@ -205,7 +199,7 @@ class _ModlogTable extends StatelessWidget {
RichText(
text: TextSpan(
children: [
if (lockedPost.modLockPost.locked)
if (lockedPost.modLockPost.locked ?? false)
const TextSpan(text: 'locked')
else
const TextSpan(text: 'unlocked'),
@ -231,7 +225,7 @@ class _ModlogTable extends StatelessWidget {
RichText(
text: TextSpan(
children: [
if (stickiedPost.modStickyPost.stickied)
if (stickiedPost.modStickyPost.stickied ?? false)
const TextSpan(text: 'stickied')
else
const TextSpan(text: 'unstickied'),
@ -257,7 +251,7 @@ class _ModlogTable extends StatelessWidget {
RichText(
text: TextSpan(
children: [
if (removedComment.modRemoveComment.removed)
if (removedComment.modRemoveComment.removed ?? false)
const TextSpan(text: 'removed')
else
const TextSpan(text: 'restored'),
@ -286,7 +280,7 @@ class _ModlogTable extends StatelessWidget {
RichText(
text: TextSpan(
children: [
if (removedCommunity.modRemoveCommunity.removed)
if (removedCommunity.modRemoveCommunity.removed ?? false)
const TextSpan(text: 'removed')
else
const TextSpan(text: 'restored'),
@ -303,7 +297,7 @@ class _ModlogTable extends StatelessWidget {
RichText(
text: TextSpan(
children: [
if (bannedFromCommunity.modBanFromCommunity.banned)
if (bannedFromCommunity.modBanFromCommunity.banned ?? false)
const TextSpan(text: 'banned ')
else
const TextSpan(text: 'unbanned '),
@ -321,7 +315,7 @@ class _ModlogTable extends StatelessWidget {
RichText(
text: TextSpan(
children: [
if (banned.modBan.banned)
if (banned.modBan.banned ?? false)
const TextSpan(text: 'banned ')
else
const TextSpan(text: 'unbanned '),
@ -337,7 +331,7 @@ class _ModlogTable extends StatelessWidget {
RichText(
text: TextSpan(
children: [
if (addedToCommunity.modAddCommunity.removed)
if (addedToCommunity.modAddCommunity.removed ?? false)
const TextSpan(text: 'removed ')
else
const TextSpan(text: 'appointed '),
@ -355,7 +349,7 @@ class _ModlogTable extends StatelessWidget {
RichText(
text: TextSpan(
children: [
if (added.modAdd.removed)
if (added.modAdd.removed ?? false)
const TextSpan(text: 'removed ')
else
const TextSpan(text: 'apointed '),
@ -402,16 +396,14 @@ class _ModlogEntry {
final DateTime when;
final PersonSafe mod;
final Widget action;
final String reason;
final String? reason;
const _ModlogEntry({
@required this.when,
@required this.mod,
@required this.action,
required this.when,
required this.mod,
required this.action,
this.reason,
}) : assert(when != null),
assert(mod != null),
assert(action != null);
});
_ModlogEntry.fromModRemovePostView(
ModRemovePostView removedPost,
@ -539,7 +531,7 @@ class _ModlogEntry {
),
),
action,
if (reason == null) const Center(child: Text('-')) else Text(reason),
if (reason == null) const Center(child: Text('-')) else Text(reason!),
]
.map(
(widget) => Padding(

View File

@ -80,7 +80,7 @@ class UserProfileTab extends HookWidget {
Text(
// TODO: fix overflow issues
displayValue,
style: theme.appBarTheme.textTheme.headline6,
style: theme.appBarTheme.textTheme?.headline6,
overflow: TextOverflow.fade,
),
const Icon(Icons.expand_more),
@ -91,8 +91,8 @@ class UserProfileTab extends HookWidget {
actions: actions,
),
body: UserProfile(
userId: accountsStore.defaultToken.payload.sub,
instanceHost: accountsStore.defaultInstanceHost,
userId: accountsStore.defaultUserData!.userId,
instanceHost: accountsStore.defaultInstanceHost!,
),
);
}

View File

@ -13,15 +13,24 @@ class SavedPage extends HookWidget {
Widget build(BuildContext context) {
final accountStore = useAccountsStore();
if (accountStore.hasNoAccount) {
Scaffold(
appBar: AppBar(),
body: const Center(
child: Text('no account found'),
),
);
}
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).saved),
title: Text(L10n.of(context)!.saved),
bottom: TabBar(
tabs: [
Tab(text: L10n.of(context).posts),
Tab(text: L10n.of(context).comments),
Tab(text: L10n.of(context)!.posts),
Tab(text: L10n.of(context)!.comments),
],
),
),
@ -29,27 +38,27 @@ class SavedPage extends HookWidget {
children: [
InfinitePostList(
fetcher: (page, batchSize, sortType) =>
LemmyApiV3(accountStore.defaultInstanceHost).run(
LemmyApiV3(accountStore.defaultInstanceHost!).run(
GetPosts(
type: PostListingType.all,
sort: sortType,
savedOnly: true,
page: page,
limit: batchSize,
auth: accountStore.defaultToken.raw,
auth: accountStore.defaultUserData!.jwt.raw,
),
),
),
InfiniteCommentList(
fetcher: (page, batchSize, sortType) =>
LemmyApiV3(accountStore.defaultInstanceHost).run(
LemmyApiV3(accountStore.defaultInstanceHost!).run(
GetComments(
type: CommentListingType.all,
sort: sortType,
savedOnly: true,
page: page,
limit: batchSize,
auth: accountStore.defaultToken.raw,
auth: accountStore.defaultUserData!.jwt.raw,
),
),
),

View File

@ -15,11 +15,9 @@ class SearchResultsPage extends HookWidget {
final String query;
SearchResultsPage({
@required this.instanceHost,
@required this.query,
}) : assert(instanceHost != null),
assert(query != null),
assert(instanceHost.isNotEmpty),
required this.instanceHost,
required this.query,
}) : assert(instanceHost.isNotEmpty),
assert(query.isNotEmpty);
@override
@ -31,10 +29,10 @@ class SearchResultsPage extends HookWidget {
bottom: TabBar(
isScrollable: true,
tabs: [
Tab(text: L10n.of(context).posts),
Tab(text: L10n.of(context).comments),
Tab(text: L10n.of(context).users),
Tab(text: L10n.of(context).communities),
Tab(text: L10n.of(context)!.posts),
Tab(text: L10n.of(context)!.comments),
Tab(text: L10n.of(context)!.users),
Tab(text: L10n.of(context)!.communities),
],
),
),
@ -68,24 +66,22 @@ class _SearchResultsList extends HookWidget {
final String instanceHost;
const _SearchResultsList({
@required this.type,
@required this.query,
@required this.instanceHost,
}) : assert(type != null),
assert(query != null),
assert(instanceHost != null);
required this.type,
required this.query,
required this.instanceHost,
});
@override
Widget build(BuildContext context) {
final accStore = useAccountsStore();
return SortableInfiniteList(
return SortableInfiniteList<Object>(
fetcher: (page, batchSize, sort) async {
final s = await LemmyApiV3(instanceHost).run(Search(
q: query,
sort: sort,
type: type,
auth: accStore.defaultTokenFor(instanceHost)?.raw,
auth: accStore.defaultUserDataFor(instanceHost)?.jwt.raw,
page: page,
limit: batchSize,
));

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -18,7 +19,7 @@ class SearchTab extends HookWidget {
final accStore = useAccountsStore();
// null if there are no added instances
final instanceHost = useState(
accStore.instances.firstWhere((_) => true, orElse: () => null),
accStore.instances.firstWhereOrNull((_) => true),
);
if (instanceHost.value == null) {
@ -29,47 +30,52 @@ class SearchTab extends HookWidget {
),
);
}
handleSearch() => searchInputController.text.isNotEmpty
? goTo(
context,
(context) => SearchResultsPage(
instanceHost: instanceHost.value!,
query: searchInputController.text,
),
)
: null;
return Scaffold(
appBar: AppBar(),
body: GestureDetector(
onTapDown: (_) => primaryFocus.unfocus(),
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
TextField(
controller: searchInputController,
textAlign: TextAlign.center,
decoration: InputDecoration(hintText: L10n.of(context).search),
),
const SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text('instance:',
style: Theme.of(context).textTheme.subtitle1),
body: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
TextField(
controller: searchInputController,
keyboardType: TextInputType.text,
textAlign: TextAlign.center,
onSubmitted: (_) => handleSearch(),
decoration: InputDecoration(hintText: L10n.of(context)!.search),
),
const SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text('instance:',
style: Theme.of(context).textTheme.subtitle1),
),
Expanded(
child: RadioPicker<String>(
values: accStore.instances.toList(),
groupValue: instanceHost.value!,
onChanged: (value) => instanceHost.value = value,
),
Expanded(
child: RadioPicker<String>(
values: accStore.instances.toList(),
groupValue: instanceHost.value,
onChanged: (value) => instanceHost.value = value,
),
),
],
),
if (searchInputController.text.isNotEmpty)
ElevatedButton(
onPressed: () => goTo(
context,
(c) => SearchResultsPage(
instanceHost: instanceHost.value,
query: searchInputController.text,
)),
child: Text(L10n.of(context).search),
)
],
),
),
],
),
if (searchInputController.text.isNotEmpty)
ElevatedButton(
onPressed: handleSearch,
child: Text(L10n.of(context)!.search),
)
],
),
);
}

View File

@ -2,13 +2,14 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_slidable/flutter_slidable.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';
import '../util/goto.dart';
import '../widgets/about_tile.dart';
import '../widgets/bottom_modal.dart';
import '../widgets/radio_picker.dart';
import 'add_account.dart';
import 'add_instance.dart';
@ -21,10 +22,17 @@ class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).settings),
title: Text(L10n.of(context)!.settings),
),
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'),
@ -66,10 +72,10 @@ class AppearanceConfigPage extends HookWidget {
title: Text(describeEnum(theme)),
groupValue: configStore.theme,
onChanged: (selected) {
configStore.theme = selected;
if (selected != null) configStore.theme = selected;
},
),
SwitchListTile(
SwitchListTile.adaptive(
title: const Text('AMOLED dark mode'),
value: configStore.amoledDarkMode,
onChanged: (checked) {
@ -77,9 +83,69 @@ 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).language),
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(
width: 120,
child: RadioPicker<Locale>(
@ -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();
@ -117,44 +281,47 @@ class AccountsConfigPage extends HookWidget {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(L10n.of(context).no),
child: Text(L10n.of(context)!.no),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(L10n.of(context).yes),
child: Text(L10n.of(context)!.yes),
),
],
),
) ??
false) {
await accountsStore.removeInstance(instanceHost);
Navigator.of(context).pop();
}
}
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),
),
],
void accountActions(String instanceHost, String username) {
showBottomModal(
context: context,
builder: (context) => _AccountOptions(
instanceHost: instanceHost,
username: username,
),
);
}
void instanceActions(String instanceHost) {
showBottomModal(
context: context,
builder: (context) => Column(
children: [
ListTile(
leading: const Icon(Icons.delete),
title: const Text('Remove instance'),
onTap: () => removeInstanceDialog(instanceHost),
),
) ??
false) {
return accountsStore.removeAccount(instanceHost, username);
}
],
),
);
}
// TODO: speeddial v3 has really stupid defaults here https://github.com/darioielardi/flutter_speed_dial/issues/149
return Scaffold(
key: _scaffoldKey,
appBar: AppBar(
@ -206,62 +373,30 @@ class AccountsConfigPage extends HookWidget {
),
for (final instance in accountsStore.instances) ...[
const SizedBox(height: 40),
Slidable(
actionPane: const SlidableBehindActionPane(),
secondaryActions: [
IconSlideAction(
onTap: () => removeInstanceDialog(instance),
icon: Icons.delete_sweep,
color: Colors.red,
),
],
key: Key(instance),
// TODO: missing ripple effect
child: Container(
color: theme.scaffoldBackgroundColor,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: _SectionHeading(instance),
),
),
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
onLongPress: () => instanceActions(instance),
title: _SectionHeading(instance),
),
for (final username in accountsStore.usernamesFor(instance)) ...[
Slidable(
actionPane: const SlidableBehindActionPane(),
key: Key('$username@$instance'),
secondaryActions: [
IconSlideAction(
onTap: () => removeUserDialog(instance, username),
icon: Icons.delete_sweep,
color: Colors.red,
),
],
// TODO: missing ripple effect
child: Container(
color: theme.scaffoldBackgroundColor,
child: ListTile(
trailing:
username == accountsStore.defaultUsernameFor(instance)
? Icon(
Icons.check_circle_outline,
color: theme.accentColor,
)
: null,
title: Text(username),
onLongPress: () {
accountsStore.setDefaultAccountFor(instance, username);
},
onTap: () {
goTo(
context,
(_) => ManageAccountPage(
instanceHost: instance,
username: username,
));
},
),
),
ListTile(
trailing: username == accountsStore.defaultUsernameFor(instance)
? Icon(
Icons.check_circle_outline,
color: theme.accentColor,
)
: null,
title: Text(username),
onLongPress: () => accountActions(instance, username),
onTap: () {
goTo(
context,
(_) => ManageAccountPage(
instanceHost: instance,
username: username,
));
},
),
],
if (accountsStore.usernamesFor(instance).isEmpty)
@ -292,7 +427,7 @@ class _SectionHeading extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(left: 20),
child: Text(text.toUpperCase(),
style: theme.textTheme.subtitle2.copyWith(color: theme.accentColor)),
style: theme.textTheme.subtitle2?.copyWith(color: theme.accentColor)),
);
}
}

View File

@ -10,20 +10,16 @@ import 'write_message.dart';
/// Page showing posts, comments, and general info about a user.
class UserPage extends HookWidget {
final int userId;
final int? userId;
final String instanceHost;
final Future<FullPersonView> _userDetails;
UserPage({@required this.userId, @required this.instanceHost})
: assert(userId != null),
assert(instanceHost != null),
_userDetails = LemmyApiV3(instanceHost).run(GetPersonDetails(
UserPage({required this.userId, required this.instanceHost})
: _userDetails = LemmyApiV3(instanceHost).run(GetPersonDetails(
personId: userId, savedOnly: true, sort: SortType.active));
UserPage.fromName({@required this.instanceHost, @required String username})
: assert(instanceHost != null),
assert(username != null),
userId = null,
UserPage.fromName({required this.instanceHost, required String username})
: userId = null,
_userDetails = LemmyApiV3(instanceHost).run(GetPersonDetails(
username: username, savedOnly: true, sort: SortType.active));
@ -33,7 +29,7 @@ class UserPage extends HookWidget {
final body = () {
if (userDetailsSnap.hasData) {
return UserProfile.fromFullPersonView(userDetailsSnap.data);
return UserProfile.fromFullPersonView(userDetailsSnap.data!);
} else if (userDetailsSnap.hasError) {
return const Center(child: Text('Could not find that user.'));
} else {
@ -46,11 +42,11 @@ class UserPage extends HookWidget {
appBar: AppBar(
actions: [
if (userDetailsSnap.hasData) ...[
SendMessageButton(userDetailsSnap.data.personView.person),
SendMessageButton(userDetailsSnap.data!.personView.person),
IconButton(
icon: const Icon(Icons.share),
onPressed: () => share(
userDetailsSnap.data.personView.person.actorId,
userDetailsSnap.data!.personView.person.actorId,
context: context,
),
),

View File

@ -11,9 +11,8 @@ class UsersListPage extends StatelessWidget {
final String title;
final List<PersonViewSafe> users;
const UsersListPage({Key key, @required this.users, this.title})
: assert(users != null),
super(key: key);
const UsersListPage({Key? key, required this.users, this.title = ''})
: super(key: key);
@override
Widget build(BuildContext context) {
@ -23,7 +22,7 @@ class UsersListPage extends StatelessWidget {
return Scaffold(
appBar: AppBar(
backgroundColor: theme.cardColor,
title: Text(title ?? ''),
title: Text(title),
),
body: ListView.builder(
itemBuilder: (context, i) => UsersListItem(user: users[i]),
@ -36,9 +35,7 @@ class UsersListPage extends StatelessWidget {
class UsersListItem extends StatelessWidget {
final PersonViewSafe user;
const UsersListItem({Key key, @required this.user})
: assert(user != null),
super(key: key);
const UsersListItem({Key? key, required this.user}) : super(key: key);
@override
Widget build(BuildContext context) => ListTile(
@ -47,7 +44,7 @@ class UsersListItem extends StatelessWidget {
? Opacity(
opacity: 0.5,
child: MarkdownText(
user.person.bio,
user.person.bio!,
instanceHost: user.instanceHost,
),
)

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import '../hooks/stores.dart';
import '../hooks/logged_in_action.dart';
import '../l10n/l10n.dart';
import '../util/extensions/api.dart';
import '../widgets/markdown_mode_icon.dart';
@ -14,16 +14,14 @@ class WriteMessagePage extends HookWidget {
final String instanceHost;
/// if it's non null then this page is used for edit
final PrivateMessage privateMessage;
final PrivateMessage? privateMessage;
final bool _isEdit;
const WriteMessagePage.send({
@required this.recipient,
@required this.instanceHost,
}) : assert(recipient != null),
assert(instanceHost != null),
privateMessage = null,
required this.recipient,
required this.instanceHost,
}) : privateMessage = null,
_isEdit = false;
WriteMessagePage.edit(PrivateMessageView pmv)
@ -34,22 +32,21 @@ class WriteMessagePage extends HookWidget {
@override
Widget build(BuildContext context) {
final accStore = useAccountsStore();
final showFancy = useState(false);
final bodyController =
useTextEditingController(text: privateMessage?.content);
final loading = useState(false);
final loggedInAction = useLoggedInAction(instanceHost);
final submit = _isEdit ? L10n.of(context)!.save : 'send';
final title = _isEdit ? 'Edit message' : L10n.of(context)!.send_message;
final submit = _isEdit ? L10n.of(context).save : 'send';
final title = _isEdit ? 'Edit message' : L10n.of(context).send_message;
handleSubmit() async {
handleSubmit(Jwt token) async {
if (_isEdit) {
loading.value = true;
try {
final msg = await LemmyApiV3(instanceHost).run(EditPrivateMessage(
auth: accStore.defaultTokenFor(instanceHost)?.raw,
privateMessageId: privateMessage.id,
auth: token.raw,
privateMessageId: privateMessage!.id,
content: bodyController.text,
));
Navigator.of(context).pop(msg);
@ -65,7 +62,7 @@ class WriteMessagePage extends HookWidget {
loading.value = true;
try {
await LemmyApiV3(instanceHost).run(CreatePrivateMessage(
auth: accStore.defaultTokenFor(instanceHost)?.raw,
auth: token.raw,
content: bodyController.text,
recipientId: recipient.id,
));
@ -89,6 +86,7 @@ class WriteMessagePage extends HookWidget {
TextField(
controller: bodyController,
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
maxLines: null,
minLines: 5,
autofocus: true,
@ -124,7 +122,7 @@ class WriteMessagePage extends HookWidget {
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: loading.value ? () {} : handleSubmit,
onPressed: loading.value ? () {} : loggedInAction(handleSubmit),
child: loading.value
? const SizedBox(
height: 20,

View File

@ -11,27 +11,27 @@ part 'accounts_store.g.dart';
/// Store that manages all accounts
@JsonSerializable()
class AccountsStore extends ChangeNotifier {
static const prefsKey = 'v3:AccountsStore';
static const prefsKey = 'v4:AccountsStore';
static final _prefs = SharedPreferences.getInstance();
/// Map containing JWT tokens of specific users.
/// Map containing user data (jwt token, userId) of specific accounts.
/// If a token is in this map, the user is considered logged in
/// for that account.
/// `tokens['instanceHost']['username']`
/// `accounts['instanceHost']['username']`
@protected
@JsonKey(defaultValue: {'lemmy.ml': {}})
Map<String, Map<String, Jwt>> tokens;
late Map<String, Map<String, UserData>> accounts;
/// default account for a given instance
/// map where keys are instanceHosts and values are usernames
@protected
@JsonKey(defaultValue: {})
Map<String, String> defaultAccounts;
late Map<String, String> defaultAccounts;
/// default account for the app
/// It is in a form of `username@instanceHost`
@protected
String defaultAccount;
String? defaultAccount;
static Future<AccountsStore> load() async {
final prefs = await _prefs;
@ -63,8 +63,8 @@ class AccountsStore extends ChangeNotifier {
.toList()
.forEach(defaultAccounts.remove);
if (defaultAccount != null) {
final instance = defaultAccount.split('@')[1];
final username = defaultAccount.split('@')[0];
final instance = defaultAccount!.split('@')[1];
final username = defaultAccount!.split('@')[0];
// if instance or username doesn't exist, remove
if (!instances.contains(instance) ||
!usernamesFor(instance).contains(username)) {
@ -97,23 +97,20 @@ class AccountsStore extends ChangeNotifier {
}
}
String get defaultUsername {
String? get defaultUsername => defaultAccount?.split('@')[0];
String? get defaultInstanceHost => defaultAccount?.split('@')[1];
UserData? get defaultUserData {
if (defaultAccount == null) {
return null;
}
return defaultAccount.split('@')[0];
final userTag = defaultAccount!.split('@');
return accounts[userTag[1]]?[userTag[0]];
}
String get defaultInstanceHost {
if (defaultAccount == null) {
return null;
}
return defaultAccount.split('@')[1];
}
String defaultUsernameFor(String instanceHost) {
String? defaultUsernameFor(String instanceHost) {
if (isAnonymousFor(instanceHost)) {
return null;
}
@ -121,29 +118,20 @@ class AccountsStore extends ChangeNotifier {
return defaultAccounts[instanceHost];
}
Jwt get defaultToken {
if (defaultAccount == null) {
return null;
}
final userTag = defaultAccount.split('@');
return tokens[userTag[1]][userTag[0]];
}
Jwt defaultTokenFor(String instanceHost) {
UserData? defaultUserDataFor(String instanceHost) {
if (isAnonymousFor(instanceHost)) {
return null;
}
return tokens[instanceHost][defaultAccounts[instanceHost]];
return accounts[instanceHost]?[defaultAccounts[instanceHost]];
}
Jwt tokenFor(String instanceHost, String username) {
UserData? userDataFor(String instanceHost, String username) {
if (!usernamesFor(instanceHost).contains(username)) {
return null;
}
return tokens[instanceHost][username];
return accounts[instanceHost]?[username];
}
/// sets globally default account
@ -169,20 +157,20 @@ class AccountsStore extends ChangeNotifier {
return true;
}
return tokens[instanceHost].isEmpty;
return accounts[instanceHost]!.isEmpty;
}
/// `true` if no added instance has an account assigned to it
bool get hasNoAccount => loggedInInstances.isEmpty;
Iterable<String> get instances => tokens.keys;
Iterable<String> get instances => accounts.keys;
Iterable<String> get loggedInInstances =>
instances.where((e) => !isAnonymousFor(e));
/// Usernames that are assigned to a given instance
Iterable<String> usernamesFor(String instanceHost) =>
tokens[instanceHost].keys;
accounts[instanceHost]?.keys ?? const Iterable.empty();
/// adds a new account
/// if it's the first account ever the account is
@ -199,15 +187,16 @@ class AccountsStore extends ChangeNotifier {
}
final lemmy = LemmyApiV3(instanceHost);
final token = await lemmy.run(Login(
final jwt = await lemmy.run(Login(
usernameOrEmail: usernameOrEmail,
password: password,
));
final userData =
await lemmy.run(GetSite(auth: token.raw)).then((value) => value.myUser);
await lemmy.run(GetSite(auth: jwt.raw)).then((value) => value.myUser!);
tokens[instanceHost][userData.person.name] = token.copyWith(
payload: token.payload.copyWith(sub: userData.person.id),
accounts[instanceHost]![userData.person.name] = UserData(
jwt: jwt,
userId: userData.person.id,
);
await _assignDefaultAccounts();
@ -235,7 +224,7 @@ class AccountsStore extends ChangeNotifier {
}
}
tokens[instanceHost] = HashMap();
accounts[instanceHost] = HashMap();
await _assignDefaultAccounts();
notifyListeners();
@ -244,7 +233,7 @@ class AccountsStore extends ChangeNotifier {
/// This also removes all accounts assigned to this instance
Future<void> removeInstance(String instanceHost) async {
tokens.remove(instanceHost);
accounts.remove(instanceHost);
await _assignDefaultAccounts();
notifyListeners();
@ -252,10 +241,31 @@ class AccountsStore extends ChangeNotifier {
}
Future<void> removeAccount(String instanceHost, String username) async {
tokens[instanceHost].remove(username);
if (!accounts.containsKey(instanceHost)) {
throw Exception("instance doesn't exist");
}
accounts[instanceHost]!.remove(username);
await _assignDefaultAccounts();
notifyListeners();
return save();
}
}
/// Stores data associated with a logged in user
@JsonSerializable()
class UserData {
final Jwt jwt;
final int userId;
const UserData({
required this.jwt,
required this.userId,
});
factory UserData.fromJson(Map<String, dynamic> json) =>
_$UserDataFromJson(json);
Map<String, dynamic> toJson() => _$UserDataToJson(this);
}

View File

@ -8,25 +8,37 @@ part of 'accounts_store.dart';
AccountsStore _$AccountsStoreFromJson(Map<String, dynamic> json) {
return AccountsStore()
..tokens = (json['tokens'] as Map<String, dynamic>)?.map(
..accounts = (json['accounts'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(
k,
(e as Map<String, dynamic>)?.map(
(e as Map<String, dynamic>).map(
(k, e) =>
MapEntry(k, e == null ? null : Jwt.fromJson(e as String)),
MapEntry(k, UserData.fromJson(e as Map<String, dynamic>)),
)),
) ??
{'lemmy.ml': {}}
..defaultAccounts = (json['defaultAccounts'] as Map<String, dynamic>)?.map(
..defaultAccounts = (json['defaultAccounts'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
) ??
{}
..defaultAccount = json['defaultAccount'] as String;
..defaultAccount = json['defaultAccount'] as String?;
}
Map<String, dynamic> _$AccountsStoreToJson(AccountsStore instance) =>
<String, dynamic>{
'tokens': instance.tokens,
'accounts': instance.accounts,
'defaultAccounts': instance.defaultAccounts,
'defaultAccount': instance.defaultAccount,
};
UserData _$UserDataFromJson(Map<String, dynamic> json) {
return UserData(
jwt: Jwt.fromJson(json['jwt'] as String),
userId: json['userId'] as int,
);
}
Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'jwt': instance.jwt,
'userId': instance.userId,
};

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';
@ -15,7 +16,7 @@ class ConfigStore extends ChangeNotifier {
static const prefsKey = 'v1:ConfigStore';
static final _prefs = SharedPreferences.getInstance();
ThemeMode _theme;
late ThemeMode _theme;
@JsonKey(defaultValue: ThemeMode.system)
ThemeMode get theme => _theme;
set theme(ThemeMode theme) {
@ -24,7 +25,7 @@ class ConfigStore extends ChangeNotifier {
save();
}
bool _amoledDarkMode;
late bool _amoledDarkMode;
@JsonKey(defaultValue: false)
bool get amoledDarkMode => _amoledDarkMode;
set amoledDarkMode(bool amoledDarkMode) {
@ -33,9 +34,9 @@ class ConfigStore extends ChangeNotifier {
save();
}
Locale _locale;
// default value is set in the `load` method because json_serializable does
// not accept non-literals as constant values
late Locale _locale;
// default value is set in the `LocaleSerde.fromJson` method because json_serializable does
// not accept non-literals as defaultValue
@JsonKey(fromJson: LocaleSerde.fromJson, toJson: LocaleSerde.toJson)
Locale get locale => _locale;
set locale(Locale locale) {
@ -44,12 +45,96 @@ 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;
return _$ConfigStoreFromJson(
jsonDecode(prefs.getString(prefsKey) ?? '{}') as Map<String, dynamic>,
).._locale ??= const Locale('en');
);
}
Future<void> save() async {
@ -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

@ -10,8 +10,14 @@ ConfigStore _$ConfigStoreFromJson(Map<String, dynamic> json) {
return ConfigStore()
..theme = _$enumDecodeNullable(_$ThemeModeEnumMap, json['theme']) ??
ThemeMode.system
..amoledDarkMode = json['amoledDarkMode'] as bool ?? false
..locale = LocaleSerde.fromJson(json['locale'] as String);
..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 =
_postListingTypeFromJson(json['defaultListingType'] as String?);
}
Map<String, dynamic> _$ConfigStoreToJson(ConfigStore instance) =>
@ -19,38 +25,48 @@ 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,
};
T _$enumDecode<T>(
Map<T, dynamic> enumValues,
dynamic source, {
T unknownValue,
K _$enumDecode<K, V>(
Map<K, V> enumValues,
Object? source, {
K? unknownValue,
}) {
if (source == null) {
throw ArgumentError('A value must be provided. Supported values: '
'${enumValues.values.join(', ')}');
throw ArgumentError(
'A value must be provided. Supported values: '
'${enumValues.values.join(', ')}',
);
}
final value = enumValues.entries
.singleWhere((e) => e.value == source, orElse: () => null)
?.key;
if (value == null && unknownValue == null) {
throw ArgumentError('`$source` is not one of the supported values: '
'${enumValues.values.join(', ')}');
}
return value ?? unknownValue;
return enumValues.entries.singleWhere(
(e) => e.value == source,
orElse: () {
if (unknownValue == null) {
throw ArgumentError(
'`$source` is not one of the supported values: '
'${enumValues.values.join(', ')}',
);
}
return MapEntry(unknownValue, enumValues.values.first);
},
).key;
}
T _$enumDecodeNullable<T>(
Map<T, dynamic> enumValues,
K? _$enumDecodeNullable<K, V>(
Map<K, V> enumValues,
dynamic source, {
T unknownValue,
K? unknownValue,
}) {
if (source == null) {
return null;
}
return _$enumDecode<T>(enumValues, source, unknownValue: unknownValue);
return _$enumDecode<K, V>(enumValues, source, unknownValue: unknownValue);
}
const _$ThemeModeEnumMap = {

View File

@ -24,7 +24,7 @@ ThemeData _themeFactory({bool dark = false, bool amoled = false}) {
iconTheme: IconThemeData(color: theme.colorScheme.onSurface),
textTheme: TextTheme(
headline6: theme.textTheme.headline6
.copyWith(fontSize: 20, fontWeight: FontWeight.w500),
?.copyWith(fontSize: 20, fontWeight: FontWeight.w500),
),
),
tabBarTheme: TabBarTheme(

View File

@ -13,9 +13,9 @@ import 'util/goto.dart';
/// Decides where does a link link to. Either somewhere in-app:
/// opens the correct page, or outside of the app: opens in a browser
Future<void> linkLauncher({
@required BuildContext context,
@required String url,
@required String instanceHost,
required BuildContext context,
required String url,
required String instanceHost,
}) async {
push(Widget Function() builder) {
goTo(context, (c) => builder());
@ -46,8 +46,8 @@ Future<void> linkLauncher({
final matchedInstance = match?.group(1);
final rest = match?.group(2);
if (matchedInstance != null && instances.any((e) => e == match.group(1))) {
if (rest.isEmpty || rest == '/') {
if (matchedInstance != null && instances.any((e) => e == match?.group(1))) {
if (rest == null || rest.isEmpty || rest == '/') {
return push(() => InstancePage(instanceHost: matchedInstance));
}
final split = rest.split('/');
@ -107,7 +107,7 @@ Future<void> linkLauncher({
Future<void> openInBrowser(String url) async {
if (await ul.canLaunch(url)) {
return await ul.launch(url);
await ul.launch(url);
} else {
throw Exception();
// TODO: handle opening links to stuff in app

View File

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:lemmy_api_client/v3.dart';
@ -7,28 +6,24 @@ import '../hooks/delayed_loading.dart';
/// Executes an API action that uses [DelayedLoading], has a try-catch
/// that displays a [SnackBar] when the action fails
Future<void> delayedAction<T>({
@required BuildContext context,
@required DelayedLoading delayedLoading,
@required String instanceHost,
@required LemmyApiQuery<T> query,
Function(T) onSuccess,
Function(T) cleanup,
required BuildContext context,
required DelayedLoading delayedLoading,
required String instanceHost,
required LemmyApiQuery<T> query,
void Function(T)? onSuccess,
void Function(T?)? cleanup,
}) async {
assert(delayedLoading != null);
assert(instanceHost != null);
assert(query != null);
assert(context != null);
T val;
T? val;
try {
delayedLoading.start();
val = await LemmyApiV3(instanceHost).run<T>(query);
onSuccess?.call(val);
onSuccess?.call(val as T);
// ignore: avoid_catches_without_on_clauses
} catch (e) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
} finally {
cleanup?.call(val);
delayedLoading.cancel();
}
cleanup?.call(val);
delayedLoading.cancel();
}

View File

@ -34,8 +34,9 @@ extension CommunityDisplayNames on CommunitySafe {
extension UserDisplayNames on PersonSafe {
String get displayName {
if (preferredUsername != null && preferredUsername.isNotEmpty) {
return preferredUsername;
final prefName = preferredUsername;
if (prefName != null && prefName.isNotEmpty) {
return prefName;
}
return '@$name';

View File

@ -6,12 +6,10 @@ import 'package:share/share.dart';
/// on platforms that do not support native sharing
Future<void> share(
String text, {
String subject,
Rect sharePositionOrigin,
@required BuildContext context,
String? subject,
Rect? sharePositionOrigin,
required BuildContext context,
}) async {
assert(context != null);
try {
return await Share.share(
text,

View File

@ -23,7 +23,12 @@ class AboutTile extends HookWidget {
return await PackageInfo.fromPlatform();
} on MissingPluginException {
// when we get here it means PackageInfo does not support this platform
return PackageInfo(version: '');
return PackageInfo(
appName: 'lemmur',
packageName: '',
version: '',
buildNumber: '',
);
}
},
);
@ -31,13 +36,16 @@ class AboutTile extends HookWidget {
final changelogSnap =
useMemoFuture(() => assetBundle.loadString('CHANGELOG.md'));
if (!packageInfoSnap.hasData || !changelogSnap.hasData) {
return const SizedBox.shrink();
}
final packageInfo = packageInfoSnap.data;
final changelog = changelogSnap.data;
if (!packageInfoSnap.hasData ||
!changelogSnap.hasData ||
packageInfo == null ||
changelog == null) {
return const SizedBox.shrink();
}
return AboutListTile(
icon: const Icon(Icons.info),
aboutBoxChildren: [

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,
Key? key,
required this.url,
this.radius = 25,
this.noBlank = false,
this.alwaysShow = false,
}) : super(key: key);
final String url;
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();
@ -27,7 +38,9 @@ class Avatar extends StatelessWidget {
);
}();
if (url == null) {
final imageUrl = url;
if (imageUrl == null || !showAvatars) {
return blankWidget;
}
@ -35,7 +48,7 @@ class Avatar extends StatelessWidget {
child: CachedNetworkImage(
height: radius * 2,
width: radius * 2,
imageUrl: url,
imageUrl: imageUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => blankWidget,
),

View File

@ -3,16 +3,15 @@ import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
/// Should be spawned with a [showBottomModal], not routed to.
class BottomModal extends StatelessWidget {
final String title;
final String? title;
final EdgeInsets padding;
final Widget child;
const BottomModal({
this.title,
this.padding = EdgeInsets.zero,
@required this.child,
}) : assert(padding != null),
assert(child != null);
required this.child,
});
@override
Widget build(BuildContext context) {
@ -40,7 +39,7 @@ class BottomModal extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(left: 70),
child: Text(
title,
title!,
style: theme.textTheme.subtitle2,
textAlign: TextAlign.left,
),
@ -65,10 +64,10 @@ class BottomModal extends StatelessWidget {
}
/// Helper function for showing a [BottomModal]
Future<T> showBottomModal<T>({
@required BuildContext context,
@required WidgetBuilder builder,
String title,
Future<T?> showBottomModal<T>({
required BuildContext context,
required WidgetBuilder builder,
String? title,
EdgeInsets padding = EdgeInsets.zero,
}) =>
showCustomModalBottomSheet<T>(

View File

@ -33,7 +33,7 @@ class CommentWidget extends HookWidget {
final bool wasVoted;
final bool canBeMarkedAsRead;
final bool hideOnRead;
final int userMentionId;
final int? userMentionId;
static const colors = [
Colors.pink,
@ -93,12 +93,11 @@ 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
.defaultTokenFor(commentTree.comment.instanceHost)
?.payload
?.sub;
accStore.defaultUserDataFor(commentTree.comment.instanceHost)?.userId;
final selectable = useState(false);
final showRaw = useState(false);
final collapsed = useState(false);
@ -133,6 +132,25 @@ class CommentWidget extends HookWidget {
);
}
handleEdit() async {
final editedComment = await showCupertinoModalPopup<CommentView>(
context: context,
builder: (_) => WriteComment.edit(
comment: comment.comment,
post: comment.post,
),
);
if (editedComment != null) {
commentTree.comment = editedComment;
// TODO: workaround to force this widget to rebuild
// TODO: These problems should go away once we move to an actual state management lib
// TODO: work being done here should also help: https://github.com/krawieck/lemmur/tree/fix/better-local-updates
(context as Element).markNeedsBuild();
Navigator.of(context).pop();
}
}
void _openMoreMenu(BuildContext context) {
pop() => Navigator.of(context).pop();
@ -177,6 +195,12 @@ class CommentWidget extends HookWidget {
pop();
},
),
if (isMine)
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Edit'),
onTap: handleEdit,
),
if (isMine)
ListTile(
leading: Icon(isDeleted.value ? Icons.restore : Icons.delete),
@ -221,7 +245,7 @@ class CommentWidget extends HookWidget {
if (isDeleted.value) {
return Flexible(
child: Text(
L10n.of(context).deleted_by_creator,
L10n.of(context)!.deleted_by_creator,
style: const TextStyle(fontStyle: FontStyle.italic),
),
);
@ -294,7 +318,7 @@ class CommentWidget extends HookWidget {
icon: Icons.more_horiz,
onPressed: () => _openMoreMenu(context),
delayedLoading: delayedDeletion,
tooltip: L10n.of(context).more,
tooltip: L10n.of(context)!.more,
),
_SaveComment(commentTree.comment),
if (!isDeleted.value &&
@ -303,7 +327,7 @@ class CommentWidget extends HookWidget {
TileAction(
icon: Icons.reply,
onPressed: loggedInAction((_) => reply()),
tooltip: L10n.of(context).reply,
tooltip: L10n.of(context)!.reply,
),
TileAction(
icon: Icons.arrow_upward,
@ -369,7 +393,7 @@ class CommentWidget extends HookWidget {
if (isOP) _CommentTag('OP', theme.accentColor),
if (comment.creator.admin)
_CommentTag(
L10n.of(context).admin.toUpperCase(),
L10n.of(context)!.admin.toUpperCase(),
theme.accentColor,
),
if (comment.creator.banned)
@ -390,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),
],
),
@ -417,33 +444,32 @@ class CommentWidget extends HookWidget {
class _MarkAsRead extends HookWidget {
final CommentView commentView;
final ValueChanged<bool> onChanged;
final int userMentionId;
final ValueChanged<bool>? onChanged;
final int? userMentionId;
const _MarkAsRead(
this.commentView, {
@required this.onChanged,
@required this.userMentionId,
}) : assert(commentView != null);
required this.onChanged,
required this.userMentionId,
});
@override
Widget build(BuildContext context) {
final accStore = useAccountsStore();
final comment = commentView.comment;
final instanceHost = commentView.instanceHost;
final loggedInAction = useLoggedInAction(instanceHost);
final isRead = useState(comment.read);
final delayedRead = useDelayedLoading();
Future<void> handleMarkAsSeen() => delayedAction<FullCommentView>(
Future<void> handleMarkAsSeen(Jwt token) => delayedAction<FullCommentView>(
context: context,
delayedLoading: delayedRead,
instanceHost: instanceHost,
query: MarkCommentAsRead(
commentId: comment.id,
read: !isRead.value,
auth: accStore.defaultTokenFor(instanceHost)?.raw,
auth: token.raw,
),
onSuccess: (val) {
isRead.value = val.commentView.comment.read;
@ -451,14 +477,15 @@ class _MarkAsRead extends HookWidget {
},
);
Future<void> handleMarkMentionAsSeen() => delayedAction<PersonMentionView>(
Future<void> handleMarkMentionAsSeen(Jwt token) =>
delayedAction<PersonMentionView>(
context: context,
delayedLoading: delayedRead,
instanceHost: instanceHost,
query: MarkPersonMentionAsRead(
personMentionId: userMentionId,
personMentionId: userMentionId!,
read: !isRead.value,
auth: accStore.defaultTokenFor(instanceHost)?.raw,
auth: token.raw,
),
onSuccess: (val) {
isRead.value = val.personMention.read;
@ -469,12 +496,13 @@ class _MarkAsRead extends HookWidget {
return TileAction(
icon: Icons.check,
delayedLoading: delayedRead,
onPressed:
userMentionId != null ? handleMarkMentionAsSeen : handleMarkAsSeen,
onPressed: userMentionId != null
? loggedInAction(handleMarkMentionAsSeen)
: loggedInAction(handleMarkAsSeen),
iconColor: isRead.value ? Theme.of(context).accentColor : null,
tooltip: isRead.value
? L10n.of(context).mark_as_unread
: L10n.of(context).mark_as_read,
? L10n.of(context)!.mark_as_unread
: L10n.of(context)!.mark_as_read,
);
}
}
@ -487,7 +515,7 @@ class _SaveComment extends HookWidget {
@override
Widget build(BuildContext context) {
final loggedInAction = useLoggedInAction(comment.instanceHost);
final isSaved = useState(comment.saved ?? false);
final isSaved = useState(comment.saved);
final delayed = useDelayedLoading();
handleSave(Jwt token) => delayedAction<FullCommentView>(
@ -529,7 +557,7 @@ class _CommentTag extends StatelessWidget {
child: Text(text,
style: TextStyle(
color: textColorBasedOnBackground(bgColor),
fontSize: Theme.of(context).textTheme.bodyText1.fontSize - 5,
fontSize: Theme.of(context).textTheme.bodyText1!.fontSize! - 5,
fontWeight: FontWeight.w800,
)),
),

View File

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
@ -26,13 +25,12 @@ class CommentSection extends HookWidget {
CommentSection(
List<CommentView> rawComments, {
@required this.postCreatorId,
required this.postCreatorId,
this.sortType = CommentSortType.hot,
}) : comments =
CommentTree.sortList(sortType, CommentTree.fromList(rawComments)),
rawComments = rawComments
..sort((b, a) => a.comment.published.compareTo(b.comment.published)),
assert(postCreatorId != null);
..sort((b, a) => a.comment.published.compareTo(b.comment.published));
@override
Widget build(BuildContext context) {
@ -76,7 +74,7 @@ class CommentSection extends HookWidget {
},
child: Row(
children: [
Text((sortPairs[sorting.value][1] as String).tr(context)),
Text((sortPairs[sorting.value]![1] as String).tr(context)),
const Icon(Icons.arrow_drop_down),
],
),

58
lib/widgets/editor.dart Normal file
View File

@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'markdown_text.dart';
/// A text field with added functionality for ease of editing
class Editor extends HookWidget {
final TextEditingController? controller;
final FocusNode? focusNode;
final ValueChanged<String>? onSubmitted;
final int? minLines;
final String? labelText;
final bool autofocus;
/// Whether the editor should be preview the contents
final bool fancy;
final String instanceHost;
const Editor({
Key? key,
this.controller,
this.focusNode,
this.onSubmitted,
this.minLines = 5,
this.labelText,
this.fancy = false,
required this.instanceHost,
this.autofocus = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final defaultController = useTextEditingController();
final actualController = controller ?? defaultController;
if (fancy) {
return Padding(
padding: const EdgeInsets.all(8),
child: MarkdownText(
actualController.text,
instanceHost: instanceHost,
),
);
}
return TextField(
controller: actualController,
focusNode: focusNode,
autofocus: autofocus,
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
onSubmitted: onSubmitted,
maxLines: null,
minLines: minLines,
decoration: InputDecoration(labelText: labelText),
);
}
}

View File

@ -10,9 +10,9 @@ class FullscreenableImage extends StatelessWidget {
final Widget child;
const FullscreenableImage({
Key key,
@required this.url,
@required this.child,
Key? key,
required this.url,
required this.child,
}) : super(key: key);
@override

View File

@ -8,7 +8,7 @@ import '../hooks/ref.dart';
import 'bottom_safe.dart';
class InfiniteScrollController {
VoidCallback clear;
late VoidCallback clear;
InfiniteScrollController() {
usedBeforeCreation() => throw Exception(
@ -16,10 +16,6 @@ class InfiniteScrollController {
clear = usedBeforeCreation;
}
void dispose() {
clear = null;
}
}
/// `ListView.builder` with asynchronous data fetching and no `itemCount`
@ -38,20 +34,20 @@ class InfiniteScroll<T> extends HookWidget {
/// is considered finished
final Future<List<T>> Function(int page, int batchSize) fetcher;
final InfiniteScrollController controller;
final InfiniteScrollController? controller;
/// Widget to be added at the beginning of the list
final Widget leading;
/// Padding for the [ListView.builder]
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? padding;
/// 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;
final Object Function(T item)? uniqueProp;
const InfiniteScroll({
this.batchSize = 10,
@ -59,14 +55,12 @@ class InfiniteScroll<T> extends HookWidget {
this.padding,
this.loadingWidget =
const ListTile(title: Center(child: CircularProgressIndicator())),
@required this.itemBuilder,
@required this.fetcher,
required this.itemBuilder,
required this.fetcher,
this.controller,
this.noItems = const SizedBox.shrink(),
this.uniqueProp,
}) : assert(itemBuilder != null),
assert(fetcher != null),
assert(batchSize > 0);
}) : assert(batchSize > 0);
@override
Widget build(BuildContext context) {
@ -78,7 +72,7 @@ class InfiniteScroll<T> extends HookWidget {
useEffect(() {
if (controller != null) {
controller.clear = () {
controller?.clear = () {
data.value = [];
hasMore.current = true;
};
@ -91,7 +85,9 @@ class InfiniteScroll<T> extends HookWidget {
return RefreshIndicator(
onRefresh: () async {
controller.clear();
data.value = [];
hasMore.current = true;
await HapticFeedback.mediumImpact();
await Future.delayed(const Duration(seconds: 1));
},
@ -132,7 +128,8 @@ class InfiniteScroll<T> extends HookWidget {
// append new data
data.value = [...data.value, ...newData];
dataSet.current.addAll(newData.map(uniqueProp ?? (e) => e));
dataSet.current
.addAll(newData.map(uniqueProp ?? (e) => e as Object));
}).whenComplete(() => isFetching.current = false);
}

View File

@ -3,13 +3,10 @@ import 'package:flutter/material.dart';
import 'bottom_modal.dart';
void showInfoTablePopup({
@required BuildContext context,
@required Map<String, dynamic> table,
String title,
required BuildContext context,
required Map<String, dynamic> table,
String? title,
}) {
assert(context != null);
assert(table != null);
showBottomModal(
context: context,
title: title,

View File

@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
/// used mostly for pages where markdown editor is used
///
/// brush icon is rotated to look similarly to build icon
Widget markdownModeIcon({@required bool fancy}) => fancy
Widget markdownModeIcon({required bool fancy}) => fancy
? const Icon(Icons.build)
: const RotatedBox(
quarterTurns: 1,

View File

@ -15,8 +15,7 @@ class MarkdownText extends StatelessWidget {
final bool selectable;
const MarkdownText(this.text,
{@required this.instanceHost, this.selectable = false})
: assert(instanceHost != null);
{required this.instanceHost, this.selectable = false});
@override
Widget build(BuildContext context) {
@ -32,9 +31,10 @@ class MarkdownText extends StatelessWidget {
),
code: theme.textTheme.bodyText1
// TODO: use a font from google fonts maybe? the defaults aren't very pretty
.copyWith(fontFamily: Platform.isIOS ? 'Courier' : 'monospace'),
?.copyWith(fontFamily: Platform.isIOS ? 'Courier' : 'monospace'),
),
onTapLink: (text, href, title) {
if (href == null) return;
linkLauncher(context: context, url: href, instanceHost: instanceHost)
.catchError(
(e) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(

View File

@ -5,12 +5,16 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:intl/intl.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/logged_in_action.dart';
import '../hooks/stores.dart';
import '../l10n/l10n.dart';
import '../pages/create_post.dart';
import '../pages/full_post.dart';
import '../stores/accounts_store.dart';
import '../url_launcher.dart';
import '../util/cleanup_url.dart';
import '../util/extensions/api.dart';
@ -33,7 +37,7 @@ enum MediaType {
none,
}
MediaType whatType(String url) {
MediaType whatType(String? url) {
if (url == null || url.isEmpty) return MediaType.none;
// TODO: make detection more nuanced
@ -60,7 +64,17 @@ class PostWidget extends HookWidget {
// == ACTIONS ==
static void showMoreMenu(BuildContext context, PostView post) {
static void showMoreMenu({
required BuildContext context,
required PostView post,
bool fullPost = false,
}) {
final isMine = context
.read<AccountsStore>()
.defaultUserDataFor(post.instanceHost)
?.userId ==
post.creator.id;
showBottomModal(
context: context,
builder: (context) => Column(
@ -84,6 +98,32 @@ class PostWidget extends HookWidget {
});
},
),
if (isMine)
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Edit'),
onTap: () async {
final postView = await showCupertinoModalPopup<PostView>(
context: context,
builder: (_) => CreatePostPage.edit(post.post),
);
if (postView != null) {
Navigator.of(context).pop();
if (fullPost) {
await goToReplace(
context,
(_) => FullPostPage.fromPostView(postView),
);
} else {
await goTo(
context,
(_) => FullPostPage.fromPostView(postView),
);
}
}
},
),
],
),
);
@ -95,13 +135,13 @@ class PostWidget extends HookWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
void _openLink() => linkLauncher(
context: context, url: post.post.url, instanceHost: instanceHost);
void _openLink(String url) =>
linkLauncher(context: context, url: url, instanceHost: instanceHost);
final urlDomain = () {
if (whatType(post.post.url) == MediaType.none) return null;
return urlHost(post.post.url);
return urlHost(post.post.url!);
}();
/// assemble info section
@ -141,7 +181,7 @@ class PostWidget extends HookWidget {
text: TextSpan(
style: TextStyle(
fontSize: 15,
color: theme.textTheme.bodyText1.color),
color: theme.textTheme.bodyText1?.color),
children: [
const TextSpan(
text: '!',
@ -179,10 +219,10 @@ class PostWidget extends HookWidget {
text: TextSpan(
style: TextStyle(
fontSize: 13,
color: theme.textTheme.bodyText1.color),
color: theme.textTheme.bodyText1?.color),
children: [
TextSpan(
text: L10n.of(context).by,
text: L10n.of(context)!.by,
style: const TextStyle(
fontWeight: FontWeight.w300),
),
@ -206,7 +246,7 @@ class PostWidget extends HookWidget {
if (post.post.nsfw) const TextSpan(text: ' · '),
if (post.post.nsfw)
TextSpan(
text: L10n.of(context).nsfw,
text: L10n.of(context)!.nsfw,
style:
const TextStyle(color: Colors.red)),
if (urlDomain != null)
@ -227,7 +267,8 @@ class PostWidget extends HookWidget {
Column(
children: [
IconButton(
onPressed: () => showMoreMenu(context, post),
onPressed: () =>
showMoreMenu(context: context, post: post),
icon: Icon(moreIcon),
padding: const EdgeInsets.all(0),
visualDensity: VisualDensity.compact,
@ -260,13 +301,13 @@ class PostWidget extends HookWidget {
const Spacer(),
InkWell(
borderRadius: BorderRadius.circular(20),
onTap: _openLink,
onTap: () => _openLink(post.post.url!),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: CachedNetworkImage(
imageUrl: post.post.thumbnailUrl,
imageUrl: post.post.thumbnailUrl!,
width: 70,
height: 70,
fit: BoxFit.cover,
@ -297,11 +338,11 @@ class PostWidget extends HookWidget {
return Padding(
padding: const EdgeInsets.all(10),
child: InkWell(
onTap: _openLink,
onTap: () => _openLink(post.post.url!),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).iconTheme.color.withAlpha(170)),
color: Theme.of(context).iconTheme.color!.withAlpha(170)),
borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.all(10),
@ -312,7 +353,7 @@ class PostWidget extends HookWidget {
const Spacer(),
Text('$urlDomain ',
style: theme.textTheme.caption
.apply(fontStyle: FontStyle.italic)),
?.apply(fontStyle: FontStyle.italic)),
const Icon(Icons.launch, size: 12),
],
),
@ -322,7 +363,7 @@ class PostWidget extends HookWidget {
child: Text(
post.post.embedTitle ?? '',
style: theme.textTheme.subtitle1
.apply(fontWeightDelta: 2),
?.apply(fontWeightDelta: 2),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
@ -330,12 +371,12 @@ class PostWidget extends HookWidget {
],
),
if (post.post.embedDescription != null &&
post.post.embedDescription.isNotEmpty)
post.post.embedDescription!.isNotEmpty)
Row(
children: [
Flexible(
child: Text(
post.post.embedDescription,
post.post.embedDescription!,
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
@ -355,9 +396,9 @@ class PostWidget extends HookWidget {
assert(post.post.url != null);
return FullscreenableImage(
url: post.post.url,
url: post.post.url!,
child: CachedNetworkImage(
imageUrl: post.post.url,
imageUrl: post.post.url!,
errorWidget: (_, __, ___) => const Icon(Icons.warning),
progressIndicatorBuilder: (context, url, progress) =>
CircularProgressIndicator(value: progress.progress),
@ -375,7 +416,7 @@ class PostWidget extends HookWidget {
Expanded(
flex: 999,
child: Text(
L10n.of(context).number_of_comments(post.counts.comments),
L10n.of(context)!.number_of_comments(post.counts.comments),
overflow: TextOverflow.fade,
softWrap: false,
),
@ -411,13 +452,13 @@ class PostWidget extends HookWidget {
if (whatType(post.post.url) != MediaType.other &&
whatType(post.post.url) != MediaType.none)
postImage()
else if (post.post.url != null && post.post.url.isNotEmpty)
else if (post.post.url != null && post.post.url!.isNotEmpty)
linkPreview(),
if (post.post.body != null && fullPost)
Padding(
padding: const EdgeInsets.all(10),
child: MarkdownText(
post.post.body,
post.post.body!,
instanceHost: instanceHost,
selectable: true,
),
@ -446,7 +487,7 @@ class PostWidget extends HookWidget {
heightFactor: 0.8,
child: Padding(
padding: const EdgeInsets.all(10),
child: MarkdownText(post.post.body,
child: MarkdownText(post.post.body!,
instanceHost: instanceHost)),
),
),
@ -469,7 +510,7 @@ class PostWidget extends HookWidget {
} else {
return Padding(
padding: const EdgeInsets.all(10),
child: MarkdownText(post.post.body,
child: MarkdownText(post.post.body!,
instanceHost: instanceHost));
}
},
@ -489,14 +530,15 @@ class _Voting extends HookWidget {
final bool wasVoted;
_Voting(this.post)
: assert(post != null),
wasVoted = (post.myVote ?? VoteType.none) != VoteType.none;
: wasVoted = (post.myVote ?? VoteType.none) != VoteType.none;
@override
Widget build(BuildContext context) {
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 {
@ -519,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(

View File

@ -12,10 +12,10 @@ class PostListOptions extends StatelessWidget {
final bool styleButton;
const PostListOptions({
@required this.onSortChanged,
@required this.sortValue,
required this.onSortChanged,
required this.sortValue,
this.styleButton = true,
}) : assert(sortValue != null);
});
@override
Widget build(BuildContext context) => Padding(

View File

@ -6,31 +6,29 @@ import 'bottom_modal.dart';
class RadioPicker<T> extends StatelessWidget {
final List<T> values;
final T groupValue;
final ValueChanged<T> onChanged;
final ValueChanged<T>? onChanged;
/// Map a given value to a string for display
final String Function(T) mapValueToString;
final String title;
final String Function(T)? mapValueToString;
final String? title;
/// custom button builder. When null, an OutlinedButton is used
final Widget Function(
BuildContext context, String displayValue, VoidCallback onPressed)
BuildContext context, String displayValue, VoidCallback? onPressed)?
buttonBuilder;
final Widget trailing;
final Widget? trailing;
const RadioPicker({
Key key,
@required this.values,
@required this.groupValue,
@required this.onChanged,
Key? key,
required this.values,
required this.groupValue,
required this.onChanged,
this.mapValueToString,
this.buttonBuilder,
this.title,
this.trailing,
}) : assert(values != null),
assert(groupValue != null),
super(key: key);
}) : super(key: key);
@override
Widget build(BuildContext context) {
@ -63,7 +61,7 @@ class RadioPicker<T> extends StatelessWidget {
title: Text(mapValueToString(value)),
onChanged: (value) => Navigator.of(context).pop(value),
),
if (trailing != null) trailing
if (trailing != null) trailing!
],
),
);
@ -73,6 +71,10 @@ class RadioPicker<T> extends StatelessWidget {
}
}
return buttonBuilder(context, mapValueToString(groupValue), onPressed);
return buttonBuilder(
context,
mapValueToString(groupValue),
onChanged == null ? null : onPressed,
);
}
}

View File

@ -14,14 +14,12 @@ class RevealAfterScroll extends HookWidget {
final bool fade;
const RevealAfterScroll({
@required this.scrollController,
@required this.child,
@required this.after,
required this.scrollController,
required this.child,
required this.after,
this.transition = 15,
this.fade = false,
}) : assert(scrollController != null),
assert(child != null),
assert(after != null);
});
@override
Widget build(BuildContext context) {

View File

@ -14,7 +14,7 @@ class SavePostButton extends HookWidget {
@override
Widget build(BuildContext context) {
final isSaved = useState(post.saved ?? false);
final isSaved = useState(post.saved);
final savedIcon = isSaved.value ? Icons.bookmark : Icons.bookmark_border;
final loading = useDelayedLoading();
final loggedInAction = useLoggedInAction(post.instanceHost);

View File

@ -16,23 +16,21 @@ typedef FetcherWithSorting<T> = Future<List<T>> Function(
class SortableInfiniteList<T> extends HookWidget {
final FetcherWithSorting<T> fetcher;
final Widget Function(T) itemBuilder;
final InfiniteScrollController controller;
final Function onStyleChange;
final InfiniteScrollController? controller;
final Function? onStyleChange;
final Widget noItems;
final SortType defaultSort;
final Object Function(T item) uniqueProp;
final Object Function(T item)? uniqueProp;
const SortableInfiniteList({
@required this.fetcher,
@required this.itemBuilder,
required this.fetcher,
required this.itemBuilder,
this.controller,
this.onStyleChange,
this.noItems,
this.noItems = const SizedBox.shrink(),
this.defaultSort = SortType.active,
this.uniqueProp,
}) : assert(fetcher != null),
assert(itemBuilder != null),
assert(defaultSort != null);
});
@override
Widget build(BuildContext context) {
@ -65,8 +63,8 @@ class SortableInfiniteList<T> extends HookWidget {
class InfinitePostList extends SortableInfiniteList<PostView> {
InfinitePostList({
@required FetcherWithSorting<PostView> fetcher,
InfiniteScrollController controller,
required FetcherWithSorting<PostView> fetcher,
InfiniteScrollController? controller,
}) : super(
itemBuilder: (post) => Column(
children: [
@ -83,8 +81,8 @@ class InfinitePostList extends SortableInfiniteList<PostView> {
class InfiniteCommentList extends SortableInfiniteList<CommentView> {
InfiniteCommentList({
@required FetcherWithSorting<CommentView> fetcher,
InfiniteScrollController controller,
required FetcherWithSorting<CommentView> fetcher,
InfiniteScrollController? controller,
}) : super(
itemBuilder: (comment) => CommentWidget(
CommentTree(comment),

View File

@ -9,20 +9,17 @@ class TileAction extends StatelessWidget {
final IconData icon;
final VoidCallback onPressed;
final String tooltip;
final DelayedLoading delayedLoading;
final Color iconColor;
final DelayedLoading? delayedLoading;
final Color? iconColor;
const TileAction({
Key key,
Key? key,
this.delayedLoading,
this.iconColor,
@required this.icon,
@required this.onPressed,
@required this.tooltip,
}) : assert(icon != null),
assert(onPressed != null),
assert(tooltip != null),
super(key: key);
required this.icon,
required this.onPressed,
required this.tooltip,
}) : super(key: key);
@override
Widget build(BuildContext context) => IconButton(
@ -34,7 +31,7 @@ class TileAction extends StatelessWidget {
: Icon(
icon,
color: iconColor ??
Theme.of(context).iconTheme.color.withAlpha(190),
Theme.of(context).iconTheme.color?.withAlpha(190),
),
splashRadius: 25,
onPressed: delayedLoading?.pending ?? false ? () {} : onPressed,

View File

@ -22,16 +22,13 @@ class UserProfile extends HookWidget {
final String instanceHost;
final int userId;
final FullPersonView _fullUserView;
final FullPersonView? _fullUserView;
const UserProfile({@required this.userId, @required this.instanceHost})
: assert(userId != null),
assert(instanceHost != null),
_fullUserView = null;
const UserProfile({required this.userId, required this.instanceHost})
: _fullUserView = null;
UserProfile.fromFullPersonView(this._fullUserView)
: assert(_fullUserView != null),
userId = _fullUserView.personView.person.id,
UserProfile.fromFullPersonView(FullPersonView this._fullUserView)
: userId = _fullUserView.personView.person.id,
instanceHost = _fullUserView.instanceHost;
@override
@ -45,7 +42,7 @@ class UserProfile extends HookWidget {
personId: userId,
savedOnly: false,
sort: SortType.active,
auth: accountsStore.defaultTokenFor(instanceHost)?.raw,
auth: accountsStore.defaultUserDataFor(instanceHost)?.jwt.raw,
));
}, [userId, instanceHost]);
@ -62,8 +59,8 @@ class UserProfile extends HookWidget {
]),
);
}
final userView = userDetailsSnap.data.personView;
final fullPersonView = userDetailsSnap.data!;
final userView = fullPersonView.personView;
return DefaultTabController(
length: 3,
@ -83,8 +80,8 @@ class UserProfile extends HookWidget {
color: theme.cardColor,
child: TabBar(
tabs: [
Tab(text: L10n.of(context).posts),
Tab(text: L10n.of(context).comments),
Tab(text: L10n.of(context)!.posts),
Tab(text: L10n.of(context)!.comments),
const Tab(text: 'About'),
],
),
@ -103,7 +100,7 @@ class UserProfile extends HookWidget {
sort: SortType.active,
page: page,
limit: batchSize,
auth: accountsStore.defaultTokenFor(instanceHost)?.raw,
auth: accountsStore.defaultUserDataFor(instanceHost)?.jwt.raw,
))
.then((val) => val.posts),
),
@ -115,11 +112,11 @@ class UserProfile extends HookWidget {
sort: SortType.active,
page: page,
limit: batchSize,
auth: accountsStore.defaultTokenFor(instanceHost)?.raw,
auth: accountsStore.defaultUserDataFor(instanceHost)?.jwt.raw,
))
.then((val) => val.comments),
),
_AboutTab(userDetailsSnap.data),
_AboutTab(fullPersonView),
]),
),
);
@ -146,9 +143,9 @@ class _UserOverview extends HookWidget {
if (userView.person.banner != null)
// TODO: for some reason doesnt react to presses
FullscreenableImage(
url: userView.person.banner,
url: userView.person.banner!,
child: CachedNetworkImage(
imageUrl: userView.person.banner,
imageUrl: userView.person.banner!,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
)
@ -207,9 +204,9 @@ class _UserOverview extends HookWidget {
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: FullscreenableImage(
url: userView.person.avatar,
url: userView.person.avatar!,
child: CachedNetworkImage(
imageUrl: userView.person.avatar,
imageUrl: userView.person.avatar!,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
@ -255,7 +252,7 @@ class _UserOverview extends HookWidget {
),
const SizedBox(width: 4),
Text(
L10n.of(context)
L10n.of(context)!
.number_of_posts(userView.counts.postCount),
style: TextStyle(color: colorOnTopOfAccentColor),
),
@ -273,7 +270,7 @@ class _UserOverview extends HookWidget {
),
const SizedBox(width: 4),
Text(
L10n.of(context)
L10n.of(context)!
.number_of_comments(userView.counts.commentCount),
style: TextStyle(color: colorOnTopOfAccentColor),
),
@ -338,7 +335,7 @@ class _AboutTab extends HookWidget {
child: const Divider(),
);
communityTile(String name, String icon, int id) => ListTile(
communityTile(String name, String? icon, int id) => ListTile(
dense: true,
onTap: () => goToCommunity.byId(context, instanceHost, id),
title: Text('!$name'),
@ -371,7 +368,7 @@ class _AboutTab extends HookWidget {
if (userDetails.personView.person.bio != null) ...[
Padding(
padding: wallPadding,
child: MarkdownText(userDetails.personView.person.bio,
child: MarkdownText(userDetails.personView.person.bio!,
instanceHost: instanceHost)),
divider,
],
@ -380,7 +377,7 @@ class _AboutTab extends HookWidget {
title: Center(
child: Text(
'Moderates:',
style: theme.textTheme.headline6.copyWith(fontSize: 18),
style: theme.textTheme.headline6?.copyWith(fontSize: 18),
),
),
),
@ -396,7 +393,7 @@ class _AboutTab extends HookWidget {
title: Center(
child: Text(
'Subscribed:',
style: theme.textTheme.headline6.copyWith(fontSize: 18),
style: theme.textTheme.headline6?.copyWith(fontSize: 18),
),
),
),

View File

@ -3,29 +3,39 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import '../hooks/delayed_loading.dart';
import '../hooks/stores.dart';
import '../hooks/logged_in_action.dart';
import '../l10n/l10n.dart';
import 'editor.dart';
import 'markdown_mode_icon.dart';
import 'markdown_text.dart';
/// Modal for writing a comment to a given post/comment (aka reply)
/// Modal for writing/editing a comment to a given post/comment (aka reply)
/// on submit pops the navigator stack with a [CommentView]
/// or `null` if cancelled
class WriteComment extends HookWidget {
final Post post;
final Comment comment;
final Comment? comment;
final bool _isEdit;
const WriteComment.toPost(this.post) : comment = null;
const WriteComment.toComment({@required this.comment, @required this.post})
: assert(comment != null),
assert(post != null);
const WriteComment.toPost(this.post)
: comment = null,
_isEdit = false;
const WriteComment.toComment({
required Comment this.comment,
required this.post,
}) : _isEdit = false;
const WriteComment.edit({
required Comment this.comment,
required this.post,
}) : _isEdit = true;
@override
Widget build(BuildContext context) {
final controller = useTextEditingController();
final controller =
useTextEditingController(text: _isEdit ? comment?.content : null);
final showFancy = useState(false);
final delayed = useDelayedLoading();
final accStore = useAccountsStore();
final loggedInAction = useLoggedInAction(post.instanceHost);
final preview = () {
final body = () {
@ -38,35 +48,39 @@ class WriteComment extends HookWidget {
);
}();
if (post != null) {
return Column(
children: [
SelectableText(
post.name,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
body,
],
);
}
return body;
return Column(
children: [
SelectableText(
post.name,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
body,
],
);
}();
handleSubmit() async {
handleSubmit(Jwt token) async {
final api = LemmyApiV3(post.instanceHost);
final token = accStore.defaultTokenFor(post.instanceHost);
delayed.start();
try {
final res = await api.run(CreateComment(
content: controller.text,
postId: post.id,
parentId: comment?.id,
auth: token.raw,
));
final res = await () {
if (_isEdit) {
return api.run(EditComment(
commentId: comment!.id,
content: controller.text,
auth: token.raw,
));
} else {
return api.run(CreateComment(
content: controller.text,
postId: post.id,
parentId: comment?.id,
auth: token.raw,
));
}
}();
Navigator.of(context).pop(res.commentView);
// ignore: avoid_catches_without_on_clauses
} catch (e) {
@ -97,32 +111,23 @@ class WriteComment extends HookWidget {
),
),
const Divider(),
IndexedStack(
index: showFancy.value ? 1 : 0,
children: [
TextField(
controller: controller,
autofocus: true,
minLines: 5,
maxLines: null,
),
Padding(
padding: const EdgeInsets.all(16),
child: MarkdownText(
controller.text,
instanceHost: post.instanceHost,
),
)
],
Editor(
instanceHost: post.instanceHost,
controller: controller,
autofocus: true,
fancy: showFancy.value,
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: delayed.pending ? () {} : handleSubmit,
onPressed:
delayed.pending ? () {} : loggedInAction(handleSubmit),
child: delayed.loading
? const CircularProgressIndicator()
: Text(L10n.of(context).post),
: Text(_isEdit
? L10n.of(context)!.edit
: L10n.of(context)!.post),
)
],
),

View File

@ -105,7 +105,7 @@ packages:
name: cached_network_image
url: "https://pub.dartlang.org"
source: hosted
version: "2.5.1"
version: "3.0.0"
characters:
dependency: transitive
description:
@ -168,14 +168,14 @@ packages:
name: crypto
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
version: "3.0.1"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.3"
version: "1.0.2"
dart_style:
dependency: transitive
description:
@ -222,14 +222,14 @@ packages:
name: flutter_blurhash
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.0"
version: "0.6.0"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
version: "3.0.1"
flutter_hooks:
dependency: "direct main"
description:
@ -262,21 +262,14 @@ packages:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
flutter_slidable:
dependency: "direct main"
description:
name: flutter_slidable
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.7"
version: "2.0.1"
flutter_speed_dial:
dependency: "direct main"
description:
name: flutter_speed_dial
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.5"
version: "3.0.5"
flutter_test:
dependency: "direct dev"
description: flutter
@ -300,7 +293,7 @@ packages:
name: fuzzy
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.0"
version: "0.4.0-nullsafety.0"
glob:
dependency: transitive
description:
@ -349,7 +342,7 @@ packages:
name: image_picker
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.3"
version: "0.7.4"
image_picker_for_web:
dependency: transitive
description:
@ -363,7 +356,7 @@ packages:
name: image_picker_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.0.1"
intl:
dependency: "direct main"
description:
@ -399,13 +392,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.0"
keyboard_dismisser:
dependency: "direct main"
description:
name: keyboard_dismisser
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
latinize:
dependency: transitive
description:
name: latinize
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.2"
version: "0.1.0-nullsafety.0"
lemmy_api_client:
dependency: "direct main"
description:
@ -440,7 +440,7 @@ packages:
name: matrix4_transform
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.7"
version: "2.0.0"
meta:
dependency: transitive
description:
@ -461,7 +461,7 @@ packages:
name: modal_bottom_sheet
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0+1"
version: "2.0.0"
nested:
dependency: transitive
description:
@ -475,7 +475,7 @@ packages:
name: octo_image
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.0"
version: "1.0.0+1"
package_config:
dependency: transitive
description:
@ -489,7 +489,7 @@ packages:
name: package_info
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.3+4"
version: "2.0.0"
path:
dependency: transitive
description:
@ -524,7 +524,7 @@ packages:
name: path_provider_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.0.1"
path_provider_windows:
dependency: transitive
description:
@ -545,14 +545,14 @@ packages:
name: petitparser
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.2"
version: "4.1.0"
photo_view:
dependency: "direct main"
description:
name: photo_view
url: "https://pub.dartlang.org"
source: hosted
version: "0.10.3"
version: "0.11.1"
platform:
dependency: transitive
description:
@ -566,7 +566,7 @@ packages:
name: plugin_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
version: "2.0.0"
pool:
dependency: transitive
description:
@ -587,7 +587,7 @@ packages:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "4.3.3"
version: "5.0.0"
pub_semver:
dependency: transitive
description:
@ -608,7 +608,7 @@ packages:
name: rxdart
url: "https://pub.dartlang.org"
source: hosted
version: "0.25.0"
version: "0.26.0"
share:
dependency: "direct main"
description:
@ -622,28 +622,42 @@ packages:
name: shared_preferences
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.7+3"
version: "2.0.5"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
shared_preferences_macos:
dependency: transitive
description:
name: shared_preferences_macos
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+11"
version: "2.0.0"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
version: "2.0.0"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.2+7"
version: "2.0.0"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
shelf:
dependency: transitive
description:
@ -746,7 +760,7 @@ packages:
name: timeago
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.30"
version: "3.0.2"
timing:
dependency: transitive
description:
@ -767,42 +781,42 @@ packages:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "5.7.10"
version: "6.0.3"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+4"
version: "2.0.0"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+9"
version: "2.0.0"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.9"
version: "2.0.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.5+3"
version: "2.0.0"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+3"
version: "2.0.0"
uuid:
dependency: transitive
description:
@ -851,7 +865,7 @@ packages:
name: xml
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.2"
version: "5.1.0"
yaml:
dependency: transitive
description:
@ -861,4 +875,4 @@ packages:
version: "3.1.0"
sdks:
dart: ">=2.12.0 <3.0.0"
flutter: ">=1.24.0-10"
flutter: ">=1.24.0-10.2.pre"

View File

@ -15,39 +15,39 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.4.1+14
version: 0.4.2+15
environment:
sdk: ">=2.7.0 <3.0.0"
sdk: ">=2.12.0 <3.0.0"
dependencies:
# widgets
flutter_speed_dial: ^1.2.5
flutter_slidable: ^0.5.7
photo_view: ^0.10.2
flutter_speed_dial: ^3.0.5
photo_view: ^0.11.1
markdown: ^4.0.0
flutter_markdown: ^0.6.1
cached_network_image: ^2.2.0+1
modal_bottom_sheet: ^1.0.0+1
cached_network_image: ^3.0.0
modal_bottom_sheet: ^2.0.0
# native
share: ^2.0.1
url_launcher: ^5.5.1
shared_preferences: ">=0.5.0 <2.0.0"
package_info: ^0.4.3
image_picker: ^0.7.3
url_launcher: ^6.0.3
shared_preferences: ^2.0.5
package_info: ^2.0.0
image_picker: ^0.7.4
# state management
flutter_hooks: ^0.16.0
provider: ^4.3.1
provider: ^5.0.0
# utils
timeago: ^2.0.27
fuzzy: <1.0.0
timeago: ^3.0.2
fuzzy: ^0.4.0-nullsafety.0
lemmy_api_client: ^0.14.0
intl: ^0.17.0
matrix4_transform: ^1.1.7
matrix4_transform: ^2.0.0
json_annotation: ^4.0.1
keyboard_dismisser: ^2.0.0
flutter:
sdk: flutter
@ -56,7 +56,7 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^0.1.3
cupertino_icons: ^1.0.2
dev_dependencies:
flutter_test:

View File

@ -13,8 +13,8 @@ void confirm(String message) {
}
}
void printError(String message, {bool shouldExit = true}) {
Never printError(String message) {
stderr.writeln('\x1B[31m$message\x1B[0m');
if (shouldExit) exit(1);
exit(1);
}

View File

@ -12,7 +12,7 @@ Future<void> main(List<String> args) async {
final keys = strings.keys.where((key) => !key.startsWith('@')).toSet();
final keysWithoutVariables = keys.where((key) {
final metadata = strings['@$key'] as Map<String, dynamic>;
final placeholders = metadata['placeholders'] as Map<String, dynamic>;
final placeholders = metadata['placeholders'] as Map<String, dynamic>?;
return placeholders?.isEmpty ?? true;
}).toSet();
@ -30,7 +30,7 @@ ${keys.map((key) => " static const $key = '$key';").join('\n')}
extension L10nFromString on String {
String tr(BuildContext context) {
switch (this) {
${keysWithoutVariables.map((key) => " case L10nStrings.$key:\n return L10n.of(context).$key;").join('\n')}
${keysWithoutVariables.map((key) => " case L10nStrings.$key:\n return L10n.of(context)!.$key;").join('\n')}
default:
return this;

View File

@ -10,17 +10,17 @@ import 'gen_l10n_from_string.dart' as gen;
// ignore: camel_case_types
class _ {
final String key;
final String rename;
final String? rename;
/// make all letters except the first one lower case
final bool decapitalize;
final bool toLowerCase;
/// arb format for the placeholder
final String format;
final String? format;
/// arb type for the placeholder
final String type;
final String? type;
const _(
this.key, {
@ -281,18 +281,20 @@ void portStrings(
}
}
final baseTranslations = lemmyTranslations[baseLanguage]!;
for (final migrate in toMigrate) {
if (!lemmyTranslations[baseLanguage].containsKey(migrate.key)) {
if (!baseTranslations.containsKey(migrate.key)) {
printError('"${migrate.key}" does not exist in $repoName');
}
if (lemmurTranslations[baseLanguage].containsKey(migrate.renamedKey) &&
if (lemmurTranslations[baseLanguage]!.containsKey(migrate.renamedKey) &&
!force) {
confirm('"${migrate.key}" already exists in lemmur, overwrite?');
}
final variableName = RegExp(r'{{([\w_]+)}|')
.firstMatch(lemmyTranslations[baseLanguage][migrate.key])
.firstMatch(baseTranslations[migrate.key]!)
?.group(1);
final metadata = <String, dynamic>{
@ -305,20 +307,20 @@ void portStrings(
},
};
// ignore: omit_local_variable_types
String Function(Map<String, String> translations) transformer =
String? Function(Map<String, String> translations) transformer =
(translations) => translations[migrate.key];
// check if it has a plural form
if (lemmyTranslations[baseLanguage].containsKey('${migrate.key}_plural')) {
if (baseTranslations.containsKey('${migrate.key}_plural')) {
transformer = (translations) {
if (translations[migrate.key] == null) return null;
final fixedVariables = translations[migrate.key]
final fixedVariables = translations[migrate.key]!
.replaceAll('{{$variableName}}', '{$variableName}');
final pluralForm = () {
if (translations.containsKey('${migrate.key}_plural')) {
return translations['${migrate.key}_plural']
return translations['${migrate.key}_plural']!
.replaceAll('{{$variableName}}', '{$variableName}');
}
@ -337,11 +339,14 @@ void portStrings(
final language = trans.key;
final strings = trans.value;
lemmurTranslations[language][migrate.renamedKey] = transformer(strings);
lemmurTranslations[language]![migrate.renamedKey] = transformer(strings);
}
lemmurTranslations[baseLanguage][migrate.renamedKey] =
migrate.transform(transformer(lemmyTranslations[baseLanguage]));
lemmurTranslations[baseLanguage]['@${migrate.renamedKey}'] = metadata;
final transformed = transformer(baseTranslations);
if (transformed != null) {
lemmurTranslations[baseLanguage]![migrate.renamedKey] =
migrate.transform(transformed);
}
lemmurTranslations[baseLanguage]!['@${migrate.renamedKey}'] = metadata;
}
}
@ -359,7 +364,7 @@ Future<void> save(Map<String, Map<String, dynamic>> lemmurTranslations) async {
}
}
for (final rem in toRemove) {
lemmurTranslations[rem[0]].remove(rem[1]);
lemmurTranslations[rem[0]]?.remove(rem[1]);
}
for (final language in lemmurTranslations.keys) {

View File

@ -32,7 +32,8 @@ Future<void> assertNoStagedGit() async {
}
class Version {
int major, minor, patch, code;
final int major, minor, patch, code;
Version(this.major, this.minor, this.patch, this.code);
String toString() => '$major.$minor.$patch+$code';
String toStringNoCode() => '$major.$minor.$patch';
}
@ -44,10 +45,14 @@ Future<Version> bumpedVersion(String versionBumpType) async {
final versionMatch = RegExp(r'version: (\d+)\.(\d+)\.(\d+)\+(\d+)')
.firstMatch(pubspecContents);
var major = int.parse(versionMatch.group(1));
var minor = int.parse(versionMatch.group(2));
var patch = int.parse(versionMatch.group(3));
var code = int.parse(versionMatch.group(4));
if (versionMatch == null) {
printError('Failed to find version in pubspec.yaml');
}
var major = int.parse(versionMatch.group(1)!);
var minor = int.parse(versionMatch.group(2)!);
var patch = int.parse(versionMatch.group(3)!);
var code = int.parse(versionMatch.group(4)!);
switch (versionBumpType) {
case 'patch':
@ -65,11 +70,7 @@ Future<Version> bumpedVersion(String versionBumpType) async {
}
code++;
return Version()
..major = major
..minor = minor
..patch = patch
..code = code;
return Version(major, minor, patch, code);
}
Future<void> updatePubspec(Version version) async {
@ -90,6 +91,11 @@ Future<void> updateChangelog(Version version) async {
var currentChangelog =
RegExp(r'^## Unreleased$.+?^##[^#]', multiLine: true, dotAll: true)
.stringMatch(changelogContents);
if (currentChangelog == null) {
printError('No changelog found');
}
currentChangelog = currentChangelog.substring(0, currentChangelog.length - 4);
final date = DateTime.now();