diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 00ac6a895..70cc5b45f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,7 +12,7 @@ + + + + + + + + + + intentBacklog = new ArrayList<>(); + private boolean streamCanceled = false; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); GeneratedPluginRegistrant.registerWith(this); + + new EventChannel(getFlutterView(), SHARE_STREAM).setStreamHandler( + new EventChannel.StreamHandler() { + @Override + public void onListen(Object args, final EventChannel.EventSink events) { + eventSink = events; + streamCanceled = false; + for (int i = 0; i < intentBacklog.size(); i++) { + sendIntent(intentBacklog.remove(i)); + } + } + + @Override + public void onCancel(Object args) { + eventSink = null; + streamCanceled = true; + } + } + ); + + sendIntent(getIntent()); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + sendIntent(intent); + } + + private void sendIntent(Intent intent) { + if (intent.getAction().equals(Intent.ACTION_SEND)) { + if (eventSink == null) { + if (!streamCanceled && !intentBacklog.contains(intent)) { + intentBacklog.add(intent); + } + return; + } + + Map args = new HashMap<>(); + if (intent.getType().startsWith("image/")) { + Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + uri = copyImageToTempFile(uri); + args.put("path", uri.toString()); + } else if (intent.getType().startsWith("text/")) { + args.put("text", intent.getStringExtra(Intent.EXTRA_TEXT)); + } else { + Log.w(getClass().getSimpleName(), "unknown intent type \"" + intent.getType() + "\" received, ignoring"); + return; + } + Log.i(getClass().getSimpleName(), "sending intent to flutter"); + eventSink.success(args); + } + } + + private Uri copyImageToTempFile(Uri imageUri) { + try { + InputStream inputStream; + if (imageUri.getScheme().equals("content")) { + inputStream = this.getContentResolver().openInputStream(imageUri); + } else { + inputStream = new FileInputStream(new File(imageUri.getPath())); + } + Bitmap bmp = BitmapFactory.decodeStream(inputStream); + inputStream.close(); + if (bmp == null) return null; + + ByteArrayOutputStream imageDataStream = new ByteArrayOutputStream(); + bmp.compress(Bitmap.CompressFormat.JPEG, 100, imageDataStream); + + File imageFile = createTemporaryFile(".jpeg"); + FileOutputStream fileOutput = new FileOutputStream(imageFile); + fileOutput.write(imageDataStream.toByteArray()); + inputStream.close(); + fileOutput.close(); + + return Uri.fromFile(imageFile); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private File createTemporaryFile(String extension) { + try { + String name = UUID.randomUUID().toString(); + return File.createTempFile(name, extension, this.getExternalFilesDir(Environment.DIRECTORY_PICTURES)); + } catch (IOException e) { + throw new RuntimeException(e); + } } } diff --git a/assets/images/icons/expand-icon.png b/assets/images/icons/expand-icon.png new file mode 100644 index 000000000..1f3a337bf Binary files /dev/null and b/assets/images/icons/expand-icon.png differ diff --git a/ios/OneSignalNotificationServiceExtension/Info.plist b/ios/OneSignalNotificationServiceExtension/Info.plist index ba021c755..9eca1008a 100644 --- a/ios/OneSignalNotificationServiceExtension/Info.plist +++ b/ios/OneSignalNotificationServiceExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 0.0.21 + 0.0.34 CFBundleVersion - 21 + 34 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8d603742c..cbc13092e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -24,6 +24,8 @@ PODS: - OneSignal (2.9.5) - path_provider (0.0.1): - Flutter + - share (0.5.2): + - Flutter - sqflite (0.0.1): - Flutter - FMDB (~> 2.7.2) @@ -37,7 +39,7 @@ PODS: DEPENDENCIES: - device_info (from `.symlinks/plugins/device_info/ios`) - - Flutter (from `.symlinks/flutter/ios-release`) + - Flutter (from `.symlinks/flutter/ios`) - flutter_exif_rotation (from `.symlinks/plugins/flutter_exif_rotation/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - image_cropper (from `.symlinks/plugins/image_cropper/ios`) @@ -46,6 +48,7 @@ DEPENDENCIES: - OneSignal (< 3.0, >= 2.9.5) - onesignal (from `.symlinks/plugins/onesignal/ios`) - path_provider (from `.symlinks/plugins/path_provider/ios`) + - share (from `.symlinks/plugins/share/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`) - uni_links (from `.symlinks/plugins/uni_links/ios`) - url_launcher (from `.symlinks/plugins/url_launcher/ios`) @@ -62,7 +65,7 @@ EXTERNAL SOURCES: device_info: :path: ".symlinks/plugins/device_info/ios" Flutter: - :path: ".symlinks/flutter/ios-release" + :path: ".symlinks/flutter/ios" flutter_exif_rotation: :path: ".symlinks/plugins/flutter_exif_rotation/ios" flutter_secure_storage: @@ -77,6 +80,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/onesignal/ios" path_provider: :path: ".symlinks/plugins/path_provider/ios" + share: + :path: ".symlinks/plugins/share/ios" sqflite: :path: ".symlinks/plugins/sqflite/ios" uni_links: @@ -99,6 +104,7 @@ SPEC CHECKSUMS: OneSignal: ccdeb961882f8668305e5b694e2cb7cb325fc907 onesignal: c2122c20ffcb03d65445f3e0b49273c10f9c37a6 path_provider: 09407919825bfe3c2deae39453b7a5b44f467873 + share: 222b5dcc8031238af9d7de91149df65bad1aef75 sqflite: d1612813fa7db7c667bed9f1d1b508deffc56999 TOCropViewController: 0a075f02c253e88095143bbac7b013fc6fba5090 uni_links: 5ee5240df5cbffc52d9e7f8017a576b6a6bc5141 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index af421a92a..199bf340e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -703,7 +703,7 @@ ); inputPaths = ( "${SRCROOT}/Pods/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", - "${PODS_ROOT}/../.symlinks/flutter/ios-release/Flutter.framework", + "${PODS_ROOT}/../.symlinks/flutter/ios/Flutter.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index ad08a22fb..27fd7d079 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -17,11 +17,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.0.21 + 0.0.34 CFBundleSignature ???? CFBundleVersion - 21 + 34 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS @@ -35,6 +35,10 @@ We use this permissions to allow you to choose a profile picture or share existing photos in your library. NSCameraUsageDescription We use this permissions to allow you to share photos taken within the application. + NSLocationAlwaysAndWhenInUseUsageDescription + OneSignal, our current push notification library includes code that can use your location, we have disabled this and are waiting on them to update their library to remove this permission completely. + NSLocationWhenInUseUsageDescription + OneSignal, our current push notification library includes code that can use your location, we have disabled this and are waiting on them to update their library to remove this permission completely. NSMicrophoneUsageDescription We use this permission to record audio when you take a video within the application. NSPhotoLibraryUsageDescription diff --git a/lib/libs/future_queue.dart b/lib/libs/future_queue.dart new file mode 100644 index 000000000..3a4ded4c7 --- /dev/null +++ b/lib/libs/future_queue.dart @@ -0,0 +1,19 @@ +import 'dart:async'; + +class FutureQueue { + Future _next = new Future.value(null); + + /// Request [operation] to be run exclusively. + /// + /// Waits for all previously requested operations to complete, + /// then runs the operation and completes the returned future with the + /// result. + /// All creds to https://stackoverflow.com/a/42091982/2608145 + Future run(Future operation()) { + var completer = new Completer(); + _next.whenComplete(() { + completer.complete(new Future.sync(operation)); + }); + return _next = completer.future; + } +} diff --git a/lib/libs/pretty_count.dart b/lib/libs/pretty_count.dart index 87527b0e7..476263022 100644 --- a/lib/libs/pretty_count.dart +++ b/lib/libs/pretty_count.dart @@ -1,6 +1,6 @@ String getPrettyCount(int value) { String postfix; - double finalValue = value.toDouble(); + double finalValue; if (value < 0) { throw 'Invalid value'; @@ -17,5 +17,5 @@ String getPrettyCount(int value) { finalValue = value / 1000000000; } - return finalValue.toString() + postfix; + return finalValue.round().toString() + postfix; } diff --git a/lib/models/badge.dart b/lib/models/badge.dart index 00d028809..0955c27cf 100644 --- a/lib/models/badge.dart +++ b/lib/models/badge.dart @@ -18,6 +18,7 @@ class Badge { static BadgeKeyword _getBadgeKeywordEnum(keyword) { switch(keyword) { + case 'ANGEL': return BadgeKeyword.angel; break; case 'VERIFIED': return BadgeKeyword.verified; break; case 'FOUNDER': return BadgeKeyword.founder; break; case 'GOLDEN_FOUNDER': return BadgeKeyword.golden_founder; break; @@ -38,4 +39,4 @@ class Badge { } } -enum BadgeKeyword { verified, founder, golden_founder, diamond_founder, super_founder, none } \ No newline at end of file +enum BadgeKeyword { angel, verified, founder, golden_founder, diamond_founder, super_founder, none } \ No newline at end of file diff --git a/lib/models/badges_list.dart b/lib/models/badges_list.dart new file mode 100644 index 000000000..45f9e2222 --- /dev/null +++ b/lib/models/badges_list.dart @@ -0,0 +1,18 @@ +import 'package:Openbook/models/badge.dart'; + +class BadgesList { + final List badges; + + BadgesList({ + this.badges, + }); + + factory BadgesList.fromJson(List parsedJson) { + List badges = + parsedJson.map((badgeJson) => Badge.fromJson(badgeJson)).toList(); + + return new BadgesList( + badges: badges, + ); + } +} diff --git a/lib/models/community.dart b/lib/models/community.dart index df0840be0..010497d8f 100644 --- a/lib/models/community.dart +++ b/lib/models/community.dart @@ -37,6 +37,10 @@ class Community extends UpdatableModel { return result; } + static void clearCache() { + factory.clearCache(); + } + final int id; final User creator; String name; @@ -108,6 +112,10 @@ class Community extends UpdatableModel { return type == CommunityType.private; } + bool isPublic() { + return type == CommunityType.public; + } + bool isAdministrator(User user) { CommunityMembership membership = getMembershipForUser(user); if (membership == null) return false; @@ -244,7 +252,7 @@ class Community extends UpdatableModel { class CommunityFactory extends UpdatableModelFactory { @override SimpleCache cache = - LruCache(storage: UpdatableModelSimpleStorage(size: 50)); + SimpleCache(storage: UpdatableModelSimpleStorage(size: 200)); @override Community makeFromJson(Map json) { diff --git a/lib/models/notifications/notification.dart b/lib/models/notifications/notification.dart index 5ec416b6d..6e6a10184 100644 --- a/lib/models/notifications/notification.dart +++ b/lib/models/notifications/notification.dart @@ -76,7 +76,7 @@ class OBNotification extends UpdatableModel { class NotificationFactory extends UpdatableModelFactory { @override SimpleCache cache = - SimpleCache(storage: UpdatableModelSimpleStorage(size: 20)); + SimpleCache(storage: UpdatableModelSimpleStorage(size: 120)); @override OBNotification makeFromJson(Map json) { diff --git a/lib/models/notifications/post_comment_notification.dart b/lib/models/notifications/post_comment_notification.dart index ad983fb62..6331a64c4 100644 --- a/lib/models/notifications/post_comment_notification.dart +++ b/lib/models/notifications/post_comment_notification.dart @@ -12,7 +12,7 @@ class PostCommentNotification { } static PostComment _parsePostComment(Map postCommentData) { - return PostComment.fromJson(postCommentData); + return PostComment.fromJSON(postCommentData); } int getPostCreatorId() { diff --git a/lib/models/post.dart b/lib/models/post.dart index c821d2c6f..fba32b20f 100644 --- a/lib/models/post.dart +++ b/lib/models/post.dart @@ -36,6 +36,7 @@ class Post extends UpdatableModel { bool isMuted; bool isEncircled; + bool isEdited; static final factory = PostFactory(); @@ -66,7 +67,8 @@ class Post extends UpdatableModel { this.community, this.publicReactions, this.isMuted, - this.isEncircled}) + this.isEncircled, + this.isEdited}) : super(); void updateFromJson(Map json) { @@ -94,6 +96,8 @@ class Post extends UpdatableModel { if (json.containsKey('is_encircled')) isEncircled = json['is_encircled']; + if (json.containsKey('is_edited')) isEdited = json['is_edited']; + if (json.containsKey('image')) image = factory.parseImage(json['image']); if (json.containsKey('video')) video = factory.parseVideo(json['video']); @@ -206,10 +210,6 @@ class Post extends UpdatableModel { return video.video; } - String getText() { - return text; - } - String getRelativeCreated() { return timeago.format(created); } @@ -281,7 +281,7 @@ class Post extends UpdatableModel { class PostFactory extends UpdatableModelFactory { @override SimpleCache cache = - LruCache(storage: UpdatableModelSimpleStorage(size: 250)); + SimpleCache(storage: UpdatableModelSimpleStorage(size: 100)); @override Post makeFromJson(Map json) { @@ -304,6 +304,7 @@ class PostFactory extends UpdatableModelFactory { community: parseCommunity(json['community']), commentsList: parseCommentList(json['comments']), isEncircled: json['is_encircled'], + isEdited: json['is_edited'], reactionsEmojiCounts: parseReactionsEmojiCounts(json['reactions_emoji_counts'])); } diff --git a/lib/models/post_comment.dart b/lib/models/post_comment.dart index 95d7c0c8f..76d751efd 100644 --- a/lib/models/post_comment.dart +++ b/lib/models/post_comment.dart @@ -1,8 +1,18 @@ import 'package:Openbook/models/post.dart'; import 'package:Openbook/models/user.dart'; +import 'package:dcache/dcache.dart'; import 'package:timeago/timeago.dart' as timeago; +import 'package:Openbook/models/updatable_model.dart'; + +class PostComment extends UpdatableModel { + final int id; + int creatorId; + DateTime created; + String text; + User commenter; + Post post; + bool isEdited; -class PostComment { static convertPostCommentSortTypeToString(PostCommentsSortType type) { String result; switch (type) { @@ -31,45 +41,50 @@ class PostComment { return type; } - final int id; - final int creatorId; - final DateTime created; - final String text; - final User commenter; - final Post post; - - PostComment({ - this.id, - this.created, - this.text, - this.creatorId, - this.commenter, - this.post, - }); - - factory PostComment.fromJson(Map parsedJson) { - DateTime created; - if (parsedJson.containsKey('created')) { - created = DateTime.parse(parsedJson['created']).toLocal(); + static void clearCache() { + factory.clearCache(); + } + + PostComment( + {this.id, + this.created, + this.text, + this.creatorId, + this.commenter, + this.post, + this.isEdited}); + + static final factory = PostCommentFactory(); + + factory PostComment.fromJSON(Map json) { + return factory.fromJson(json); + } + + @override + void updateFromJson(Map json) { + if (json.containsKey('commenter')) { + commenter = factory.parseUser(json['commenter']); } - User commenter; - if (parsedJson.containsKey('commenter')) { - commenter = User.fromJson(parsedJson['commenter']); + if (json.containsKey('creater_id')) { + creatorId = json['creator_id']; } - Post post; - if (parsedJson.containsKey('post')) { - post = Post.fromJson(parsedJson['post']); + if (json.containsKey('is_edited')) { + isEdited = json['is_edited']; } - return PostComment( - id: parsedJson['id'], - creatorId: parsedJson['creator_id'], - created: created, - commenter: commenter, - post: post, - text: parsedJson['text']); + if (json.containsKey('text')) { + text = json['text']; + } + + if (json.containsKey('post')) { + post = factory.parsePost(json['post']); + } + + if (json.containsKey('created')) { + created = factory.parseCreated(json['created']); + } } String getRelativeCreated() { @@ -97,4 +112,37 @@ class PostComment { } } +class PostCommentFactory extends UpdatableModelFactory { + @override + SimpleCache cache = + LruCache(storage: UpdatableModelSimpleStorage(size: 200)); + + @override + PostComment makeFromJson(Map json) { + return PostComment( + id: json['id'], + creatorId: json['creator_id'], + created: parseCreated(json['created']), + commenter: parseUser(json['commenter']), + post: parsePost(json['post']), + isEdited: json['is_edited'], + text: json['text']); + } + + Post parsePost(Map post) { + if (post == null) return null; + return Post.fromJson(post); + } + + DateTime parseCreated(String created) { + if (created == null) return null; + return DateTime.parse(created).toLocal(); + } + + User parseUser(Map userData) { + if (userData == null) return null; + return User.fromJson(userData); + } +} + enum PostCommentsSortType { asc, dec } diff --git a/lib/models/post_comment_list.dart b/lib/models/post_comment_list.dart index 3bab3e7b3..0bbe52106 100644 --- a/lib/models/post_comment_list.dart +++ b/lib/models/post_comment_list.dart @@ -10,7 +10,7 @@ class PostCommentList { factory PostCommentList.fromJson(List parsedJson) { List postComments = - parsedJson.map((postJson) => PostComment.fromJson(postJson)).toList(); + parsedJson.map((postJson) => PostComment.fromJSON(postJson)).toList(); return new PostCommentList( comments: postComments, diff --git a/lib/models/updatable_model.dart b/lib/models/updatable_model.dart index 62f2ba47f..4c5b375e7 100644 --- a/lib/models/updatable_model.dart +++ b/lib/models/updatable_model.dart @@ -23,7 +23,7 @@ abstract class UpdatableModel { void updateFromJson(Map json); - void dispose() { + void dipose() { _updateChangeSubject.close(); } } @@ -106,7 +106,8 @@ class UpdatableModelSimpleStorage @override void remove(K key) { CacheEntry item = get(key); - item.value.dispose(); + // https://stackoverflow.com/questions/49879438/dart-do-i-have-to-cancel-stream-subscriptions-and-close-streamsinks + // item.value.dispose(); this._internalMap.remove(key); } @@ -128,3 +129,5 @@ class UpdatableModelSimpleStorage @override int get capacity => this._size; } + +typedef void UpdateCallback(Map json); diff --git a/lib/models/user.dart b/lib/models/user.dart index 71e0d3de3..0bee2aa95 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -24,6 +24,7 @@ class User extends UpdatableModel { int followingCount; int unreadNotificationsCount; int postsCount; + int inviteCount; bool isFollowing; bool isConnected; bool isFullyConnected; @@ -77,6 +78,7 @@ class User extends UpdatableModel { this.followingCount, this.unreadNotificationsCount, this.postsCount, + this.inviteCount, this.isFollowing, this.isConnected, this.isFullyConnected, @@ -113,6 +115,7 @@ class User extends UpdatableModel { if (json.containsKey('unread_notifications_count')) unreadNotificationsCount = json['unread_notifications_count']; if (json.containsKey('posts_count')) postsCount = json['posts_count']; + if (json.containsKey('invite_count')) inviteCount = json['invite_count']; if (json.containsKey('is_following')) isFollowing = json['is_following']; if (json.containsKey('is_connected')) isConnected = json['is_connected']; if (json.containsKey('connections_circle_id')) @@ -297,6 +300,7 @@ class UserFactory extends UpdatableModelFactory { connectionsCircleId: json['connections_circle_id'], followersCount: json['followers_count'], postsCount: json['posts_count'], + inviteCount: json['invite_count'], unreadNotificationsCount: json['unread_notifications_count'], email: json['email'], username: json['username'], @@ -309,6 +313,7 @@ class UserFactory extends UpdatableModelFactory { connectedCircles: parseCircles(json['connected_circles']), communitiesMemberships: parseMemberships(json['communities_memberships']), + communitiesInvites: parseInvites(json['communities_invites']), followLists: parseFollowsLists(json['follow_lists'])); } diff --git a/lib/models/user_invite.dart b/lib/models/user_invite.dart new file mode 100644 index 000000000..d7ac48349 --- /dev/null +++ b/lib/models/user_invite.dart @@ -0,0 +1,102 @@ +import 'package:Openbook/models/updatable_model.dart'; +import 'package:Openbook/models/user.dart'; +import 'package:dcache/dcache.dart'; + +class UserInvite extends UpdatableModel { + final int id; + String email; + final DateTime created; + User createdUser; + String nickname; + final String token; + bool isInviteEmailSent; + + static getShareMessageForInviteWithToken(String token, String apiURL) { + const String IOS_DOWNLOAD_LINK = 'https://testflight.apple.com/join/XniAjdyF'; + const String ANDROID_DOWNLOAD_LINK = 'https://play.google.com/apps/testing/social.openbook.app'; + String inviteLink = '$apiURL/api/auth/invite?token=$token'; + + String message = 'Hey, I\'d like to invite you to Openbook. First, Download the app on iTunes ($IOS_DOWNLOAD_LINK) or the Play store ($ANDROID_DOWNLOAD_LINK). ' + 'Second, paste this personalised invite link in the \'Sign up\' form in the Openbook App: $inviteLink '; + + return message; + } + + static convertUserInviteStatusToBool(UserInviteFilterByStatus value) { + bool isPending; + switch (value) { + case UserInviteFilterByStatus.all: + isPending = null; + break; + case UserInviteFilterByStatus.pending: + isPending = true; + break; + case UserInviteFilterByStatus.accepted: + isPending = false; + break; + default: + throw 'Unsupported post comment sort type'; + } + return isPending; + } + + static void clearCache() { + factory.clearCache(); + } + + static final factory = UserInviteFactory(); + + factory UserInvite.fromJSON(Map json) { + return factory.fromJson(json); + } + + UserInvite({this.id, this.email, this.created, this.createdUser, this.nickname, this.token, this.isInviteEmailSent}); + + @override + void updateFromJson(Map json) { + if (json.containsKey('email')) { + email = json['email']; + } + + if (json.containsKey('created_user')) { + createdUser = factory.parseUser(json['created_user']); + } + + if (json.containsKey('nickname')) { + nickname = json['nickname']; + } + + if (json.containsKey('is_invite_email_sent')) { + isInviteEmailSent = json['is_invite_email_sent']; + } + } +} + +class UserInviteFactory extends UpdatableModelFactory { + @override + SimpleCache cache = + LruCache(storage: UpdatableModelSimpleStorage(size: 10)); + + @override + UserInvite makeFromJson(Map json) { + DateTime created; + var createdData = json['created']; + if (createdData != null) created = DateTime.parse(createdData).toLocal(); + + return UserInvite( + id: json['id'], + email: json['email'], + created: created, + createdUser: parseUser(json['created_user']), + nickname: json['nickname'], + token: json['token'], + isInviteEmailSent: json['is_invite_email_sent']); + } + + User parseUser(Map userData) { + if (userData == null) return null; + return User.fromJson(userData); + } +} + +enum UserInviteFilterByStatus { pending, accepted, all } diff --git a/lib/models/user_invites_list.dart b/lib/models/user_invites_list.dart new file mode 100644 index 000000000..6a06be200 --- /dev/null +++ b/lib/models/user_invites_list.dart @@ -0,0 +1,18 @@ +import 'package:Openbook/models/user_invite.dart'; + +class UserInvitesList { + final List invites; + + UserInvitesList({ + this.invites, + }); + + factory UserInvitesList.fromJson(List parsedJson) { + List userInvites = + parsedJson.map((inviteJson) => UserInvite.fromJSON(inviteJson)).toList(); + + return new UserInvitesList( + invites: userInvites, + ); + } +} diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart index a00140215..eecf1de86 100644 --- a/lib/models/user_profile.dart +++ b/lib/models/user_profile.dart @@ -1,4 +1,5 @@ import 'package:Openbook/models/badge.dart'; +import 'package:Openbook/models/badges_list.dart'; class UserProfile { final int id; @@ -23,13 +24,6 @@ class UserProfile { this.followersCountVisible}); factory UserProfile.fromJSON(Map parsedJson) { - List badgesList; - if (parsedJson.containsKey('badges')) { - List badges = parsedJson['badges']; - badgesList = - badges.map((badgeJson) => Badge.fromJson(badgeJson)).toList(); - } - return UserProfile( id: parsedJson['id'], name: parsedJson['name'], @@ -38,10 +32,15 @@ class UserProfile { bio: parsedJson['bio'], url: parsedJson['url'], location: parsedJson['location'], - badges: badgesList, + badges: parseBadges(parsedJson['badges']), followersCountVisible: parsedJson['followers_count_visible']); } + static List parseBadges(List badges) { + if (badges == null) return null; + return BadgesList.fromJson(badges).badges; + } + void updateFromJson(Map json) { if (json.containsKey('name')) name = json['name']; if (json.containsKey('avatar')) avatar = json['avatar']; @@ -49,6 +48,7 @@ class UserProfile { if (json.containsKey('bio')) bio = json['bio']; if (json.containsKey('url')) url = json['url']; if (json.containsKey('location')) location = json['location']; + if (json.containsKey('badges')) badges = parseBadges(json['badges']); if (json.containsKey('followers_count_visible')) followersCountVisible = json['followers_count_visible']; } diff --git a/lib/pages/auth/login.dart b/lib/pages/auth/login.dart index 0721236d1..99fc26555 100644 --- a/lib/pages/auth/login.dart +++ b/lib/pages/auth/login.dart @@ -249,6 +249,7 @@ class OBAuthLoginPageState extends State { contentPadding: inputContentPadding, labelText: usernameInputLabel, border: OutlineInputBorder(), + errorMaxLines: 3 ), autocorrect: false, ), diff --git a/lib/pages/auth/reset_password/forgot_password_step.dart b/lib/pages/auth/reset_password/forgot_password_step.dart index 21eda9999..0f9528085 100644 --- a/lib/pages/auth/reset_password/forgot_password_step.dart +++ b/lib/pages/auth/reset_password/forgot_password_step.dart @@ -232,6 +232,7 @@ class OBAuthForgotPasswordPageState extends State { contentPadding: inputContentPadding, labelText: usernameInputLabel, border: OutlineInputBorder(), + errorMaxLines: 3 ), autocorrect: false, ), @@ -252,6 +253,7 @@ class OBAuthForgotPasswordPageState extends State { contentPadding: inputContentPadding, labelText: emailInputLabel, border: OutlineInputBorder(), + errorMaxLines: 3 ), autocorrect: false, ), diff --git a/lib/pages/home/bottom_sheets/comment_more_actions.dart b/lib/pages/home/bottom_sheets/comment_more_actions.dart new file mode 100644 index 000000000..be80e6afd --- /dev/null +++ b/lib/pages/home/bottom_sheets/comment_more_actions.dart @@ -0,0 +1,99 @@ +import 'package:Openbook/models/post.dart'; +import 'package:Openbook/models/post_comment.dart'; +import 'package:Openbook/models/user.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/modal_service.dart'; +import 'package:Openbook/services/toast.dart'; +import 'package:Openbook/services/user.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:flutter/material.dart'; + +class OBCommentMoreActionsBottomSheet extends StatefulWidget { + final PostComment postComment; + final Post post; + + const OBCommentMoreActionsBottomSheet({ + @required this.post, + @required this.postComment, + Key key}) + : super(key: key); + + @override + State createState() { + return OBCommentMoreActionsBottomSheetState(); + } +} + +class OBCommentMoreActionsBottomSheetState extends State { + ToastService _toastService; + UserService _userService; + ModalService _modalService; + bool _requestInProgress; + + @override + void initState() { + _requestInProgress = false; + super.initState(); + } + + @override + Widget build(BuildContext context) { + OpenbookProviderState provider = OpenbookProvider.of(context); + _toastService = provider.toastService; + _userService = provider.userService; + _modalService = provider.modalService; + List _moreCommentActions = []; + + User loggedInUser = _userService.getLoggedInUser(); + bool loggedInUserIsCommunityAdministrator = false; + bool loggedInUserIsCommunityModerator = false; + + Post post = widget.post; + User postCommenter = widget.postComment.commenter; + + _moreCommentActions.add( + ListTile( + leading: const OBIcon(OBIcons.editPost), + title: const OBText( + 'Report comment', + ), + onTap: _reportPostComment, + ), + ); + + + return OBPrimaryColorContainer( + mainAxisSize: MainAxisSize.min, + child: Column( + children: _moreCommentActions, + mainAxisSize: MainAxisSize.min, + ), + ); + } + + void _reportPostComment() async { + _toastService.error(message: 'Not implemented yet', context: context); + Navigator.pop(context); + } + + void _onError(error) async { + if (error is HttpieConnectionRefusedError) { + _toastService.error( + message: error.toHumanReadableMessage(), context: context); + } else if (error is HttpieRequestError) { + String errorMessage = await error.toHumanReadableMessage(); + _toastService.error(message: errorMessage, context: context); + } else { + _toastService.error(message: 'Unknown error', context: context); + throw error; + } + } + + void _setRequestInProgress(bool requestInProgress) { + setState(() { + _requestInProgress = requestInProgress; + }); + } +} diff --git a/lib/pages/home/bottom_sheets/photo_picker.dart b/lib/pages/home/bottom_sheets/photo_picker.dart index 12c826fd7..85fc24ca5 100644 --- a/lib/pages/home/bottom_sheets/photo_picker.dart +++ b/lib/pages/home/bottom_sheets/photo_picker.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:Openbook/provider.dart'; import 'package:Openbook/services/image_picker.dart'; +import 'package:Openbook/services/toast.dart'; import 'package:Openbook/widgets/icon.dart'; import 'package:Openbook/widgets/theming/primary_color_container.dart'; import 'package:Openbook/widgets/theming/text.dart'; @@ -17,6 +18,7 @@ class OBPhotoPickerBottomSheet extends StatelessWidget { Widget build(BuildContext context) { ImagePickerService imagePickerService = OpenbookProvider.of(context).imagePickerService; + ToastService toastService = OpenbookProvider.of(context).toastService; List photoPickerActions = [ ListTile( @@ -25,9 +27,14 @@ class OBPhotoPickerBottomSheet extends StatelessWidget { 'From gallery', ), onTap: () async { - File image = await imagePickerService.pickImage( - imageType: imageType, source: ImageSource.gallery); - Navigator.pop(context, image); + try { + File image = await imagePickerService.pickImage( + imageType: imageType, source: ImageSource.gallery); + Navigator.pop(context, image); + } on ImageTooLargeException catch (e) { + int limit = e.getLimitInMB(); + toastService.error(message: 'Image too large (limit: $limit MB)', context: context); + } }, ), ListTile( @@ -36,19 +43,27 @@ class OBPhotoPickerBottomSheet extends StatelessWidget { 'From camera', ), onTap: () async { - File image = await imagePickerService.pickImage( - imageType: imageType, source: ImageSource.camera); - Navigator.pop(context, image); + try { + File image = await imagePickerService.pickImage( + imageType: imageType, source: ImageSource.camera); + Navigator.pop(context, image); + } on ImageTooLargeException catch (e) { + int limit = e.getLimitInMB(); + toastService.error(message: 'Image too large (limit: $limit MB)', context: context); + } }, ) ]; return OBPrimaryColorContainer( mainAxisSize: MainAxisSize.min, - child: Column( - children: photoPickerActions, - mainAxisSize: MainAxisSize.min, - ), + child: Padding( + padding: EdgeInsets.only(bottom: 16), + child: Column( + children: photoPickerActions, + mainAxisSize: MainAxisSize.min, + ), + ) ); } } diff --git a/lib/pages/home/bottom_sheets/post_actions.dart b/lib/pages/home/bottom_sheets/post_actions.dart index f6a1bb45e..6f2825d7a 100644 --- a/lib/pages/home/bottom_sheets/post_actions.dart +++ b/lib/pages/home/bottom_sheets/post_actions.dart @@ -1,7 +1,9 @@ +import 'dart:io'; import 'package:Openbook/models/community.dart'; import 'package:Openbook/models/post.dart'; import 'package:Openbook/models/user.dart'; import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/modal_service.dart'; import 'package:Openbook/services/toast.dart'; import 'package:Openbook/services/user.dart'; import 'package:Openbook/services/httpie.dart'; @@ -32,12 +34,14 @@ class OBPostActionsBottomSheet extends StatefulWidget { class OBPostActionsBottomSheetState extends State { UserService _userService; + ModalService _modalService; ToastService _toastService; @override Widget build(BuildContext context) { var openbookProvider = OpenbookProvider.of(context); _userService = openbookProvider.userService; + _modalService = openbookProvider.modalService; _toastService = openbookProvider.toastService; List postActions = []; @@ -67,9 +71,20 @@ class OBPostActionsBottomSheetState extends State { onUnmutedPost: _dismiss, )); + if (loggedInUserIsPostCreator) { + postActions.add(ListTile( + leading: const OBIcon(OBIcons.editPost), + title: const OBText( + 'Edit post', + ), + onTap: _onWantsToEditPost, + )); + } + if (loggedInUserIsPostCreator || loggedInUserIsCommunityAdministrator || loggedInUserIsCommunityModerator) { + postActions.add(ListTile( leading: const OBIcon(OBIcons.deletePost), title: const OBText( @@ -107,6 +122,18 @@ class OBPostActionsBottomSheetState extends State { } } + Future _onWantsToEditPost() async { + try { + await _modalService.openEditPost( + context: context, + post: widget.post + ); + Navigator.pop(context); + } catch (error) { + _onError(error); + } + } + void _onError(error) async { if (error is HttpieConnectionRefusedError) { _toastService.error( diff --git a/lib/pages/home/bottom_sheets/video_picker.dart b/lib/pages/home/bottom_sheets/video_picker.dart index 409abbb64..c82acd6bd 100644 --- a/lib/pages/home/bottom_sheets/video_picker.dart +++ b/lib/pages/home/bottom_sheets/video_picker.dart @@ -40,10 +40,13 @@ class OBVideoPickerBottomSheet extends StatelessWidget { return OBPrimaryColorContainer( mainAxisSize: MainAxisSize.min, - child: Column( - children: videoPickerActions, - mainAxisSize: MainAxisSize.min, - ), + child: Padding( + padding: EdgeInsets.only(bottom: 16), + child: Column( + children: videoPickerActions, + mainAxisSize: MainAxisSize.min, + ), + ) ); } } diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 974220ce2..5fd4b1140 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:io'; + import 'package:Openbook/models/push_notification.dart'; import 'package:Openbook/pages/home/lib/poppable_page_controller.dart'; import 'package:Openbook/services/intercom.dart'; @@ -13,10 +15,15 @@ import 'package:Openbook/pages/home/pages/search/search.dart'; import 'package:Openbook/pages/home/widgets/bottom-tab-bar.dart'; import 'package:Openbook/pages/home/widgets/own_profile_active_icon.dart'; import 'package:Openbook/pages/home/widgets/tab-scaffold.dart'; +import 'package:Openbook/plugins/share/receive_share_state.dart'; +import 'package:Openbook/plugins/share/share.dart'; import 'package:Openbook/provider.dart'; import 'package:Openbook/services/httpie.dart'; +import 'package:Openbook/services/image_picker.dart'; +import 'package:Openbook/services/modal_service.dart'; import 'package:Openbook/services/toast.dart'; import 'package:Openbook/services/user.dart'; +import 'package:Openbook/services/validation.dart'; import 'package:Openbook/widgets/avatars/avatar.dart'; import 'package:Openbook/widgets/badges/badge.dart'; import 'package:Openbook/widgets/icon.dart'; @@ -31,12 +38,15 @@ class OBHomePage extends StatefulWidget { } } -class OBHomePageState extends State with WidgetsBindingObserver { +class OBHomePageState extends ReceiveShareState + with WidgetsBindingObserver { static const String oneSignalAppId = '66074bf4-9943-4504-a011-531c2635698b'; UserService _userService; ToastService _toastService; PushNotificationsService _pushNotificationsService; IntercomService _intercomService; + ModalService _modalService; + ValidationService _validationService; int _currentIndex; int _lastIndex; @@ -60,6 +70,7 @@ class OBHomePageState extends State with WidgetsBindingObserver { @override void initState() { super.initState(); + enableSharing(); BackButtonInterceptor.add(_backButtonInterceptor); WidgetsBinding.instance.addObserver(this); _needsBootstrap = true; @@ -98,6 +109,8 @@ class OBHomePageState extends State with WidgetsBindingObserver { _pushNotificationsService = openbookProvider.pushNotificationsService; _intercomService = openbookProvider.intercomService; _toastService = openbookProvider.toastService; + _modalService = openbookProvider.modalService; + _validationService = openbookProvider.validationService; _bootstrap(); _needsBootstrap = false; } @@ -116,6 +129,33 @@ class OBHomePageState extends State with WidgetsBindingObserver { ); } + @override + void onShare(Share share) async { + String text; + File image; + if (share.path != null) { + image = File.fromUri(Uri.parse(share.path)); + if (!await _validationService.isImageAllowedSize( + image, OBImageType.post)) { + int limit = _validationService.getAllowedImageSize(OBImageType.post) ~/ + 1048576; + _toastService.error( + message: 'Image too large (limit: $limit MB)', context: context); + return; + } + } + if (share.text != null) { + text = share.text; + if (!_validationService.isPostTextAllowedLength(text)) { + _toastService.error( + message: 'Text too long (limit: ${ValidationService.POST_MAX_LENGTH} characters)', + context: context); + return; + } + } + _modalService.openCreatePost(context: context, text: text, image: image); + } + Widget _getPageForTabIndex(int index) { Widget page; switch (OBHomePageTabs.values[index]) { @@ -349,17 +389,14 @@ class OBHomePageState extends State with WidgetsBindingObserver { throw 'No tab controller to pop'; } + bool canPopRootRoute = Navigator.of(context, rootNavigator: true).canPop(); bool canPopRoute = currentTabController.canPop(); bool preventCloseApp = false; - if (canPopRoute) { + if (canPopRoute && !canPopRootRoute) { currentTabController.pop(); // Stop default preventCloseApp = true; - } else if (currentTab != OBHomePageTabs.timeline) { -// print('Navigating to timeline'); -// _navigateToTab(OBHomePageTabs.timeline); -// preventCloseApp = true; } // Close the app diff --git a/lib/pages/home/modals/create_post/create_post.dart b/lib/pages/home/modals/create_post/create_post.dart index 64dffcdb6..da21b8548 100644 --- a/lib/pages/home/modals/create_post/create_post.dart +++ b/lib/pages/home/modals/create_post/create_post.dart @@ -28,8 +28,11 @@ import 'package:pigment/pigment.dart'; class CreatePostModal extends StatefulWidget { final Community community; + final String text; + final File image; - const CreatePostModal({Key key, this.community}) : super(key: key); + const CreatePostModal({Key key, this.community, this.text, this.image}) + : super(key: key); @override State createState() { @@ -45,9 +48,11 @@ class CreatePostModalState extends State { UserService _userService; TextEditingController _textController; + FocusNode _focusNode; int _charactersCount; bool _isPostTextAllowedLength; + bool _hasFocus; bool _hasImage; bool _hasVideo; File _postImage; @@ -64,17 +69,26 @@ class CreatePostModalState extends State { void initState() { super.initState(); _textController = TextEditingController(); + if (widget.text != null) { + _textController.text = widget.text; + } _textController.addListener(_onPostTextChanged); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusNodeChanged); + _hasFocus = false; _charactersCount = 0; _isPostTextAllowedLength = false; _hasImage = false; _hasVideo = false; - _postItemsWidgets = [OBCreatePostText(controller: _textController)]; + _postItemsWidgets = [OBCreatePostText(controller: _textController, focusNode: _focusNode)]; if (widget.community != null) _postItemsWidgets.add(OBPostCommunityPreviewer( community: widget.community, )); + if (widget.image != null) { + _setPostImage(widget.image); + } _isCreateCommunityPostInProgress = false; } @@ -82,6 +96,7 @@ class CreatePostModalState extends State { void dispose() { super.dispose(); _textController.removeListener(_onPostTextChanged); + _focusNode.removeListener(_onFocusNodeChanged); } @override @@ -208,8 +223,8 @@ class CreatePostModalState extends State { if (postActions.isEmpty) return const SizedBox(); return Container( - height: 51.0, - padding: EdgeInsets.only(top: 8.0, bottom: 8.0), + height: _hasFocus == true ? 51 : 67, + padding: EdgeInsets.only(top: 8.0, bottom: _hasFocus == true ? 8 : 24), color: Color.fromARGB(5, 0, 0, 0), child: ListView.separated( physics: const ClampingScrollPhysics(), @@ -263,6 +278,10 @@ class CreatePostModalState extends State { }); } + void _onFocusNodeChanged() { + _hasFocus = _focusNode.hasFocus; + } + void _setPostImage(File image) { setState(() { this._postImage = image; diff --git a/lib/pages/home/modals/create_post/widgets/create_post_text.dart b/lib/pages/home/modals/create_post/widgets/create_post_text.dart index ce652b69f..70ef4ca06 100644 --- a/lib/pages/home/modals/create_post/widgets/create_post_text.dart +++ b/lib/pages/home/modals/create_post/widgets/create_post_text.dart @@ -5,8 +5,10 @@ import 'package:flutter/material.dart'; class OBCreatePostText extends StatelessWidget { final TextEditingController controller; + final FocusNode focusNode; + String hintText; - OBCreatePostText({this.controller}); + OBCreatePostText({this.controller, this.focusNode, this.hintText}); @override Widget build(BuildContext context) { @@ -23,6 +25,7 @@ class OBCreatePostText extends StatelessWidget { return TextField( controller: controller, autofocus: true, + focusNode: focusNode, textCapitalization: TextCapitalization.sentences, keyboardType: TextInputType.multiline, maxLines: null, @@ -31,7 +34,7 @@ class OBCreatePostText extends StatelessWidget { fontSize: 18.0), decoration: InputDecoration( border: InputBorder.none, - hintText: 'What\'s going on?', + hintText: this.hintText != null ? this.hintText : 'What\'s going on?', hintStyle: TextStyle( color: themeValueParserService .parseColor(theme.secondaryTextColor), diff --git a/lib/pages/home/modals/edit_post/edit_post.dart b/lib/pages/home/modals/edit_post/edit_post.dart new file mode 100644 index 000000000..7fe63c219 --- /dev/null +++ b/lib/pages/home/modals/edit_post/edit_post.dart @@ -0,0 +1,245 @@ +import 'package:Openbook/models/post.dart'; +import 'package:Openbook/pages/home/modals/create_post/widgets/create_post_text.dart'; +import 'package:Openbook/pages/home/modals/create_post/widgets/post_community_previewer.dart'; +import 'package:Openbook/pages/home/modals/create_post/widgets/remaining_post_characters.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/httpie.dart'; +import 'package:Openbook/services/toast.dart'; +import 'package:Openbook/services/user.dart'; +import 'package:Openbook/services/validation.dart'; +import 'package:Openbook/widgets/avatars/logged_in_user_avatar.dart'; +import 'package:Openbook/widgets/avatars/avatar.dart'; +import 'package:Openbook/widgets/buttons/button.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_advanced_networkimage/provider.dart'; + +class EditPostModal extends StatefulWidget { + final Post post; + + const EditPostModal({Key key, this.post}) : super(key: key); + + @override + State createState() { + return EditPostModalState(); + } +} + +class EditPostModalState extends State { + ValidationService _validationService; + ToastService _toastService; + UserService _userService; + + TextEditingController _textController; + FocusNode _focusNode; + int _charactersCount; + + bool _isPostTextAllowedLength; + String _originalText; + + List _postItemsWidgets; + + bool _isSaveInProgress; + + @override + void initState() { + super.initState(); + _originalText = widget.post.hasText() ? widget.post.text : null; + _textController = TextEditingController(text: _originalText); + _textController.addListener(_onPostTextChanged); + _focusNode = FocusNode(); + _charactersCount = 0; + _isPostTextAllowedLength = false; + _postItemsWidgets = [ + OBCreatePostText(controller: _textController, focusNode: _focusNode) + ]; + + if (widget.post.hasCommunity()) + _postItemsWidgets.add(OBPostCommunityPreviewer( + community: widget.post.community, + )); + + if (widget.post.hasImage()) { + _setPostImage(widget.post.getImage()); + } + if (widget.post.hasVideo()) { + _setPostImage(widget.post.getVideo()); + } + _isSaveInProgress = false; + } + + @override + void dispose() { + super.dispose(); + _textController.removeListener(_onPostTextChanged); + } + + @override + Widget build(BuildContext context) { + var openbookProvider = OpenbookProvider.of(context); + _validationService = openbookProvider.validationService; + _userService = openbookProvider.userService; + _toastService = openbookProvider.toastService; + + return CupertinoPageScaffold( + backgroundColor: Colors.transparent, + navigationBar: _buildNavigationBar(), + child: OBPrimaryColorContainer( + child: Column( + children: [_buildEditPostContent()], + ))); + } + + Widget _buildNavigationBar() { + bool isPrimaryActionButtonIsEnabled = _isPostTextAllowedLength && + _charactersCount > 0 && + (_originalText != _textController.text); + + return OBThemedNavigationBar( + leading: GestureDetector( + child: const OBIcon(OBIcons.close), + onTap: () { + Navigator.pop(context); + }, + ), + title: 'Edit post', + trailing: + _buildPrimaryActionButton(isEnabled: isPrimaryActionButtonIsEnabled), + ); + } + + Widget _buildPrimaryActionButton({bool isEnabled}) { + return OBButton( + type: OBButtonType.primary, + child: Text('Save'), + size: OBButtonSize.small, + onPressed: _onWantsToSavePost, + isDisabled: !isEnabled, + isLoading: _isSaveInProgress); + } + + void _onWantsToSavePost() async { + _setSaveInProgress(true); + Post editedPost; + try { + editedPost = await _userService.editPost( + postUuid: widget.post.uuid, text: _textController.text); + Navigator.pop(context, editedPost); + } catch (error) { + _onError(error); + } finally { + _setSaveInProgress(false); + } + } + + Widget _buildEditPostContent() { + return Expanded( + child: Padding( + padding: EdgeInsets.only(left: 20.0, top: 20.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + OBLoggedInUserAvatar( + size: OBAvatarSize.medium, + ), + const SizedBox( + height: 12.0, + ), + OBRemainingPostCharacters( + maxCharacters: ValidationService.POST_MAX_LENGTH, + currentCharacters: _charactersCount, + ), + ], + ), + Expanded( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Padding( + padding: + EdgeInsets.only(left: 20.0, right: 20.0, bottom: 30.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _postItemsWidgets)), + ), + ) + ], + ), + )); + } + + void _onPostTextChanged() { + String text = _textController.text; + setState(() { + _charactersCount = text.length; + _isPostTextAllowedLength = + _validationService.isPostTextAllowedLength(text); + }); + } + + void _setPostImage(String imageUrl) { + setState(() { + var postImageWidget = ClipRRect( + borderRadius: BorderRadius.circular(10.0), + child: Image( + height: 200.0, + width: 200.0, + fit: BoxFit.cover, + image: AdvancedNetworkImage(imageUrl, + useDiskCache: true, + fallbackAssetImage: 'assets/images/fallbacks/post-fallback.png', + retryLimit: 0)), + ); + + _addPostItemWidget(postImageWidget); + }); + } + + void _onError(error) async { + if (error is HttpieConnectionRefusedError) { + _toastService.error( + message: error.toHumanReadableMessage(), context: context); + } else if (error is HttpieRequestError) { + String errorMessage = await error.toHumanReadableMessage(); + _toastService.error(message: errorMessage, context: context); + } else { + _toastService.error(message: 'Unknown error', context: context); + throw error; + } + } + + VoidCallback _addPostItemWidget(Widget postItemWidget) { + var widgetSpacing = const SizedBox( + height: 20.0, + ); + + List newPostItemsWidgets = List.from(_postItemsWidgets); + newPostItemsWidgets.insert(1, widgetSpacing); + newPostItemsWidgets.insert(1, postItemWidget); + + _setPostItemsWidgets(newPostItemsWidgets); + + return () { + List newPostItemsWidgets = List.from(_postItemsWidgets); + newPostItemsWidgets.remove(postItemWidget); + newPostItemsWidgets.remove(widgetSpacing); + _setPostItemsWidgets(newPostItemsWidgets); + }; + } + + void _setPostItemsWidgets(List postItemsWidgets) { + setState(() { + _postItemsWidgets = postItemsWidgets; + }); + } + + void _setSaveInProgress(bool saveInProgress) { + setState(() { + _isSaveInProgress = saveInProgress; + }); + } +} diff --git a/lib/pages/home/modals/edit_user_profile/edit_user_profile.dart b/lib/pages/home/modals/edit_user_profile/edit_user_profile.dart index c28fa8d40..2dda697d7 100644 --- a/lib/pages/home/modals/edit_user_profile/edit_user_profile.dart +++ b/lib/pages/home/modals/edit_user_profile/edit_user_profile.dart @@ -308,6 +308,8 @@ class OBEditUserProfileModalState extends State { } void _showImageBottomSheet({@required OBImageType imageType}) { + ToastService toastService = OpenbookProvider.of(context).toastService; + showModalBottomSheet( context: context, builder: (BuildContext context) { @@ -316,20 +318,30 @@ class OBEditUserProfileModalState extends State { leading: new Icon(Icons.camera_alt), title: new Text('Camera'), onTap: () async { - var image = await _imagePickerService.pickImage( - source: ImageSource.camera, imageType: imageType); - _onUserImageSelected(image: image, imageType: imageType); + try { + var image = await _imagePickerService.pickImage( + source: ImageSource.camera, imageType: imageType); + _onUserImageSelected(image: image, imageType: imageType); + //if (image != null) createAccountBloc.avatar.add(image); + } on ImageTooLargeException catch(e) { + int limit = e.getLimitInMB(); + toastService.error(message: 'Image too large (limit: $limit MB)', context: context); + } Navigator.pop(context); - //if (image != null) createAccountBloc.avatar.add(image); }, ), new ListTile( leading: new Icon(Icons.photo_library), title: new Text('Gallery'), onTap: () async { - var image = await _imagePickerService.pickImage( - source: ImageSource.gallery, imageType: imageType); - _onUserImageSelected(image: image, imageType: imageType); + try { + var image = await _imagePickerService.pickImage( + source: ImageSource.gallery, imageType: imageType); + _onUserImageSelected(image: image, imageType: imageType); + } on ImageTooLargeException catch(e) { + int limit = e.getLimitInMB(); + toastService.error(message: 'Image too large (limit: $limit MB)', context: context); + } Navigator.pop(context); }, ) diff --git a/lib/pages/home/modals/invite_to_community.dart b/lib/pages/home/modals/invite_to_community.dart index df99cff51..d13b1be4a 100644 --- a/lib/pages/home/modals/invite_to_community.dart +++ b/lib/pages/home/modals/invite_to_community.dart @@ -51,6 +51,7 @@ class OBInviteToCommunityModalState extends State { ), child: OBPrimaryColorContainer( child: OBHttpList( + key: Key('inviteToCommunityUserList'), listItemBuilder: _buildLinkedUserListItem, searchResultListItemBuilder: _buildLinkedUserListItem, listRefresher: _refreshLinkedUsers, diff --git a/lib/pages/home/modals/post_comment/post-commenter-expanded.dart b/lib/pages/home/modals/post_comment/post-commenter-expanded.dart new file mode 100644 index 000000000..95dceb9a2 --- /dev/null +++ b/lib/pages/home/modals/post_comment/post-commenter-expanded.dart @@ -0,0 +1,200 @@ +import 'package:Openbook/models/community.dart'; +import 'package:Openbook/models/post.dart'; +import 'package:Openbook/models/post_comment.dart'; +import 'package:Openbook/pages/home/modals/create_post/widgets/create_post_text.dart'; +import 'package:Openbook/pages/home/modals/create_post/widgets/remaining_post_characters.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/bottom_sheet.dart'; +import 'package:Openbook/services/httpie.dart'; +import 'package:Openbook/services/navigation_service.dart'; +import 'package:Openbook/services/toast.dart'; +import 'package:Openbook/services/user.dart'; +import 'package:Openbook/services/validation.dart'; +import 'package:Openbook/widgets/avatars/logged_in_user_avatar.dart'; +import 'package:Openbook/widgets/avatars/avatar.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + + +class OBPostCommenterExpandedModal extends StatefulWidget { + final Post post; + final PostComment postComment; + + const OBPostCommenterExpandedModal({Key key, this.post, this.postComment}) : super(key: key); + + @override + State createState() { + return OBPostCommenterExpandedModalState(); + } +} + +class OBPostCommenterExpandedModalState extends State { + ValidationService _validationService; + ToastService _toastService; + UserService _userService; + + TextEditingController _textController; + int _charactersCount; + bool _isPostCommentTextAllowedLength; + bool _isPostCommentTextOriginal; + List _postCommentItemsWidgets; + String _originalText; + + @override + void initState() { + super.initState(); + _textController = TextEditingController(text: widget.postComment != null ? widget.postComment.text: ''); + _textController.addListener(_onPostCommentTextChanged); + _charactersCount = 0; + _isPostCommentTextAllowedLength = false; + _isPostCommentTextOriginal = false; + _originalText = widget.postComment.text; + String hintText = widget.post.commentsCount > 0 ? 'Join the conversation..' : 'Start the conversation..'; + _postCommentItemsWidgets = [OBCreatePostText(controller: _textController, hintText: hintText)]; + + } + + @override + void dispose() { + super.dispose(); + _textController.removeListener(_onPostCommentTextChanged); + } + + @override + Widget build(BuildContext context) { + var openbookProvider = OpenbookProvider.of(context); + _validationService = openbookProvider.validationService; + _userService = openbookProvider.userService; + _toastService = openbookProvider.toastService; + + return CupertinoPageScaffold( + backgroundColor: Colors.transparent, + navigationBar: _buildNavigationBar(), + child: OBPrimaryColorContainer( + child: Column( + children: [_buildPostCommentContent()], + ))); + } + + Widget _buildNavigationBar() { + bool isPrimaryActionButtonIsEnabled = + (_isPostCommentTextAllowedLength && _charactersCount > 0 && !_isPostCommentTextOriginal); + + return OBThemedNavigationBar( + leading: GestureDetector( + child: const OBIcon(OBIcons.close), + onTap: () { + Navigator.pop(context); + }, + ), + title: 'Edit comment', + trailing: + _buildPrimaryActionButton(isEnabled: isPrimaryActionButtonIsEnabled), + ); + } + + Widget _buildPrimaryActionButton({bool isEnabled}) { + Widget primaryButton; + + if (isEnabled) { + primaryButton = GestureDetector( + onTap: _onWantsToSaveComment, + child: const OBText('Save'), + ); + } else { + primaryButton = Opacity( + opacity: 0.5, + child: const OBText('Save'), + ); + } + + return primaryButton; + } + + void _onWantsToSaveComment() async { + PostComment comment; + if (widget.postComment != null) { + comment = await _userService.editPostComment( + post: widget.post, + postComment: widget.postComment, + text: _textController.text); + } else { + comment = await _userService.commentPost( + post: widget.post, + text: _textController.text); + } + + if (comment != null) { + // Remove modal + Navigator.pop(context, comment); + } + } + + Widget _buildPostCommentContent() { + return Expanded( + child: Padding( + padding: EdgeInsets.only(left: 20.0, top: 20.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + OBLoggedInUserAvatar( + size: OBAvatarSize.medium, + ), + const SizedBox( + height: 12.0, + ), + OBRemainingPostCharacters( + maxCharacters: ValidationService.POST_COMMENT_MAX_LENGTH, + currentCharacters: _charactersCount, + ), + ], + ), + Expanded( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Padding( + padding: + EdgeInsets.only(left: 20.0, right: 20.0, bottom: 30.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _postCommentItemsWidgets)), + ), + ) + ], + ), + )); + } + + void _onPostCommentTextChanged() { + String text = _textController.text; + setState(() { + _charactersCount = text.length; + _isPostCommentTextAllowedLength = + _validationService.isPostCommentAllowedLength(text); + _isPostCommentTextOriginal = _originalText == _textController.text; + }); + } + + void _onError(error) async { + if (error is HttpieConnectionRefusedError) { + _toastService.error( + message: error.toHumanReadableMessage(), context: context); + } else if (error is HttpieRequestError) { + String errorMessage = await error.toHumanReadableMessage(); + _toastService.error(message: errorMessage, context: context); + } else { + _toastService.error(message: 'Unknown error', context: context); + throw error; + } + } + + void _unfocusTextField() { + FocusScope.of(context).requestFocus(new FocusNode()); + } +} diff --git a/lib/pages/home/modals/post_reactions/widgets/post_reaction_list.dart b/lib/pages/home/modals/post_reactions/widgets/post_reaction_list.dart index 4fa9e2658..4207da4a5 100644 --- a/lib/pages/home/modals/post_reactions/widgets/post_reaction_list.dart +++ b/lib/pages/home/modals/post_reactions/widgets/post_reaction_list.dart @@ -9,7 +9,7 @@ import 'package:Openbook/widgets/progress_indicator.dart'; import 'package:Openbook/widgets/tiles/post_reaction_tile.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:loadmore/loadmore.dart'; +import 'package:Openbook/widgets/load_more.dart'; class OBPostReactionList extends StatefulWidget { // The emoji to show reactions of diff --git a/lib/pages/home/modals/react_to_post/react_to_post.dart b/lib/pages/home/modals/react_to_post/react_to_post.dart index c417c8919..95ce25307 100644 --- a/lib/pages/home/modals/react_to_post/react_to_post.dart +++ b/lib/pages/home/modals/react_to_post/react_to_post.dart @@ -9,6 +9,7 @@ import 'package:Openbook/services/user.dart'; import 'package:Openbook/widgets/emoji_picker/emoji_picker.dart'; import 'package:Openbook/widgets/theming/primary_color_container.dart'; import 'package:Openbook/widgets/theming/text.dart'; +import 'package:async/async.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -30,6 +31,7 @@ class OBReactToPostBottomSheetState extends State { ToastService _toastService; bool _isReactToPostInProgress; + CancelableOperation _reactOperation; @override void initState() { @@ -37,6 +39,12 @@ class OBReactToPostBottomSheetState extends State { _isReactToPostInProgress = false; } + @override + void dispose() { + super.dispose(); + if (_reactOperation != null) _reactOperation.cancel(); + } + @override Widget build(BuildContext context) { var openbookProvider = OpenbookProvider.of(context); @@ -52,10 +60,16 @@ class OBReactToPostBottomSheetState extends State { children: [ SizedBox( height: screenHeight / 3, - child: OBEmojiPicker( - hasSearch: false, - isReactionsPicker: true, - onEmojiPicked: _onEmojiPicked, + child: IgnorePointer( + ignoring: _isReactToPostInProgress, + child: Opacity( + opacity: _isReactToPostInProgress ? 0.5 : 1, + child: OBEmojiPicker( + hasSearch: false, + isReactionsPicker: true, + onEmojiPicked: _reactToPost, + ), + ), ), ) ], @@ -63,24 +77,24 @@ class OBReactToPostBottomSheetState extends State { ); } - void _onEmojiPicked(Emoji pressedEmoji, EmojiGroup emojiGroup) { - _reactToPost(pressedEmoji, emojiGroup); - } - Future _reactToPost(Emoji emoji, EmojiGroup emojiGroup) async { if (_isReactToPostInProgress) return null; _setReactToPostInProgress(true); try { - PostReaction postReaction = await _userService.reactToPost( - post: widget.post, emoji: emoji, emojiGroup: emojiGroup); + _reactOperation = CancelableOperation.fromFuture(_userService.reactToPost( + post: widget.post, emoji: emoji, emojiGroup: emojiGroup)); + + PostReaction postReaction = await _reactOperation.value; widget.post.setReaction(postReaction); // Remove modal Navigator.pop(context); + _setReactToPostInProgress(false); } catch (error) { _onError(error); - } finally { _setReactToPostInProgress(false); + } finally { + _reactOperation = null; } } diff --git a/lib/pages/home/modals/save_community.dart b/lib/pages/home/modals/save_community.dart index 397e0b5c9..96811222b 100644 --- a/lib/pages/home/modals/save_community.dart +++ b/lib/pages/home/modals/save_community.dart @@ -615,7 +615,9 @@ class OBSaveCommunityModalState extends State { usersAdjective: _usersAdjectiveController.text, categories: _categories, invitesEnabled: _invitesEnabled, - color: _color); + color: _color, + avatar: _avatarFile, + cover: _coverFile); } Future _isNameTaken(String communityName) async { diff --git a/lib/pages/home/modals/user_invites/create_user_invite.dart b/lib/pages/home/modals/user_invites/create_user_invite.dart new file mode 100644 index 000000000..8f916dc58 --- /dev/null +++ b/lib/pages/home/modals/user_invites/create_user_invite.dart @@ -0,0 +1,208 @@ +import 'package:Openbook/models/user_invite.dart'; +import 'package:Openbook/services/navigation_service.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/httpie.dart'; +import 'package:Openbook/services/toast.dart'; +import 'package:Openbook/services/user.dart'; +import 'package:Openbook/services/validation.dart'; +import 'package:Openbook/widgets/buttons/button.dart'; +import 'package:Openbook/widgets/fields/text_form_field.dart'; +import 'package:Openbook/widgets/page_scaffold.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:async/async.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBCreateUserInviteModal extends StatefulWidget { + final UserInvite userInvite; + final bool autofocusNameTextField; + + OBCreateUserInviteModal( + {this.userInvite, this.autofocusNameTextField = false}); + + @override + OBCreateUserInviteModalState createState() { + return OBCreateUserInviteModalState(); + } +} + +class OBCreateUserInviteModalState + extends State { + + UserService _userService; + ToastService _toastService; + NavigationService _navigationService; + ValidationService _validationService; + + bool _requestInProgress; + bool _formWasSubmitted; + bool _formValid; + bool _hasExistingUserInvite; + + GlobalKey _formKey; + + TextEditingController _nicknameController; + + CancelableOperation _createUpdateOperation; + + @override + void initState() { + super.initState(); + _formValid = true; + _requestInProgress = false; + _formWasSubmitted = false; + _nicknameController = TextEditingController(); + _formKey = GlobalKey(); + _hasExistingUserInvite = widget.userInvite != null; + if (_hasExistingUserInvite) { + _nicknameController.text = widget.userInvite.nickname; + } + + _nicknameController.addListener(_updateFormValid); + } + + @override + void dispose() { + super.dispose(); + if (_createUpdateOperation != null) + _createUpdateOperation.cancel(); + } + + @override + Widget build(BuildContext context) { + var openbookProvider = OpenbookProvider.of(context); + _userService = openbookProvider.userService; + _toastService = openbookProvider.toastService; + _validationService = openbookProvider.validationService; + _navigationService = openbookProvider.navigationService; + + return OBCupertinoPageScaffold( + navigationBar: _buildNavigationBar(), + child: OBPrimaryColorContainer( + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.0), + child: Column( + children: [ + OBTextFormField( + textCapitalization: + TextCapitalization.sentences, + size: OBTextFormFieldSize.large, + autofocus: widget.autofocusNameTextField, + controller: _nicknameController, + decoration: InputDecoration( + labelText: 'Nickname', + hintText: 'e.g. Jane Doe'), + validator: (String userInviteNickname) { + if (!_formWasSubmitted) return null; + return _validationService.validateUserProfileName(userInviteNickname); + }), + ], + )), + ], + )), + ), + )); + } + + Widget _buildNavigationBar() { + return OBThemedNavigationBar( + leading: GestureDetector( + child: const OBIcon(OBIcons.close), + onTap: () { + Navigator.pop(context); + }, + ), + title: _hasExistingUserInvite ? 'Edit invite' : 'Create invite', + trailing: OBButton( + isDisabled: !_formValid, + isLoading: _requestInProgress, + size: OBButtonSize.small, + onPressed: _submitForm, + child: Text('Next'), + )); + } + + bool _validateForm() { + return _formKey.currentState.validate(); + } + + bool _updateFormValid() { + var formValid = _validateForm(); + _setFormValid(formValid); + return formValid; + } + + void _submitForm() async { + _formWasSubmitted = true; + + var formIsValid = _updateFormValid(); + if (!formIsValid) return; + _setRequestInProgress(true); + try { + + _createUpdateOperation = CancelableOperation.fromFuture(_hasExistingUserInvite + ? _userService.updateUserInvite(userInvite: widget.userInvite, + nickname: _nicknameController.text != widget.userInvite.nickname ? _nicknameController.text : null) + : _userService.createUserInvite( + nickname: _nicknameController.text)); + + UserInvite userInvite = await _createUpdateOperation.value; + if (!_hasExistingUserInvite) { + _navigateToShareInvite(userInvite); + } else { + Navigator.of(context).pop(userInvite); + } + } catch (error) { + _onError(error); + } finally { + _setRequestInProgress(false); + _createUpdateOperation = null; + } + } + + void _navigateToShareInvite(UserInvite userInvite) async { + UserInvite sharedInvite = await _navigationService.navigateToShareInvite( + context: context, + userInvite: userInvite); + Navigator.of(context).pop(userInvite); + if (sharedInvite != null) { + // Remove modal + Navigator.of(context).pop(sharedInvite); + } + } + + void _onError(error) async { + if (error is HttpieConnectionRefusedError) { + _toastService.error( + message: error.toHumanReadableMessage(), context: context); + } else if (error is HttpieRequestError) { + String errorMessage = await error.toHumanReadableMessage(); + _toastService.error(message: errorMessage, context: context); + } else { + _toastService.error(message: 'Unknown error', context: context); + throw error; + } + } + + void _setRequestInProgress(bool requestInProgress) { + setState(() { + _requestInProgress = requestInProgress; + }); + } + + void _setFormValid(bool formValid) { + setState(() { + _formValid = formValid; + }); + } + +} diff --git a/lib/pages/home/modals/user_invites/send_invite_email.dart b/lib/pages/home/modals/user_invites/send_invite_email.dart new file mode 100644 index 000000000..acb3e0f1d --- /dev/null +++ b/lib/pages/home/modals/user_invites/send_invite_email.dart @@ -0,0 +1,190 @@ +import 'package:Openbook/models/user_invite.dart'; +import 'package:Openbook/services/navigation_service.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/httpie.dart'; +import 'package:Openbook/services/toast.dart'; +import 'package:Openbook/services/user.dart'; +import 'package:Openbook/services/validation.dart'; +import 'package:Openbook/widgets/buttons/button.dart'; +import 'package:Openbook/widgets/fields/text_form_field.dart'; +import 'package:Openbook/widgets/page_scaffold.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:async/async.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBSendUserInviteEmailModal extends StatefulWidget { + final UserInvite userInvite; + final bool autofocusEmailTextField; + + OBSendUserInviteEmailModal( + {this.userInvite, this.autofocusEmailTextField = false}); + + @override + OBSendUserInviteEmailModalState createState() { + return OBSendUserInviteEmailModalState(); + } +} + +class OBSendUserInviteEmailModalState + extends State { + + UserService _userService; + ToastService _toastService; + ValidationService _validationService; + + CancelableOperation _emailOperation; + + bool _requestInProgress; + bool _formWasSubmitted; + bool _formValid; + + GlobalKey _formKey; + + TextEditingController _emailController; + + @override + void dispose() { + super.dispose(); + if (_emailOperation != null) _emailOperation.cancel(); + } + + @override + void initState() { + super.initState(); + _formValid = true; + _requestInProgress = false; + _formWasSubmitted = false; + _emailController = TextEditingController(); + _formKey = GlobalKey(); + if (widget.userInvite.email != null) { + _emailController.text = widget.userInvite.email; + } + + _emailController.addListener(_updateFormValid); + } + + @override + Widget build(BuildContext context) { + var openbookProvider = OpenbookProvider.of(context); + _userService = openbookProvider.userService; + _toastService = openbookProvider.toastService; + _validationService = openbookProvider.validationService; + + return OBCupertinoPageScaffold( + navigationBar: _buildNavigationBar(), + child: OBPrimaryColorContainer( + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.0), + child: Column( + children: [ + OBTextFormField( + textCapitalization: + TextCapitalization.sentences, + size: OBTextFormFieldSize.large, + autofocus: widget.autofocusEmailTextField, + controller: _emailController, + decoration: InputDecoration( + labelText: 'Email', + hintText: 'e.g. janedoe@email.com'), + validator: (String email) { + if (!_formWasSubmitted) return null; + return _validationService.validateUserEmail(email); + }), + ], + )), + ], + )), + ), + )); + } + + Widget _buildNavigationBar() { + return OBThemedNavigationBar( + leading: GestureDetector( + child: const OBIcon(OBIcons.close), + onTap: () { + Navigator.pop(context); + }, + ), + title: 'Email invite', + trailing: OBButton( + isDisabled: !_formValid, + isLoading: _requestInProgress, + size: OBButtonSize.small, + onPressed: _submitForm, + child: Text('Send'), + )); + } + + bool _validateForm() { + return _formKey.currentState.validate(); + } + + bool _updateFormValid() { + var formValid = _validateForm(); + _setFormValid(formValid); + return formValid; + } + + void _submitForm() async { + _formWasSubmitted = true; + + var formIsValid = _updateFormValid(); + if (!formIsValid) return; + _setRequestInProgress(true); + try { + _emailOperation = CancelableOperation.fromFuture( + _userService.sendUserInviteEmail( + widget.userInvite, _emailController.text) + ); + await _emailOperation.value; + _showUserInviteSent(); + Navigator.of(context).pop(widget.userInvite); + } catch (error) { + _onError(error); + } finally { + _setRequestInProgress(false); + _emailOperation = null; + } + } + + void _showUserInviteSent() { + _toastService.success(message: 'Invite email sent', context: context); + } + + void _onError(error) async { + if (error is HttpieConnectionRefusedError) { + _toastService.error( + message: error.toHumanReadableMessage(), context: context); + } else if (error is HttpieRequestError) { + String errorMessage = await error.toHumanReadableMessage(); + _toastService.error(message: errorMessage, context: context); + } else { + _toastService.error(message: 'Unknown error', context: context); + throw error; + } + } + + void _setRequestInProgress(bool requestInProgress) { + setState(() { + _requestInProgress = requestInProgress; + }); + } + + void _setFormValid(bool formValid) { + setState(() { + _formValid = formValid; + }); + } + +} diff --git a/lib/pages/home/modals/zoomable_photo.dart b/lib/pages/home/modals/zoomable_photo.dart index 9de22dfcb..6f557e87b 100644 --- a/lib/pages/home/modals/zoomable_photo.dart +++ b/lib/pages/home/modals/zoomable_photo.dart @@ -1,9 +1,11 @@ import 'package:Openbook/widgets/icon.dart'; import 'package:Openbook/widgets/page_scaffold.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_advanced_networkimage/provider.dart'; import 'package:photo_view/photo_view.dart'; import 'package:pigment/pigment.dart'; +import "dart:math" show pi; class OBZoomablePhotoModal extends StatefulWidget { final String imageUrl; @@ -16,37 +18,247 @@ class OBZoomablePhotoModal extends StatefulWidget { } } -class OBZoomablePhotoModalState extends State { - bool isDismissible; +class OBZoomablePhotoModalState extends State + with SingleTickerProviderStateMixin { + bool _isDismissible; + AnimationController _controller; + Animation _offset; + Animation _rotationAnimation; + double _rotationAngle; + double _rotationDirection; + double _posX; + double _posY; + double _velocityX; + double _velocityY; + PointerDownEvent startDragDetails; + PointerMoveEvent updateDragDetails; + static const VELOCITY_THRESHOLD = 10.0; + + // THRESHOLD_SECOND_POINTER_EVENT: + // max delta distance above which we classify that another pointer event has begin somewhere + // else on the screen, as dist between two drag pointer events will not be above + // this threshold (foreg. when scaling with two fingers, we ignore the other pointer to prevent a rebuild) + static const THRESHOLD_SECOND_POINTER_EVENT = 50.0; + + // rate at which X and Y will increase while exiting the screen + static const EXIT_RATE_MULTIPLIER = 50.0; + static const CLOCKWISE = 1.0; + static const ANTICLOCKWISE = -1.0; @override void initState() { super.initState(); - isDismissible = true; + _controller = + AnimationController(vsync: this, duration: Duration(milliseconds: 300)); + _rotationAngle = 0.0; + _rotationDirection = CLOCKWISE; + _posX = 0.0; + _velocityX = 0.0; + _velocityY = 0.0; + _posY = 0.0; + _isDismissible = true; } @override Widget build(BuildContext context) { - return OBCupertinoPageScaffold( - backgroundColor: Color.fromARGB(0, 0, 0, 0), - child: Stack( - children: [ - PhotoView( - backgroundDecoration: BoxDecoration(color: Colors.transparent), - key: Key(widget.imageUrl), - enableRotation: false, - scaleStateChangedCallback: _photoViewScaleStateChangedCallback, - imageProvider: AdvancedNetworkImage(widget.imageUrl, - retryLimit: 0, - useDiskCache: true, - fallbackAssetImage: - 'assets/images/fallbacks/post-fallback.png'), - maxScale: PhotoViewComputedScale.covered, - minScale: PhotoViewComputedScale.contained, - ), - _buildCloseButton() - ], - )); + return WillPopScope( + child: OBCupertinoPageScaffold( + backgroundColor: Colors.black26, + child: Stack( + overflow: Overflow.visible, + children: [ + Listener( + child: Stack( + overflow: Overflow.visible, + children: [_getPositionedZoomableImage()], + ), + onPointerDown: (PointerDownEvent details) { + if (startDragDetails == null) { + setState(() { + startDragDetails = details; + }); + } + }, + onPointerMove: (PointerMoveEvent updatedDetails) { + double deltaY = 0.0; + double deltaX = 0.0; + if (updateDragDetails == null && startDragDetails != null) { + deltaY = updatedDetails.position.dy - + startDragDetails.position.dy; + deltaX = updatedDetails.position.dx - + startDragDetails.position.dx; + } else if (updateDragDetails != null) { + deltaY = updatedDetails.position.dy - + updateDragDetails.position.dy; + deltaX = updatedDetails.position.dx - + updateDragDetails.position.dx; + } + _updateDragValues(deltaX, deltaY, updatedDetails); + }, + onPointerUp: (PointerUpEvent details) { + _checkIsDismissible(); + }, + ), + _buildCloseButton() + ], + )), + onWillPop: _dismissModalNoPop, + ); + } + + Widget _getPositionedZoomableImage() { + double screenWidth = MediaQuery.of(context).size.width; + double screenHeight = MediaQuery.of(context).size.height; + + return Positioned( + top: _posY != 0 ? _posY : 0, + left: _posX != 0 ? _posX : 0, + width: screenWidth, + height: screenHeight, + child: Transform.rotate( + angle: _rotationAngle, + child: PhotoView( + backgroundDecoration: BoxDecoration(color: Colors.transparent), + key: Key(widget.imageUrl), + enableRotation: false, + scaleStateChangedCallback: _photoViewScaleStateChangedCallback, + imageProvider: AdvancedNetworkImage(widget.imageUrl, + retryLimit: 0, + useDiskCache: true, + fallbackAssetImage: 'assets/images/fallbacks/post-fallback.png'), + maxScale: PhotoViewComputedScale.covered, + minScale: PhotoViewComputedScale.contained, + ), + ), + ); + } + + void _updateDragValues( + double deltaX, double deltaY, PointerMoveEvent updatedDetails) { + if (deltaX.abs() > THRESHOLD_SECOND_POINTER_EVENT || + deltaY.abs() > THRESHOLD_SECOND_POINTER_EVENT || + !_isDismissible) return; + setState(() { + _posX = _posX + deltaX; + _posY = _posY + deltaY; + updateDragDetails = updatedDetails; + }); + _updateRotationValues(); + // Last reading of velocity is low (below threshold) as the finger leaves the screen, + // which causes the dismiss to be cancelled, so we update it lazily. + _updateVelocityLazily(deltaX, deltaY); + } + + void _updateVelocityLazily(double deltaX, double deltaY) async { + Future.delayed(Duration(milliseconds: 0), () { + setState(() { + _velocityX = deltaX; + _velocityY = deltaY; + }); + }); + } + + void _setBackToOrginalPosition() { + setState(() { + _offset = + Tween(begin: Offset(_posX, _posY), end: Offset(0.0, 0.0)) + .chain(CurveTween(curve: Curves.easeInOutSine)) + .animate(_controller) + ..addListener(() { + _posX = _offset.value.dx; + _posY = _offset.value.dy; + setState(() {}); + }); + startDragDetails = null; + updateDragDetails = null; + }); + _rotationAnimation = Tween(begin: _rotationAngle, end: 0.0) + .chain(CurveTween(curve: Curves.easeInOutCubic)) + .animate(_controller) + ..addListener(() { + _rotationAngle = _rotationAnimation.value; + setState(() {}); + }); + _controller.reset(); + _controller.forward(); + } + + void _checkIsDismissible() { + if (_velocityX.abs() > VELOCITY_THRESHOLD || + _velocityY.abs() > VELOCITY_THRESHOLD) { + _setTweensWithVelocity(); + _dismissModal(); + } else { + _setBackToOrginalPosition(); + } + } + + void _updateRotationValues() { + if (startDragDetails == null) return; + double screenWidth = MediaQuery.of(context).size.width; + double screenHeight = MediaQuery.of(context).size.height; + double screenMid = screenWidth / 2; + double maxRotationAngle = pi / 2; + // Rotation increases proportional to distance from mid of screen + double rotationRatio = + (startDragDetails.position.dx - screenMid).abs() / screenMid; + + if (startDragDetails.position.dx < screenMid) { + maxRotationAngle = -pi / 2; + } + // Rotation increases proportional to drag in Y direction + double distanceRatio = _posY / screenHeight; + + double rotationDirection; + if ((maxRotationAngle < 0 && _velocityY < 0) || + (maxRotationAngle > 0 && _velocityY > 0)) { + rotationDirection = CLOCKWISE; + } else { + rotationDirection = ANTICLOCKWISE; + } + setState(() { + _rotationAngle = distanceRatio * rotationRatio * maxRotationAngle; + _rotationDirection = rotationDirection; + }); + } + + void _setTweensWithVelocity() { + setState(() { + _offset = Tween( + begin: Offset(_posX, _posY), + end: Offset(_velocityX * EXIT_RATE_MULTIPLIER, + _velocityY * EXIT_RATE_MULTIPLIER)) + .chain(CurveTween(curve: Curves.easeInOutSine)) + .animate(_controller) + ..addListener(() { + _posX = _offset.value.dx + _velocityX / 2; + _posY = _offset.value.dy + _velocityY / 2; + setState(() {}); + }); + + _rotationAnimation = Tween( + begin: _rotationAngle, end: 1.5 * pi * _rotationDirection) + .chain(CurveTween(curve: Curves.easeInOutCubic)) + .animate(_controller) + ..addListener(() { + _rotationAngle = _rotationAnimation.value; + setState(() {}); + }); + + startDragDetails = null; + updateDragDetails = null; + }); + _controller.reset(); + } + + Future _dismissModal() async { + await _controller.forward(); + Navigator.pop(context); + } + + Future _dismissModalNoPop() async { + _dismissModal(); + return false; } Widget _buildCloseButton() { @@ -54,51 +266,47 @@ class OBZoomablePhotoModalState extends State { bottom: 50, left: 0, right: 0, - child: AnimatedOpacity( - opacity: (isDismissible ? 1 : 0), - duration: Duration(milliseconds: 50), - child: SafeArea( - child: Column( - children: [ - GestureDetector( - onTapDown: (tap) { - Navigator.pop(context); - }, - child: Container( - padding: EdgeInsets.all(10), - decoration: BoxDecoration( - color: Pigment.fromString('#1d1d1d'), - borderRadius: BorderRadius.circular(50)), - child: const OBIcon( - OBIcons.close, - size: OBIconSize.large, - color: Colors.white, - ), + child: SafeArea( + child: Column( + children: [ + GestureDetector( + onTapDown: (tap) { + Navigator.pop(context); + }, + child: Container( + padding: EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(50)), + child: const OBIcon( + OBIcons.close, + size: OBIconSize.large, + color: Colors.white, ), - ) - ], - )), - ), + ), + ) + ], + )), ); } void _photoViewScaleStateChangedCallback(PhotoViewScaleState state) { switch (state) { case PhotoViewScaleState.initial: - setIsDismissible(true); + _setIsDismissible(true); break; default: - setIsDismissible(false); + _setIsDismissible(false); } } - void setIsDismissible(bool isDismissible) { + void _setIsDismissible(bool isDismissible) { setState(() { - this.isDismissible = isDismissible; + this._isDismissible = isDismissible; }); } void toggleIsDismissible() { - setIsDismissible(!isDismissible); + _setIsDismissible(!_isDismissible); } } diff --git a/lib/pages/home/pages/communities/widgets/my_communities/widgets/my_communities_group.dart b/lib/pages/home/pages/communities/widgets/my_communities/widgets/my_communities_group.dart index 723cde7cc..fa28969cd 100644 --- a/lib/pages/home/pages/communities/widgets/my_communities/widgets/my_communities_group.dart +++ b/lib/pages/home/pages/communities/widgets/my_communities/widgets/my_communities_group.dart @@ -8,6 +8,7 @@ import 'package:Openbook/widgets/http_list.dart'; import 'package:Openbook/widgets/icon.dart'; import 'package:Openbook/widgets/theming/secondary_text.dart'; import 'package:Openbook/widgets/theming/text.dart'; +import 'package:async/async.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -47,6 +48,7 @@ class OBMyCommunitiesGroupState extends State { NavigationService _navigationService; List _communityGroupList; bool _refreshInProgress; + CancelableOperation _refreshOperation; @override void initState() { @@ -113,6 +115,7 @@ class OBMyCommunitiesGroupState extends State { void dispose() { super.dispose(); if (widget.controller != null) widget.controller.detach(); + if (_refreshOperation != null) _refreshOperation.cancel(); } Widget _buildSeeAllButton() { @@ -153,14 +156,19 @@ class OBMyCommunitiesGroupState extends State { } Future _refreshJoinedCommunities() async { + if (_refreshOperation != null) _refreshOperation.cancel(); _setRefreshInProgress(true); try { - List groupCommunities = - await widget.communityGroupListRefresher(); + _refreshOperation = + CancelableOperation.fromFuture(widget.communityGroupListRefresher()); + + List groupCommunities = await _refreshOperation.value; + _setCommunityGroupList(groupCommunities); } catch (error) { _onError(error); } finally { + _refreshOperation = null; _setRefreshInProgress(false); } } diff --git a/lib/pages/home/pages/community/community.dart b/lib/pages/home/pages/community/community.dart index 539ce80a4..9e202d7cf 100644 --- a/lib/pages/home/pages/community/community.dart +++ b/lib/pages/home/pages/community/community.dart @@ -20,6 +20,7 @@ import 'package:Openbook/widgets/post/post.dart'; import 'package:Openbook/widgets/progress_indicator.dart'; import 'package:Openbook/widgets/theming/primary_color_container.dart'; import 'package:Openbook/widgets/theming/text.dart'; +import 'package:async/async.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_pagewise/flutter_pagewise.dart'; @@ -61,6 +62,9 @@ class OBCommunityPageState extends State // https://github.com/AbdulRahmanAlHamali/flutter_pagewise/issues/55 Post _createdPostToInsertOnNextRefresh; + CancelableOperation _loadMorePostsOperation; + CancelableOperation _refreshCommunityOperation; + @override void initState() { super.initState(); @@ -76,6 +80,13 @@ class OBCommunityPageState extends State _pageStorageKey = PageStorageKey(TabBar); } + @override + void dispose() { + super.dispose(); + if (_loadMorePostsOperation != null) _loadMorePostsOperation.cancel(); + if (_refreshCommunityOperation != null) _refreshCommunityOperation.cancel(); + } + @override Widget build(BuildContext context) { if (_needsBootstrap) { @@ -350,7 +361,10 @@ class OBCommunityPageState extends State } Future _refreshCommunity() async { - var community = await _userService.getCommunityWithName(_community.name); + if (_refreshCommunityOperation != null) _refreshCommunityOperation.cancel(); + _refreshCommunityOperation = CancelableOperation.fromFuture( + _userService.getCommunityWithName(_community.name)); + var community = await _refreshCommunityOperation.value; _setCommunity(community); } @@ -370,6 +384,8 @@ class OBCommunityPageState extends State return currentPosts; } + if (_loadMorePostsOperation != null) _loadMorePostsOperation.cancel(); + List morePosts = []; int lastPostId; @@ -383,12 +399,14 @@ class OBCommunityPageState extends State } try { - morePosts = (await _userService.getPostsForCommunity(_community, - maxId: lastPostId)) - .posts; + _loadMorePostsOperation = CancelableOperation.fromFuture( + _userService.getPostsForCommunity(_community, maxId: lastPostId)); + morePosts = (await _loadMorePostsOperation.value).posts; _addPosts(morePosts); } catch (error) { _onError(error); + } finally { + _loadMorePostsOperation = null; } return morePosts; diff --git a/lib/pages/home/pages/community/pages/community_members.dart b/lib/pages/home/pages/community/pages/community_members.dart new file mode 100644 index 000000000..d59581f2b --- /dev/null +++ b/lib/pages/home/pages/community/pages/community_members.dart @@ -0,0 +1,120 @@ +import 'dart:async'; + +import 'package:Openbook/models/community.dart'; +import 'package:Openbook/models/user.dart'; +import 'package:Openbook/models/users_list.dart'; +import 'package:Openbook/services/navigation_service.dart'; +import 'package:Openbook/widgets/buttons/actions/follow_button.dart'; +import 'package:Openbook/widgets/http_list.dart'; +import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Openbook/widgets/page_scaffold.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/user.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:Openbook/widgets/tiles/user_tile.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBCommunityMembersPage extends StatefulWidget { + final Community community; + + const OBCommunityMembersPage({Key key, @required this.community}) + : super(key: key); + + @override + State createState() { + return OBCommunityMembersPageState(); + } +} + +class OBCommunityMembersPageState extends State { + UserService _userService; + NavigationService _navigationService; + + OBHttpListController _httpListController; + bool _needsBootstrap; + + @override + void initState() { + super.initState(); + _httpListController = OBHttpListController(); + _needsBootstrap = true; + } + + @override + Widget build(BuildContext context) { + if (_needsBootstrap) { + var provider = OpenbookProvider.of(context); + _userService = provider.userService; + _navigationService = provider.navigationService; + _needsBootstrap = false; + } + + String title = widget.community.usersAdjective ?? 'Community Members'; + String singularName = widget.community.userAdjective ?? 'member'; + String pluralName = widget.community.usersAdjective ?? 'members'; + + return OBCupertinoPageScaffold( + navigationBar: OBThemedNavigationBar( + title: title, + ), + child: OBPrimaryColorContainer( + child: OBHttpList( + controller: _httpListController, + listItemBuilder: _buildCommunityMemberListItem, + searchResultListItemBuilder: _buildCommunityMemberListItem, + listRefresher: _refreshCommunityMembers, + listOnScrollLoader: _loadMoreCommunityMembers, + listSearcher: _searchCommunityMembers, + resourceSingularName: singularName.toLowerCase(), + resourcePluralName: pluralName.toLowerCase(), + ), + ), + ); + } + + Widget _buildCommunityMemberListItem(BuildContext context, User user) { + bool isLoggedInUser = _userService.isLoggedInUser(user); + + return OBUserTile(user, + onUserTilePressed: _onCommunityMemberListItemPressed, + trailing: isLoggedInUser + ? null + : OBFollowButton( + user, + size: OBButtonSize.small, + unfollowButtonType: OBButtonType.highlight, + )); + } + + void _onCommunityMemberListItemPressed(User communityMember) { + _navigationService.navigateToUserProfile( + user: communityMember, context: context); + } + + Future> _refreshCommunityMembers() async { + UsersList communityMembers = + await _userService.getMembersForCommunity(widget.community); + return communityMembers.users; + } + + Future> _loadMoreCommunityMembers( + List communityMembersList) async { + var lastCommunityMember = communityMembersList.last; + var lastCommunityMemberId = lastCommunityMember.id; + var moreCommunityMembers = (await _userService.getMembersForCommunity( + widget.community, + maxId: lastCommunityMemberId, + count: 20, + )) + .users; + return moreCommunityMembers; + } + + Future> _searchCommunityMembers(String query) async { + UsersList results = await _userService.searchCommunityMembers( + query: query, community: widget.community); + + return results.users; + } +} diff --git a/lib/pages/home/pages/community/widgets/community_card/widgets/community_details/widgets/community_members_count.dart b/lib/pages/home/pages/community/widgets/community_card/widgets/community_details/widgets/community_members_count.dart index 8387d28b4..ab673d0bb 100644 --- a/lib/pages/home/pages/community/widgets/community_card/widgets/community_details/widgets/community_members_count.dart +++ b/lib/pages/home/pages/community/widgets/community_card/widgets/community_details/widgets/community_members_count.dart @@ -23,6 +23,8 @@ class OBCommunityMembersCount extends StatelessWidget { var openbookProvider = OpenbookProvider.of(context); var themeService = openbookProvider.themeService; var themeValueParserService = openbookProvider.themeValueParserService; + var userService = openbookProvider.userService; + var navigationService = openbookProvider.navigationService; return StreamBuilder( stream: themeService.themeChange, @@ -30,29 +32,42 @@ class OBCommunityMembersCount extends StatelessWidget { builder: (BuildContext context, AsyncSnapshot snapshot) { var theme = snapshot.data; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: RichText( - text: TextSpan(children: [ - TextSpan( - text: count, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: themeValueParserService - .parseColor(theme.primaryTextColor))), - TextSpan(text: ' '), - TextSpan( - text: membersCount == 1 ? userAdjective : usersAdjective, - style: TextStyle( - fontSize: 16, - color: themeValueParserService - .parseColor(theme.secondaryTextColor))) - ])), - ), - ], + return GestureDetector( + onTap: () { + bool isPublicCommunity = community.isPublic(); + bool isLoggedInUserMember = + community.isMember(userService.getLoggedInUser()); + + if (isPublicCommunity || isLoggedInUserMember) { + navigationService.navigateToCommunityMembers( + community: community, context: context); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: RichText( + text: TextSpan(children: [ + TextSpan( + text: count, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: themeValueParserService + .parseColor(theme.primaryTextColor))), + TextSpan(text: ' '), + TextSpan( + text: + membersCount == 1 ? userAdjective : usersAdjective, + style: TextStyle( + fontSize: 16, + color: themeValueParserService + .parseColor(theme.secondaryTextColor))) + ])), + ), + ], + ), ); }); } diff --git a/lib/pages/home/pages/menu/menu.dart b/lib/pages/home/pages/menu/menu.dart index 3d58e6a3a..03cf74d7f 100644 --- a/lib/pages/home/pages/menu/menu.dart +++ b/lib/pages/home/pages/menu/menu.dart @@ -1,6 +1,5 @@ import 'package:Openbook/models/user.dart'; import 'package:Openbook/pages/home/lib/poppable_page_controller.dart'; -import 'package:Openbook/pages/home/pages/menu/widgets/curated_themes.dart'; import 'package:Openbook/widgets/icon.dart'; import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; import 'package:Openbook/provider.dart'; @@ -51,13 +50,41 @@ class OBMainMenuPage extends StatelessWidget { navigationService.navigateToFollowsLists(context: context); }, ), + ListTile( + leading: const OBIcon(OBIcons.connections), + title: const OBText('My invites'), + onTap: () { + navigationService.navigateToUserInvites(context: context); + }, + ), + ListTile( + leading: const OBIcon(OBIcons.followers), + title: const OBText('My followers'), + onTap: () { + navigationService.navigateToFollowersPage(context: context); + }, + ), + ListTile( + leading: const OBIcon(OBIcons.following), + title: const OBText('My following'), + onTap: () { + navigationService.navigateToFollowingPage(context: context); + }, + ), ListTile( leading: const OBIcon(OBIcons.settings), - title: OBText('Account'), + title: OBText('Settings'), onTap: () { navigationService.navigateToSettingsPage(context: context); }, ), + ListTile( + leading: const OBIcon(OBIcons.themes), + title: OBText('Themes'), + onTap: () { + navigationService.navigateToThemesPage(context: context); + }, + ), StreamBuilder( stream: userService.loggedInUserChange, initialData: userService.getLoggedInUser(), @@ -76,6 +103,14 @@ class OBMainMenuPage extends StatelessWidget { ); }, ), + ListTile( + leading: const OBIcon(OBIcons.link), + title: OBText('Useful links'), + onTap: () { + navigationService.navigateToUsefulLinksPage( + context: context); + }, + ), ListTile( leading: const OBIcon(OBIcons.logout), title: OBText(localizationService.trans('DRAWER.LOGOUT')), @@ -84,8 +119,7 @@ class OBMainMenuPage extends StatelessWidget { }, ) ], - )), - OBCuratedThemes() + )) ], ), ), diff --git a/lib/pages/home/pages/menu/pages/connections_circle/connections_circle.dart b/lib/pages/home/pages/menu/pages/connections_circle/connections_circle.dart index c3dcbf30d..2e93fbe0e 100644 --- a/lib/pages/home/pages/menu/pages/connections_circle/connections_circle.dart +++ b/lib/pages/home/pages/menu/pages/connections_circle/connections_circle.dart @@ -38,7 +38,7 @@ class OBConnectionsCirclePageState extends State { @override void initState() { super.initState(); - _isConnectionsCircle = false; + _isConnectionsCircle = true; _refreshIndicatorKey = GlobalKey(); _needsBootstrap = true; } diff --git a/lib/pages/home/pages/menu/pages/followers.dart b/lib/pages/home/pages/menu/pages/followers.dart new file mode 100644 index 000000000..c5120cfb6 --- /dev/null +++ b/lib/pages/home/pages/menu/pages/followers.dart @@ -0,0 +1,101 @@ +import 'dart:async'; + +import 'package:Openbook/models/user.dart'; +import 'package:Openbook/models/users_list.dart'; +import 'package:Openbook/services/navigation_service.dart'; +import 'package:Openbook/widgets/buttons/actions/follow_button.dart'; +import 'package:Openbook/widgets/http_list.dart'; +import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Openbook/widgets/page_scaffold.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/user.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:Openbook/widgets/tiles/user_tile.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBFollowersPage extends StatefulWidget { + @override + State createState() { + return OBFollowersPageState(); + } +} + +class OBFollowersPageState extends State { + UserService _userService; + NavigationService _navigationService; + + OBHttpListController _httpListController; + bool _needsBootstrap; + + @override + void initState() { + super.initState(); + _httpListController = OBHttpListController(); + _needsBootstrap = true; + } + + @override + Widget build(BuildContext context) { + if (_needsBootstrap) { + var provider = OpenbookProvider.of(context); + _userService = provider.userService; + _navigationService = provider.navigationService; + _needsBootstrap = false; + } + + return OBCupertinoPageScaffold( + navigationBar: OBThemedNavigationBar( + title: 'Followers', + ), + child: OBPrimaryColorContainer( + child: OBHttpList( + controller: _httpListController, + listItemBuilder: _buildFollowerListItem, + searchResultListItemBuilder: _buildFollowerListItem, + listRefresher: _refreshFollowers, + listOnScrollLoader: _loadMoreFollowers, + listSearcher: _searchFollowers, + resourceSingularName: 'follower', + resourcePluralName: 'followers', + ), + ), + ); + } + + Widget _buildFollowerListItem(BuildContext context, User user) { + return OBUserTile(user, + onUserTilePressed: _onFollowerListItemPressed, + trailing: OBFollowButton( + user, + size: OBButtonSize.small, + unfollowButtonType: OBButtonType.highlight, + )); + } + + void _onFollowerListItemPressed(User follower) { + _navigationService.navigateToUserProfile(user: follower, context: context); + } + + Future> _refreshFollowers() async { + UsersList followers = await _userService.getFollowers(); + return followers.users; + } + + Future> _loadMoreFollowers(List followersList) async { + var lastFollower = followersList.last; + var lastFollowerId = lastFollower.id; + var moreFollowers = (await _userService.getFollowers( + maxId: lastFollowerId, + count: 20, + )) + .users; + return moreFollowers; + } + + Future> _searchFollowers(String query) async { + UsersList results = await _userService.searchFollowers(query: query); + + return results.users; + } +} diff --git a/lib/pages/home/pages/menu/pages/following.dart b/lib/pages/home/pages/menu/pages/following.dart new file mode 100644 index 000000000..3d198004b --- /dev/null +++ b/lib/pages/home/pages/menu/pages/following.dart @@ -0,0 +1,101 @@ +import 'dart:async'; + +import 'package:Openbook/models/user.dart'; +import 'package:Openbook/models/users_list.dart'; +import 'package:Openbook/services/navigation_service.dart'; +import 'package:Openbook/widgets/buttons/actions/follow_button.dart'; +import 'package:Openbook/widgets/http_list.dart'; +import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Openbook/widgets/page_scaffold.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/user.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:Openbook/widgets/tiles/user_tile.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBFollowingPage extends StatefulWidget { + @override + State createState() { + return OBFollowingPageState(); + } +} + +class OBFollowingPageState extends State { + UserService _userService; + NavigationService _navigationService; + + OBHttpListController _httpListController; + bool _needsBootstrap; + + @override + void initState() { + super.initState(); + _httpListController = OBHttpListController(); + _needsBootstrap = true; + } + + @override + Widget build(BuildContext context) { + if (_needsBootstrap) { + var provider = OpenbookProvider.of(context); + _userService = provider.userService; + _navigationService = provider.navigationService; + _needsBootstrap = false; + } + + return OBCupertinoPageScaffold( + navigationBar: OBThemedNavigationBar( + title: 'Following', + ), + child: OBPrimaryColorContainer( + child: OBHttpList( + controller: _httpListController, + listItemBuilder: _buildFollowingListItem, + searchResultListItemBuilder: _buildFollowingListItem, + listRefresher: _refreshFollowing, + listOnScrollLoader: _loadMoreFollowing, + listSearcher: _searchFollowing, + resourceSingularName: 'following', + resourcePluralName: 'following', + ), + ), + ); + } + + Widget _buildFollowingListItem(BuildContext context, User user) { + return OBUserTile(user, + onUserTilePressed: _onFollowingListItemPressed, + trailing: OBFollowButton( + user, + size: OBButtonSize.small, + unfollowButtonType: OBButtonType.highlight, + )); + } + + void _onFollowingListItemPressed(User following) { + _navigationService.navigateToUserProfile(user: following, context: context); + } + + Future> _refreshFollowing() async { + UsersList following = await _userService.getFollowings(); + return following.users; + } + + Future> _loadMoreFollowing(List followingList) async { + var lastFollowing = followingList.last; + var lastFollowingId = lastFollowing.id; + var moreFollowing = (await _userService.getFollowings( + maxId: lastFollowingId, + count: 20, + )) + .users; + return moreFollowing; + } + + Future> _searchFollowing(String query) async { + UsersList results = await _userService.searchFollowings(query: query); + + return results.users; + } +} diff --git a/lib/pages/home/pages/menu/pages/settings/pages/account_settings/account_settings.dart b/lib/pages/home/pages/menu/pages/settings/pages/account_settings/account_settings.dart new file mode 100644 index 000000000..cc7d7d100 --- /dev/null +++ b/lib/pages/home/pages/menu/pages/settings/pages/account_settings/account_settings.dart @@ -0,0 +1,70 @@ +import 'package:Openbook/pages/home/pages/menu/pages/settings/pages/account_settings/modals/change-password/change_password.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/settings/pages/account_settings/modals/change_email/change_email.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBAccountSettingsPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + var openbookProvider = OpenbookProvider.of(context); + var localizationService = openbookProvider.localizationService; + var navigationService = openbookProvider.navigationService; + + return CupertinoPageScaffold( + backgroundColor: Color.fromARGB(0, 0, 0, 0), + navigationBar: OBThemedNavigationBar(title: 'Settings'), + child: OBPrimaryColorContainer( + child: ListView( + physics: const ClampingScrollPhysics(), + // Important: Remove any padding from the ListView. + padding: EdgeInsets.zero, + children: [ + ListTile( + leading: const OBIcon(OBIcons.email), + title: OBText(localizationService.trans('SETTINGS.CHANGE_EMAIL')), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) => Material( + child: OBChangeEmailModal(), + ))); + }, + ), + ListTile( + leading: const OBIcon(OBIcons.lock), + title: + OBText(localizationService.trans('SETTINGS.CHANGE_PASSWORD')), + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) => Material( + child: OBChangePasswordModal(), + ))); + }, + ), + ListTile( + leading: const OBIcon(OBIcons.notifications), + title: OBText('Notifications'), + onTap: () { + navigationService.navigateToNotificationsSettings( + context: context); + }, + ), + ListTile( + leading: const OBIcon(OBIcons.deleteCommunity), + title: OBText('Delete account'), + onTap: () { + navigationService.navigateToDeleteAccount(context: context); + }, + ) + ], + ), + ), + ); + } +} diff --git a/lib/pages/home/pages/menu/pages/settings/modals/change-password/change_password.dart b/lib/pages/home/pages/menu/pages/settings/pages/account_settings/modals/change-password/change_password.dart similarity index 100% rename from lib/pages/home/pages/menu/pages/settings/modals/change-password/change_password.dart rename to lib/pages/home/pages/menu/pages/settings/pages/account_settings/modals/change-password/change_password.dart diff --git a/lib/pages/home/pages/menu/pages/settings/modals/change_email/change_email.dart b/lib/pages/home/pages/menu/pages/settings/pages/account_settings/modals/change_email/change_email.dart similarity index 93% rename from lib/pages/home/pages/menu/pages/settings/modals/change_email/change_email.dart rename to lib/pages/home/pages/menu/pages/settings/pages/account_settings/modals/change_email/change_email.dart index 492a19575..3abd81325 100644 --- a/lib/pages/home/pages/menu/pages/settings/modals/change_email/change_email.dart +++ b/lib/pages/home/pages/menu/pages/settings/pages/account_settings/modals/change_email/change_email.dart @@ -10,6 +10,7 @@ import 'package:Openbook/widgets/icon.dart'; import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; import 'package:Openbook/widgets/page_scaffold.dart'; import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:async/async.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -33,6 +34,7 @@ class OBChangeEmailModalState extends State { bool _changedEmailTaken = false; bool _formValid = true; TextEditingController _emailController = TextEditingController(); + CancelableOperation _requestOperation; @override void initState() { @@ -44,6 +46,12 @@ class OBChangeEmailModalState extends State { _emailController.addListener(_updateFormValid); } + @override + void dispose() { + super.dispose(); + if (_requestOperation != null) _requestOperation.cancel(); + } + @override Widget build(BuildContext context) { var openbookProvider = OpenbookProvider.of(context); @@ -122,13 +130,9 @@ class OBChangeEmailModalState extends State { _setRequestInProgress(true); try { var email = _emailController.text; - var originalEmail = _userService.getLoggedInUser().getEmail(); - User user = await _userService.updateUserEmail(email); - if (user.getEmail() != email || originalEmail == user.getEmail()) { - _setChangedEmailTaken(true); - _validateForm(); - return; - } + _requestOperation = + CancelableOperation.fromFuture(_userService.updateUserEmail(email)); + await _requestOperation.value; _toastService.success( message: 'We\'ve sent a confirmation link to your new email address, click it to verify your new email', @@ -137,6 +141,7 @@ class OBChangeEmailModalState extends State { } catch (error) { _onError(error); } finally { + _requestOperation = null; _setRequestInProgress(false); } } diff --git a/lib/pages/home/pages/menu/pages/settings/pages/application_settings.dart b/lib/pages/home/pages/menu/pages/settings/pages/application_settings.dart new file mode 100644 index 000000000..c7b09cc07 --- /dev/null +++ b/lib/pages/home/pages/menu/pages/settings/pages/application_settings.dart @@ -0,0 +1,28 @@ +import 'package:Openbook/provider.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:Openbook/widgets/tiles/actions/clear_application_cache_tile.dart'; +import 'package:Openbook/widgets/tiles/actions/clear_application_preferences_tile.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBApplicationSettingsPage extends StatelessWidget { + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: Color.fromARGB(0, 0, 0, 0), + navigationBar: OBThemedNavigationBar(title: 'Application settings'), + child: OBPrimaryColorContainer( + child: ListView( + physics: const ClampingScrollPhysics(), + // Important: Remove any padding from the ListView. + padding: EdgeInsets.zero, + children: [ + OBClearApplicationCacheTile(), + OBClearApplicationPreferencesTile(), + ], + ), + ), + ); + } +} diff --git a/lib/pages/home/pages/menu/pages/settings/settings.dart b/lib/pages/home/pages/menu/pages/settings/settings.dart index 2f7db97a8..2c734b76e 100644 --- a/lib/pages/home/pages/menu/pages/settings/settings.dart +++ b/lib/pages/home/pages/menu/pages/settings/settings.dart @@ -1,5 +1,3 @@ -import 'package:Openbook/pages/home/pages/menu/pages/settings/modals/change-password/change_password.dart'; -import 'package:Openbook/pages/home/pages/menu/pages/settings/modals/change_email/change_email.dart'; import 'package:Openbook/provider.dart'; import 'package:Openbook/widgets/icon.dart'; import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; @@ -12,7 +10,6 @@ class OBSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { var openbookProvider = OpenbookProvider.of(context); - var localizationService = openbookProvider.localizationService; var navigationService = openbookProvider.navigationService; return CupertinoPageScaffold( @@ -25,43 +22,21 @@ class OBSettingsPage extends StatelessWidget { padding: EdgeInsets.zero, children: [ ListTile( - leading: const OBIcon(OBIcons.email), - title: OBText(localizationService.trans('SETTINGS.CHANGE_EMAIL')), + leading: const OBIcon(OBIcons.account), + title: OBText('Account settings'), onTap: () { - Navigator.of(context).push(MaterialPageRoute( - fullscreenDialog: true, - builder: (BuildContext context) => Material( - child: OBChangeEmailModal(), - ))); - }, - ), - ListTile( - leading: const OBIcon(OBIcons.lock), - title: - OBText(localizationService.trans('SETTINGS.CHANGE_PASSWORD')), - onTap: () { - Navigator.of(context).push(MaterialPageRoute( - fullscreenDialog: true, - builder: (BuildContext context) => Material( - child: OBChangePasswordModal(), - ))); + navigationService.navigateToAccountSettingsPage( + context: context); }, ), ListTile( - leading: const OBIcon(OBIcons.notifications), - title: OBText('Notifications'), + leading: const OBIcon(OBIcons.application), + title: OBText('Application settings'), onTap: () { - navigationService.navigateToNotificationsSettings( + navigationService.navigateToApplicationSettingsPage( context: context); }, ), - ListTile( - leading: const OBIcon(OBIcons.deleteCommunity), - title: OBText('Delete account'), - onTap: () { - navigationService.navigateToDeleteAccount(context: context); - }, - ) ], ), ), diff --git a/lib/pages/home/pages/menu/pages/themes/themes.dart b/lib/pages/home/pages/menu/pages/themes/themes.dart new file mode 100644 index 000000000..50f875570 --- /dev/null +++ b/lib/pages/home/pages/menu/pages/themes/themes.dart @@ -0,0 +1,37 @@ +import 'package:Openbook/models/user.dart'; +import 'package:Openbook/pages/home/lib/poppable_page_controller.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/themes/widgets/curated_themes.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:Openbook/widgets/theming/secondary_text.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBThemesPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: OBThemedNavigationBar( + title: 'Themes', + ), + child: OBPrimaryColorContainer( + child: Column( + children: [ + Expanded( + child: ListView( + physics: const ClampingScrollPhysics(), + // Important: Remove any padding from the ListView. + padding: EdgeInsets.zero, + children: [ + OBCuratedThemes() + ], + )), + ], + ), + ), + ); + } +} diff --git a/lib/pages/home/pages/menu/pages/themes/widgets/curated_themes.dart b/lib/pages/home/pages/menu/pages/themes/widgets/curated_themes.dart new file mode 100644 index 000000000..cfffd0656 --- /dev/null +++ b/lib/pages/home/pages/menu/pages/themes/widgets/curated_themes.dart @@ -0,0 +1,54 @@ +import 'package:Openbook/models/theme.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/themes/widgets/theme_preview.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:Openbook/services/theme.dart'; +import 'package:flutter/material.dart'; + +class OBCuratedThemes extends StatelessWidget { + ThemeService _themeService; + + @override + Widget build(BuildContext context) { + _themeService = OpenbookProvider.of(context).themeService; + + return Padding( + padding: EdgeInsets.only(left: 20.0, right: 20.0, bottom: 10.0), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + GridView.extent( + padding: EdgeInsets.symmetric(vertical: 20), + primary: false, + physics: const NeverScrollableScrollPhysics(), + maxCrossAxisExtent: OBThemePreview.maxWidth, + children: _buildThemePreviews(), + shrinkWrap: true, + ), + ], + ), + )); + } + + List _buildThemePreviews() { + var themes = _themeService.getCuratedThemes(); + var res = []; + + for (var theme in themes) { + var builder = Builder(builder: (buildContext) { + return OBThemePreview( + theme, + onThemePreviewPressed: (OBTheme theme) { + _themeService.setActiveTheme(theme); + }, + ); + }); + + res.add(builder); + } + + return res; + } +} diff --git a/lib/pages/home/pages/menu/widgets/theme_preview.dart b/lib/pages/home/pages/menu/pages/themes/widgets/theme_preview.dart similarity index 92% rename from lib/pages/home/pages/menu/widgets/theme_preview.dart rename to lib/pages/home/pages/menu/pages/themes/widgets/theme_preview.dart index 4bccc3313..32b6f15cf 100644 --- a/lib/pages/home/pages/menu/widgets/theme_preview.dart +++ b/lib/pages/home/pages/menu/pages/themes/widgets/theme_preview.dart @@ -4,6 +4,7 @@ import 'package:Openbook/widgets/theming/text.dart'; import 'package:flutter/material.dart'; class OBThemePreview extends StatelessWidget { + static const maxWidth = 120.0; final OBTheme theme; final OnThemePreviewPressed onThemePreviewPressed; @@ -32,7 +33,7 @@ class OBThemePreview extends StatelessWidget { } }, child: ConstrainedBox( - constraints: BoxConstraints(minWidth: 70), + constraints: BoxConstraints(minWidth: 70, maxWidth: maxWidth), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -56,6 +57,8 @@ class OBThemePreview extends StatelessWidget { OBText( theme.name, size: OBTextSize.small, + maxLines: 3, + textAlign: TextAlign.center, style: TextStyle(fontWeight: FontWeight.bold), ) ], diff --git a/lib/pages/home/pages/menu/pages/useful_links.dart b/lib/pages/home/pages/menu/pages/useful_links.dart new file mode 100644 index 000000000..6c179cdc0 --- /dev/null +++ b/lib/pages/home/pages/menu/pages/useful_links.dart @@ -0,0 +1,86 @@ +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:Openbook/widgets/theming/secondary_text.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBUsefulLinksPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + var openbookProvider = OpenbookProvider.of(context); + var urlLauncherService = openbookProvider.urlLauncherService; + + return CupertinoPageScaffold( + navigationBar: OBThemedNavigationBar( + title: 'Useful links', + ), + child: OBPrimaryColorContainer( + child: Column( + children: [ + Expanded( + child: ListView( + physics: const ClampingScrollPhysics(), + // Important: Remove any padding from the ListView. + padding: EdgeInsets.zero, + children: [ + ListTile( + leading: const OBIcon(OBIcons.dashboard ), + title: OBText('Github project board'), + subtitle: OBSecondaryText( + 'Take a look at what we\'re currently working on'), + onTap: () { + urlLauncherService.launchUrl( + 'https://github.com/orgs/OpenbookOrg/projects/3'); + }, + ), + ListTile( + leading: const OBIcon(OBIcons.featureRequest), + title: OBText('Feature requests'), + subtitle: OBSecondaryText( + 'Request a feature or upvote existing requests'), + onTap: () { + urlLauncherService.launchUrl( + 'https://openbook.canny.io/feature-requests'); + }, + ), + ListTile( + leading: const OBIcon(OBIcons.bug), + title: OBText('Bug tracker'), + subtitle: + OBSecondaryText('Report a bug or upvote existing bugs'), + onTap: () { + urlLauncherService.launchUrl( + 'https://openbook.canny.io/bugs'); + }, + ), + ListTile( + leading: const OBIcon(OBIcons.guide), + title: OBText('Community guide'), + subtitle: OBSecondaryText( + 'An introduction to the Openbook Experience by @meep'), + onTap: () { + urlLauncherService + .launchUrl('https://openbook.support/'); + }, + ), + ListTile( + leading: const OBIcon(OBIcons.slackChannel), + title: OBText('Community slack channel'), + subtitle: OBSecondaryText( + 'A place to discuss everything about Openbook'), + onTap: () { + urlLauncherService.launchUrl( + 'https://join.slack.com/t/openbookorg/shared_invite/enQtNDI2NjI3MDM0MzA2LTYwM2E1Y2NhYWRmNTMzZjFhYWZlYmM2YTQ0MWEwYjYyMzcxMGI0MTFhNTIwYjU2ZDI1YjllYzlhOWZjZDc4ZWY'); + }, + ), + ], + )), + ], + ), + ), + ); + } +} diff --git a/lib/pages/home/pages/menu/pages/user_invites/pages/user_invite_detail.dart b/lib/pages/home/pages/menu/pages/user_invites/pages/user_invite_detail.dart new file mode 100644 index 000000000..9dabd366c --- /dev/null +++ b/lib/pages/home/pages/menu/pages/user_invites/pages/user_invite_detail.dart @@ -0,0 +1,146 @@ +import 'package:Openbook/models/user_invite.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/user_invites/widgets/user_invite_detail_header.dart'; +import 'package:Openbook/services/modal_service.dart'; +import 'package:Openbook/services/user_invites_api.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Openbook/widgets/page_scaffold.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/toast.dart'; +import 'package:Openbook/services/user.dart'; +import 'package:Openbook/widgets/theming/primary_accent_text.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:Openbook/widgets/theming/secondary_text.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:Openbook/services/httpie.dart'; +import 'package:share/share.dart'; + +class OBUserInviteDetailPage extends StatefulWidget { + final UserInvite userInvite; + final bool showEdit; + + OBUserInviteDetailPage({ + @required this.userInvite, + this.showEdit = false + }); + + @override + State createState() { + return OBUserInviteDetailPageState(); + } +} + +class OBUserInviteDetailPageState extends State { + UserService _userService; + ToastService _toastService; + ModalService _modalService; + UserInvitesApiService _userInvitesApiService; + bool _needsBootstrap; + + @override + void initState() { + super.initState(); + _needsBootstrap = true; + } + + @override + Widget build(BuildContext context) { + var provider = OpenbookProvider.of(context); + _userService = provider.userService; + _toastService = provider.toastService; + _modalService = provider.modalService; + _userInvitesApiService = provider.userInvitesApiService; + + if (_needsBootstrap) { + _needsBootstrap = false; + } + + return OBCupertinoPageScaffold( + backgroundColor: Color.fromARGB(0, 0, 0, 0), + navigationBar: OBThemedNavigationBar( + title: 'Invite', + trailing: _buildNavigationBarTrailingItem(), + ), + child: OBPrimaryColorContainer( + child: SizedBox( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OBUserInviteDetailHeader(widget.userInvite), + Expanded( + child: ListView( + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.zero, + children: _buildActionsList()) + ), + ], + ), + ), + ), + ); + } + + List _buildActionsList() { + if (widget.userInvite.createdUser != null) { + return [ + const SizedBox() + ]; + } + + return [ + ListTile( + leading: const OBIcon(OBIcons.chat), + title: const OBText('Share invite yourself'), + subtitle: const OBSecondaryText( + 'Choose from messaging apps, etc.', + ), + onTap: () { + String apiURL = _userInvitesApiService.apiURL; + String token = widget.userInvite.token; + Share.share(UserInvite.getShareMessageForInviteWithToken(token, apiURL)); + }, + ), + ListTile( + leading: const OBIcon(OBIcons.email), + title: const OBText('Share invite by email'), + subtitle: const OBSecondaryText( + 'We will send an invitation email with instructions on your behalf', + ), + onTap: () async { + await _modalService.openSendUserInviteEmail( + context: context, + userInvite: widget.userInvite); + Navigator.of(context).pop(); + }, + ) + ]; + } + + + Widget _buildNavigationBarTrailingItem() { + if (!widget.showEdit) return const SizedBox(); + return GestureDetector( + onTap: () { + _modalService.openEditUserInvite( + userInvite: widget.userInvite, + context: context); + }, + child: OBPrimaryAccentText('Edit'), + ); + } + + void _onError(error) async { + if (error is HttpieConnectionRefusedError) { + _toastService.error( + message: error.toHumanReadableMessage(), context: context); + } else if (error is HttpieRequestError) { + String errorMessage = await error.toHumanReadableMessage(); + _toastService.error(message: errorMessage, context: context); + } else { + _toastService.error(message: 'Unknown error', context: context); + throw error; + } + } +} diff --git a/lib/pages/home/pages/menu/pages/user_invites/user_invites.dart b/lib/pages/home/pages/menu/pages/user_invites/user_invites.dart new file mode 100644 index 000000000..73361b36d --- /dev/null +++ b/lib/pages/home/pages/menu/pages/user_invites/user_invites.dart @@ -0,0 +1,269 @@ +import 'package:Openbook/models/user.dart'; +import 'package:Openbook/models/user_invite.dart'; +import 'package:Openbook/models/user_invites_list.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/user_invites/widgets/my_invite_group.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/user_invites/widgets/user_invite_count.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/user_invites/widgets/user_invite_tile.dart'; +import 'package:Openbook/services/modal_service.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/icon_button.dart'; +import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Openbook/widgets/page_scaffold.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/toast.dart'; +import 'package:Openbook/services/user.dart'; +import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:async/async.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:Openbook/services/httpie.dart'; + +class OBUserInvitesPage extends StatefulWidget { + @override + State createState() { + return OBUserInvitesPageState(); + } +} + +class OBUserInvitesPageState extends State { + UserService _userService; + ToastService _toastService; + ModalService _modalService; + + User _user; + GlobalKey _refreshIndicatorKey; + ScrollController _userInvitesScrollController; + List _userInvites = []; + List _userInvitesSearchResults = []; + OBMyInvitesGroupController _acceptedInvitesGroupController; + OBMyInvitesGroupController _pendingInvitesGroupController; + + CancelableOperation _refreshUserOperation; + + bool _needsBootstrap; + + @override + void initState() { + super.initState(); + _userInvitesScrollController = ScrollController(); + _refreshIndicatorKey = GlobalKey(); + _acceptedInvitesGroupController = OBMyInvitesGroupController(); + _pendingInvitesGroupController = OBMyInvitesGroupController(); + _needsBootstrap = true; + _userInvites = []; + } + + @override + void dispose() { + super.dispose(); + if (_refreshUserOperation != null) _refreshUserOperation.cancel(); + } + + @override + Widget build(BuildContext context) { + if (_needsBootstrap) { + var provider = OpenbookProvider.of(context); + _userService = provider.userService; + _toastService = provider.toastService; + _modalService = provider.modalService; + _user = _userService.getLoggedInUser(); + _bootstrap(); + _needsBootstrap = false; + } + + return OBCupertinoPageScaffold( + backgroundColor: Color.fromARGB(0, 0, 0, 0), + navigationBar: OBThemedNavigationBar( + title: 'My Invites', + trailing: Opacity( + opacity: _user.inviteCount == 0 ? 0.5 : 1.0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + OBUserInviteCount( + count: _user.inviteCount, + ), + const SizedBox( + width: 10, + ), + OBIconButton( + OBIcons.add, + themeColor: OBIconThemeColor.primaryAccent, + onPressed: _onWantsToCreateInvite, + ), + ], + ), + ), + ), + child: Stack( + children: [ + OBPrimaryColorContainer( + child: Column( + children: [ + Expanded( + child: RefreshIndicator( + key: _refreshIndicatorKey, + onRefresh: _refreshInvites, + child: ListView( + key: Key('myUserInvites'), + controller: _userInvitesScrollController, + // Need always scrollable for pull to refresh to work + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.all(0), + children: [ + Column( + children: [ + OBMyInvitesGroup( + key: Key('AcceptedInvitesGroup'), + controller: _acceptedInvitesGroupController, + title: 'Accepted', + groupName: 'accepted invites', + groupItemName: 'accepted invite', + maxGroupListPreviewItems: 5, + inviteListSearcher: _searchAcceptedUserInvites, + inviteGroupListItemDeleteCallback: _onUserInviteDeletedCallback, + inviteGroupListRefresher: _refreshAcceptedInvites, + inviteGroupListOnScrollLoader: _loadMoreAcceptedInvites, + ), + OBMyInvitesGroup( + key: Key('PendingInvitesGroup'), + controller: _pendingInvitesGroupController, + title: 'Pending', + groupName: 'pending invites', + groupItemName: 'pending invite', + maxGroupListPreviewItems: 5, + inviteListSearcher: _searchPendingUserInvites, + inviteGroupListItemDeleteCallback: _onUserInviteDeletedCallback, + inviteGroupListRefresher: _refreshPendingInvites, + inviteGroupListOnScrollLoader: _loadMorePendingInvites), + ], + ) + ]), + ), + ), + ], + )), + ], + )); + } + + Widget _onUserInviteDeletedCallback(BuildContext context, UserInvite userInvite) { + _refreshUser(); + } + + void _bootstrap() async { + await _refreshInvites(); + } + + Future _refreshInvites() async { + try { + await Future.wait([ + _refreshUser(), + _acceptedInvitesGroupController.refresh(), + _pendingInvitesGroupController.refresh() + ]); + _scrollToTop(); + } catch (error) { + _onError(error); + } + } + + Future _refreshUser() async { + if (_refreshUserOperation != null) _refreshUserOperation.cancel(); + _refreshUserOperation = CancelableOperation.fromFuture(_userService.refreshUser()); + await _refreshUserOperation.value; + User refreshedUser = _userService.getLoggedInUser(); + setState(() { + _user = refreshedUser; + }); + } + + Future> _refreshPendingInvites() async { + UserInvitesList pendingInvitesList = await _userService.getUserInvites(status: UserInviteFilterByStatus.pending); + return pendingInvitesList.invites; + } + + Future> _refreshAcceptedInvites() async { + UserInvitesList acceptedInvitesList = await _userService.getUserInvites(status: UserInviteFilterByStatus.accepted); + return acceptedInvitesList.invites; + } + + Future> _loadMorePendingInvites( + List currentInviteList) async { + int offset = currentInviteList.length; + + UserInvitesList morePendingInvites = await _userService.getUserInvites(offset: offset, status: UserInviteFilterByStatus.pending); + return morePendingInvites.invites; + } + + Future> _loadMoreAcceptedInvites( + List currentInviteList) async { + int offset = currentInviteList.length; + + UserInvitesList moreAcceptedInvites = await _userService.getUserInvites(offset: offset, status: UserInviteFilterByStatus.accepted); + return moreAcceptedInvites.invites; + } + + Future> _searchPendingUserInvites(String query) async { + UserInvitesList results = await _userService.searchUserInvites(query: query, status: UserInviteFilterByStatus.pending); + return results.invites; + } + + Future> _searchAcceptedUserInvites(String query) async { + UserInvitesList results = await _userService.searchUserInvites(query: query, status: UserInviteFilterByStatus.accepted); + return results.invites; + } + + void _onError(error) async { + if (error is HttpieConnectionRefusedError) { + _toastService.error( + message: error.toHumanReadableMessage(), context: context); + } else if (error is HttpieRequestError) { + String errorMessage = await error.toHumanReadableMessage(); + _toastService.error(message: errorMessage, context: context); + } else { + _toastService.error(message: 'Unknown error', context: context); + throw error; + } + } + + void _onWantsToCreateInvite() async { + if (_user.inviteCount == 0) { + _showNoInvitesLeft(); + return; + } + UserInvite createdUserInvite = + await _modalService.openCreateUserInvite(context: context); + if (createdUserInvite != null) { + _onUserInviteCreated(createdUserInvite); + } + } + + void _showNoInvitesLeft() { + _toastService.error(message: 'You have no invites left', context: context); + } + + void _removeUserInvite(UserInvite userInvite) { + setState(() { + _userInvites.remove(userInvite); + _userInvitesSearchResults.remove(userInvite); + }); + } + + void _onUserInviteCreated(UserInvite createdUserInvite) { + _refreshInvites(); + _scrollToTop(); + } + + void _scrollToTop() { + _userInvitesScrollController.animateTo( + 0.0, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 300), + ); + } +} + +typedef Future OnWantsToCreateUserInvite(); +typedef Future OnWantsToEditUserInvite(UserInvite userInvite); +typedef void OnWantsToSeeUserInvite(UserInvite userInvite); diff --git a/lib/pages/home/pages/menu/pages/user_invites/widgets/my_invite_group.dart b/lib/pages/home/pages/menu/pages/user_invites/widgets/my_invite_group.dart new file mode 100644 index 000000000..44a76bdac --- /dev/null +++ b/lib/pages/home/pages/menu/pages/user_invites/widgets/my_invite_group.dart @@ -0,0 +1,268 @@ +import 'package:Openbook/libs/str_utils.dart'; +import 'package:Openbook/models/user_invite.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/user_invites/widgets/user_invite_tile.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/httpie.dart'; +import 'package:Openbook/services/navigation_service.dart'; +import 'package:Openbook/services/toast.dart'; +import 'package:Openbook/widgets/http_list.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/theming/secondary_text.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:async/async.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBMyInvitesGroup extends StatefulWidget { + final OBHttpListRefresher inviteGroupListRefresher; + final OBHttpListSearcher inviteListSearcher; + final void Function(BuildContext, UserInvite) inviteGroupListItemDeleteCallback; + final OBHttpListOnScrollLoader inviteGroupListOnScrollLoader; + final OBMyInvitesGroupFallbackBuilder noGroupItemsFallbackBuilder; + final OBMyInvitesGroupController controller; + final String groupItemName; + final String groupName; + final int maxGroupListPreviewItems; + final String title; + + const OBMyInvitesGroup({ + Key key, + @required this.inviteGroupListRefresher, + @required this.inviteGroupListOnScrollLoader, + @required this.groupItemName, + @required this.inviteListSearcher, + @required this.groupName, + @required this.title, + @required this.maxGroupListPreviewItems, + @required this.inviteGroupListItemDeleteCallback, + this.noGroupItemsFallbackBuilder, + this.controller, + }) : super(key: key); + + @override + OBMyInvitesGroupState createState() { + return OBMyInvitesGroupState(); + } +} + +class OBMyInvitesGroupState extends State { + bool _needsBootstrap; + ToastService _toastService; + NavigationService _navigationService; + List _inviteGroupList; + bool _refreshInProgress; + CancelableOperation _refreshOperation; + + @override + void initState() { + super.initState(); + if (widget.controller != null) widget.controller.attach(this); + _needsBootstrap = true; + _inviteGroupList = []; + _refreshInProgress = false; + } + + @override + Widget build(BuildContext context) { + if (_needsBootstrap) { + var openbookProvider = OpenbookProvider.of(context); + _toastService = openbookProvider.toastService; + _navigationService = openbookProvider.navigationService; + _bootstrap(); + _needsBootstrap = false; + } + + int listItemCount = + _inviteGroupList.length < widget.maxGroupListPreviewItems + ? _inviteGroupList.length + : widget.maxGroupListPreviewItems; + + if (listItemCount == 0) { + if (widget.noGroupItemsFallbackBuilder != null && !_refreshInProgress) { + return widget.noGroupItemsFallbackBuilder( + context, _refreshInvites); + } + return const SizedBox(); + } + + List columnItems = [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: OBText( + widget.title, + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 24), + ), + ), + ListView.separated( + key: Key(widget.groupName + 'invitesGroup'), + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: _buildInviteSeparator, + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 0), + shrinkWrap: true, + itemCount: listItemCount, + itemBuilder: _buildGroupListPreviewItem), + ]; + + if (_inviteGroupList.length > widget.maxGroupListPreviewItems) { + columnItems.add(_buildSeeAllButton()); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: columnItems, + ); + } + + @override + void dispose() { + super.dispose(); + if (widget.controller != null) widget.controller.detach(); + if (_refreshOperation != null) _refreshOperation.cancel(); + } + + Widget _buildSeeAllButton() { + return GestureDetector( + onTap: _onWantsToSeeAll, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OBSecondaryText( + 'See all ' + widget.groupName, + style: TextStyle(fontSize: 16), + ), + const SizedBox( + width: 5, + ), + OBIcon(OBIcons.seeMore, themeColor: OBIconThemeColor.secondaryText) + ], + ), + ), + ); + } + + Widget _buildGroupListPreviewItem(BuildContext context, index) { + UserInvite userInvite = _inviteGroupList[index]; + return _buildInviteTile(context, userInvite); + } + + Widget _buildInviteTile(BuildContext context, UserInvite userInvite) { + var onUserInviteDeletedCallback = () { + _removeUserInvite(userInvite); + widget.inviteGroupListItemDeleteCallback(context, userInvite); + }; + + return OBUserInviteTile( + userInvite: userInvite, + onUserInviteDeletedCallback: onUserInviteDeletedCallback, + ); + } + + Widget _buildInviteSeparator(BuildContext context, int index) { + return const SizedBox(); + } + + void _bootstrap() { + _refreshInvites(); + } + + void _removeUserInvite(UserInvite userInvite) { + setState(() { + _inviteGroupList.remove(userInvite); + }); + } + + Future _refreshInvites() async { + if (_refreshOperation != null) _refreshOperation.cancel(); + _setRefreshInProgress(true); + try { + _refreshOperation = + CancelableOperation.fromFuture(widget.inviteGroupListRefresher()); + + List groupInvites = await _refreshOperation.value; + + _setUserInviteGroupList(groupInvites); + } catch (error) { + _onError(error); + } finally { + _refreshOperation = null; + _setRefreshInProgress(false); + } + } + + void _onError(error) async { + if (error is HttpieConnectionRefusedError) { + _toastService.error( + message: error.toHumanReadableMessage(), context: context); + } else if (error is HttpieRequestError) { + String errorMessage = await error.toHumanReadableMessage(); + _toastService.error(message: errorMessage, context: context); + } else { + _toastService.error(message: 'Unknown error', context: context); + throw error; + } + } + + void _onWantsToSeeAll() { + _navigationService.navigateToBlankPageWithWidget( + context: context, + key: Key('obMyUserInvitesGroup' + widget.groupItemName), + navBarTitle: capitalize(widget.groupName), + widget: _buildSeeAllGroupItemsPage()); + } + + Widget _buildSeeAllGroupItemsPage() { + return Column( + children: [ + Expanded( + child: OBHttpList( + separatorBuilder: _buildInviteSeparator, + listSearcher: widget.inviteListSearcher, + searchResultListItemBuilder: _buildInviteTile, + listItemBuilder: _buildInviteTile, + listRefresher: widget.inviteGroupListRefresher, + listOnScrollLoader: widget.inviteGroupListOnScrollLoader, + resourcePluralName: widget.groupName, + resourceSingularName: widget.groupItemName + ), + ), + ], + ); + } + + void _setUserInviteGroupList(List invites) { + setState(() { + _inviteGroupList = invites; + }); + } + + void _setRefreshInProgress(bool refreshInProgress) { + setState(() { + _refreshInProgress = refreshInProgress; + }); + } +} + +class OBMyInvitesGroupController { + OBMyInvitesGroupState _state; + + void attach(OBMyInvitesGroupState state) { + this._state = state; + } + + void detach() { + this._state = null; + } + + Future refresh() { + if (_state == null) return Future.value(); + return _state._refreshInvites(); + } +} + +typedef Future OBMyInvitesGroupRetry(); + +typedef Widget OBMyInvitesGroupFallbackBuilder( + BuildContext context, OBMyInvitesGroupRetry retry); diff --git a/lib/pages/home/pages/menu/pages/user_invites/widgets/user_invite_count.dart b/lib/pages/home/pages/menu/pages/user_invites/widgets/user_invite_count.dart new file mode 100644 index 000000000..dc40270d2 --- /dev/null +++ b/lib/pages/home/pages/menu/pages/user_invites/widgets/user_invite_count.dart @@ -0,0 +1,53 @@ +import 'package:Openbook/models/theme.dart'; +import 'package:Openbook/provider.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBUserInviteCount extends StatelessWidget { + final int count; + final double size; + + const OBUserInviteCount({Key key, this.count, this.size=19}) : super(key: key); + + @override + Widget build(BuildContext context) { + var openbookProvider = OpenbookProvider.of(context); + var toastService = openbookProvider.toastService; + var themeService = openbookProvider.themeService; + var themeValueParserService = openbookProvider.themeValueParserService; + + return StreamBuilder( + stream: themeService.themeChange, + initialData: themeService.getActiveTheme(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + var theme = snapshot.data; + var primaryAccentColor = + themeValueParserService.parseGradient(theme.primaryAccentColor); + return GestureDetector( + onTap: () { + if (count != 1) { + toastService.info(message: 'You have $count invites left', context: context); + } else { + toastService.info(message: 'You have $count invite left', context: context); + } + }, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + gradient: primaryAccentColor, + borderRadius: BorderRadius.circular(50)), + child: Center( + child: count != null ? Text( + count.toString(), + style: TextStyle( + color: Colors.white, + fontSize: count < 10 ? 12 : 10, + fontWeight: FontWeight.bold), + ) : const SizedBox() + ), + ), + ); + }); + } +} diff --git a/lib/pages/home/pages/menu/pages/user_invites/widgets/user_invite_detail_header.dart b/lib/pages/home/pages/menu/pages/user_invites/widgets/user_invite_detail_header.dart new file mode 100644 index 000000000..fca31c30b --- /dev/null +++ b/lib/pages/home/pages/menu/pages/user_invites/widgets/user_invite_detail_header.dart @@ -0,0 +1,66 @@ +import 'package:Openbook/models/user_invite.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/user_invites/widgets/user_invite_nickname.dart'; +import 'package:Openbook/widgets/theming/actionable_smart_text.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:flutter/material.dart'; + +class OBUserInviteDetailHeader extends StatelessWidget { + final UserInvite userInvite; + + OBUserInviteDetailHeader(this.userInvite); + + @override + Widget build(BuildContext context) { + + return StreamBuilder( + stream: this.userInvite.updateSubject, + initialData: this.userInvite, + builder: (BuildContext context, AsyncSnapshot snapshot) { + var userInvite = snapshot.data; + + List columnItems = [_buildUserInviteNickname(userInvite)]; + + columnItems.add(_buildUserDescription(userInvite)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: columnItems, + ); + }); + } + + Widget _buildUserInviteNickname(UserInvite userInvite) { + + return Padding( + padding: EdgeInsets.only(left: 20.0, right: 20, top: 20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: OBUserInviteNickname(userInvite), + ), + ], + ), + ); + } + + Widget _buildUserDescription(UserInvite userInvite) { + Widget _description; + if (userInvite.createdUser != null) { + _description = OBActionableSmartText(text: 'Joined with username @${userInvite.createdUser.username}'); + } else if (userInvite.isInviteEmailSent) { + _description = OBText('Pending, invite email sent to ${userInvite.email}'); + } else { + _description = OBText('Pending'); + } + + return Padding( + padding: EdgeInsets.only(left: 20.0, right: 20, top: 10.0, bottom: 20), + child: Column( + children: [ + _description, + ], + ), + ); + } +} diff --git a/lib/pages/home/pages/menu/pages/user_invites/widgets/user_invite_nickname.dart b/lib/pages/home/pages/menu/pages/user_invites/widgets/user_invite_nickname.dart new file mode 100644 index 000000000..5b0779d83 --- /dev/null +++ b/lib/pages/home/pages/menu/pages/user_invites/widgets/user_invite_nickname.dart @@ -0,0 +1,44 @@ +import 'package:Openbook/models/user_invite.dart'; +import 'package:Openbook/widgets/theming/primary_accent_text.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:Openbook/widgets/theming/secondary_text.dart'; +import 'package:flutter/material.dart'; + +class OBUserInviteNickname extends StatelessWidget { + final UserInvite userInvite; + + OBUserInviteNickname(this.userInvite); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: userInvite.updateSubject, + initialData: userInvite, + builder: (BuildContext context, AsyncSnapshot snapshot) { + var userInvite = snapshot.data; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OBSecondaryText( + 'Nickname', + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: OBPrimaryAccentText( + userInvite.nickname, + size: OBTextSize.extraLarge, + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ) + ], + ) + ], + ); + }); + } +} diff --git a/lib/pages/home/pages/menu/pages/user_invites/widgets/user_invite_tile.dart b/lib/pages/home/pages/menu/pages/user_invites/widgets/user_invite_tile.dart new file mode 100644 index 000000000..1bfabb01f --- /dev/null +++ b/lib/pages/home/pages/menu/pages/user_invites/widgets/user_invite_tile.dart @@ -0,0 +1,151 @@ +import 'package:Openbook/models/user_invite.dart'; +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/theme.dart'; +import 'package:Openbook/services/toast.dart'; +import 'package:Openbook/services/user.dart'; +import 'package:Openbook/widgets/emoji_picker/widgets/emoji_groups/widgets/emoji_group/widgets/emoji.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/theming/actionable_smart_text.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:Openbook/widgets/theming/secondary_text.dart'; +import 'package:async/async.dart'; +import 'package:flutter/material.dart'; +import 'package:Openbook/services/httpie.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +class OBUserInviteTile extends StatefulWidget { + final UserInvite userInvite; + final VoidCallback onUserInviteDeletedCallback; + + OBUserInviteTile( + {@required this.userInvite, Key key, this.onUserInviteDeletedCallback}) + : super(key: key); + + @override + State createState() { + return OBUserInviteTileState(); + } +} + +class OBUserInviteTileState extends State { + bool _requestInProgress; + UserService _userService; + ToastService _toastService; + + CancelableOperation _deleteOperation; + + @override + void initState() { + super.initState(); + _requestInProgress = false; + } + + @override + void dispose() { + super.dispose(); + if (_deleteOperation != null) + _deleteOperation.cancel(); + } + + @override + Widget build(BuildContext context) { + var provider = OpenbookProvider.of(context); + _userService = provider.userService; + _toastService = provider.toastService; + var navigationService = provider.navigationService; + Widget tile; + + if (widget.userInvite.createdUser != null) { + tile = ListTile( + onTap: () { + navigationService.navigateToUserProfile( + user: widget.userInvite.createdUser, + context: context); + }, + leading: const OBIcon(OBIcons.invite), + title: OBText( + widget.userInvite.nickname, + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: _buildActionableSecondaryText() + ); + } else { + tile = Slidable( + delegate: new SlidableDrawerDelegate(), + actionExtentRatio: 0.25, + child: ListTile( + onTap: () { + navigationService.navigateToInviteDetailPage( + userInvite: widget.userInvite, + context: context + ); + }, + leading: const OBIcon(OBIcons.invite), + title: OBText( + widget.userInvite.nickname, + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: _buildActionableSecondaryText()), + secondaryActions: [ + new IconSlideAction( + caption: 'Delete', + color: Colors.red, + icon: Icons.delete, + onTap: _deleteUserInvite), + ], + ); + } + + if (_requestInProgress) { + tile = Opacity(opacity: 0.5, child: tile); + } + return tile; + } + + Widget _buildActionableSecondaryText() { + if (widget.userInvite.createdUser != null) { + return OBActionableSmartText( + size: OBTextSize.mediumSecondary, + text: 'Joined with username @${widget.userInvite.createdUser.username}', + ); + } else { + return OBSecondaryText('Pending'); + } + } + + void _deleteUserInvite() async { + _setRequestInProgress(true); + try { + _deleteOperation = CancelableOperation.fromFuture(_userService.deleteUserInvite(widget.userInvite)); + await _deleteOperation.value; + _setRequestInProgress(false); + if (widget.onUserInviteDeletedCallback != null) { + widget.onUserInviteDeletedCallback(); + } + } catch (error) { + _onError(error); + } finally { + _setRequestInProgress(false); + _deleteOperation = null; + } + } + + void _onError(error) async { + if (error is HttpieConnectionRefusedError) { + _toastService.error( + message: error.toHumanReadableMessage(), context: context); + } else if (error is HttpieRequestError) { + String errorMessage = await error.toHumanReadableMessage(); + _toastService.error(message: errorMessage, context: context); + } else { + _toastService.error(message: 'Unknown error', context: context); + throw error; + } + } + + void _setRequestInProgress(bool requestInProgress) { + setState(() { + _requestInProgress = requestInProgress; + }); + } +} diff --git a/lib/pages/home/pages/menu/widgets/curated_themes.dart b/lib/pages/home/pages/menu/widgets/curated_themes.dart deleted file mode 100644 index b41b5b5a7..000000000 --- a/lib/pages/home/pages/menu/widgets/curated_themes.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:Openbook/models/theme.dart'; -import 'package:Openbook/pages/home/pages/menu/widgets/theme_preview.dart'; -import 'package:Openbook/provider.dart'; -import 'package:Openbook/widgets/theming/text.dart'; -import 'package:flutter/material.dart'; - -class OBCuratedThemes extends StatelessWidget { - @override - Widget build(BuildContext context) { - // TODO: implement build - - var themeService = OpenbookProvider.of(context).themeService; - var themes = themeService.getCuratedThemes(); - - return Padding( - padding: EdgeInsets.only(left: 20.0, right: 20.0, bottom: 10.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: EdgeInsets.only(bottom: 15.0), - child: const OBText( - 'Curated themes', - style: TextStyle(fontWeight: FontWeight.bold), - size: OBTextSize.large, - ), - ), - SizedBox( - height: 70, - child: ListView.separated( - physics: const ClampingScrollPhysics(), - scrollDirection: Axis.horizontal, - itemCount: themes.length, - itemBuilder: (BuildContext context, int index) { - return OBThemePreview( - themes[index], - onThemePreviewPressed: (OBTheme theme) { - themeService.setActiveTheme(theme); - }, - ); - }, - separatorBuilder: (BuildContext context, int index) { - return const SizedBox( - width: 20, - ); - }, - ), - ) - ], - ), - ); - } -} diff --git a/lib/pages/home/pages/post_comments/post.dart b/lib/pages/home/pages/post_comments/post.dart index 3c5e0304f..4f2d6feb0 100644 --- a/lib/pages/home/pages/post_comments/post.dart +++ b/lib/pages/home/pages/post_comments/post.dart @@ -5,7 +5,6 @@ import 'package:Openbook/pages/home/pages/post_comments/widgets/post_comment/pos import 'package:Openbook/services/theme.dart'; import 'package:Openbook/services/theme_value_parser.dart'; import 'package:Openbook/services/user_preferences.dart'; -import 'package:Openbook/widgets/icon.dart'; import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; import 'package:Openbook/widgets/page_scaffold.dart'; import 'package:Openbook/provider.dart'; @@ -14,9 +13,10 @@ import 'package:Openbook/services/user.dart'; import 'package:Openbook/widgets/theming/primary_color_container.dart'; import 'package:Openbook/widgets/theming/secondary_text.dart'; import 'package:Openbook/widgets/theming/text.dart'; +import 'package:async/async.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:loadmore/loadmore.dart'; +import 'package:Openbook/widgets/load_more.dart'; import 'package:Openbook/services/httpie.dart'; class OBPostCommentsPage extends StatefulWidget { @@ -49,6 +49,11 @@ class OBPostCommentsPageState extends State { FocusNode _commentInputFocusNode; PostCommentsSortType _currentSort; + CancelableOperation _refreshCommentsOperation; + CancelableOperation _refreshPostOperation; + CancelableOperation _loadMoreBottomCommentsOperation; + CancelableOperation _toggleSortCommentsOperation; + @override void initState() { super.initState(); @@ -61,6 +66,17 @@ class OBPostCommentsPageState extends State { _commentInputFocusNode = FocusNode(); } + @override + void dispose() { + super.dispose(); + if (_refreshCommentsOperation != null) _refreshCommentsOperation.cancel(); + if (_loadMoreBottomCommentsOperation != null) + _loadMoreBottomCommentsOperation.cancel(); + if (_refreshPostOperation != null) _refreshPostOperation.cancel(); + if (_toggleSortCommentsOperation != null) + _toggleSortCommentsOperation.cancel(); + } + @override Widget build(BuildContext context) { if (_needsBootstrap) { @@ -115,12 +131,12 @@ class OBPostCommentsPageState extends State { _removePostCommentAtIndex(commentIndex); }; - return OBExpandedPostComment( - postComment: postComment, - post: widget.post, - onPostCommentDeletedCallback: - onPostCommentDeletedCallback, - ); + return OBPostComment( + key: Key('postComment#${postComment.id}'), + postComment: postComment, + post: widget.post, + onPostCommentDeletedCallback: + onPostCommentDeletedCallback); }), onLoadMore: _loadMoreBottomComments), ), @@ -131,6 +147,7 @@ class OBPostCommentsPageState extends State { autofocus: widget.autofocusCommentInput, commentTextFieldFocusNode: _commentInputFocusNode, onPostCommentCreated: _onPostCommentCreated, + onPostCommentWillBeCreated: _onPostCommentWillBeCreated, ) ], ), @@ -191,6 +208,8 @@ class OBPostCommentsPageState extends State { } void _onWantsToToggleSortComments() async { + if (_toggleSortCommentsOperation != null) + _toggleSortCommentsOperation.cancel(); PostCommentsSortType newSortType; if (_currentSort == PostCommentsSortType.asc) { @@ -200,9 +219,10 @@ class OBPostCommentsPageState extends State { } try { - _postComments = (await _userService.getCommentsForPost(widget.post, - sort: newSortType)) - .comments; + _toggleSortCommentsOperation = CancelableOperation.fromFuture( + _userService.getCommentsForPost(widget.post, sort: newSortType)); + + _postComments = (await _toggleSortCommentsOperation.value).comments; _setCurrentSortValue(newSortType); _userPreferencesService.setPostCommentsSortType(newSortType); _setPostComments(_postComments); @@ -210,32 +230,44 @@ class OBPostCommentsPageState extends State { _setNoMoreItemsToLoad(false); } catch (error) { _onError(error); + } finally { + _toggleSortCommentsOperation = null; } } Future _refreshComments() async { + if (_refreshCommentsOperation != null) _refreshCommentsOperation.cancel(); try { - _postComments = (await _userService.getCommentsForPost(widget.post, - sort: _currentSort)) - .comments; + _refreshCommentsOperation = CancelableOperation.fromFuture( + _userService.getCommentsForPost(widget.post, sort: _currentSort)); + _postComments = (await _refreshCommentsOperation.value).comments; _setPostComments(_postComments); _scrollToTop(); _setNoMoreItemsToLoad(false); } catch (error) { _onError(error); + } finally { + _refreshCommentsOperation = null; } } Future _refreshPost() async { + if (_refreshPostOperation != null) _refreshPostOperation.cancel(); try { // This will trigger the updateSubject of the post - await _userService.getPostWithUuid(widget.post.uuid); + _refreshPostOperation = CancelableOperation.fromFuture( + _userService.getPostWithUuid(widget.post.uuid)); + await _refreshPostOperation.value; } catch (error) { _onError(error); + } finally { + _refreshPostOperation = null; } } Future _loadMoreBottomComments() async { + if (_loadMoreBottomCommentsOperation != null) + _loadMoreBottomCommentsOperation.cancel(); if (_postComments.length == 0) return true; var lastPost = _postComments.last; @@ -245,16 +277,16 @@ class OBPostCommentsPageState extends State { var moreComments; if (_currentSort == PostCommentsSortType.dec) { - moreComments = (await _userService.getCommentsForPost( - widget.post, - maxId: lastPostId)) - .comments; + _loadMoreBottomCommentsOperation = CancelableOperation.fromFuture( + _userService.getCommentsForPost(widget.post, maxId: lastPostId)); } else { - moreComments = (await _userService.getCommentsForPost(widget.post, - minId: lastPostId + 1, sort: _currentSort)) - .comments; + _loadMoreBottomCommentsOperation = CancelableOperation.fromFuture( + _userService.getCommentsForPost(widget.post, + minId: lastPostId + 1, sort: _currentSort)); } + moreComments = (await _loadMoreBottomCommentsOperation.value).comments; + if (moreComments.length == 0) { _setNoMoreItemsToLoad(true); } else { @@ -263,6 +295,8 @@ class OBPostCommentsPageState extends State { return true; } catch (error) { _onError(error); + } finally { + _loadMoreBottomCommentsOperation = null; } return false; @@ -281,6 +315,11 @@ class OBPostCommentsPageState extends State { }); } + Future _onPostCommentWillBeCreated() { + _setCurrentSortValue(PostCommentsSortType.dec); + return _refreshComments(); + } + void _setCurrentSortValue(PostCommentsSortType newSortType) { setState(() { _currentSort = newSortType; diff --git a/lib/pages/home/pages/post_comments/post_comments_linked.dart b/lib/pages/home/pages/post_comments/post_comments_linked.dart index 8e7c74d82..5beadaf90 100644 --- a/lib/pages/home/pages/post_comments/post_comments_linked.dart +++ b/lib/pages/home/pages/post_comments/post_comments_linked.dart @@ -4,6 +4,7 @@ import 'package:Openbook/pages/home/pages/post_comments/widgets/post-commenter.d import 'package:Openbook/pages/home/pages/post_comments/widgets/post_comment/post_comment.dart'; import 'package:Openbook/services/theme.dart'; import 'package:Openbook/services/theme_value_parser.dart'; +import 'package:Openbook/services/user_preferences.dart'; import 'package:Openbook/widgets/icon.dart'; import 'package:Openbook/widgets/nav_bars/themed_nav_bar.dart'; import 'package:Openbook/widgets/page_scaffold.dart'; @@ -19,9 +20,10 @@ import 'package:Openbook/widgets/theming/post_divider.dart'; import 'package:Openbook/widgets/theming/primary_color_container.dart'; import 'package:Openbook/widgets/theming/secondary_text.dart'; import 'package:Openbook/widgets/theming/text.dart'; +import 'package:async/async.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:loadmore/loadmore.dart'; +import 'package:Openbook/widgets/load_more.dart'; import 'package:Openbook/services/httpie.dart'; class OBPostCommentsLinkedPage extends StatefulWidget { @@ -42,6 +44,7 @@ class OBPostCommentsLinkedPage extends StatefulWidget { class OBPostCommentsLinkedPageState extends State with SingleTickerProviderStateMixin { UserService _userService; + UserPreferencesService _userPreferencesService; ToastService _toastService; ThemeService _themeService; ThemeValueParserService _themeValueParserService; @@ -53,8 +56,8 @@ class OBPostCommentsLinkedPageState extends State double _positionTopCommentSection; ScrollController _postCommentsScrollController; List _postComments = []; - bool _noMoreItemsToLoad; - bool _noMoreEarlierItemsToLoad; + bool _noMoreBottomItemsToLoad; + bool _noMoreTopItemsToLoad; bool _needsBootstrap; bool _shouldHideStackedLoadingScreen; bool _startScrollWasInitialised; @@ -82,15 +85,23 @@ class OBPostCommentsLinkedPageState extends State static const TOTAL_COMMENTS_IN_SLICE = COUNT_MIN_INCLUDING_LINKED_COMMENT + COUNT_MAX_AFTER_LINKED_COMMENT; + CancelableOperation _refreshCommentsOperation; + CancelableOperation _refreshCommentsSliceOperation; + CancelableOperation _refreshCommentsWithCreatedPostCommentVisibleOperation; + CancelableOperation _refreshPostOperation; + CancelableOperation _loadMoreBottomCommentsOperation; + CancelableOperation _loadMoreTopCommentsOperation; + CancelableOperation _toggleSortCommentsOperation; + @override void initState() { super.initState(); _post = widget.postComment.post; _needsBootstrap = true; _postComments = []; - _noMoreItemsToLoad = true; + _noMoreBottomItemsToLoad = true; _currentSort = PostCommentsSortType.dec; - _noMoreEarlierItemsToLoad = false; + _noMoreTopItemsToLoad = false; _startScrollWasInitialised = false; _shouldHideStackedLoadingScreen = false; _commentInputFocusNode = FocusNode(); @@ -106,6 +117,7 @@ class OBPostCommentsLinkedPageState extends State if (_needsBootstrap) { var provider = OpenbookProvider.of(context); _userService = provider.userService; + _userPreferencesService = provider.userPreferencesService; _toastService = provider.toastService; _themeValueParserService = provider.themeValueParserService; _themeService = provider.themeService; @@ -126,13 +138,32 @@ class OBPostCommentsLinkedPageState extends State } void _bootstrap() async { + await _setPostCommentsSortTypeFromPreferences(); await _refreshPost(); await _refreshCommentsSlice(); } + Future _setPostCommentsSortTypeFromPreferences() async { + PostCommentsSortType sortType = + await _userPreferencesService.getPostCommentsSortType(); + _currentSort = sortType; + } + void dispose() { super.dispose(); _animation.removeStatusListener(_onAnimationStatusChanged); + if (_refreshCommentsOperation != null) _refreshCommentsOperation.cancel(); + if (_refreshCommentsSliceOperation != null) + _refreshCommentsSliceOperation.cancel(); + if (_loadMoreBottomCommentsOperation != null) + _loadMoreBottomCommentsOperation.cancel(); + if (_refreshPostOperation != null) _refreshPostOperation.cancel(); + if (_toggleSortCommentsOperation != null) + _toggleSortCommentsOperation.cancel(); + if (_loadMoreTopCommentsOperation != null) + _loadMoreTopCommentsOperation.cancel(); + if (_refreshCommentsWithCreatedPostCommentVisibleOperation != null) + _refreshCommentsWithCreatedPostCommentVisibleOperation.cancel(); } void _onAnimationStatusChanged(status) { @@ -146,8 +177,6 @@ class OBPostCommentsLinkedPageState extends State List _getStackChildren() { var theme = _themeService.getActiveTheme(); var primaryColor = _themeValueParserService.parseColor(theme.primaryColor); - double screenWidth = MediaQuery.of(context).size.width; - double screenHeight = MediaQuery.of(context).size.height; List _stackChildren = []; @@ -197,7 +226,7 @@ class OBPostCommentsLinkedPageState extends State onTap: _unfocusCommentInput, child: LoadMore( whenEmptyLoad: false, - isFinish: _noMoreItemsToLoad, + isFinish: _noMoreBottomItemsToLoad, delegate: OBInfinitePostCommentsLoadMoreDelegate(), child: ListView.builder( physics: const ClampingScrollPhysics(), @@ -219,18 +248,13 @@ class OBPostCommentsLinkedPageState extends State _post, autofocus: widget.autofocusCommentInput, commentTextFieldFocusNode: _commentInputFocusNode, - onPostCommentCreated: _onPostCommentCreated, - onPostCommentWillBeCreated: _onWantsToLoadnewestComments, + onPostCommentCreated: _refreshCommentsWithCreatedPostCommentVisible, ) ]); return _columnChildren; } - Future _onWantsToRefreshComments() async { - await _onWantsToLoadnewestComments(); - } - Widget _getCommentTile(int index) { int commentIndex = index - 1; var postComment = _postComments[commentIndex]; @@ -240,10 +264,12 @@ class OBPostCommentsLinkedPageState extends State if (_animationController.status != AnimationStatus.completed && !_startScrollWasInitialised) { - _postCommentsScrollController.animateTo( - _positionTopCommentSection - 100.0, - duration: Duration(milliseconds: 5), - curve: Curves.easeIn); + Future.delayed(Duration(milliseconds: 0), () { + _postCommentsScrollController.animateTo( + _positionTopCommentSection - 100.0, + duration: Duration(milliseconds: 5), + curve: Curves.easeIn); + }); } if (commentIndex == 0) { @@ -268,14 +294,16 @@ class OBPostCommentsLinkedPageState extends State ? Color.fromARGB(20, 255, 255, 255) : Color.fromARGB(10, 0, 0, 0), ), - child: OBExpandedPostComment( + child: OBPostComment( + key: Key('postComment#${postComment.id}'), postComment: postComment, post: _post, onPostCommentDeletedCallback: onPostCommentDeletedCallback, ), ); } else { - return OBExpandedPostComment( + return OBPostComment( + key: Key('postComment#${postComment.id}'), postComment: postComment, post: _post, onPostCommentDeletedCallback: onPostCommentDeletedCallback, @@ -305,24 +333,31 @@ class OBPostCommentsLinkedPageState extends State height: 16, ), OBPostDivider(), - _buildLoadEarlierCommentsBar(), + _buildLoadTopCommentsBar(), ], ); } Future _refreshCommentsSlice() async { + if (_refreshCommentsSliceOperation != null) + _refreshCommentsSliceOperation.cancel(); try { - _postComments = (await _userService.getCommentsForPost(_post, + _refreshCommentsSliceOperation = CancelableOperation.fromFuture( + _userService.getCommentsForPost(_post, minId: widget.postComment.id, maxId: widget.postComment.id, countMax: COUNT_MAX_AFTER_LINKED_COMMENT, - countMin: COUNT_MIN_INCLUDING_LINKED_COMMENT)) - .comments; + countMin: COUNT_MIN_INCLUDING_LINKED_COMMENT, + sort: _currentSort)); + + _postComments = (await _refreshCommentsSliceOperation.value).comments; _setPostComments(_postComments); - _checkIfMoreEarlierItemsToLoad(); - _setNoMoreItemsToLoad(false); + _checkIfMoreTopItemsToLoad(); + _setNoMoreBottomItemsToLoad(false); } catch (error) { _onError(error); + } finally { + _refreshCommentsSliceOperation = null; } } @@ -333,72 +368,101 @@ class OBPostCommentsLinkedPageState extends State } Future _refreshPost() async { + if (_refreshPostOperation != null) _refreshPostOperation.cancel(); try { // This will trigger the updateSubject of the post - await _userService.getPostWithUuid(_post.uuid); + _refreshPostOperation = CancelableOperation.fromFuture( + _userService.getPostWithUuid(_post.uuid)); + + await _refreshPostOperation.value; _setPositionTopCommentSection(); } catch (error) { _onError(error); + } finally { + _refreshPostOperation = null; } } Future _loadMoreBottomComments() async { + if (_loadMoreBottomCommentsOperation != null) + _loadMoreBottomCommentsOperation.cancel(); if (_postComments.length == 0) return true; - var lastPost = _postComments.last; - var lastPostId = lastPost.id; - var moreComments; + PostComment lastPost = _postComments.last; + int lastPostId = lastPost.id; + List moreComments; try { if (_currentSort == PostCommentsSortType.dec) { - moreComments = (await _userService.getCommentsForPost(_post, + _loadMoreBottomCommentsOperation = CancelableOperation.fromFuture( + _userService.getCommentsForPost(_post, countMax: LOAD_MORE_COMMENTS_COUNT, maxId: lastPostId, - sort: _currentSort)) - .comments; + sort: _currentSort)); } else { - moreComments = (await _userService.getCommentsForPost(_post, + _loadMoreBottomCommentsOperation = CancelableOperation.fromFuture( + _userService.getCommentsForPost(_post, countMin: LOAD_MORE_COMMENTS_COUNT, minId: lastPostId + 1, - sort: _currentSort)) - .comments; + sort: _currentSort)); } + moreComments = (await _loadMoreBottomCommentsOperation.value).comments; + if (moreComments.length == 0) { - _setNoMoreItemsToLoad(true); + _setNoMoreBottomItemsToLoad(true); } else { _addPostComments(moreComments); } return true; } catch (error) { _onError(error); + } finally { + _loadMoreBottomCommentsOperation = null; } return false; } Future _loadMoreTopComments() async { + if (_loadMoreTopCommentsOperation != null) + _loadMoreTopCommentsOperation.cancel(); if (_postComments.length == 0) return true; - var firstPost = _postComments.first; - var firstPostId = firstPost.id; - + List topComments; + PostComment firstPost = _postComments.first; + int firstPostId = firstPost.id; try { - var moreComments = (await _userService.getCommentsForPost(_post, - countMin: LOAD_MORE_COMMENTS_COUNT, minId: firstPostId + 1)) - .comments; - - if (moreComments.length < LOAD_MORE_COMMENTS_COUNT && - moreComments.length != 0) { - _setNoMoreEarlierItemsToLoad(true); - _addToStartPostComments(moreComments); - } else if (moreComments.length == LOAD_MORE_COMMENTS_COUNT) { - _addToStartPostComments(moreComments); + if (_currentSort == PostCommentsSortType.dec) { + _loadMoreTopCommentsOperation = CancelableOperation.fromFuture( + _userService.getCommentsForPost(_post, + sort: PostCommentsSortType.dec, + countMin: LOAD_MORE_COMMENTS_COUNT, + minId: firstPostId + 1)); + } else if (_currentSort == PostCommentsSortType.asc) { + _loadMoreTopCommentsOperation = CancelableOperation.fromFuture( + _userService.getCommentsForPost(_post, + sort: PostCommentsSortType.asc, + countMax: LOAD_MORE_COMMENTS_COUNT, + maxId: firstPostId)); + } + + topComments = (await _loadMoreTopCommentsOperation.value).comments; + + if (topComments.length < LOAD_MORE_COMMENTS_COUNT && + topComments.length != 0) { + _setNoMoreTopItemsToLoad(true); + _addToStartPostComments(topComments); + } else if (topComments.length == LOAD_MORE_COMMENTS_COUNT) { + _addToStartPostComments(topComments); } else { - _setNoMoreEarlierItemsToLoad(true); + _setNoMoreTopItemsToLoad(true); + _showNoMoreTopItemsToLoadToast(); } return true; } catch (error) { _onError(error); + } finally { + _loadMoreTopCommentsOperation = null; } return false; @@ -410,38 +474,77 @@ class OBPostCommentsLinkedPageState extends State }); } - void _onPostCommentCreated(PostComment createdPostComment) { + void _refreshCommentsWithCreatedPostCommentVisible( + PostComment createdPostComment) async { + if (_refreshCommentsWithCreatedPostCommentVisibleOperation != null) + _refreshCommentsWithCreatedPostCommentVisibleOperation.cancel(); _unfocusCommentInput(); - setState(() { - this._postComments.insert(0, createdPostComment); - }); + List comments; + int createdCommentId = createdPostComment.id; + try { + if (_currentSort == PostCommentsSortType.dec) { + _refreshCommentsWithCreatedPostCommentVisibleOperation = + CancelableOperation.fromFuture(_userService.getCommentsForPost( + _post, + sort: PostCommentsSortType.dec, + countMin: LOAD_MORE_COMMENTS_COUNT, + minId: createdCommentId)); + } else if (_currentSort == PostCommentsSortType.asc) { + _refreshCommentsWithCreatedPostCommentVisibleOperation = + CancelableOperation.fromFuture(_userService.getCommentsForPost( + _post, + sort: PostCommentsSortType.asc, + countMax: LOAD_MORE_COMMENTS_COUNT, + maxId: createdCommentId + 1)); + } + + comments = + (await _refreshCommentsWithCreatedPostCommentVisibleOperation.value) + .comments; + + _setPostComments(comments); + _setNoMoreTopItemsToLoad(false); + _setNoMoreBottomItemsToLoad(false); + _scrollToNewComment(); + } catch (error) { + _onError(error); + } finally { + _refreshCommentsWithCreatedPostCommentVisibleOperation = null; + } } void _onPostDeleted(Post post) { Navigator.of(context).pop(); } - void _checkIfMoreEarlierItemsToLoad() { - var linkedCommentId = widget.postComment.id; - var listBeforeLinkedComment = - _postComments.where((comment) => comment.id > linkedCommentId); + void _checkIfMoreTopItemsToLoad() { + int linkedCommentId = widget.postComment.id; + Iterable listBeforeLinkedComment = []; + if (_currentSort == PostCommentsSortType.dec) { + listBeforeLinkedComment = + _postComments.where((comment) => comment.id > linkedCommentId); + } else if (_currentSort == PostCommentsSortType.asc) { + listBeforeLinkedComment = + _postComments.where((comment) => comment.id < linkedCommentId); + } if (listBeforeLinkedComment.length < 2) { - _setNoMoreEarlierItemsToLoad(true); + _setNoMoreTopItemsToLoad(true); } } - void _onWantsToLoadEarlierComments() async { - await _loadMoreTopComments(); - } - - Future _onWantsToLoadnewestComments() async { + Future _onWantsToRefreshComments() async { + if (_refreshCommentsOperation != null) _refreshCommentsOperation.cancel(); try { - _postComments = (await _userService.getCommentsForPost(_post)).comments; + _refreshCommentsOperation = CancelableOperation.fromFuture( + _userService.getCommentsForPost(_post, sort: _currentSort)); + _postComments = (await _refreshCommentsOperation.value).comments; _setPostComments(_postComments); - _setNoMoreItemsToLoad(false); - _setNoMoreEarlierItemsToLoad(true); + _setNoMoreBottomItemsToLoad(false); + _setNoMoreTopItemsToLoad(true); } catch (error) { _onError(error); + } finally { + _refreshCommentsOperation = null; } } @@ -473,24 +576,40 @@ class OBPostCommentsLinkedPageState extends State }); } - void _setNoMoreItemsToLoad(bool noMoreItemsToLoad) { + void _setNoMoreBottomItemsToLoad(bool noMoreItemsToLoad) { setState(() { - _noMoreItemsToLoad = noMoreItemsToLoad; + _noMoreBottomItemsToLoad = noMoreItemsToLoad; }); } - void _setNoMoreEarlierItemsToLoad(bool noMoreItemsToLoad) { + void _setNoMoreTopItemsToLoad(bool noMoreItemsToLoad) { setState(() { - _noMoreEarlierItemsToLoad = noMoreItemsToLoad; + _noMoreTopItemsToLoad = noMoreItemsToLoad; }); } + void _showNoMoreTopItemsToLoadToast() { + _toastService.info(context: context, message: 'No more comments to load'); + } + void _setCurrentSortValue(PostCommentsSortType newSortValue) { setState(() { _currentSort = newSortValue; }); } + void _scrollToNewComment() { + if (_currentSort == PostCommentsSortType.asc) { + _postCommentsScrollController.animateTo(10000, + duration: Duration(milliseconds: 5), curve: Curves.easeIn); + } else if (_currentSort == PostCommentsSortType.dec) { + _postCommentsScrollController.animateTo( + _positionTopCommentSection - 200.0, + duration: Duration(milliseconds: 5), + curve: Curves.easeIn); + } + } + void _onWantsToToggleSortComments() async { PostCommentsSortType newSortType; @@ -499,17 +618,9 @@ class OBPostCommentsLinkedPageState extends State } else { newSortType = PostCommentsSortType.asc; } - - try { - _postComments = - (await _userService.getCommentsForPost(_post, sort: newSortType)) - .comments; - _setCurrentSortValue(newSortType); - _setPostComments(_postComments); - _setNoMoreItemsToLoad(false); - } catch (error) { - _onError(error); - } + _userPreferencesService.setPostCommentsSortType(newSortType); + _setCurrentSortValue(newSortType); + _onWantsToRefreshComments(); } void _onError(error) async { @@ -567,44 +678,45 @@ class OBPostCommentsLinkedPageState extends State return totalOffsetY; } - Widget _buildLoadEarlierCommentsBar() { + Widget _buildLoadTopCommentsBar() { var theme = _themeService.getActiveTheme(); - if (_noMoreEarlierItemsToLoad) { + if (_noMoreTopItemsToLoad) { return Container( padding: EdgeInsets.symmetric(horizontal: 0.0, vertical: 10.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 0.0), - child: OBSecondaryText( - _postComments.length > 0 - ? _currentSort == PostCommentsSortType.dec - ? 'Newest comments' - : 'Oldest comments' - : 'Be the first to comment!', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0), + Expanded( + child: Padding( + padding: EdgeInsets.fromLTRB(10.0, 0.0, 0.0, 0.0), + child: OBSecondaryText( + _postComments.length > 0 + ? _currentSort == PostCommentsSortType.dec + ? 'Newest comments' + : 'Oldest comments' + : 'Be the first to comment!', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0), + ), ), ), - FlatButton( - child: Row( - children: [ - OBText( - _postComments.length > 0 - ? _currentSort == PostCommentsSortType.dec - ? 'See oldest comments' - : 'See newest comments' - : '', - style: TextStyle( - color: _themeValueParserService - .parseGradient(theme.primaryAccentColor) - .colors[1], - fontWeight: FontWeight.bold), - ), - ], - ), - onPressed: _onWantsToToggleSortComments), + Expanded( + child: FlatButton( + child: OBText( + _postComments.length > 0 + ? _currentSort == PostCommentsSortType.dec + ? 'See oldest comments' + : 'See newest comments' + : '', + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: _themeValueParserService + .parseGradient(theme.primaryAccentColor) + .colors[1], + fontWeight: FontWeight.bold), + ), + onPressed: _onWantsToToggleSortComments), + ), ], ), ); @@ -614,32 +726,39 @@ class OBPostCommentsLinkedPageState extends State child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - FlatButton( - child: Row( - children: [ - OBIcon(OBIcons.arrowUp), - const SizedBox(width: 10.0), - OBText( - 'Earlier', - style: TextStyle(fontWeight: FontWeight.bold), - ), - ], - ), - onPressed: _onWantsToLoadEarlierComments), - FlatButton( - child: Row( - children: [ - OBText( - 'View newest comments', - style: TextStyle( - color: _themeValueParserService - .parseGradient(theme.primaryAccentColor) - .colors[1], - fontWeight: FontWeight.bold), - ), - ], - ), - onPressed: _onWantsToLoadnewestComments), + Expanded( + flex: 4, + child: FlatButton( + child: Row( + children: [ + OBIcon(OBIcons.arrowUp), + const SizedBox(width: 10.0), + OBText( + _currentSort == PostCommentsSortType.dec + ? 'Newer' + : 'Older', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + onPressed: _loadMoreTopComments), + ), + Expanded( + flex: 6, + child: FlatButton( + child: OBText( + _currentSort == PostCommentsSortType.dec + ? 'View newest comments' + : 'View oldest comments', + style: TextStyle( + color: _themeValueParserService + .parseGradient(theme.primaryAccentColor) + .colors[1], + fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + onPressed: _onWantsToRefreshComments), + ), ], ), ); diff --git a/lib/pages/home/pages/post_comments/widgets/post-commenter.dart b/lib/pages/home/pages/post_comments/widgets/post-commenter.dart index dc4751013..f2e627c3c 100644 --- a/lib/pages/home/pages/post_comments/widgets/post-commenter.dart +++ b/lib/pages/home/pages/post_comments/widgets/post-commenter.dart @@ -10,6 +10,7 @@ import 'package:Openbook/widgets/avatars/logged_in_user_avatar.dart'; import 'package:Openbook/widgets/avatars/avatar.dart'; import 'package:Openbook/widgets/buttons/button.dart'; import 'package:Openbook/widgets/fields/text_form_field.dart'; +import 'package:async/async.dart'; import 'package:flutter/material.dart'; import 'package:Openbook/services/httpie.dart'; @@ -45,6 +46,8 @@ class OBPostCommenterState extends State { ToastService _toastService; ValidationService _validationService; + CancelableOperation _submitFormOperation; + final _formKey = GlobalKey(); @override @@ -60,6 +63,12 @@ class OBPostCommenterState extends State { _textController.addListener(_onPostCommentChanged); } + @override + void dispose() { + super.dispose(); + if (_submitFormOperation != null) _submitFormOperation.cancel(); + } + @override Widget build(BuildContext context) { if (_needsBootstrap) { @@ -105,7 +114,8 @@ class OBPostCommenterState extends State { child: Form( key: _formKey, child: LayoutBuilder(builder: (context, size) { - TextStyle style = TextStyle(fontSize: 14.0); + TextStyle style = TextStyle( + fontSize: 14.0, fontFamilyFallback: ['NunitoSans']); TextSpan text = new TextSpan(text: _textController.text, style: style); @@ -172,6 +182,7 @@ class OBPostCommenterState extends State { } void _submitForm() async { + if (_submitFormOperation != null) _submitFormOperation.cancel(); _setFormWasSubmitted(true); bool formIsValid = _validateForm(); @@ -184,8 +195,9 @@ class OBPostCommenterState extends State { ? widget.onPostCommentWillBeCreated() : Future.value()); String commentText = _textController.text; - PostComment createdPostComment = - await _userService.commentPost(text: commentText, post: widget.post); + _submitFormOperation = CancelableOperation.fromFuture( + _userService.commentPost(text: commentText, post: widget.post)); + PostComment createdPostComment = await _submitFormOperation.value; widget.post.incrementCommentsCount(); _textController.clear(); _setFormWasSubmitted(false); @@ -196,6 +208,7 @@ class OBPostCommenterState extends State { } catch (error) { _onError(error); } finally { + _submitFormOperation = null; _setCommentInProgress(false); } } @@ -237,12 +250,6 @@ class OBPostCommenterState extends State { }); } - void _setIsMultilline(bool isMultiline) { - setState(() { - _isMultiline = isMultiline; - }); - } - void _setCharactersCount(int charactersCount) { setState(() { _charactersCount = charactersCount; diff --git a/lib/pages/home/pages/post_comments/widgets/post_comment/post_comment.dart b/lib/pages/home/pages/post_comments/widgets/post_comment/post_comment.dart index a07a6a98b..180db43b7 100644 --- a/lib/pages/home/pages/post_comments/widgets/post_comment/post_comment.dart +++ b/lib/pages/home/pages/post_comments/widgets/post_comment/post_comment.dart @@ -2,41 +2,45 @@ import 'package:Openbook/models/community.dart'; import 'package:Openbook/models/post.dart'; import 'package:Openbook/models/post_comment.dart'; import 'package:Openbook/models/user.dart'; -import 'package:Openbook/pages/home/pages/post_comments/widgets/post_comment/packages/post_comment_text.dart'; +import 'package:Openbook/pages/home/pages/post_comments/widgets/post_comment/widgets/post_comment_text.dart'; import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/modal_service.dart'; import 'package:Openbook/services/navigation_service.dart'; import 'package:Openbook/services/toast.dart'; import 'package:Openbook/services/user.dart'; import 'package:Openbook/widgets/avatars/avatar.dart'; import 'package:Openbook/widgets/icon.dart'; import 'package:Openbook/widgets/theming/secondary_text.dart'; +import 'package:async/async.dart'; import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; -import 'package:Openbook/services/httpie.dart'; -class OBExpandedPostComment extends StatefulWidget { +class OBPostComment extends StatefulWidget { final PostComment postComment; final Post post; final VoidCallback onPostCommentDeletedCallback; - OBExpandedPostComment( + OBPostComment( {@required this.post, @required this.postComment, - Key key, - this.onPostCommentDeletedCallback}) + this.onPostCommentDeletedCallback, + Key key}) : super(key: key); @override State createState() { - return OBExpandedPostCommentState(); + return OBPostCommentState(); } } -class OBExpandedPostCommentState extends State { - bool _requestInProgress; +class OBPostCommentState extends State { + NavigationService _navigationService; UserService _userService; ToastService _toastService; - NavigationService _navigationService; + ModalService _modalService; + bool _requestInProgress; + + CancelableOperation _requestOperation; @override void initState() { @@ -44,28 +48,97 @@ class OBExpandedPostCommentState extends State { _requestInProgress = false; } + @override + void dispose() { + super.dispose(); + if (_requestOperation != null) _requestOperation.cancel(); + } + @override Widget build(BuildContext context) { var provider = OpenbookProvider.of(context); + _navigationService = provider.navigationService; _userService = provider.userService; _toastService = provider.toastService; - _navigationService = provider.navigationService; + _modalService = provider.modalService; + Widget postTile = _buildPostCommentTile(widget.postComment); - User loggedInUser = _userService.getLoggedInUser(); - - Widget postTile = _buildPostTile(); + Widget postComment = _buildPostCommentActions( + child: postTile, + ); if (_requestInProgress) { - postTile = Opacity( - opacity: 0.5, - child: postTile, + postComment = IgnorePointer( + child: Opacity( + opacity: 0.5, + child: postComment, + ), ); } + return postComment; + } + + Widget _buildPostCommentTile(PostComment postComment) { + return StreamBuilder( + stream: widget.postComment.updateSubject, + initialData: widget.postComment, + builder: (BuildContext context, AsyncSnapshot snapshot) { + PostComment postComment = snapshot.data; + + return Padding( + padding: + const EdgeInsets.symmetric(vertical: 10.0, horizontal: 20.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OBAvatar( + onPressed: () { + _navigationService.navigateToUserProfile( + user: postComment.commenter, context: context); + }, + size: OBAvatarSize.small, + avatarUrl: postComment.getCommenterProfileAvatar(), + ), + const SizedBox( + width: 20.0, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OBPostCommentText( + postComment, + badge: _getCommunityBadge(postComment), + onUsernamePressed: () { + _navigationService.navigateToUserProfile( + user: postComment.commenter, context: context); + }, + ), + const SizedBox( + height: 5.0, + ), + OBSecondaryText( + postComment.getRelativeCreated(), + style: TextStyle(fontSize: 12.0), + ) + ], + )) + ], + ), + ); + }); + } + + Widget _buildPostCommentActions({@required Widget child}) { + List _editCommentActions = []; + + User loggedInUser = _userService.getLoggedInUser(); bool loggedInUserIsCommunityAdministrator = false; bool loggedInUserIsCommunityModerator = false; Post post = widget.post; + User postCommenter = widget.postComment.commenter; if (post.hasCommunity()) { Community postCommunity = post.community; @@ -77,80 +150,87 @@ class OBExpandedPostCommentState extends State { postCommunity.isModerator(loggedInUser); } + if (postCommenter.id == loggedInUser.id) { + _editCommentActions.add( + new IconSlideAction( + caption: 'Edit', + color: Colors.blueGrey, + icon: Icons.edit, + onTap: _editPostComment, + ), + ); + } + if (widget.postComment.getCommenterId() == loggedInUser.id || loggedInUserIsCommunityAdministrator || loggedInUserIsCommunityModerator || post.creator.id == loggedInUser.id) { - // Its our own comment - postTile = Slidable( - delegate: new SlidableDrawerDelegate(), - actionExtentRatio: 0.25, - child: postTile, - secondaryActions: [ - new IconSlideAction( - caption: 'Delete', - color: Colors.red, - icon: Icons.delete, - onTap: _deletePostComment, - ), - ], + _editCommentActions.add( + new IconSlideAction( + caption: 'Delete', + color: Colors.red, + icon: Icons.delete, + onTap: _deletePostComment, + ), ); } - return postTile; + return Slidable( + delegate: new SlidableDrawerDelegate(), + actionExtentRatio: 0.2, + child: child, + secondaryActions: _editCommentActions, + ); } - @override - void dispose() { - super.dispose(); + void _editPostComment() async { + await _modalService.openExpandedCommenter( + context: context, post: widget.post, postComment: widget.postComment); } - Widget _buildPostTile() { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 20.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - OBAvatar( - onPressed: () { - _navigationService.navigateToUserProfile( - user: widget.postComment.commenter, context: context); - }, - size: OBAvatarSize.small, - avatarUrl: widget.postComment.getCommenterProfileAvatar(), - ), - const SizedBox( - width: 20.0, - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - OBPostCommentText( - widget.postComment, - badge: _getCommunityBadge(), - onUsernamePressed: () { - _navigationService.navigateToUserProfile( - user: widget.postComment.commenter, context: context); - }, - ), - const SizedBox( - height: 5.0, - ), - OBSecondaryText( - widget.postComment.getRelativeCreated(), - style: TextStyle(fontSize: 12.0), - ) - ], - )) - ], - ), - ); + void _deletePostComment() async { + if (_requestInProgress) return; + _setRequestInProgress(true); + try { + _requestOperation = CancelableOperation.fromFuture( + _userService.deletePostComment( + postComment: widget.postComment, post: widget.post)); + + await _requestOperation.value; + widget.post.decreaseCommentsCount(); + _toastService.success(message: 'Comment deleted', context: context); + if (widget.onPostCommentDeletedCallback != null) { + widget.onPostCommentDeletedCallback(); + } + } catch (error) { + _onError(error); + } finally { + _setRequestInProgress(false); + } + } + + void _setRequestInProgress(bool requestInProgress) { + setState(() { + _requestInProgress = requestInProgress; + }); } - Widget _getCommunityBadge() { + void _onError(error) async { + if (error is HttpieConnectionRefusedError) { + _toastService.error( + message: error.toHumanReadableMessage(), context: context); + } else if (error is HttpieRequestError) { + String errorMessage = await error.toHumanReadableMessage(); + _toastService.error(message: errorMessage, context: context); + } else { + _toastService.error(message: 'Unknown error', context: context); + throw error; + } + } + + Widget _getCommunityBadge(PostComment postComment) { Post post = widget.post; - User postCommenter = widget.postComment.commenter; + User postCommenter = postComment.commenter; if (post.hasCommunity()) { Community postCommunity = post.community; @@ -188,42 +268,6 @@ class OBExpandedPostCommentState extends State { themeColor: OBIconThemeColor.primaryAccent, ); } - - void _deletePostComment() async { - _setRequestInProgress(true); - try { - await _userService.deletePostComment( - postComment: widget.postComment, post: widget.post); - widget.post.decreaseCommentsCount(); - _setRequestInProgress(false); - if (widget.onPostCommentDeletedCallback != null) { - widget.onPostCommentDeletedCallback(); - } - } catch (error) { - _onError(error); - } finally { - _setRequestInProgress(false); - } - } - - void _onError(error) async { - if (error is HttpieConnectionRefusedError) { - _toastService.error( - message: error.toHumanReadableMessage(), context: context); - } else if (error is HttpieRequestError) { - String errorMessage = await error.toHumanReadableMessage(); - _toastService.error(message: errorMessage, context: context); - } else { - _toastService.error(message: 'Unknown error', context: context); - throw error; - } - } - - void _setRequestInProgress(bool requestInProgress) { - setState(() { - _requestInProgress = requestInProgress; - }); - } } typedef void OnWantsToSeeUserProfile(User user); diff --git a/lib/pages/home/pages/post_comments/widgets/post_comment/packages/post_comment_text.dart b/lib/pages/home/pages/post_comments/widgets/post_comment/widgets/post_comment_text.dart similarity index 69% rename from lib/pages/home/pages/post_comments/widgets/post_comment/packages/post_comment_text.dart rename to lib/pages/home/pages/post_comments/widgets/post_comment/widgets/post_comment_text.dart index d3f4f891b..ded1497a6 100644 --- a/lib/pages/home/pages/post_comments/widgets/post_comment/packages/post_comment_text.dart +++ b/lib/pages/home/pages/post_comments/widgets/post_comment/widgets/post_comment_text.dart @@ -1,15 +1,19 @@ import 'package:Openbook/models/post_comment.dart'; import 'package:Openbook/models/theme.dart'; import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/toast.dart'; import 'package:Openbook/widgets/theming/actionable_smart_text.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; class OBPostCommentText extends StatelessWidget { final PostComment postComment; final VoidCallback onUsernamePressed; final Widget badge; + ToastService _toastService; + BuildContext _context; - const OBPostCommentText(this.postComment, + OBPostCommentText(this.postComment, {Key key, this.onUsernamePressed, this.badge}) : super(key: key); @@ -19,6 +23,9 @@ class OBPostCommentText extends StatelessWidget { var themeService = openbookProvider.themeService; var themeValueParserService = openbookProvider.themeValueParserService; + _toastService = openbookProvider.toastService; + _context = context; + return StreamBuilder( stream: themeService.themeChange, initialData: themeService.getActiveTheme(), @@ -53,14 +60,33 @@ class OBPostCommentText extends StatelessWidget { Row( children: [ Flexible( - child: OBActionableSmartText( - text: postComment.text, + child: GestureDetector( + onLongPress: _copyText, + child:_getActionableSmartText(postComment.isEdited), ), - ) + ), ], ) ], ); }); } + + Widget _getActionableSmartText(bool isEdited) { + if (isEdited) { + return OBActionableSmartText( + text: postComment.text, + trailingSmartTextElement: SecondaryTextElement(' (edited)') + ); + } else { + return OBActionableSmartText( + text: postComment.text + ); + } + } + + void _copyText(){ + Clipboard.setData(ClipboardData(text: postComment.text)); + _toastService.toast(message: 'Text copied!', context: _context, type: ToastType.info); + } } diff --git a/lib/pages/home/pages/profile/profile.dart b/lib/pages/home/pages/profile/profile.dart index 0158be793..4e0b853d8 100644 --- a/lib/pages/home/pages/profile/profile.dart +++ b/lib/pages/home/pages/profile/profile.dart @@ -1,11 +1,10 @@ import 'package:Openbook/models/post.dart'; -import 'package:Openbook/models/posts_list.dart'; import 'package:Openbook/models/user.dart'; import 'package:Openbook/pages/home/pages/profile/widgets/profile_card/profile_card.dart'; import 'package:Openbook/pages/home/pages/profile/widgets/profile_cover.dart'; import 'package:Openbook/pages/home/pages/profile/widgets/profile_nav_bar.dart'; import 'package:Openbook/pages/home/pages/profile/widgets/profile_no_posts.dart'; -import 'package:Openbook/pages/home/pages/timeline/widgets/timeline-posts.dart'; +import 'package:Openbook/widgets/loadmore/loadmore_delegate.dart'; import 'package:Openbook/provider.dart'; import 'package:Openbook/services/httpie.dart'; import 'package:Openbook/services/toast.dart'; @@ -13,9 +12,10 @@ import 'package:Openbook/services/user.dart'; import 'package:Openbook/widgets/post/post.dart'; import 'package:Openbook/widgets/progress_indicator.dart'; import 'package:Openbook/widgets/theming/primary_color_container.dart'; +import 'package:async/async.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:loadmore/loadmore.dart'; +import 'package:Openbook/widgets/load_more.dart'; class OBProfilePage extends StatefulWidget { final OBProfilePageController controller; @@ -42,6 +42,10 @@ class OBProfilePageState extends State { ScrollController _scrollController; bool _refreshPostsInProgress; + CancelableOperation _loadMoreOperation; + CancelableOperation _refreshUserOperation; + CancelableOperation _refreshPostsOperation; + final GlobalKey _refreshIndicatorKey = GlobalKey(); @@ -57,6 +61,14 @@ class OBProfilePageState extends State { if (widget.controller != null) widget.controller.attach(this); } + @override + void dispose() { + super.dispose(); + if (_loadMoreOperation != null) _loadMoreOperation.cancel(); + if (_refreshUserOperation != null) _refreshUserOperation.cancel(); + if (_refreshPostsOperation != null) _refreshPostsOperation.cancel(); + } + @override Widget build(BuildContext context) { var openbookProvider = OpenbookProvider.of(context); @@ -163,26 +175,51 @@ class OBProfilePageState extends State { } Future _refreshUser() async { - var user = await _userService.getUserWithUsername(_user.username); - _setUser(user); + if (_refreshUserOperation != null) _refreshUserOperation.cancel(); + try { + _refreshUserOperation = CancelableOperation.fromFuture( + _userService.getUserWithUsername(_user.username)); + var user = await _refreshUserOperation.value; + _setUser(user); + } catch (error) { + _onError(error); + } finally { + _refreshUserOperation = null; + } } Future _refreshPosts() async { + if (_refreshPostsOperation != null) _refreshPostsOperation.cancel(); _setRefreshPostsInProgress(true); - PostsList postsList = - await _userService.getTimelinePosts(username: _user.username); - _posts = postsList.posts; - _setPosts(_posts); - _setRefreshPostsInProgress(false); + + try { + _refreshPostsOperation = CancelableOperation.fromFuture( + _userService.getTimelinePosts(username: _user.username)); + _posts = (await _refreshPostsOperation.value).posts; + _setPosts(_posts); + _setMorePostsToLoad(true); + } catch (error) { + _onError(error); + } finally { + _setRefreshPostsInProgress(false); + _refreshPostsOperation = null; + } } Future _loadMorePosts() async { - var lastPost = _posts.last; - var lastPostId = lastPost.id; + if (_loadMoreOperation != null) _loadMoreOperation.cancel(); + + var lastPostId; + if (_posts.isNotEmpty) { + Post lastPost = _posts.last; + lastPostId = lastPost.id; + } + try { - var morePosts = (await _userService.getTimelinePosts( - maxId: lastPostId, username: _user.username)) - .posts; + _loadMoreOperation = CancelableOperation.fromFuture(_userService + .getTimelinePosts(maxId: lastPostId, username: _user.username)); + + var morePosts = (await _loadMoreOperation.value).posts; if (morePosts.length == 0) { _setMorePostsToLoad(false); @@ -194,6 +231,8 @@ class OBProfilePageState extends State { return true; } catch (error) { _onError(error); + } finally { + _loadMoreOperation = null; } return false; diff --git a/lib/pages/home/pages/profile/widgets/profile_card/profile_card.dart b/lib/pages/home/pages/profile/widgets/profile_card/profile_card.dart index 89521bf48..f6931033e 100644 --- a/lib/pages/home/pages/profile/widgets/profile_card/profile_card.dart +++ b/lib/pages/home/pages/profile/widgets/profile_card/profile_card.dart @@ -11,6 +11,7 @@ import 'package:Openbook/pages/home/pages/profile/widgets/profile_card/widgets/p import 'package:Openbook/pages/home/pages/profile/widgets/profile_card/widgets/profile_name.dart'; import 'package:Openbook/pages/home/pages/profile/widgets/profile_card/widgets/profile_username.dart'; import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/toast.dart'; import 'package:Openbook/widgets/avatars/avatar.dart'; import 'package:Openbook/widgets/user_badge.dart'; import 'package:flutter/material.dart'; @@ -50,14 +51,8 @@ class OBProfileCard extends StatelessWidget { const SizedBox( height: 20, ), - GestureDetector( - onTap: () { - toastService.info( - message: _getUserBadgeDescription(user), - context: context); - }, - child: _buildNameRow(user), - ), + _buildNameRow( + user: user, context: context, toastService: toastService), OBProfileUsername(user), OBProfileBio(user), OBProfileDetails(user), @@ -111,18 +106,33 @@ class OBProfileCard extends StatelessWidget { ); } - Widget _getUserBadge(User user) { - Badge badge = user.getProfileBadges()[0]; - return OBUserBadge(badge: badge, size: OBUserBadgeSize.small); - } - - Widget _buildNameRow(User user) { + Widget _buildNameRow( + {@required User user, + @required BuildContext context, + @required ToastService toastService}) { if (user.hasProfileBadges() && user.getProfileBadges().length > 0) { - return Row(children: [OBProfileName(user), _getUserBadge(user)]); + return Row(children: [ + OBProfileName(user), + _getUserBadge(user: user, toastService: toastService, context: context) + ]); } return OBProfileName(user); } + Widget _getUserBadge( + {@required User user, + @required ToastService toastService, + @required BuildContext context}) { + Badge badge = user.getProfileBadges()[0]; + return GestureDetector( + onTap: () { + toastService.info( + message: _getUserBadgeDescription(user), context: context); + }, + child: OBUserBadge(badge: badge, size: OBUserBadgeSize.small), + ); + } + String _getUserBadgeDescription(User user) { Badge badge = user.getProfileBadges()[0]; return badge.getKeywordDescription(); diff --git a/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_actions/profile_actions.dart b/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_actions/profile_actions.dart index c11e33ec0..e827c6d32 100644 --- a/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_actions/profile_actions.dart +++ b/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_actions/profile_actions.dart @@ -22,7 +22,14 @@ class OBProfileActions extends StatelessWidget { List actions = []; if (isLoggedInUser) { - actions.add(_buildEditButton(modalService, context)); + actions.add( + Padding( + // The margin compensates for the height of the (missing) OBProfileActionMore + // Fixes cut-off Edit profile button, and level out layout distances + padding: EdgeInsets.only(top: 6.5, bottom: 6.5), + child: _buildEditButton(modalService, context), + ) + ); } else { actions.addAll([ OBFollowButton(user), diff --git a/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_counts/widgets/profile_followers_count.dart b/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_counts/widgets/profile_followers_count.dart index dac76a0c5..4e60b933b 100644 --- a/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_counts/widgets/profile_followers_count.dart +++ b/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_counts/widgets/profile_followers_count.dart @@ -15,13 +15,16 @@ class OBProfileFollowersCount extends StatelessWidget { if (followersCount == null || followersCount == 0 || - user.getProfileFollowersCountVisible() == false) return const SizedBox(); + user.getProfileFollowersCountVisible() == false) + return const SizedBox(); String count = getPrettyCount(followersCount); var openbookProvider = OpenbookProvider.of(context); var themeService = openbookProvider.themeService; var themeValueParserService = openbookProvider.themeValueParserService; + var userService = openbookProvider.userService; + var navigationService = openbookProvider.navigationService; return StreamBuilder( stream: themeService.themeChange, @@ -29,30 +32,37 @@ class OBProfileFollowersCount extends StatelessWidget { builder: (BuildContext context, AsyncSnapshot snapshot) { var theme = snapshot.data; - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Flexible( - child: RichText( - text: TextSpan(children: [ - TextSpan( - text: count, - style: TextStyle( - fontWeight: FontWeight.bold, - color: themeValueParserService - .parseColor(theme.primaryTextColor))), - TextSpan( - text: followersCount == 1 ? ' Follower' : ' Followers', - style: TextStyle( - color: themeValueParserService - .parseColor(theme.secondaryTextColor))) - ])), - ), - const SizedBox( - width: 10, - ) - ], + return GestureDetector( + onTap: () { + if (userService.isLoggedInUser(user)) { + navigationService.navigateToFollowersPage(context: context); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: RichText( + text: TextSpan(children: [ + TextSpan( + text: count, + style: TextStyle( + fontWeight: FontWeight.bold, + color: themeValueParserService + .parseColor(theme.primaryTextColor))), + TextSpan( + text: followersCount == 1 ? ' Follower' : ' Followers', + style: TextStyle( + color: themeValueParserService + .parseColor(theme.secondaryTextColor))) + ])), + ), + const SizedBox( + width: 10, + ) + ], + ), ); }); } diff --git a/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_counts/widgets/profile_following_count.dart b/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_counts/widgets/profile_following_count.dart index 5de1c9bf2..ed08ccaf9 100644 --- a/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_counts/widgets/profile_following_count.dart +++ b/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_counts/widgets/profile_following_count.dart @@ -20,6 +20,8 @@ class OBProfileFollowingCount extends StatelessWidget { var openbookProvider = OpenbookProvider.of(context); var themeService = openbookProvider.themeService; var themeValueParserService = openbookProvider.themeValueParserService; + var userService = openbookProvider.userService; + var navigationService = openbookProvider.navigationService; return StreamBuilder( stream: themeService.themeChange, @@ -27,28 +29,37 @@ class OBProfileFollowingCount extends StatelessWidget { builder: (BuildContext context, AsyncSnapshot snapshot) { var theme = snapshot.data; - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Flexible( - child: RichText( - text: TextSpan(children: [ - TextSpan( - text: count, - style: TextStyle( - fontWeight: FontWeight.bold, - color: themeValueParserService.parseColor(theme.primaryTextColor))), - TextSpan( - text: ' Following', - style: TextStyle( - color: themeValueParserService.parseColor(theme.secondaryTextColor))) - ])), - ), - const SizedBox( - width: 10, - ) - ], + return GestureDetector( + onTap: () { + if (userService.isLoggedInUser(user)) { + navigationService.navigateToFollowingPage(context: context); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: RichText( + text: TextSpan(children: [ + TextSpan( + text: count, + style: TextStyle( + fontWeight: FontWeight.bold, + color: themeValueParserService + .parseColor(theme.primaryTextColor))), + TextSpan( + text: ' Following', + style: TextStyle( + color: themeValueParserService + .parseColor(theme.secondaryTextColor))) + ])), + ), + const SizedBox( + width: 10, + ) + ], + ), ); }); } diff --git a/lib/pages/home/pages/timeline/widgets/timeline-posts.dart b/lib/pages/home/pages/timeline/widgets/timeline-posts.dart index 02b2886f6..65d174175 100644 --- a/lib/pages/home/pages/timeline/widgets/timeline-posts.dart +++ b/lib/pages/home/pages/timeline/widgets/timeline-posts.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:Openbook/models/circle.dart'; import 'package:Openbook/models/follows_list.dart'; import 'package:Openbook/models/post.dart'; +import 'package:Openbook/models/posts_list.dart'; import 'package:Openbook/models/user.dart'; import 'package:Openbook/provider.dart'; import 'package:Openbook/services/httpie.dart'; @@ -11,10 +12,12 @@ import 'package:Openbook/services/user.dart'; import 'package:Openbook/widgets/buttons/button.dart'; import 'package:Openbook/widgets/icon.dart'; import 'package:Openbook/widgets/post/post.dart'; -import 'package:Openbook/widgets/progress_indicator.dart'; +import 'package:Openbook/widgets/theming/secondary_text.dart'; import 'package:Openbook/widgets/theming/text.dart'; +import 'package:Openbook/widgets/tiles/loading_indicator_tile.dart'; +import 'package:Openbook/widgets/tiles/retry_tile.dart'; +import 'package:async/async.dart'; import 'package:flutter/material.dart'; -import 'package:loadmore/loadmore.dart'; class OBTimelinePosts extends StatefulWidget { final OBTimelinePostsController controller; @@ -34,35 +37,39 @@ class OBTimelinePostsState extends State { List _filteredCircles; List _filteredFollowsLists; bool _needsBootstrap; + bool _cacheLoadAttempted; + bool _isFirstLoad; UserService _userService; ToastService _toastService; StreamSubscription _loggedInUserChangeSubscription; ScrollController _postsScrollController; - bool _refreshInProgress; final GlobalKey _refreshIndicatorKey = GlobalKey(); - // Whether we have loaded all posts infinite-scroll wise - bool _loadingFinished; + OBTimelinePostsStatus _status; + CancelableOperation _timelineRequest; @override void initState() { super.initState(); if (widget.controller != null) widget.controller.attach(this); - _refreshInProgress = false; _posts = []; _filteredCircles = []; _filteredFollowsLists = []; _needsBootstrap = true; - _loadingFinished = false; + _cacheLoadAttempted = false; + _isFirstLoad = true; + _status = OBTimelinePostsStatus.refreshingPosts; _postsScrollController = ScrollController(); + _postsScrollController.addListener(_onScroll); } @override void dispose() { super.dispose(); _loggedInUserChangeSubscription.cancel(); + _postsScrollController.removeListener(_onScroll); } @override @@ -74,81 +81,159 @@ class OBTimelinePostsState extends State { _bootstrap(); _needsBootstrap = false; } - return _posts.isEmpty ? _buildNoTimelinePosts() : _buildTimelinePosts(); + + Widget timelinePostsWidget = _posts.isEmpty && _cacheLoadAttempted + ? _buildDrHoo() + : _buildTimelinePosts(); + + return RefreshIndicator( + key: _refreshIndicatorKey, + onRefresh: _refreshPosts, + child: timelinePostsWidget, + ); } Widget _buildTimelinePosts() { - return RefreshIndicator( - key: _refreshIndicatorKey, - onRefresh: _onRefresh, - child: LoadMore( - whenEmptyLoad: false, - isFinish: _loadingFinished, - delegate: const OBHomePostsLoadMoreDelegate(), - child: ListView.builder( - controller: _postsScrollController, - physics: const ClampingScrollPhysics(), - cacheExtent: 30, - addAutomaticKeepAlives: true, - padding: const EdgeInsets.all(0), - itemCount: _posts.length, - itemBuilder: (context, index) { - var post = _posts[index]; - return OBPost( - post, - onPostDeleted: _onPostDeleted, - key: Key( - post.id.toString(), - ), - ); - }), - onLoadMore: _loadMorePosts)); - } - - Widget _buildNoTimelinePosts() { + return ListView.builder( + controller: _postsScrollController, + physics: const ClampingScrollPhysics(), + cacheExtent: 30, + padding: const EdgeInsets.all(0), + itemCount: _posts.length, + itemBuilder: _buildTimelinePost); + } + + Widget _buildTimelinePost(BuildContext context, int index) { + Post post = _posts[index]; + OBPost postWidget = OBPost( + post, + onPostDeleted: _onPostDeleted, + key: Key( + post.id.toString(), + ), + ); + + bool isLastItem = index == _posts.length - 1; + + if (isLastItem && _status != OBTimelinePostsStatus.idle) { + switch (_status) { + case OBTimelinePostsStatus.loadingMorePosts: + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + postWidget, + OBLoadingIndicatorTile(), + ], + ); + break; + case OBTimelinePostsStatus.loadingMorePostsFailed: + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + postWidget, + OBRetryTile( + onWantsToRetry: _loadMorePosts, + ), + ], + ); + break; + case OBTimelinePostsStatus.noMorePostsToLoad: + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + postWidget, + ListTile( + title: OBSecondaryText( + '🎉 All posts loaded', + textAlign: TextAlign.center, + ), + ), + ], + ); + default: + } + } + + return postWidget; + } + + Widget _buildDrHoo() { + String drHooTitle; + String drHooSubtitle; + bool hasRefreshButton = !_isFirstLoad; + Function refreshFunction = _refreshPosts; + + switch (_status) { + case OBTimelinePostsStatus.refreshingPosts: + drHooTitle = 'Hang in there!'; + drHooSubtitle = 'Loading your timeline.'; + break; + case OBTimelinePostsStatus.noMorePostsToLoad: + drHooTitle = 'Your timeline is empty.'; + drHooSubtitle = 'Follow users or join a community to get started!'; + break; + case OBTimelinePostsStatus.loadingMorePostsFailed: + drHooTitle = 'Could not load your timeline.'; + drHooSubtitle = 'Try again in a couple seconds'; + refreshFunction = _bootstrapPosts; + hasRefreshButton = true; + break; + default: + drHooTitle = 'Something\'s not right.'; + drHooSubtitle = 'Try refreshing the timeline.'; + hasRefreshButton = true; + } + + List drHooColumnItems = [ + Image.asset( + 'assets/images/stickers/owl-instructor.png', + height: 100, + ), + const SizedBox( + height: 20.0, + ), + OBText( + drHooTitle, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18.0, + ), + textAlign: TextAlign.center, + ), + const SizedBox( + height: 10.0, + ), + OBText( + drHooSubtitle, + textAlign: TextAlign.center, + ) + ]; + + if (hasRefreshButton) { + drHooColumnItems.addAll([ + const SizedBox( + height: 30, + ), + OBButton( + icon: const OBIcon( + OBIcons.refresh, + size: OBIconSize.small, + ), + type: OBButtonType.highlight, + child: const OBText('Refresh posts'), + onPressed: refreshFunction, + isLoading: _timelineRequest != null, + ) + ]); + } + return SizedBox( child: Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: 200), child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - 'assets/images/stickers/owl-instructor.png', - height: 100, - ), - const SizedBox( - height: 20.0, - ), - const OBText( - 'Your timeline is empty.', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18.0, - ), - textAlign: TextAlign.center, - ), - const SizedBox( - height: 10.0, - ), - const OBText( - 'Follow users or join a community to get started!', - textAlign: TextAlign.center, - ), - const SizedBox( - height: 30, - ), - OBButton( - icon: const OBIcon( - OBIcons.refresh, - size: OBIconSize.small, - ), - type: OBButtonType.highlight, - child: const OBText('Refresh posts'), - onPressed: _onRefresh, - isLoading: _refreshInProgress, - ) - ], + children: drHooColumnItems, ), ), ), @@ -201,55 +286,101 @@ class OBTimelinePostsState extends State { _userService.loggedInUserChange.listen(_onLoggedInUserChange); } - Future _onRefresh() { - return _refreshPosts(); + void _onScroll() { + if (_status == OBTimelinePostsStatus.loadingMorePosts || + _status == OBTimelinePostsStatus.noMorePostsToLoad) return; + if (_postsScrollController.position.pixels > + _postsScrollController.position.maxScrollExtent * 0.1) { + _loadMorePosts(); + } + } + + void _cancelPreviousTimelineRequest() { + if (_timelineRequest != null) { + _timelineRequest.cancel(); + _timelineRequest = null; + } } void _onLoggedInUserChange(User newUser) async { if (newUser == null) return; - _refreshPosts(); + _bootstrapPosts(); _loggedInUserChangeSubscription.cancel(); } + Future _bootstrapPosts() async { + PostsList storedPosts = await _userService.getStoredFirstPosts(); + if (storedPosts.posts != null) _setPosts(storedPosts.posts); + _setCacheLoadAttempted(true); + _refreshIndicatorKey.currentState.show(); + } + Future _refreshPosts() async { - _setRefreshInProgress(true); + _cancelPreviousTimelineRequest(); + _setStatus(OBTimelinePostsStatus.refreshingPosts); try { - _posts = (await _userService.getTimelinePosts( - circles: _filteredCircles, followsLists: _filteredFollowsLists)) - .posts; - _setPosts(_posts); - _setLoadingFinished(false); + bool areFirstPosts = _isFirstLoad; + bool cachePosts = + _filteredCircles.isEmpty && _filteredFollowsLists.isEmpty; + + Future postsListFuture = _userService.getTimelinePosts( + count: 10, + circles: _filteredCircles, + followsLists: _filteredFollowsLists, + cachePosts: cachePosts, + areFirstPosts: areFirstPosts); + + _timelineRequest = CancelableOperation.fromFuture(postsListFuture); + + List posts = (await postsListFuture).posts; + + if (_isFirstLoad) _isFirstLoad = false; + + if (posts.length == 0) { + _setStatus(OBTimelinePostsStatus.noMorePostsToLoad); + } else { + _setStatus(OBTimelinePostsStatus.idle); + } + _setPosts(posts); } catch (error) { + _setStatus(OBTimelinePostsStatus.loadingMorePostsFailed); _onError(error); } finally { - _setRefreshInProgress(false); + _timelineRequest = null; } } - Future _loadMorePosts() async { + Future _loadMorePosts() async { + if (_status == OBTimelinePostsStatus.refreshingPosts || + _status == OBTimelinePostsStatus.noMorePostsToLoad) return null; + _cancelPreviousTimelineRequest(); + _setStatus(OBTimelinePostsStatus.loadingMorePosts); + var lastPost = _posts.last; var lastPostId = lastPost.id; try { - var morePosts = (await _userService.getTimelinePosts( - maxId: lastPostId, - circles: _filteredCircles, - count: 20, - followsLists: _filteredFollowsLists)) - .posts; + Future morePostsListFuture = _userService.getTimelinePosts( + maxId: lastPostId, + circles: _filteredCircles, + count: 10, + followsLists: _filteredFollowsLists); + + _timelineRequest = CancelableOperation.fromFuture(morePostsListFuture); + + List morePosts = (await morePostsListFuture).posts; if (morePosts.length == 0) { - _setLoadingFinished(true); + _setStatus(OBTimelinePostsStatus.noMorePostsToLoad); } else { - setState(() { - _posts.addAll(morePosts); - }); + _setStatus(OBTimelinePostsStatus.idle); + _addPosts(morePosts); } - return true; } catch (error) { + _setStatus(OBTimelinePostsStatus.loadingMorePostsFailed); _onError(error); + } finally { + _timelineRequest = null; } - - return false; } void _onPostDeleted(Post deletedPost) { @@ -258,35 +389,41 @@ class OBTimelinePostsState extends State { }); } + void _onError(error) async { + if (error is HttpieConnectionRefusedError) { + _toastService.error( + message: error.toHumanReadableMessage(), context: context); + } else if (error is HttpieRequestError) { + String errorMessage = await error.toHumanReadableMessage(); + _toastService.error(message: errorMessage, context: context); + } else { + _toastService.error(message: 'Unknown error', context: context); + throw error; + } + } + void _setPosts(List posts) { setState(() { _posts = posts; }); } - void _setLoadingFinished(bool loadingFinished) { + void _addPosts(List posts) { setState(() { - _loadingFinished = loadingFinished; + _posts.addAll(posts); }); } - void _setRefreshInProgress(bool refreshInProgress) { + void _setStatus(OBTimelinePostsStatus status) { setState(() { - _refreshInProgress = refreshInProgress; + _status = status; }); } - void _onError(error) async { - if (error is HttpieConnectionRefusedError) { - _toastService.error( - message: error.toHumanReadableMessage(), context: context); - } else if (error is HttpieRequestError) { - String errorMessage = await error.toHumanReadableMessage(); - _toastService.error(message: errorMessage, context: context); - } else { - _toastService.error(message: 'Unknown error', context: context); - throw error; - } + void _setCacheLoadAttempted(bool cacheLoadAttempted) { + setState(() { + _cacheLoadAttempted = cacheLoadAttempted; + }); } } @@ -339,43 +476,10 @@ class OBTimelinePostsController { } } -class OBHomePostsLoadMoreDelegate extends LoadMoreDelegate { - const OBHomePostsLoadMoreDelegate(); - - @override - Widget buildChild(LoadMoreStatus status, - {LoadMoreTextBuilder builder = DefaultLoadMoreTextBuilder.chinese}) { - String text = builder(status); - - if (status == LoadMoreStatus.fail) { - return SizedBox( - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.refresh), - const SizedBox( - width: 10.0, - ), - Text('Tap to retry loading posts.') - ], - ), - ); - } - if (status == LoadMoreStatus.idle) { - // No clue why is this even a state. - return const SizedBox(); - } - if (status == LoadMoreStatus.loading) { - return SizedBox( - child: Center( - child: OBProgressIndicator(), - )); - } - if (status == LoadMoreStatus.nomore) { - return const SizedBox(); - } - - return Text(text); - } +enum OBTimelinePostsStatus { + refreshingPosts, + loadingMorePosts, + loadingMorePostsFailed, + noMorePostsToLoad, + idle } diff --git a/lib/plugins/share/receive_share_state.dart b/lib/plugins/share/receive_share_state.dart new file mode 100644 index 000000000..5f5d5e817 --- /dev/null +++ b/lib/plugins/share/receive_share_state.dart @@ -0,0 +1,35 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'share.dart'; + +abstract class ReceiveShareState extends State { + static const stream = const EventChannel('openbook.social/receive_share'); + + StreamSubscription shareReceiveSubscription = null; + + void enableSharing() { + if(Platform.isAndroid){ + if (shareReceiveSubscription == null) { + shareReceiveSubscription = + stream.receiveBroadcastStream().listen(_onReceiveShare); + } + } + } + + void disableSharing() { + if (shareReceiveSubscription != null) { + shareReceiveSubscription.cancel(); + shareReceiveSubscription = null; + } + } + + void _onReceiveShare(dynamic shared) { + onShare(Share.fromReceived(shared)); + } + + void onShare(Share share); +} diff --git a/lib/plugins/share/share.dart b/lib/plugins/share/share.dart new file mode 100644 index 000000000..9cdf04775 --- /dev/null +++ b/lib/plugins/share/share.dart @@ -0,0 +1,24 @@ +class Share { + static const String PATH = 'path'; + static const String TEXT = 'text'; + + final String path; + final String text; + + const Share({ + this.path, + this.text, + }); + + static Share fromReceived(Map received) { + String text; + String path; + if (received.containsKey(TEXT)) { + text = received[TEXT]; + } + if (received.containsKey(PATH)) { + path = received[PATH]; + } + return Share(path: path, text: text); + } +} diff --git a/lib/provider.dart b/lib/provider.dart index 394c31d62..ca5d81d74 100644 --- a/lib/provider.dart +++ b/lib/provider.dart @@ -30,6 +30,7 @@ import 'package:Openbook/services/theme_value_parser.dart'; import 'package:Openbook/services/toast.dart'; import 'package:Openbook/services/url_launcher.dart'; import 'package:Openbook/services/user.dart'; +import 'package:Openbook/services/user_invites_api.dart'; import 'package:Openbook/services/user_preferences.dart'; import 'package:Openbook/services/utils_service.dart'; import 'package:Openbook/services/validation.dart'; @@ -81,6 +82,7 @@ class OpenbookProviderState extends State { ConnectionsCirclesApiService connectionsCirclesApiService = ConnectionsCirclesApiService(); FollowsListsApiService followsListsApiService = FollowsListsApiService(); + UserInvitesApiService userInvitesApiService = UserInvitesApiService(); ThemeValueParserService themeValueParserService = ThemeValueParserService(); ModalService modalService = ModalService(); NavigationService navigationService = NavigationService(); @@ -111,6 +113,8 @@ class OpenbookProviderState extends State { communitiesApiService.setStringTemplateService(stringTemplateService); followsListsApiService.setHttpService(httpService); followsListsApiService.setStringTemplateService(stringTemplateService); + userInvitesApiService.setHttpService(httpService); + userInvitesApiService.setStringTemplateService(stringTemplateService); connectionsApiService.setHttpService(httpService); authApiService.setHttpService(httpService); followsApiService.setHttpService(httpService); @@ -121,6 +125,7 @@ class OpenbookProviderState extends State { userService.setEmojisApiService(emojisApiService); userService.setHttpieService(httpService); userService.setStorageService(storageService); + userService.setUserInvitesApiService(userInvitesApiService); userService.setFollowsApiService(followsApiService); userService.setFollowsListsApiService(followsListsApiService); userService.setConnectionsApiService(connectionsApiService); @@ -148,6 +153,7 @@ class OpenbookProviderState extends State { intercomService.setUserService(userService); dialogService.setThemeService(themeService); dialogService.setThemeValueParserService(themeValueParserService); + imagePickerService.setValidationService(validationService); } void initAsyncState() async { @@ -158,6 +164,7 @@ class OpenbookProviderState extends State { authApiService.setApiURL(environment.apiUrl); postsApiService.setApiURL(environment.apiUrl); emojisApiService.setApiURL(environment.apiUrl); + userInvitesApiService.setApiURL(environment.apiUrl); followsApiService.setApiURL(environment.apiUrl); connectionsApiService.setApiURL(environment.apiUrl); connectionsCirclesApiService.setApiURL(environment.apiUrl); diff --git a/lib/services/auth_api.dart b/lib/services/auth_api.dart index a76c09b17..87d7d78f3 100644 --- a/lib/services/auth_api.dart +++ b/lib/services/auth_api.dart @@ -20,6 +20,10 @@ class AuthApiService { static const GET_USERS_PATH = 'api/auth/users/'; static const GET_LINKED_USERS_PATH = 'api/auth/linked-users/'; static const SEARCH_LINKED_USERS_PATH = 'api/auth/linked-users/search/'; + static const GET_FOLLOWERS_PATH = 'api/auth/followers/'; + static const SEARCH_FOLLOWERS_PATH = 'api/auth/followers/search/'; + static const GET_FOLLOWINGS_PATH = 'api/auth/followings/'; + static const SEARCH_FOLLOWINGS_PATH = 'api/auth/followings/search/'; static const LOGIN_PATH = 'api/auth/login/'; static const REQUEST_RESET_PASSWORD_PATH = 'api/auth/password/reset/'; static const VERIFY_RESET_PASSWORD_PATH = 'api/auth/password/verify/'; @@ -67,8 +71,9 @@ class AuthApiService { } Future verifyEmailWithToken(String token) { - return _httpService.get('$apiURL$VERIFY_EMAIL_TOKEN$token/', - appendAuthorizationToken: true); + Map body = {'token': token}; + return _httpService.postJSON('$apiURL$VERIFY_EMAIL_TOKEN', + body: body, appendAuthorizationToken: true); } Future updateUser({ @@ -187,29 +192,78 @@ class AuthApiService { appendAuthorizationToken: authenticatedRequest); } + Future searchFollowers( + {@required String query, int count}) { + Map queryParams = {'query': query}; + + if (count != null) queryParams['count'] = count; + + return _httpService.get('$apiURL$SEARCH_FOLLOWERS_PATH', + queryParameters: queryParams, appendAuthorizationToken: true); + } + + Future getFollowers( + {bool authenticatedRequest = true, int maxId, int count}) { + Map queryParams = {}; + + if (count != null) queryParams['count'] = count; + + if (maxId != null) queryParams['max_id'] = maxId; + + return _httpService.get('$apiURL$GET_FOLLOWERS_PATH', + queryParameters: queryParams, + appendAuthorizationToken: authenticatedRequest); + } + + Future searchFollowings( + {@required String query, int count}) { + Map queryParams = {'query': query}; + + if (count != null) queryParams['count'] = count; + + return _httpService.get('$apiURL$SEARCH_FOLLOWINGS_PATH', + queryParameters: queryParams, appendAuthorizationToken: true); + } + + Future getFollowings({ + bool authenticatedRequest = true, + int maxId, + int count, + }) { + Map queryParams = {}; + + if (count != null) queryParams['count'] = count; + + if (maxId != null) queryParams['max_id'] = maxId; + + return _httpService.get('$apiURL$GET_FOLLOWINGS_PATH', + queryParameters: queryParams, + appendAuthorizationToken: authenticatedRequest); + } + Future loginWithCredentials( {@required String username, @required String password}) { return this._httpService.postJSON('$apiURL$LOGIN_PATH', body: {'username': username, 'password': password}); } - Future requestPasswordReset( - {String username, String email}) { + Future requestPasswordReset({String username, String email}) { var body = {}; if (username != null && username != '') { - body = {'username': username }; + body = {'username': username}; } if (email != null && email != '') { body['email'] = email; } - return this._httpService.postJSON('$apiURL$REQUEST_RESET_PASSWORD_PATH', - body: body); + return this + ._httpService + .postJSON('$apiURL$REQUEST_RESET_PASSWORD_PATH', body: body); } Future verifyPasswordReset( {String newPassword, String passwordResetToken}) { return this._httpService.postJSON('$apiURL$VERIFY_RESET_PASSWORD_PATH', - body: {'new_password': newPassword , 'token': passwordResetToken}); + body: {'new_password': newPassword, 'token': passwordResetToken}); } Future getAuthenticatedUserNotificationsSettings() { diff --git a/lib/services/bottom_sheet.dart b/lib/services/bottom_sheet.dart index d597df7fa..3b98f6d25 100644 --- a/lib/services/bottom_sheet.dart +++ b/lib/services/bottom_sheet.dart @@ -4,10 +4,12 @@ import 'package:Openbook/models/circle.dart'; import 'package:Openbook/models/community.dart'; import 'package:Openbook/models/follows_list.dart'; import 'package:Openbook/models/post.dart'; +import 'package:Openbook/models/post_comment.dart'; import 'package:Openbook/models/post_reaction.dart'; import 'package:Openbook/pages/home/bottom_sheets/community_actions.dart'; import 'package:Openbook/pages/home/bottom_sheets/community_type_picker.dart'; import 'package:Openbook/pages/home/bottom_sheets/connection_circles_picker.dart'; +import 'package:Openbook/pages/home/bottom_sheets/comment_more_actions.dart'; import 'package:Openbook/pages/home/bottom_sheets/follows_lists_picker.dart'; import 'package:Openbook/pages/home/bottom_sheets/photo_picker.dart'; import 'package:Openbook/pages/home/bottom_sheets/post_actions.dart'; @@ -107,6 +109,20 @@ class BottomSheetService { }); } + Future showMoreCommentActions( + {@required BuildContext context, + @required Post post, + @required PostComment postComment}) { + return showModalBottomSheetApp( + context: context, + builder: (BuildContext context) { + return OBCommentMoreActionsBottomSheet( + post: post, + postComment: postComment + ); + }); + } + Future showPhotoPicker( {@required BuildContext context, OBImageType imageType = OBImageType.post}) { diff --git a/lib/services/httpie.dart b/lib/services/httpie.dart index 048285215..4e9d7a526 100644 --- a/lib/services/httpie.dart +++ b/lib/services/httpie.dart @@ -356,7 +356,10 @@ class HttpieService { errorCode == 111 || // Network is unreachable errorCode == 101 || + errorCode == 104 || errorCode == 51 || + errorCode == 8 || + errorCode == 7 || errorCode == 64) { // Connection refused. throw HttpieConnectionRefusedError(error); diff --git a/lib/services/image_picker.dart b/lib/services/image_picker.dart index 942b2ce12..00f1972b0 100644 --- a/lib/services/image_picker.dart +++ b/lib/services/image_picker.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:image_cropper/image_cropper.dart'; import 'package:image_picker/image_picker.dart'; import 'package:meta/meta.dart'; +import 'package:Openbook/services/validation.dart'; export 'package:image_picker/image_picker.dart'; class ImagePickerService { @@ -11,6 +12,12 @@ class ImagePickerService { OBImageType.cover: {'x': 16.0, 'y': 9.0} }; + ValidationService _validationService; + + void setValidationService(ValidationService validationService) { + _validationService = validationService; + } + Future pickImage( {@required OBImageType imageType, ImageSource source = ImageSource.gallery}) async { @@ -20,6 +27,10 @@ class ImagePickerService { return null; } + if (!await _validationService.isImageAllowedSize(image, imageType)) { + throw ImageTooLargeException(_validationService.getAllowedImageSize(imageType)); + } + double ratioX = imageType != OBImageType.post ? IMAGE_RATIOS[imageType]['x'] : null; double ratioY = @@ -43,4 +54,17 @@ class ImagePickerService { } } +class ImageTooLargeException implements Exception { + final int limit; + + const ImageTooLargeException(this.limit); + + String toString() => + 'ImageToLargeException: Images can\'t be larger than $limit'; + + int getLimitInMB() { + return limit ~/ 1048576; + } +} + enum OBImageType { avatar, cover, post } diff --git a/lib/services/modal_service.dart b/lib/services/modal_service.dart index 16e67078c..7a611f65e 100644 --- a/lib/services/modal_service.dart +++ b/lib/services/modal_service.dart @@ -1,10 +1,16 @@ +import 'dart:io'; + import 'package:Openbook/models/circle.dart'; import 'package:Openbook/models/community.dart'; import 'package:Openbook/models/follows_list.dart'; import 'package:Openbook/models/post.dart'; +import 'package:Openbook/models/post_comment.dart'; import 'package:Openbook/models/post_reaction.dart'; import 'package:Openbook/models/user.dart'; +import 'package:Openbook/models/user_invite.dart'; +import 'package:Openbook/pages/home/modals/edit_post/edit_post.dart'; import 'package:Openbook/pages/home/modals/invite_to_community.dart'; +import 'package:Openbook/pages/home/modals/post_comment/post-commenter-expanded.dart'; import 'package:Openbook/pages/home/pages/community/pages/manage_community/pages/community_administrators/modals/add_community_administrator/add_community_administrator.dart'; import 'package:Openbook/pages/home/modals/create_post/create_post.dart'; import 'package:Openbook/pages/home/modals/edit_user_profile/edit_user_profile.dart'; @@ -14,19 +20,24 @@ import 'package:Openbook/pages/home/modals/save_follows_list/save_follows_list.d import 'package:Openbook/pages/home/modals/timeline_filters.dart'; import 'package:Openbook/pages/home/pages/community/pages/manage_community/pages/community_banned_users/modals/ban_community_user/ban_community_user.dart'; import 'package:Openbook/pages/home/pages/community/pages/manage_community/pages/community_moderators/modals/add_community_moderator/add_community_moderator.dart'; +import 'package:Openbook/pages/home/modals/user_invites/create_user_invite.dart'; +import 'package:Openbook/pages/home/modals/user_invites/send_invite_email.dart'; import 'package:Openbook/pages/home/pages/timeline/timeline.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class ModalService { - Future openCreatePost({@required BuildContext context, Community community}) async { + Future openCreatePost( + {@required BuildContext context, Community community, String text, File image}) async { Post createdPost = await Navigator.of(context, rootNavigator: true) .push(CupertinoPageRoute( fullscreenDialog: true, builder: (BuildContext context) { return Material( child: CreatePostModal( - community: community + community: community, + text: text, + image: image, ), ); })); @@ -34,6 +45,38 @@ class ModalService { return createdPost; } + Future openEditPost( + {@required BuildContext context, @required Post post}) async { + Post editedPost = await Navigator.of(context, rootNavigator: true) + .push(CupertinoPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return Material( + child: EditPostModal( + post: post, + ), + ); + })); + + return editedPost; + } + + Future openExpandedCommenter( + {@required BuildContext context, @required PostComment postComment, @required Post post}) async { + PostComment editedComment = await Navigator.of(context, rootNavigator: true) + .push(CupertinoPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return Material( + child: OBPostCommenterExpandedModal( + post: post, + postComment: postComment, + ), + ); + })); + return editedComment; + } + Future openEditUserProfile( {@required User user, @required BuildContext context}) async { Navigator.of(context, rootNavigator: true) @@ -216,4 +259,50 @@ class ModalService { ); })); } + + Future openCreateUserInvite({ + @required BuildContext context + }) async { + UserInvite createdUserInvite = + await Navigator.of(context).push(CupertinoPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return OBCreateUserInviteModal( + autofocusNameTextField: true, + ); + })); + + return createdUserInvite; + } + + Future openEditUserInvite({ + @required BuildContext context, + @required UserInvite userInvite + }) async { + UserInvite editedUserInvite = + await Navigator.of(context).push(CupertinoPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return OBCreateUserInviteModal( + autofocusNameTextField: true, + userInvite: userInvite, + ); + })); + + return editedUserInvite; + } + + Future openSendUserInviteEmail({ + @required BuildContext context, + @required UserInvite userInvite + }) async { + await Navigator.of(context).push(CupertinoPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) { + return OBSendUserInviteEmailModal( + autofocusEmailTextField: true, + userInvite: userInvite, + ); + })); + } } diff --git a/lib/services/navigation_service.dart b/lib/services/navigation_service.dart index 0e1e1d238..514390a2a 100644 --- a/lib/services/navigation_service.dart +++ b/lib/services/navigation_service.dart @@ -6,11 +6,13 @@ import 'package:Openbook/models/post.dart'; import 'package:Openbook/models/post_comment.dart'; import 'package:Openbook/models/post_reactions_emoji_count.dart'; import 'package:Openbook/models/user.dart'; +import 'package:Openbook/models/user_invite.dart'; import 'package:Openbook/pages/home/modals/create_post/pages/share_post/pages/share_post_with_circles.dart'; import 'package:Openbook/pages/home/modals/create_post/pages/share_post/pages/share_post_with_community.dart'; import 'package:Openbook/pages/home/modals/create_post/pages/share_post/share_post.dart'; import 'package:Openbook/pages/home/modals/post_reactions/post_reactions.dart'; import 'package:Openbook/pages/home/pages/community/community.dart'; +import 'package:Openbook/pages/home/pages/community/pages/community_members.dart'; import 'package:Openbook/pages/home/pages/community/pages/manage_community/manage_community.dart'; import 'package:Openbook/pages/home/pages/community/pages/manage_community/pages/community_administrators/community_administrators.dart'; import 'package:Openbook/pages/home/pages/community/pages/manage_community/pages/community_administrators/modals/add_community_administrator/pages/confirm_add_community_administrator.dart'; @@ -24,9 +26,17 @@ import 'package:Openbook/pages/home/pages/menu/pages/connections_circle/connecti import 'package:Openbook/pages/home/pages/menu/pages/connections_circles/connections_circles.dart'; import 'package:Openbook/pages/home/pages/menu/pages/delete_account/delete_account.dart'; import 'package:Openbook/pages/home/pages/menu/pages/delete_account/pages/confirm_delete_account.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/followers.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/following.dart'; import 'package:Openbook/pages/home/pages/menu/pages/follows_list/follows_list.dart'; import 'package:Openbook/pages/home/pages/menu/pages/follows_lists/follows_lists.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/settings/pages/account_settings/account_settings.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/settings/pages/application_settings.dart'; import 'package:Openbook/pages/home/pages/menu/pages/settings/settings.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/useful_links.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/user_invites/pages/user_invite_detail.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/user_invites/user_invites.dart'; +import 'package:Openbook/pages/home/pages/menu/pages/themes/themes.dart'; import 'package:Openbook/pages/home/pages/notifications/pages/notifications_settings.dart'; import 'package:Openbook/pages/home/pages/post/post.dart'; import 'package:Openbook/pages/home/pages/post_comments/post.dart'; @@ -165,6 +175,17 @@ class NavigationService { ))); } + Future navigateToCommunityMembers( + {@required Community community, @required BuildContext context}) { + return Navigator.push( + context, + OBSlideRightRoute( + key: Key('obCommunityMembersPage'), + widget: OBCommunityMembersPage( + community: community, + ))); + } + Future navigateToCommunityModerators( {@required Community community, @required BuildContext context}) { return Navigator.push( @@ -197,29 +218,22 @@ class NavigationService { } Future navigateToPostComments( - {@required Post post, - @required BuildContext context}) { + {@required Post post, @required BuildContext context}) { return Navigator.push( context, OBSlideRightRoute( key: Key('obSlideViewComments'), - widget: OBPostCommentsPage( - post, - autofocusCommentInput: false - ))); + widget: OBPostCommentsPage(post, autofocusCommentInput: false))); } Future navigateToPostCommentsLinked( - {@required PostComment postComment, - @required BuildContext context}) { + {@required PostComment postComment, @required BuildContext context}) { return Navigator.push( context, OBSlideRightRoute( key: Key('obSlideViewCommentsLinked'), - widget: OBPostCommentsLinkedPage( - postComment, - autofocusCommentInput: false - ))); + widget: OBPostCommentsLinkedPage(postComment, + autofocusCommentInput: false))); } Future navigateToPost({@required Post post, @required BuildContext context}) { @@ -234,6 +248,48 @@ class NavigationService { key: Key('obMenuViewSettings'), widget: OBSettingsPage())); } + Future navigateToFollowersPage({@required BuildContext context}) { + return Navigator.push( + context, + OBSlideRightRoute( + key: Key('obFollowersPage'), widget: OBFollowersPage())); + } + + Future navigateToFollowingPage({@required BuildContext context}) { + return Navigator.push( + context, + OBSlideRightRoute( + key: Key('obFollowingPage'), widget: OBFollowingPage())); + } + + Future navigateToAccountSettingsPage({@required BuildContext context}) { + return Navigator.push( + context, + OBSlideRightRoute( + key: Key('obAccountSettingsPage'), + widget: OBAccountSettingsPage())); + } + + Future navigateToApplicationSettingsPage({@required BuildContext context}) { + return Navigator.push( + context, + OBSlideRightRoute( + key: Key('obApplicationSettingsPage'), + widget: OBApplicationSettingsPage())); + } + + Future navigateToThemesPage({@required BuildContext context}) { + return Navigator.push(context, + OBSlideRightRoute(key: Key('obMenuThemes'), widget: OBThemesPage())); + } + + Future navigateToUsefulLinksPage({@required BuildContext context}) { + return Navigator.push( + context, + OBSlideRightRoute( + key: Key('obMenuUsefulLinks'), widget: OBUsefulLinksPage())); + } + Future navigateToSharePost( {@required BuildContext context, @required SharePostData sharePostData}) { return Navigator.push( @@ -274,6 +330,33 @@ class NavigationService { key: Key('obSeeFollowsLists'), widget: OBFollowsListsPage())); } + Future navigateToUserInvites({@required BuildContext context}) { + return Navigator.push( + context, + OBSlideRightRoute( + key: Key('obSeeUserInvites'), widget: OBUserInvitesPage())); + } + + Future navigateToShareInvite({@required BuildContext context, @required UserInvite userInvite}) { + return Navigator.push( + context, + OBSlideRightRoute( + key: Key('obShareUserInvitePage'), widget: OBUserInviteDetailPage( + userInvite: userInvite, + showEdit: false + ))); + } + + Future navigateToInviteDetailPage({@required BuildContext context, @required UserInvite userInvite}) { + return Navigator.push( + context, + OBSlideRightRoute( + key: Key('obSeeUserInviteDetail'), widget: OBUserInviteDetailPage( + userInvite: userInvite, + showEdit: true + ))); + } + Future navigateToConnectionsCircles({@required BuildContext context}) { return Navigator.push( context, diff --git a/lib/services/posts_api.dart b/lib/services/posts_api.dart index fe529ef3d..efa93d70a 100644 --- a/lib/services/posts_api.dart +++ b/lib/services/posts_api.dart @@ -13,8 +13,10 @@ class PostsApiService { static const GET_POSTS_PATH = 'api/posts/'; static const GET_TRENDING_POSTS_PATH = 'api/posts/trending/'; static const CREATE_POST_PATH = 'api/posts/'; + static const EDIT_POST_PATH = 'api/posts/{postUuid}/'; static const POST_PATH = 'api/posts/{postUuid}/'; static const COMMENT_POST_PATH = 'api/posts/{postUuid}/comments/'; + static const EDIT_COMMENT_POST_PATH = 'api/posts/{postUuid}/comments/{postCommentId}/'; static const MUTE_POST_PATH = 'api/posts/{postUuid}/notifications/mute/'; static const UNMUTE_POST_PATH = 'api/posts/{postUuid}/notifications/unmute/'; static const DELETE_POST_COMMENT_PATH = @@ -94,6 +96,22 @@ class PostsApiService { body: body, appendAuthorizationToken: true); } + Future editPost( + {@required String postUuid, String text}) { + Map body = {}; + + body['post_uuid'] = postUuid; + + if (text != null && text.length > 0) { + body['text'] = text; + } + + String path = _makePostPath(postUuid); + + return _httpService.patchMultiform(_makeApiUrl(path), + body: body, appendAuthorizationToken: true); + } + Future getPostWithUuid(String postUuid) { String path = _makePostPath(postUuid); @@ -132,6 +150,15 @@ class PostsApiService { body: body, appendAuthorizationToken: true); } + Future editPostComment( + {@required String postUuid, @required int postCommentId, @required String text}) { + Map body = {'text': text}; + + String path = _makeEditCommentPostPath(postUuid, postCommentId); + return _httpService.patchJSON(_makeApiUrl(path), + body: body, appendAuthorizationToken: true); + } + Future deletePostComment( {@required postCommentId, @required postUuid}) { String path = _makeDeletePostCommentPath( @@ -216,6 +243,11 @@ class PostsApiService { .parse(COMMENT_POST_PATH, {'postUuid': postUuid}); } + String _makeEditCommentPostPath(String postUuid, int postCommentId) { + return _stringTemplateService + .parse(EDIT_COMMENT_POST_PATH, {'postUuid': postUuid, 'postCommentId': postCommentId}); + } + String _makeGetPostCommentsPath(String postUuid) { return _stringTemplateService .parse(GET_POST_COMMENTS_PATH, {'postUuid': postUuid}); diff --git a/lib/services/push_notifications/push_notifications.dart b/lib/services/push_notifications/push_notifications.dart index 71b43f861..c4786f36e 100644 --- a/lib/services/push_notifications/push_notifications.dart +++ b/lib/services/push_notifications/push_notifications.dart @@ -30,7 +30,7 @@ class PushNotificationsService { OneSignal.shared .setInFocusDisplayType(OSNotificationDisplayType.notification); - + OneSignal.shared.setLocationShared(false); OneSignal.shared.setNotificationReceivedHandler(_onNotificationReceived); OneSignal.shared.setNotificationOpenedHandler(_onNotificationOpened); OneSignal.shared.setSubscriptionObserver(_onSubscriptionChanged); diff --git a/lib/services/storage.dart b/lib/services/storage.dart index 83c553d7b..0b3491717 100644 --- a/lib/services/storage.dart +++ b/lib/services/storage.dart @@ -1,4 +1,5 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter/services.dart'; class StorageService { OBStorage getSecureStorage({String namespace}) { @@ -36,21 +37,31 @@ class OBStorage { class _SecureStore implements _Store { final storage = new FlutterSecureStorage(); - - Future get(String key) { - return storage.read(key: key); + Set _storedKeys = Set(); + + Future get(String key) async { + try { + return storage.read(key: key); + } on PlatformException { + // This might happen when failed to decrypt + await storage.delete(key: key); + rethrow; + } } Future set(String key, String value) { + _storedKeys.add(value); return storage.write(key: key, value: value); } Future remove(String key) { + _storedKeys.remove(key); return storage.delete(key: key); } Future clear() { - return storage.deleteAll(); + return Future.wait( + _storedKeys.map((String key) => storage.delete(key: key))); } } diff --git a/lib/services/universal_links/handlers/email_verification_link.dart b/lib/services/universal_links/handlers/email_verification_link.dart index 494b335d9..14e61cb67 100644 --- a/lib/services/universal_links/handlers/email_verification_link.dart +++ b/lib/services/universal_links/handlers/email_verification_link.dart @@ -32,7 +32,7 @@ class EmailVerificationLinkHandler extends UniversalLinkHandler { if (response.isOk()) { toastService.success( message: 'Awesome! Your email is now verified', context: context); - } else if (response.isUnauthorized()) { + } else if (response.isBadRequest()) { toastService.error( message: 'Oops! Your token was not valid or expired, please try again', diff --git a/lib/services/user.dart b/lib/services/user.dart index 5b2e739b3..d6e0b441c 100644 --- a/lib/services/user.dart +++ b/lib/services/user.dart @@ -27,6 +27,8 @@ import 'package:Openbook/models/post_reaction_list.dart'; import 'package:Openbook/models/post_reactions_emoji_count_list.dart'; import 'package:Openbook/models/posts_list.dart'; import 'package:Openbook/models/user.dart'; +import 'package:Openbook/models/user_invite.dart'; +import 'package:Openbook/models/user_invites_list.dart'; import 'package:Openbook/models/user_notifications_settings.dart'; import 'package:Openbook/models/users_list.dart'; import 'package:Openbook/pages/auth/create_account/blocs/create_account.dart'; @@ -43,6 +45,7 @@ import 'package:Openbook/services/follows_lists_api.dart'; import 'package:Openbook/services/notifications_api.dart'; import 'package:Openbook/services/posts_api.dart'; import 'package:Openbook/services/storage.dart'; +import 'package:Openbook/services/user_invites_api.dart'; import 'package:crypto/crypto.dart'; import 'package:device_info/device_info.dart'; import 'package:flutter_advanced_networkimage/provider.dart'; @@ -67,6 +70,7 @@ class UserService { ConnectionsApiService _connectionsApiService; ConnectionsCirclesApiService _connectionsCirclesApiService; FollowsListsApiService _followsListsApiService; + UserInvitesApiService _userInvitesApiService; NotificationsApiService _notificationsApiService; DevicesApiService _devicesApiService; CreateAccountBloc _createAccountBlocService; @@ -94,6 +98,10 @@ class UserService { _followsApiService = followsApiService; } + void setUserInvitesApiService(UserInvitesApiService userInvitesApiService) { + _userInvitesApiService = userInvitesApiService; + } + void setFollowsListsApiService( FollowsListsApiService followsListsApiService) { _followsListsApiService = followsListsApiService; @@ -149,18 +157,24 @@ class UserService { Future logout() async { _deleteCurrentDevice(); - await _removeStoredFirstPostsData(); await _removeStoredUserData(); await _removeStoredAuthToken(); - DiskCache().clear(); _httpieService.removeAuthorizationToken(); _removeLoggedInUser(); - Post.clearCache(); + await clearCache(); User.clearSessionCache(); - User.clearNavigationCache(); _getOrCreateCurrentDeviceCache = null; } + Future clearCache() async { + await _removeStoredFirstPostsData(); + await DiskCache().clear(); + Post.clearCache(); + User.clearNavigationCache(); + PostComment.clearCache(); + Community.clearCache(); + } + Future loginWithCredentials( {@required String username, @required String password}) async { HttpieResponse response = await _authApiService.loginWithCredentials( @@ -217,10 +231,6 @@ class UserService { Future updateUserEmail(String email) async { HttpieStreamedResponse response = await _authApiService.updateUserEmail(email: email); - - if (response.isBadRequest()) { - return getLoggedInUser(); - } _checkResponseIsOk(response); String userData = await response.readAsString(); return _makeLoggedInUser(userData); @@ -310,32 +320,30 @@ class UserService { int maxId, int count, String username, - bool areFirstPosts = false}) async { - try { - HttpieResponse response = await _postsApiService.getTimelinePosts( - circleIds: circles.map((circle) => circle.id).toList(), - listIds: followsLists.map((followsList) => followsList.id).toList(), - maxId: maxId, - count: count, - username: username, - authenticatedRequest: true); - _checkResponseIsOk(response); - String postsData = response.body; - if (areFirstPosts) { - this._storeFirstPostsData(postsData); - } - return _makePostsList(postsData); - } on HttpieConnectionRefusedError { - if (areFirstPosts) { - // Response failed. Use stored first posts. - String firstPostsData = await this._getStoredFirstPostsData(); - if (firstPostsData != null) { - var postsList = _makePostsList(firstPostsData); - return postsList; - } - } - rethrow; + bool areFirstPosts = false, + bool cachePosts = false}) async { + HttpieResponse response = await _postsApiService.getTimelinePosts( + circleIds: circles.map((circle) => circle.id).toList(), + listIds: followsLists.map((followsList) => followsList.id).toList(), + maxId: maxId, + count: count, + username: username, + authenticatedRequest: true); + _checkResponseIsOk(response); + String postsData = response.body; + if (cachePosts) { + this._storeFirstPostsData(postsData); } + return _makePostsList(postsData); + } + + Future getStoredFirstPosts() async { + String firstPostsData = await this._getStoredFirstPostsData(); + if (firstPostsData != null) { + var postsList = _makePostsList(firstPostsData); + return postsList; + } + return PostsList(); } Future createPost( @@ -358,6 +366,16 @@ class UserService { return Post.fromJson(json.decode(responseBody)); } + Future editPost({String postUuid, String text}) async { + HttpieStreamedResponse response = + await _postsApiService.editPost(postUuid: postUuid, text: text); + + _checkResponseIsOk(response); + + String responseBody = await response.readAsString(); + return Post.fromJson(json.decode(responseBody)); + } + Future deletePost(Post post) async { HttpieResponse response = await _postsApiService.deletePostWithUuid(post.uuid); @@ -413,7 +431,17 @@ class UserService { HttpieResponse response = await _postsApiService.commentPost(postUuid: post.uuid, text: text); _checkResponseIsCreated(response); - return PostComment.fromJson(json.decode(response.body)); + return PostComment.fromJSON(json.decode(response.body)); + } + + Future editPostComment( + {@required Post post, + @required PostComment postComment, + @required String text}) async { + HttpieResponse response = await _postsApiService.editPostComment( + postUuid: post.uuid, postCommentId: postComment.id, text: text); + _checkResponseIsOk(response); + return PostComment.fromJSON(json.decode(response.body)); } Future deletePostComment( @@ -454,7 +482,6 @@ class UserService { : null); _checkResponseIsOk(response); - return PostCommentList.fromJson(json.decode(response.body)); } @@ -508,6 +535,43 @@ class UserService { return UsersList.fromJson(json.decode(response.body)); } + Future searchFollowers({@required String query, int count}) async { + HttpieResponse response = + await _authApiService.searchFollowers(query: query, count: count); + _checkResponseIsOk(response); + return UsersList.fromJson(json.decode(response.body)); + } + + Future getFollowers( + {bool authenticatedRequest = true, + int maxId, + int count, + Community withCommunity}) async { + HttpieResponse response = + await _authApiService.getFollowers(count: count, maxId: maxId); + _checkResponseIsOk(response); + return UsersList.fromJson(json.decode(response.body)); + } + + Future searchFollowings( + {@required String query, int count, Community withCommunity}) async { + HttpieResponse response = + await _authApiService.searchFollowings(query: query, count: count); + _checkResponseIsOk(response); + return UsersList.fromJson(json.decode(response.body)); + } + + Future getFollowings( + {bool authenticatedRequest = true, + int maxId, + int count, + Community withCommunity}) async { + HttpieResponse response = + await _authApiService.getFollowings(count: count, maxId: maxId); + _checkResponseIsOk(response); + return UsersList.fromJson(json.decode(response.body)); + } + Future followUserWithUsername(String username, {List followsLists = const []}) async { HttpieResponse response = await _followsApiService.followUserWithUsername( @@ -643,6 +707,58 @@ class UserService { return FollowsList.fromJSON(json.decode(response.body)); } + Future createUserInvite({String nickname}) async { + HttpieStreamedResponse response = await _userInvitesApiService. + createUserInvite(nickname: nickname); + _checkResponseIsCreated(response); + + String responseBody = await response.readAsString(); + return UserInvite.fromJSON(json.decode(responseBody)); + } + + Future updateUserInvite({String nickname, UserInvite userInvite}) async { + HttpieStreamedResponse response = await _userInvitesApiService. + updateUserInvite(nickname: nickname, userInviteId: userInvite.id); + _checkResponseIsOk(response); + + String responseBody = await response.readAsString(); + return UserInvite.fromJSON(json.decode(responseBody)); + } + + Future getUserInvites({int offset, int count, UserInviteFilterByStatus status}) async { + bool isPending = status != null ? + UserInvite.convertUserInviteStatusToBool(status) : + UserInvite.convertUserInviteStatusToBool(UserInviteFilterByStatus.all); + + HttpieResponse response = await _userInvitesApiService.getUserInvites(isStatusPending: isPending, count: count, offset: offset); + _checkResponseIsOk(response); + return UserInvitesList.fromJson(json.decode(response.body)); + } + + + Future searchUserInvites({int count, UserInviteFilterByStatus status, String query}) async { + bool isPending = status != null ? + UserInvite.convertUserInviteStatusToBool(status) : + UserInvite.convertUserInviteStatusToBool(UserInviteFilterByStatus.all); + + HttpieResponse response = await _userInvitesApiService.searchUserInvites(isStatusPending: isPending, count: count, query: query); + _checkResponseIsOk(response); + return UserInvitesList.fromJson(json.decode(response.body)); + } + + Future deleteUserInvite(UserInvite userInvite) async { + HttpieResponse response = + await _userInvitesApiService.deleteUserInvite(userInvite.id); + _checkResponseIsOk(response); + } + + Future sendUserInviteEmail(UserInvite userInvite, String email) async { + HttpieResponse response = + await _userInvitesApiService.emailUserInvite(userInviteId: userInvite.id, email: email); + + _checkResponseIsOk(response); + } + Future getTrendingCommunities({Category category}) async { HttpieResponse response = await _communitiesApiService .getTrendingCommunities(category: category?.name); diff --git a/lib/services/user_invites_api.dart b/lib/services/user_invites_api.dart new file mode 100644 index 000000000..ff8941eff --- /dev/null +++ b/lib/services/user_invites_api.dart @@ -0,0 +1,108 @@ +import 'package:Openbook/services/httpie.dart'; +import 'package:Openbook/services/string_template.dart'; +import 'package:meta/meta.dart'; + +class UserInvitesApiService { + HttpieService _httpService; + StringTemplateService _stringTemplateService; + + String apiURL; + + static const GET_USER_INVITES_PATH = 'api/invites/'; + static const SEARCH_USER_INVITES_PATH = 'api/invites/search/'; + static const CREATE_USER_INVITE_PATH = 'api/invites/'; + static const UPDATE_USER_INVITE_PATH = 'api/invites/{userInviteId}/'; + static const DELETE_INVITE_PATH = 'api/invites/{userInviteId}/'; + static const EMAIL_INVITE_PATH = 'api/invites/{userInviteId}/email/'; + + + void setHttpService(HttpieService httpService) { + _httpService = httpService; + } + + void setStringTemplateService(StringTemplateService stringTemplateService) { + _stringTemplateService = stringTemplateService; + } + + void setApiURL(String newApiURL) { + apiURL = newApiURL; + } + + Future createUserInvite( + {@required String nickname}) { + Map body = {}; + + if (nickname != null) { + body['nickname'] = nickname; + } + + return _httpService.putMultiform(_makeApiUrl(CREATE_USER_INVITE_PATH), + body: body, appendAuthorizationToken: true); + } + + Future updateUserInvite( + {@required String nickname, @required int userInviteId}) { + Map body = {}; + + if (nickname != null) { + body['nickname'] = nickname; + } + String path = _stringTemplateService.parse(UPDATE_USER_INVITE_PATH, {'userInviteId': userInviteId}); + return _httpService.patchMultiform(_makeApiUrl(path), + body: body, appendAuthorizationToken: true); + } + + Future getUserInvites( + { int offset, + int count, + bool isStatusPending}) { + Map queryParams = {}; + + if (count != null) queryParams['count'] = count; + if (offset != null) queryParams['offset'] = offset; + if (isStatusPending != null) queryParams['pending'] = isStatusPending; + + return _httpService.get(_makeApiUrl(GET_USER_INVITES_PATH), + queryParameters: queryParams, + appendAuthorizationToken: true); + } + + Future searchUserInvites( + { int count, + bool isStatusPending, + String query}) { + Map queryParams = {}; + + if (count != null) queryParams['count'] = count; + if (query != null) queryParams['query'] = query; + if (isStatusPending != null) queryParams['pending'] = isStatusPending; + + return _httpService.get(_makeApiUrl(SEARCH_USER_INVITES_PATH), + queryParameters: queryParams, + appendAuthorizationToken: true); + } + + Future deleteUserInvite(int userInviteId) { + String path = _stringTemplateService.parse(DELETE_INVITE_PATH, {'userInviteId': userInviteId}); + return _httpService.delete(_makeApiUrl(path), + appendAuthorizationToken: true); + } + + Future emailUserInvite( + {@required int userInviteId, @required String email}) { + String path = _stringTemplateService.parse(EMAIL_INVITE_PATH, {'userInviteId': userInviteId}); + Map body = {}; + + if (email != null) { + body['email'] = email; + } + + return _httpService.post(_makeApiUrl(path), + body: body, + appendAuthorizationToken: true); + } + + String _makeApiUrl(String string) { + return '$apiURL$string'; + } +} diff --git a/lib/services/user_preferences.dart b/lib/services/user_preferences.dart index f67d52213..ddb3a0b14 100644 --- a/lib/services/user_preferences.dart +++ b/lib/services/user_preferences.dart @@ -36,4 +36,8 @@ class UserPreferencesService { PostCommentsSortType _getDefaultPostCommentsSortType() { return PostCommentsSortType.asc; } + + Future clear() { + return _storage.clear(); + } } diff --git a/lib/services/validation.dart b/lib/services/validation.dart index 7e84acbe1..7975719e4 100644 --- a/lib/services/validation.dart +++ b/lib/services/validation.dart @@ -1,8 +1,10 @@ +import 'dart:io'; import 'package:Openbook/services/auth_api.dart'; import 'package:Openbook/services/communities_api.dart'; import 'package:Openbook/services/connections_circles_api.dart'; import 'package:Openbook/services/follows_lists_api.dart'; import 'package:Openbook/services/httpie.dart'; +import 'package:Openbook/services/image_picker.dart'; import 'package:validators/validators.dart' as validators; class ValidationService { @@ -28,6 +30,9 @@ class ValidationService { static const int PROFILE_NAME_MIN_LENGTH = 1; static const int PROFILE_LOCATION_MAX_LENGTH = 64; static const int PROFILE_BIO_MAX_LENGTH = 150; + static const int POST_IMAGE_MAX_SIZE = 20971520; + static const int AVATAR_IMAGE_MAX_SIZE = 10485760; + static const int COVER_IMAGE_MAX_SIZE = 10485760; void setAuthApiService(AuthApiService authApiService) { _authApiService = authApiService; @@ -214,6 +219,21 @@ class ValidationService { name.length <= PROFILE_NAME_MAX_LENGTH; } + Future isImageAllowedSize(File image, OBImageType type) async { + int size = await image.length(); + return size <= getAllowedImageSize(type); + } + + int getAllowedImageSize(OBImageType type) { + if (type == OBImageType.avatar) { + return AVATAR_IMAGE_MAX_SIZE; + } else if (type == OBImageType.cover) { + return COVER_IMAGE_MAX_SIZE; + } else { + return POST_IMAGE_MAX_SIZE; + } + } + String validateUserUsername(String username) { assert(username != null); diff --git a/lib/widgets/buttons/actions/follow_button.dart b/lib/widgets/buttons/actions/follow_button.dart index 158022ebd..d852b5780 100644 --- a/lib/widgets/buttons/actions/follow_button.dart +++ b/lib/widgets/buttons/actions/follow_button.dart @@ -4,12 +4,17 @@ import 'package:Openbook/services/httpie.dart'; import 'package:Openbook/services/toast.dart'; import 'package:Openbook/services/user.dart'; import 'package:Openbook/widgets/buttons/button.dart'; +export 'package:Openbook/widgets/buttons/button.dart'; import 'package:flutter/material.dart'; class OBFollowButton extends StatefulWidget { final User user; + final OBButtonSize size; + final OBButtonType unfollowButtonType; - OBFollowButton(this.user); + OBFollowButton(this.user, + {this.size = OBButtonSize.medium, + this.unfollowButtonType = OBButtonType.primary}); @override OBFollowButtonState createState() { @@ -49,6 +54,7 @@ class OBFollowButtonState extends State { Widget _buildFollowButton() { return OBButton( + size: widget.size, child: Text( 'Follow', style: TextStyle(fontWeight: FontWeight.bold), @@ -60,12 +66,13 @@ class OBFollowButtonState extends State { Widget _buildUnfollowButton() { return OBButton( + size: widget.size, child: Text( 'Unfollow', - style: TextStyle(fontWeight: FontWeight.bold), ), isLoading: _requestInProgress, onPressed: _unFollowUser, + type: widget.unfollowButtonType, ); } diff --git a/lib/widgets/buttons/actions/invite_user_to_community.dart b/lib/widgets/buttons/actions/invite_user_to_community.dart index d63fe0bf1..58ced7bc3 100644 --- a/lib/widgets/buttons/actions/invite_user_to_community.dart +++ b/lib/widgets/buttons/actions/invite_user_to_community.dart @@ -49,6 +49,7 @@ class OBInviteUserToCommunityButtonState return StreamBuilder( stream: widget.user.updateSubject, + initialData: widget.user, builder: (BuildContext context, AsyncSnapshot latestUserSnapshot) { User latestUser = latestUserSnapshot.data; diff --git a/lib/widgets/buttons/button.dart b/lib/widgets/buttons/button.dart index 51d58bae4..01537a430 100644 --- a/lib/widgets/buttons/button.dart +++ b/lib/widgets/buttons/button.dart @@ -118,8 +118,8 @@ class OBButton extends StatelessWidget { Widget _getLoadingIndicator(Color color) { return SizedBox( - height: 15.0, - width: 15.0, + height: 18.0, + width: 18.0, child: CircularProgressIndicator( strokeWidth: 2.0, valueColor: AlwaysStoppedAnimation(color)), ); diff --git a/lib/widgets/fields/text_form_field.dart b/lib/widgets/fields/text_form_field.dart index 270badd30..5f922c91e 100644 --- a/lib/widgets/fields/text_form_field.dart +++ b/lib/widgets/fields/text_form_field.dart @@ -104,6 +104,7 @@ class OBTextFormField extends StatelessWidget { labelText: decoration.labelText, prefixIcon: decoration.prefixIcon, prefixText: decoration.prefixText, + errorMaxLines: decoration.errorMaxLines ?? 3 ), ), hasBorder ? const OBDivider() : const SizedBox() diff --git a/lib/widgets/http_list.dart b/lib/widgets/http_list.dart index 7098344a5..9f2de1e12 100644 --- a/lib/widgets/http_list.dart +++ b/lib/widgets/http_list.dart @@ -5,12 +5,13 @@ import 'package:Openbook/services/httpie.dart'; import 'package:Openbook/services/toast.dart'; import 'package:Openbook/widgets/alerts/button_alert.dart'; import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/load_more.dart'; import 'package:Openbook/widgets/progress_indicator.dart'; import 'package:Openbook/widgets/search_bar.dart'; import 'package:Openbook/widgets/theming/text.dart'; +import 'package:async/async.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:loadmore/loadmore.dart'; class OBHttpList extends StatefulWidget { final OBHttpListItemBuilder listItemBuilder; @@ -63,6 +64,10 @@ class OBHttpListState extends State> { StreamSubscription> _searchRequestSubscription; + CancelableOperation _searchOperation; + CancelableOperation _refreshOperation; + CancelableOperation _loadMoreOperation; + ScrollPhysics noItemsPhysics = const AlwaysScrollableScrollPhysics(); @override @@ -94,15 +99,24 @@ class OBHttpListState extends State> { } void scrollToTop() { - if (_listScrollController.offset == 0) { - _listRefreshIndicatorKey.currentState.show(); + if (_listScrollController.hasClients) { + if (_listScrollController.offset == 0) { + _listRefreshIndicatorKey.currentState.show(); + } + + _listScrollController.animateTo( + 0.0, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 300), + ); } + } - _listScrollController.animateTo( - 0.0, - curve: Curves.easeOut, - duration: const Duration(milliseconds: 300), - ); + void dispose() { + super.dispose(); + if (_searchOperation != null) _searchOperation.cancel(); + if (_loadMoreOperation != null) _loadMoreOperation.cancel(); + if (_refreshOperation != null) _refreshOperation.cancel(); } @override @@ -236,15 +250,19 @@ class OBHttpListState extends State> { } Future _refreshList() async { + if (_refreshOperation != null) _refreshOperation.cancel(); _setLoadingFinished(false); _setRefreshInProgress(true); try { - _list = await widget.listRefresher(); - _setList(_list); + _refreshOperation = + CancelableOperation.fromFuture(widget.listRefresher()); + List list = await _refreshOperation.value; + _setList(list); } catch (error) { _onError(error); } finally { _setRefreshInProgress(false); + _refreshOperation = null; } } @@ -254,14 +272,20 @@ class OBHttpListState extends State> { await (shouldUseRefreshIndicator ? _listRefreshIndicatorKey.currentState.show() : _refreshList()); - if (shouldScrollToTop && _listScrollController.offset != 0) { + if (shouldScrollToTop && + _listScrollController.hasClients && + _listScrollController.offset != 0) { scrollToTop(); } } Future _loadMoreListItems() async { + if (_loadMoreOperation != null) _loadMoreOperation.cancel(); + try { - List moreListItems = await widget.listOnScrollLoader(_list); + _loadMoreOperation = + CancelableOperation.fromFuture(widget.listOnScrollLoader(_list)); + List moreListItems = await _loadMoreOperation.value; if (moreListItems.length == 0) { _setLoadingFinished(true); @@ -271,6 +295,8 @@ class OBHttpListState extends State> { return true; } catch (error) { _onError(error); + } finally { + _loadMoreOperation = null; } return false; @@ -286,21 +312,23 @@ class OBHttpListState extends State> { } } - void _searchWithQuery(String query) { - if (_searchRequestSubscription != null) _searchRequestSubscription.cancel(); + void _searchWithQuery(String query) async { + if (_searchOperation != null) _searchOperation.cancel(); _setSearchRequestInProgress(true); - _searchRequestSubscription = - widget.listSearcher(_searchQuery).asStream().listen( - (List listSearchResults) { - _searchRequestSubscription = null; - _setListSearchResults(listSearchResults); - }, - onError: _onError, - onDone: () { - _setSearchRequestInProgress(false); - }); + try { + _searchOperation = + CancelableOperation.fromFuture(widget.listSearcher(_searchQuery)); + + List listSearchResults = await _searchOperation.value; + _setListSearchResults(listSearchResults); + } catch (error) { + _onError(error); + } finally { + _setSearchRequestInProgress(false); + _searchOperation = null; + } } void _resetListSearchResults() { diff --git a/lib/widgets/icon.dart b/lib/widgets/icon.dart index d8ba3a0b7..686f0ecc1 100644 --- a/lib/widgets/icon.dart +++ b/lib/widgets/icon.dart @@ -152,6 +152,7 @@ class OBIcons { static const bio = OBIconData(nativeIcon: Icons.bookmark); static const name = OBIconData(nativeIcon: Icons.person); static const followers = OBIconData(nativeIcon: Icons.supervisor_account); + static const following = OBIconData(nativeIcon: Icons.person); static const cake = OBIconData(nativeIcon: Icons.cake); static const remove = OBIconData(nativeIcon: Icons.remove_circle_outline); static const checkCircle = @@ -164,6 +165,7 @@ class OBIcons { static const connect = OBIconData(nativeIcon: Icons.group_add); static const disconnect = OBIconData(nativeIcon: Icons.remove_circle_outline); static const deletePost = OBIconData(nativeIcon: Icons.delete); + static const clear = OBIconData(nativeIcon: Icons.delete); static const reportPost = OBIconData(nativeIcon: Icons.report); static const filter = OBIconData(nativeIcon: Icons.tune); static const gallery = OBIconData(nativeIcon: Icons.apps); @@ -190,17 +192,28 @@ class OBIcons { static const favoriteCommunity = OBIconData(nativeIcon: Icons.favorite); static const unfavoriteCommunity = OBIconData(nativeIcon: Icons.remove_circle); + static const expand = OBIconData(filename: 'expand-icon.png'); static const mutePost = OBIconData(nativeIcon: Icons.notifications_active); + static const editPost = OBIconData(nativeIcon: Icons.edit); static const unmutePost = OBIconData(nativeIcon: Icons.notifications_off); static const deleteAccount = OBIconData(nativeIcon: Icons.delete_forever); + static const account = OBIconData(nativeIcon: Icons.account_circle); + static const application = OBIconData(nativeIcon: Icons.phone_iphone); static const arrowUp = OBIconData(nativeIcon: Icons.keyboard_arrow_up); - static const arrowUpward = OBIconData(nativeIcon: Icons.arrow_upward ); + static const arrowUpward = OBIconData(nativeIcon: Icons.arrow_upward); + static const bug = OBIconData(nativeIcon: Icons.bug_report); + static const featureRequest = OBIconData(nativeIcon: Icons.new_releases); + static const guide = OBIconData(nativeIcon: Icons.book); + static const slackChannel = OBIconData(nativeIcon: Icons.tag_faces); + static const dashboard = OBIconData(nativeIcon: Icons.dashboard); + static const themes = OBIconData(nativeIcon: Icons.format_paint); + static const chat = OBIconData(nativeIcon: Icons.chat_bubble); + static const invite = OBIconData(nativeIcon: Icons.card_giftcard); static const success = OBIconData(filename: 'success-icon.png'); static const error = OBIconData(filename: 'error-icon.png'); static const warning = OBIconData(filename: 'warning-icon.png'); static const info = OBIconData(filename: 'info-icon.png'); static const profile = OBIconData(filename: 'profile-icon.png'); - static const chat = OBIconData(filename: 'chat-icon.png'); static const photo = OBIconData(filename: 'photo-icon.png'); static const video = OBIconData(filename: 'video-icon.png'); static const gif = OBIconData(filename: 'gif-icon.png'); diff --git a/lib/widgets/load_more.dart b/lib/widgets/load_more.dart new file mode 100644 index 000000000..6ea163584 --- /dev/null +++ b/lib/widgets/load_more.dart @@ -0,0 +1,398 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// return true is refresh success +/// +/// return false or null is fail +typedef Future FutureCallBack(); + +class LoadMore extends StatefulWidget { + static DelegateBuilder buildDelegate = + () => DefaultLoadMoreDelegate(); + static DelegateBuilder buildTextBuilder = + () => DefaultLoadMoreTextBuilder.chinese; + + final Widget child; + + /// return true is refresh success + /// + /// return false or null is fail + final FutureCallBack onLoadMore; + + /// if [isFinish] is true, then loadMoreWidget status is [LoadMoreStatus.nomore]. + final bool isFinish; + + /// see [LoadMoreDelegate] + final LoadMoreDelegate delegate; + + /// see [LoadMoreTextBuilder] + final LoadMoreTextBuilder textBuilder; + + /// when [whenEmptyLoad] is true, and when listView children length is 0,or the itemCount is 0,not build loadMoreWidget + final bool whenEmptyLoad; + + const LoadMore({ + Key key, + @required this.child, + @required this.onLoadMore, + this.textBuilder, + this.isFinish = false, + this.delegate, + this.whenEmptyLoad = true, + }) : super(key: key); + + @override + _LoadMoreState createState() => _LoadMoreState(); +} + +class _LoadMoreState extends State { + Widget get child => widget.child; + + LoadMoreDelegate get loadMoreDelegate => + widget.delegate ?? LoadMore.buildDelegate(); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.onLoadMore == null) { + return child; + } + if (child is ListView) { + return _buildListView(child); + } + return child; + } + + /// if call the method, then the future is not null + /// so, return a listview and item count + 1 + Widget _buildListView(ListView listView) { + var delegate = listView.childrenDelegate; + outer: + if (delegate is SliverChildBuilderDelegate) { + SliverChildBuilderDelegate delegate = listView.childrenDelegate; + if (!widget.whenEmptyLoad && delegate.estimatedChildCount == 0) { + break outer; + } + var viewCount = delegate.estimatedChildCount + 1; + IndexedWidgetBuilder builder = (context, index) { + if (index == viewCount - 1) { + return _buildLoadMoreView(); + } + return delegate.builder(context, index); + }; + + return ListView.builder( + itemBuilder: builder, + addAutomaticKeepAlives: delegate.addAutomaticKeepAlives, + addRepaintBoundaries: delegate.addRepaintBoundaries, + itemCount: viewCount, + cacheExtent: listView.cacheExtent, + controller: listView.controller, + itemExtent: listView.itemExtent, + key: listView.key, + padding: listView.padding, + physics: listView.physics, + primary: listView.primary, + reverse: listView.reverse, + scrollDirection: listView.scrollDirection, + shrinkWrap: listView.shrinkWrap, + ); + } else if (delegate is SliverChildListDelegate) { + SliverChildListDelegate delegate = listView.childrenDelegate; + + if (!widget.whenEmptyLoad && delegate.estimatedChildCount == 0) { + break outer; + } + + delegate.children.add(_buildLoadMoreView()); + return ListView( + children: delegate.children, + addAutomaticKeepAlives: delegate.addAutomaticKeepAlives, + addRepaintBoundaries: delegate.addRepaintBoundaries, + cacheExtent: listView.cacheExtent, + controller: listView.controller, + itemExtent: listView.itemExtent, + key: listView.key, + padding: listView.padding, + physics: listView.physics, + primary: listView.primary, + reverse: listView.reverse, + scrollDirection: listView.scrollDirection, + shrinkWrap: listView.shrinkWrap, + ); + } + return listView; + } + + LoadMoreStatus status = LoadMoreStatus.idle; + + Widget _buildLoadMoreView() { + if (widget.isFinish == true) { + this.status = LoadMoreStatus.nomore; + } else { + if (this.status == LoadMoreStatus.nomore) { + this.status = LoadMoreStatus.idle; + } + } + return NotificationListener<_RetryNotify>( + child: NotificationListener<_BuildNotify>( + child: DefaultLoadMoreView( + status: status, + delegate: loadMoreDelegate, + textBuilder: widget.textBuilder ?? LoadMore.buildTextBuilder(), + ), + onNotification: _onLoadMoreBuild, + ), + onNotification: _onRetry, + ); + } + + bool _onLoadMoreBuild(_BuildNotify notification) { + //判断状态,触发对应的操作 + if (status == LoadMoreStatus.loading) { + return false; + } + if (status == LoadMoreStatus.nomore) { + return false; + } + if (status == LoadMoreStatus.fail) { + return false; + } + if (status == LoadMoreStatus.idle) { + // 切换状态为加载中,并且触发回调 + loadMore(); + } + return false; + } + + void _updateStatus(LoadMoreStatus status) { + if (mounted) setState(() => this.status = status); + } + + bool _onRetry(_RetryNotify notification) { + loadMore(); + return false; + } + + void loadMore() { + _updateStatus(LoadMoreStatus.loading); + widget.onLoadMore().then((v) { + if (v == true) { + // 成功,切换状态为空闲 + _updateStatus(LoadMoreStatus.idle); + } else { + // 失败,切换状态为失败 + _updateStatus(LoadMoreStatus.fail); + } + }); + } +} + +enum LoadMoreStatus { + /// 空闲中,表示当前等待加载 + /// + /// wait for loading + idle, + + /// 刷新中,不应该继续加载,等待future返回 + /// + /// the view is loading + loading, + + /// 刷新失败,刷新失败,这时需要点击才能刷新 + /// + /// loading fail, need tap view to loading + fail, + + /// 没有更多,没有更多数据了,这个状态不触发任何条件 + /// + /// not have more data + nomore, +} + +class DefaultLoadMoreView extends StatefulWidget { + final LoadMoreStatus status; + final LoadMoreDelegate delegate; + final LoadMoreTextBuilder textBuilder; + const DefaultLoadMoreView({ + Key key, + this.status = LoadMoreStatus.idle, + @required this.delegate, + @required this.textBuilder, + }) : super(key: key); + + @override + DefaultLoadMoreViewState createState() => DefaultLoadMoreViewState(); +} + +const _defaultLoadMoreHeight = 80.0; +const _loadmoreIndicatorSize = 33.0; +const _loadMoreDelay = 16; + +class DefaultLoadMoreViewState extends State { + LoadMoreDelegate get delegate => widget.delegate; + + @override + Widget build(BuildContext context) { + notify(); + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + if (widget.status == LoadMoreStatus.fail || + widget.status == LoadMoreStatus.idle) { + _RetryNotify().dispatch(context); + } + }, + child: Container( + height: delegate.widgetHeight(widget.status), + alignment: Alignment.center, + child: delegate.buildChild( + widget.status, + builder: widget.textBuilder, + ), + ), + ); + } + + void notify() async { + var delay = max(delegate.loadMoreDelay(), Duration(milliseconds: 16)); + await Future.delayed(delay); + if (widget.status == LoadMoreStatus.idle) { + _BuildNotify().dispatch(context); + } + } + + Duration max(Duration duration, Duration duration2) { + if (duration > duration2) { + return duration; + } + return duration2; + } +} + +class _BuildNotify extends Notification {} + +class _RetryNotify extends Notification {} + +typedef T DelegateBuilder(); + +/// loadmore widget properties +abstract class LoadMoreDelegate { + static DelegateBuilder buildWidget = + () => DefaultLoadMoreDelegate(); + + const LoadMoreDelegate(); + + /// the loadmore widget height + double widgetHeight(LoadMoreStatus status) => _defaultLoadMoreHeight; + + /// build loadmore delay + Duration loadMoreDelay() => Duration(milliseconds: _loadMoreDelay); + + Widget buildChild(LoadMoreStatus status, + {LoadMoreTextBuilder builder = DefaultLoadMoreTextBuilder.chinese}); +} + +class DefaultLoadMoreDelegate extends LoadMoreDelegate { + const DefaultLoadMoreDelegate(); + + @override + Widget buildChild(LoadMoreStatus status, + {LoadMoreTextBuilder builder = DefaultLoadMoreTextBuilder.chinese}) { + String text = builder(status); + if (status == LoadMoreStatus.fail) { + return Container( + child: Text(text), + ); + } + if (status == LoadMoreStatus.idle) { + return Text(text); + } + if (status == LoadMoreStatus.loading) { + return Container( + alignment: Alignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: _loadmoreIndicatorSize, + height: _loadmoreIndicatorSize, + child: CircularProgressIndicator( + backgroundColor: Colors.blue, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text(text), + ), + ], + ), + ); + } + if (status == LoadMoreStatus.nomore) { + return Text(text); + } + + return Text(text); + } +} + +typedef String LoadMoreTextBuilder(LoadMoreStatus status); + +String _buildChineseText(LoadMoreStatus status) { + String text; + switch (status) { + case LoadMoreStatus.fail: + text = "加载失败,请点击重试"; + break; + case LoadMoreStatus.idle: + text = "等待加载更多"; + break; + case LoadMoreStatus.loading: + text = "加载中,请稍候..."; + break; + case LoadMoreStatus.nomore: + text = "到底了,别扯了"; + break; + default: + text = ""; + } + return text; +} + +String _buildEnglishText(LoadMoreStatus status) { + String text; + switch (status) { + case LoadMoreStatus.fail: + text = "load fail, tap to retry"; + break; + case LoadMoreStatus.idle: + text = "wait for loading"; + break; + case LoadMoreStatus.loading: + text = "loading, wait for moment ..."; + break; + case LoadMoreStatus.nomore: + text = "no more data"; + break; + default: + text = ""; + } + return text; +} + +class DefaultLoadMoreTextBuilder { + static const LoadMoreTextBuilder chinese = _buildChineseText; + + static const LoadMoreTextBuilder english = _buildEnglishText; +} diff --git a/lib/widgets/loadmore/loadmore_delegate.dart b/lib/widgets/loadmore/loadmore_delegate.dart new file mode 100644 index 000000000..ddd8ffece --- /dev/null +++ b/lib/widgets/loadmore/loadmore_delegate.dart @@ -0,0 +1,30 @@ +import 'package:Openbook/widgets/load_more.dart'; +import 'package:Openbook/widgets/tiles/loading_indicator_tile.dart'; +import 'package:Openbook/widgets/tiles/retry_tile.dart'; +import 'package:flutter/material.dart'; + +class OBHomePostsLoadMoreDelegate extends LoadMoreDelegate { + const OBHomePostsLoadMoreDelegate(); + + @override + Widget buildChild(LoadMoreStatus status, + {LoadMoreTextBuilder builder = DefaultLoadMoreTextBuilder.chinese}) { + String text = builder(status); + + if (status == LoadMoreStatus.fail) { + return OBRetryTile(text: 'Tap to retry loading posts'); + } + if (status == LoadMoreStatus.idle) { + // No clue why is this even a state. + return const SizedBox(); + } + if (status == LoadMoreStatus.loading) { + return OBLoadingIndicatorTile(); + } + if (status == LoadMoreStatus.nomore) { + return const SizedBox(); + } + + return Text(text); + } +} diff --git a/lib/widgets/post/widgets/post-actions/widgets/post_action_react.dart b/lib/widgets/post/widgets/post-actions/widgets/post_action_react.dart index 349bca111..0bff234cf 100644 --- a/lib/widgets/post/widgets/post-actions/widgets/post_action_react.dart +++ b/lib/widgets/post/widgets/post-actions/widgets/post_action_react.dart @@ -1,41 +1,53 @@ import 'package:Openbook/models/post.dart'; import 'package:Openbook/models/post_reaction.dart'; import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/httpie.dart'; +import 'package:Openbook/services/toast.dart'; import 'package:Openbook/widgets/buttons/button.dart'; import 'package:Openbook/widgets/icon.dart'; import 'package:Openbook/widgets/theming/text.dart'; +import 'package:async/async.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -class OBPostActionReact extends StatelessWidget { - final Post _post; +class OBPostActionReact extends StatefulWidget { + final Post post; - OBPostActionReact(this._post); + OBPostActionReact(this.post); @override - Widget build(BuildContext context) { - var openbookProvider = OpenbookProvider.of(context); - var userService = openbookProvider.userService; - var bottomSheetService = openbookProvider.bottomSheetService; + State createState() { + return OBPostActionReactState(); + } +} + +class OBPostActionReactState extends State { + CancelableOperation _clearPostReactionOperation; + bool _clearPostReactionInProgress; + + @override + void initState() { + super.initState(); + _clearPostReactionInProgress = false; + } + + @override + void dispose() { + super.dispose(); + if (_clearPostReactionOperation != null) + _clearPostReactionOperation.cancel(); + } + @override + Widget build(BuildContext context) { return StreamBuilder( - stream: _post.updateSubject, - initialData: _post, + stream: widget.post.updateSubject, + initialData: widget.post, builder: (BuildContext context, AsyncSnapshot snapshot) { Post post = snapshot.data; PostReaction reaction = post.reaction; bool hasReaction = reaction != null; - var onPressed = () async { - if (hasReaction) { - await userService.deletePostReaction( - postReaction: reaction, post: _post); - _post.clearReaction(); - } else { - bottomSheetService.showReactToPost(post: _post, context: context); - } - }; - Widget buttonChild = Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -69,12 +81,66 @@ class OBPostActionReact extends StatelessWidget { return OBButton( child: buttonChild, - onPressed: onPressed, + isLoading: _clearPostReactionInProgress, + onPressed: _onPressed, type: hasReaction ? OBButtonType.primary : OBButtonType.highlight, ); }, ); } + + void _onPressed() { + if (widget.post.hasReaction()) { + _clearPostReaction(); + } else { + var openbookProvider = OpenbookProvider.of(context); + openbookProvider.bottomSheetService + .showReactToPost(post: widget.post, context: context); + } + } + + Future _clearPostReaction() async { + if (_clearPostReactionInProgress) return; + _setClearPostReactionInProgress(true); + OpenbookProviderState openbookProvider = OpenbookProvider.of(context); + + try { + _clearPostReactionOperation = CancelableOperation.fromFuture( + openbookProvider.userService.deletePostReaction( + postReaction: widget.post.reaction, post: widget.post)); + + await _clearPostReactionOperation.value; + widget.post.clearReaction(); + } catch (error) { + _onError(error: error, openbookProvider: openbookProvider); + } finally { + _clearPostReactionOperation = null; + _setClearPostReactionInProgress(false); + } + } + + void _setClearPostReactionInProgress(bool clearPostReactionInProgress) { + setState(() { + _clearPostReactionInProgress = clearPostReactionInProgress; + }); + } + + void _onError( + {@required error, + @required OpenbookProviderState openbookProvider}) async { + ToastService toastService = openbookProvider.toastService; + + if (error is HttpieConnectionRefusedError) { + toastService.error( + message: error.toHumanReadableMessage(), context: context); + } else if (error is HttpieRequestError) { + String errorMessage = await error.toHumanReadableMessage(); + toastService.error(message: errorMessage, context: context); + } else { + toastService.error(message: 'Unknown error', context: context); + throw error; + } + } } typedef void OnWantsToReactToPost(Post post); diff --git a/lib/widgets/post/widgets/post-body/modals/zoomable_photo.dart b/lib/widgets/post/widgets/post-body/modals/zoomable_photo.dart deleted file mode 100644 index f8cb2ea6f..000000000 --- a/lib/widgets/post/widgets/post-body/modals/zoomable_photo.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:Openbook/widgets/icon.dart'; -import 'package:Openbook/widgets/page_scaffold.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_advanced_networkimage/provider.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:pigment/pigment.dart'; - -class OBZoomablePhotoModal extends StatefulWidget { - final String imageUrl; - - OBZoomablePhotoModal(this.imageUrl); - - @override - State createState() { - return OBZoomablePhotoModalState(); - } -} - -class OBZoomablePhotoModalState extends State { - bool isCloseButtonVisible; - - @override - void initState() { - super.initState(); - isCloseButtonVisible = true; - } - - @override - Widget build(BuildContext context) { - return OBCupertinoPageScaffold( - backgroundColor: Color.fromARGB(0, 0, 0, 0), - child: Stack( - children: [ - Dismissible( - movementDuration: Duration(milliseconds: 100), - background: const DecoratedBox( - decoration: - const BoxDecoration(color: Color.fromARGB(0, 0, 0, 0)), - ), - direction: DismissDirection.up, - // Each Dismissible must contain a Key. Keys allow Flutter to - // uniquely identify Widgets. - key: Key('obPhotoView'), - // We also need to provide a function that will tell our app - // what to do after an item has been swiped away. - onDismissed: (direction) { - // Show a snackbar! This snackbar could also contain "Undo" actions. - Navigator.pop(context); - }, - child: PhotoView( - enableRotation: false, - scaleStateChangedCallback: _photoViewScaleStateChangedCallback, - imageProvider: AdvancedNetworkImage(widget.imageUrl, - retryLimit: 0, - useDiskCache: true, - fallbackAssetImage: - 'assets/images/fallbacks/post-fallback.png'), - maxScale: PhotoViewComputedScale.covered, - minScale: PhotoViewComputedScale.contained, - ), - ), - ], - )); - } - - Widget _buildCloseButton() { - return Positioned( - bottom: 50, - left: 0, - right: 0, - child: AnimatedOpacity( - opacity: (isCloseButtonVisible ? 1 : 0), - duration: Duration(milliseconds: 50), - child: SafeArea( - child: Column( - children: [ - GestureDetector( - onTapDown: (tap) { - if (!isCloseButtonVisible) return; - Navigator.pop(context); - }, - child: Container( - padding: EdgeInsets.all(10), - decoration: BoxDecoration( - color: Pigment.fromString('#1d1d1d'), - borderRadius: BorderRadius.circular(50)), - child: const OBIcon( - OBIcons.close, - size: OBIconSize.large, - color: Colors.white, - ), - ), - ) - ], - )), - ), - ); - } - - void _photoViewScaleStateChangedCallback(PhotoViewScaleState state) { - switch (state) { - case PhotoViewScaleState.initial: - setIsCloseButtonVisible(true); - break; - case PhotoViewScaleState.zooming: - setIsCloseButtonVisible(false); - break; - default: - setIsCloseButtonVisible(false); - } - } - - void setIsCloseButtonVisible(bool isCloseButtonVisible) { - setState(() { - this.isCloseButtonVisible = isCloseButtonVisible; - }); - } - - void toggleIsCloseButtonVisible() { - setIsCloseButtonVisible(!isCloseButtonVisible); - } -} diff --git a/lib/widgets/post/widgets/post-body/widgets/post_body_image.dart b/lib/widgets/post/widgets/post-body/widgets/post_body_image.dart index 7a49a4b30..599212b5e 100644 --- a/lib/widgets/post/widgets/post-body/widgets/post_body_image.dart +++ b/lib/widgets/post/widgets/post-body/widgets/post_body_image.dart @@ -1,9 +1,12 @@ import 'package:Openbook/models/post.dart'; import 'package:Openbook/provider.dart'; import 'package:Openbook/widgets/progress_indicator.dart'; +import 'package:Openbook/widgets/icon.dart'; import 'package:flutter_advanced_networkimage/provider.dart'; import 'package:flutter_advanced_networkimage/transition.dart'; import 'package:flutter/material.dart'; +import 'package:pigment/pigment.dart'; +import 'dart:math'; class OBPostBodyImage extends StatelessWidget { final Post post; @@ -14,35 +17,68 @@ class OBPostBodyImage extends StatelessWidget { Widget build(BuildContext context) { String imageUrl = post.getImage(); double screenWidth = MediaQuery.of(context).size.width; - double aspectRatio = post.getImageWidth() / post.getImageHeight(); + double screenHeight = MediaQuery.of(context).size.height; + double maxBoxHeight = screenHeight * .75; + + double imageAspectRatio = post.getImageWidth() / post.getImageHeight(); + double imageHeight = (screenWidth / imageAspectRatio); + double boxHeight = min(imageHeight, maxBoxHeight); + + List stackItems = [ + _buildImageWidget(screenWidth, imageHeight, imageUrl) + ]; + + if (imageHeight > maxBoxHeight) { + stackItems.add(_buildExpandIcon()); + } return GestureDetector( - onTap: () { - var dialogService = OpenbookProvider.of(context).dialogService; - dialogService.showZoomablePhotoBoxView( - imageUrl: imageUrl, context: context); - }, - child: SizedBox( - width: screenWidth, - height: screenWidth / aspectRatio, - child: TransitionToImage( + onTap: () { + var dialogService = OpenbookProvider.of(context).dialogService; + dialogService.showZoomablePhotoBoxView( + imageUrl: imageUrl, context: context); + }, + child: SizedBox( width: screenWidth, - height: screenWidth / aspectRatio, - fit: BoxFit.contain, - image: AdvancedNetworkImage(imageUrl, - useDiskCache: true, - fallbackAssetImage: 'assets/images/fallbacks/post-fallback.png', - retryLimit: 0, - timeoutDuration: const Duration(minutes: 1)), - // This is the default placeholder widget at loading status, - // you can write your own widget with CustomPainter. - placeholder: Center( - child: const OBProgressIndicator(), + height: boxHeight, + child: Stack( + children: stackItems, ), - // This is default duration - duration: const Duration(milliseconds: 300), - ), + )); + } + + Widget _buildImageWidget(double width, double height, String imageUrl) { + return TransitionToImage( + width: width, + height: height, + fit: BoxFit.fitWidth, + alignment: Alignment.center, + image: AdvancedNetworkImage(imageUrl, + useDiskCache: true, + fallbackAssetImage: 'assets/images/fallbacks/post-fallback.png', + retryLimit: 3, + timeoutDuration: const Duration(minutes: 1)), + placeholder: Center( + child: const OBProgressIndicator(), ), + duration: const Duration(milliseconds: 300), ); } + + Widget _buildExpandIcon() { + return Positioned( + bottom: 15, + right: 15, + child: Container( + padding: EdgeInsets.all(10), + decoration: BoxDecoration( + //Same dark grey as in OBZoomablePhotoModal + color: Colors.black87, + borderRadius: BorderRadius.circular(50.0), + ), + child: OBIcon( + OBIcons.expand, + customSize: 12, + ))); + } } diff --git a/lib/widgets/post/widgets/post-body/widgets/post_body_text.dart b/lib/widgets/post/widgets/post-body/widgets/post_body_text.dart index eddc2b483..d3ba89d04 100644 --- a/lib/widgets/post/widgets/post-body/widgets/post_body_text.dart +++ b/lib/widgets/post/widgets/post-body/widgets/post_body_text.dart @@ -1,18 +1,53 @@ +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/toast.dart'; import 'package:Openbook/models/post.dart'; import 'package:Openbook/widgets/theming/actionable_smart_text.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; class OBPostBodyText extends StatelessWidget { final Post _post; + ToastService _toastService; + BuildContext _context; OBPostBodyText(this._post); @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.all(20.0), - child: OBActionableSmartText( - text: _post.getText(), - )); + _toastService = OpenbookProvider.of(context).toastService; + _context = context; + + return GestureDetector( + onLongPress: _copyText, + child: Padding( + padding: EdgeInsets.all(20.0), + child: _buildActionablePostText() + ) + ); + } + + Widget _buildActionablePostText() { + return StreamBuilder( + stream: this._post.updateSubject, + initialData: this._post, + builder: (BuildContext context, AsyncSnapshot snapshot) { + Post post = snapshot.data; + + if (post.isEdited != null && post.isEdited) { + return OBActionableSmartText( + text: post.text, + trailingSmartTextElement: SecondaryTextElement(' (edited)'), + ); + } else { + return OBActionableSmartText( + text: post.text, + ); + } + }); + } + + void _copyText() { + Clipboard.setData(ClipboardData(text: _post.text)); + _toastService.toast(message: 'Text copied!', context: _context, type: ToastType.info); } } diff --git a/lib/widgets/routes/slide_right_route.dart b/lib/widgets/routes/slide_right_route.dart index 6b4a54692..a1ac6c95b 100644 --- a/lib/widgets/routes/slide_right_route.dart +++ b/lib/widgets/routes/slide_right_route.dart @@ -11,18 +11,18 @@ class OBSlideRightRoute extends PageRouteBuilder { transitionDuration: const Duration(milliseconds: 200), pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { - return Material( - color: Color.fromARGB(0, 0, 0, 0), - child: Dismissible( - background: const DecoratedBox(decoration: const BoxDecoration( - color: Color.fromARGB(0, 0, 0, 0) - ),), - direction: DismissDirection.startToEnd, - onDismissed: (direction) { - Navigator.pop(context); - }, - key: key, - child: widget)); + return Dismissible( + background: const DecoratedBox(decoration: const BoxDecoration( + color: Color.fromARGB(0, 0, 0, 0) + ),), + direction: DismissDirection.startToEnd, + onDismissed: (direction) { + Navigator.pop(context); + }, + key: key, + child: Material( + child: widget, + )); }, transitionsBuilder: (BuildContext context, Animation animation, diff --git a/lib/widgets/theming/actionable_smart_text.dart b/lib/widgets/theming/actionable_smart_text.dart index b58438bb0..26b4d81a5 100644 --- a/lib/widgets/theming/actionable_smart_text.dart +++ b/lib/widgets/theming/actionable_smart_text.dart @@ -15,12 +15,14 @@ class OBActionableSmartText extends StatefulWidget { final String text; final OBTextSize size; final TextOverflow overflow; + final SmartTextElement trailingSmartTextElement; const OBActionableSmartText({ Key key, this.text, this.size = OBTextSize.medium, this.overflow = TextOverflow.clip, + this.trailingSmartTextElement }) : super(key: key); @override @@ -66,6 +68,7 @@ class OBActionableTextState extends State { onCommunityNameTapped: _onCommunityNameTapped, onUsernameTapped: _onUsernameTapped, onLinkTapped: _onLinkTapped, + trailingSmartTextElement: widget.trailingSmartTextElement, size: widget.size, ); } diff --git a/lib/widgets/theming/secondary_text.dart b/lib/widgets/theming/secondary_text.dart index 29671fde5..e8be7f1b3 100644 --- a/lib/widgets/theming/secondary_text.dart +++ b/lib/widgets/theming/secondary_text.dart @@ -8,8 +8,10 @@ class OBSecondaryText extends StatelessWidget { final TextStyle style; final OBTextSize size; final TextOverflow overflow; + final TextAlign textAlign; - const OBSecondaryText(this.text, {this.style, this.size, this.overflow}); + const OBSecondaryText(this.text, + {this.style, this.size, this.overflow, this.textAlign}); @override Widget build(BuildContext context) { @@ -40,6 +42,7 @@ class OBSecondaryText extends StatelessWidget { style: finalStyle, size: size, overflow: overflow, + textAlign: textAlign, ); }); } diff --git a/lib/widgets/theming/smart_text.dart b/lib/widgets/theming/smart_text.dart index 8abf59e91..84c80e1c2 100644 --- a/lib/widgets/theming/smart_text.dart +++ b/lib/widgets/theming/smart_text.dart @@ -7,6 +7,7 @@ export 'package:Openbook/widgets/theming/text.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:tinycolor/tinycolor.dart'; // Based on https://github.com/knoxpo/flutter_smart_text_view @@ -72,46 +73,101 @@ class TextElement extends SmartTextElement { } } +/// Represents an element containing secondary text +class SecondaryTextElement extends SmartTextElement { + final String text; + + SecondaryTextElement(this.text); + + @override + String toString() { + return "SecondaryTextElement: $text"; + } +} + final _linkRegex = RegExp( r"(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})", caseSensitive: false); final _tagRegex = RegExp(r"\B#\w*[a-zA-Z]+\w*", caseSensitive: false); -final _usernameRegex = RegExp(r"^@[A-Za-z0-9_.]{1,30}$", caseSensitive: false); - +// Architecture of this regex: +// (?: don't capture this group, so the mention itself is still the first capturing group +// [^A-Za-u0-9]|^ make sure that no word characters are in front of name +// ) +// ( +// @ begin of mention +// [A-Za-z0-9] first character of username +// ( +// ( +// [A-Za-z0-9]|[._-](?![._-]) word character or one of [._-] which may not be followed by another special char +// ){0,28} repeat this 0 to 28 times +// [A-Za-z0-9] always end on a word character +// )? entire part is optional to allow single character names +// ) end of mention +// (?=\b|$) next char must be either a word boundary or end of text +final _usernameRegex = RegExp(r"(?:[^A-Za-u0-9]|^)(@[A-Za-z0-9](([A-Za-z0-9]|[._-](?![._-])){0,28}[A-Za-z0-9])?)(?=\b|$)", caseSensitive: false); + +// Same idea as inner part of above regex, but only _ is allowed as special character final _communityNameRegex = - RegExp(r"^/c/[A-Za-z0-9_]{1,30}$", caseSensitive: false); + RegExp(r"(/c/([A-Za-z0-9]|[_](?![_])){1,30})(?=\b|$)", caseSensitive: false); + +class SmartMatch { + final SmartTextElement span; + final int start; + final int end; + + SmartMatch(this.span, this.start, this.end); +} /// Turns [text] into a list of [SmartTextElement] List _smartify(String text) { - final sentences = text.split('\n'); + List matches = []; + matches.addAll(_usernameRegex.allMatches(text).map((m) { return SmartMatch(UsernameElement(m.group(1)), m.start + m.group(0).indexOf("@"), m.end); })); + matches.addAll(_communityNameRegex.allMatches(text).map((m) { return SmartMatch(CommunityNameElement(m.group(0)), m.start, m.end); })); + matches.addAll(_linkRegex.allMatches(text).map((m) { return SmartMatch(LinkElement(m.group(0)), m.start, m.end); })); + // matches.addAll(_tagRegex.allMatches(text).map((m) { return SmartMatch(HashTagElement(m.group(0)), m.start, m.end); })); + matches.sort((a, b) { + return a.start.compareTo(b.start); + }); + + if (matches.length == 0) { + return [TextElement(text)]; + } + List span = []; - sentences.forEach((sentence) { - final words = sentence.split(' '); - words.forEach((word) { - if (_linkRegex.hasMatch(word)) { - span.add(LinkElement(word)); + int currentTextIndex = 0; + int matchIndex = 0; + var currentMatch = matches[matchIndex]; + while (currentTextIndex < text.length) { + if (currentMatch == null) { + // no more match found, add entire remaining text + span.add(TextElement(text.substring(currentTextIndex))); + break; + } else if (currentTextIndex < currentMatch.start) { + // there's normal text before the next match + span.add(TextElement(text.substring(currentTextIndex, currentMatch.start))); + currentTextIndex = currentMatch.start; + } else if (currentTextIndex == currentMatch.start) { + // next match starts here, add it + span.add(currentMatch.span); + currentTextIndex = currentMatch.end; + matchIndex++; + if (matchIndex < matches.length) { + currentMatch = matches[matchIndex]; + } else { + currentMatch = null; } - /*else if (_tagRegex.hasMatch(word)) { - span.add(HashTagElement(word)); - }*/ - else if (_usernameRegex.hasMatch(word)) { - span.add(UsernameElement(word)); - } else if (_communityNameRegex.hasMatch(word)) { - span.add(CommunityNameElement(word)); + } else { + // we're already past a match, this can happen if we have overlapping matches, just move on to the next match + matchIndex++; + if (matchIndex < matches.length) { + currentMatch = matches[matchIndex]; } else { - span.add(TextElement(word)); + currentMatch = null; } - span.add(TextElement(' ')); - }); - if (words.isNotEmpty) { - span.removeLast(); } - span.add(TextElement('\n')); - }); - if (sentences.isNotEmpty) { - span.removeLast(); } + return span; } @@ -144,6 +200,9 @@ class OBSmartText extends StatelessWidget { /// Callback for tapping a link final StringCallback onCommunityNameTapped; + /// SmartTextElement element to add at the end of smart text + final SmartTextElement trailingSmartTextElement; + final OBTextSize size; final TextOverflow overflow; @@ -159,6 +218,7 @@ class OBSmartText extends StatelessWidget { this.onTagTapped, this.onUsernameTapped, this.onCommunityNameTapped, + this.trailingSmartTextElement, this.size = OBTextSize.medium, }) : super(key: key); @@ -166,6 +226,7 @@ class OBSmartText extends StatelessWidget { TextSpan _buildTextSpan({ String text, TextStyle style, + TextStyle secondaryTextStyle, TextStyle linkStyle, TextStyle tagStyle, TextStyle usernameStyle, @@ -207,7 +268,11 @@ class OBSmartText extends StatelessWidget { } } - final elements = _smartify(text); + List elements = _smartify(text); + + if (this.trailingSmartTextElement != null) { + elements.add(this.trailingSmartTextElement); + } return TextSpan( children: elements.map((element) { @@ -216,6 +281,11 @@ class OBSmartText extends StatelessWidget { text: element.text, style: style, ); + } else if (element is SecondaryTextElement) { + return TextSpan( + text: element.text, + style: secondaryTextStyle, + ); } else if (element is LinkElement) { return LinkTextSpan( text: element.url, @@ -262,8 +332,23 @@ class OBSmartText extends StatelessWidget { Color primaryTextColor = themeValueParserService.parseColor(theme.primaryTextColor); - TextStyle textStyle = - TextStyle(color: primaryTextColor, fontSize: fontSize, fontFamilyFallback: ['NunitoSans', 'Emoji']); + TextStyle textStyle = TextStyle( + color: primaryTextColor, + fontSize: fontSize, + fontFamilyFallback: ['NunitoSans', 'Emoji']); + + TextStyle secondaryTextStyle; + + if (trailingSmartTextElement != null) { + // This is ugly af, why do we even need this. + Color secondaryTextColor = + themeValueParserService.parseColor(theme.secondaryTextColor); + secondaryTextColor = TinyColor(secondaryTextColor).lighten(10).color; + secondaryTextStyle = TextStyle( + color: secondaryTextColor, + fontSize: fontSize * 0.8, + fontFamilyFallback: ['NunitoSans', 'Emoji']); + } Color actionsForegroundColor = themeValueParserService .parseGradient(theme.primaryAccentColor) @@ -282,6 +367,7 @@ class OBSmartText extends StatelessWidget { text: _buildTextSpan( text: text, style: textStyle, + secondaryTextStyle: secondaryTextStyle, linkStyle: smartItemsStyle, tagStyle: smartItemsStyle, communityNameStyle: smartItemsStyle, diff --git a/lib/widgets/tiles/actions/clear_application_cache_tile.dart b/lib/widgets/tiles/actions/clear_application_cache_tile.dart new file mode 100644 index 000000000..87ccf0f66 --- /dev/null +++ b/lib/widgets/tiles/actions/clear_application_cache_tile.dart @@ -0,0 +1,60 @@ +import 'package:Openbook/provider.dart'; +import 'package:Openbook/services/toast.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/theming/secondary_text.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:Openbook/widgets/tiles/loading_tile.dart'; +import 'package:flutter/material.dart'; + +class OBClearApplicationCacheTile extends StatefulWidget { + @override + State createState() { + return OBClearApplicationCacheTileState(); + } +} + +class OBClearApplicationCacheTileState + extends State { + bool _inProgress; + ToastService _toastService; + + @override + initState() { + super.initState(); + _inProgress = false; + } + + @override + Widget build(BuildContext context) { + return OBLoadingTile( + leading: OBIcon(OBIcons.clear), + title: OBText('Clear cache'), + subtitle: OBSecondaryText('Clear cached posts, accounts, images & more.'), + isLoading: _inProgress, + onTap: _clearApplicationCache, + ); + } + + Future _clearApplicationCache() async { + _setInProgress(true); + + OpenbookProviderState openbookProvider = OpenbookProvider.of(context); + try { + await openbookProvider.userService.clearCache(); + openbookProvider.toastService + .success(message: 'Cleared cache successfully', context: context); + } catch (error) { + openbookProvider.toastService + .error(message: 'Could not clear cache', context: context); + rethrow; + } finally { + _setInProgress(false); + } + } + + void _setInProgress(bool inProgress) { + setState(() { + this._inProgress = inProgress; + }); + } +} diff --git a/lib/widgets/tiles/actions/clear_application_preferences_tile.dart b/lib/widgets/tiles/actions/clear_application_preferences_tile.dart new file mode 100644 index 000000000..0392a108b --- /dev/null +++ b/lib/widgets/tiles/actions/clear_application_preferences_tile.dart @@ -0,0 +1,57 @@ +import 'package:Openbook/provider.dart'; +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/theming/secondary_text.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:Openbook/widgets/tiles/loading_tile.dart'; +import 'package:flutter/material.dart'; + +class OBClearApplicationPreferencesTile extends StatefulWidget { + @override + State createState() { + return OBClearApplicationPreferencesTileState(); + } +} + +class OBClearApplicationPreferencesTileState + extends State { + bool _inProgress; + + @override + initState() { + super.initState(); + _inProgress = false; + } + + @override + Widget build(BuildContext context) { + return OBLoadingTile( + leading: OBIcon(OBIcons.clear), + title: OBText('Clear preferences'), + subtitle: OBSecondaryText( + 'Clear the application preferences. Currently this is only the preferred order of comments.'), + isLoading: _inProgress, + onTap: _clearApplicationPreferences, + ); + } + + Future _clearApplicationPreferences() async { + OpenbookProviderState openbookProvider = OpenbookProvider.of(context); + try { + await openbookProvider.userPreferencesService.clear(); + openbookProvider.toastService.success( + message: 'Cleared preferences successfully', context: context); + } catch (error) { + openbookProvider.toastService + .error(message: 'Could not clear preferences', context: context); + rethrow; + } finally { + _setInProgress(false); + } + } + + void _setInProgress(bool inProgress) { + setState(() { + this._inProgress = inProgress; + }); + } +} diff --git a/lib/widgets/tiles/loading_indicator_tile.dart b/lib/widgets/tiles/loading_indicator_tile.dart new file mode 100644 index 000000000..d95492d56 --- /dev/null +++ b/lib/widgets/tiles/loading_indicator_tile.dart @@ -0,0 +1,13 @@ +import 'package:Openbook/widgets/progress_indicator.dart'; +import 'package:flutter/material.dart'; + +class OBLoadingIndicatorTile extends StatelessWidget { + @override + Widget build(BuildContext context) { + return const ListTile( + title: Center( + child: OBProgressIndicator(), + ), + ); + } +} diff --git a/lib/widgets/tiles/loading_tile.dart b/lib/widgets/tiles/loading_tile.dart new file mode 100644 index 000000000..543f8c93a --- /dev/null +++ b/lib/widgets/tiles/loading_tile.dart @@ -0,0 +1,41 @@ +import 'package:Openbook/widgets/progress_indicator.dart'; +import 'package:flutter/material.dart'; + +class OBLoadingTile extends StatelessWidget { + final bool isLoading; + final Widget title; + final Widget subtitle; + final Widget leading; + final Widget trailing; + final VoidCallback onTap; + + const OBLoadingTile( + {Key key, + this.isLoading = false, + this.title, + this.subtitle, + this.onTap, + this.trailing, + this.leading}) + : super(key: key); + + @override + Widget build(BuildContext context) { + Widget tile = ListTile( + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + onTap: isLoading ? null : onTap, + ); + + if (isLoading) { + tile = Opacity( + opacity: 0.5, + child: tile, + ); + } + + return tile; + } +} diff --git a/lib/widgets/tiles/notification_tile/widgets/post_comment_notification_tile.dart b/lib/widgets/tiles/notification_tile/widgets/post_comment_notification_tile.dart index e823da17b..ba65f6822 100644 --- a/lib/widgets/tiles/notification_tile/widgets/post_comment_notification_tile.dart +++ b/lib/widgets/tiles/notification_tile/widgets/post_comment_notification_tile.dart @@ -41,7 +41,7 @@ class OBPostCommentNotificationTile extends StatelessWidget { image: AdvancedNetworkImage(post.getImage(), useDiskCache: true), height: postImagePreviewSize, width: postImagePreviewSize, - fit: BoxFit.fill, + fit: BoxFit.cover, ), ); } diff --git a/lib/widgets/tiles/retry_tile.dart b/lib/widgets/tiles/retry_tile.dart new file mode 100644 index 000000000..0d46637cc --- /dev/null +++ b/lib/widgets/tiles/retry_tile.dart @@ -0,0 +1,32 @@ +import 'package:Openbook/widgets/icon.dart'; +import 'package:Openbook/widgets/theming/text.dart'; +import 'package:flutter/material.dart'; + +class OBRetryTile extends StatelessWidget { + final String text; + final VoidCallback onWantsToRetry; + + const OBRetryTile( + {Key key, this.text = 'Tap to retry.', @required this.onWantsToRetry}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onWantsToRetry, + child: ListTile( + title: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const OBIcon(OBIcons.refresh), + const SizedBox( + width: 10.0, + ), + OBText(text) + ], + ), + ), + ); + } +} diff --git a/lib/widgets/toast.dart b/lib/widgets/toast.dart index 8cc797589..8ed8e0782 100644 --- a/lib/widgets/toast.dart +++ b/lib/widgets/toast.dart @@ -14,15 +14,14 @@ class OBToast extends StatefulWidget { } static OBToastState of(BuildContext context) { - final OBToastState toastState = context - .rootAncestorStateOfType(const TypeMatcher()); + final OBToastState toastState = + context.rootAncestorStateOfType(const TypeMatcher()); toastState._setCurrentContext(context); return toastState; } } -class OBToastState extends State - with SingleTickerProviderStateMixin { +class OBToastState extends State with SingleTickerProviderStateMixin { OverlayEntry _overlayEntry; BuildContext _currentContext; AnimationController controller; @@ -79,7 +78,7 @@ class OBToastState extends State Future _dismissToast() async { await controller.reverse(); - this._overlayEntry.remove(); + if (this._overlayEntry != null) this._overlayEntry.remove(); this._overlayEntry = null; _dismissInProgress = false; _toastInProgress = false; diff --git a/lib/widgets/user_badge.dart b/lib/widgets/user_badge.dart index fad192ec7..bcbbdaf0f 100644 --- a/lib/widgets/user_badge.dart +++ b/lib/widgets/user_badge.dart @@ -4,6 +4,7 @@ import 'package:Openbook/provider.dart'; import 'package:Openbook/widgets/icon.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; class OBUserBadge extends StatelessWidget { final Badge badge; @@ -44,6 +45,8 @@ class OBUserBadge extends StatelessWidget { return _getDiamondFounderBadge(badge); break; case BadgeKeyword.super_founder: return _getSuperFounderBadge(badge); break; + case BadgeKeyword.angel: + return _getAngelBadge(badge); break; case BadgeKeyword.none: return const SizedBox(); break; } @@ -76,6 +79,38 @@ class OBUserBadge extends StatelessWidget { ); } + Widget _getAngelBadge(Badge badge) { + double badgeSize = _getUserBadgeSize(size); + double iconSize = _getUserBadgeIconSize(size); + + return Stack( + children: [ + Shimmer.fromColors( + baseColor: Colors.pink, + highlightColor: Colors.pinkAccent[100], + child: Container( + margin: EdgeInsets.only(left: 4.0, right: 4.0), + width: badgeSize, + height: badgeSize, + decoration: BoxDecoration( + color: Colors.pink[200], + border: Border.all(color: Colors.pink), + borderRadius: BorderRadius.circular(50) + ), + child: SizedBox(), + ), + ), + Positioned( + top: 0.0, + left: 0.0, + bottom: 0.0, + right: 0.0, + child: OBIcon(OBIcons.check, customSize: iconSize, color: Colors.white), + ), + ], + ); + } + Widget _getFounderBadge(Badge badge) { double badgeSize = _getUserBadgeSize(size); diff --git a/pubspec.lock b/pubspec.lock index 268d747c7..93ab8508e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -115,13 +115,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.0+1" - flushbar: - dependency: "direct main" - description: - name: flushbar - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" flutter: dependency: "direct main" description: flutter @@ -298,13 +291,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.3.14" - loadmore: - dependency: "direct main" - description: - name: loadmore - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.3" matcher: dependency: transitive description: @@ -395,7 +381,7 @@ packages: name: pedantic url: "https://pub.dartlang.org" source: hosted - version: "1.4.0" + version: "1.5.0" petitparser: dependency: transitive description: @@ -452,6 +438,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.2.0" + share: + dependency: "direct main" + description: + name: share + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.1" shelf: dependency: transitive description: @@ -480,6 +473,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.2+4" + shimmer: + dependency: "direct main" + description: + name: shimmer + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" sky_engine: dependency: transitive description: flutter @@ -505,7 +505,7 @@ packages: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.5.4" + version: "1.5.5" sprintf: dependency: "direct main" description: @@ -682,5 +682,5 @@ packages: source: hosted version: "2.1.15" sdks: - dart: ">=2.1.0 <3.0.0" + dart: ">=2.1.1-dev.0.0 <3.0.0" flutter: ">=1.2.1 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 387101a00..2ba95846b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,7 +7,7 @@ description: Social Network # Both the version and the builder number may be overridden in flutter # build by specifying --build-name and --build-number, respectively. # Read more about versioning at semver.org. -version: 0.0.21+21 +version: 0.0.34+34 environment: sdk: ">=2.1.0 <3.0.0" @@ -31,10 +31,8 @@ dependencies: flutter_cache_manager: ^0.3.2 cached_network_image: ^0.7.0 timeago: ^2.0.9 - loadmore: ^1.0.2 pigment: ^1.0.3 photo_view: ^0.1.0 - flushbar: ^1.1.2 flutter_secure_storage: ^3.1.2 mime: ^0.9.6+2 http: ^0.12.0 @@ -44,6 +42,8 @@ dependencies: sprintf: "^4.0.0" image_picker: 0.5.0+3 image_cropper: ^1.0.0 + shimmer: ^1.0.0 + share: ^0.6.1 flutter: sdk: flutter flutter_localizations: @@ -125,6 +125,7 @@ flutter: - assets/images/icons/like-icon.png - assets/images/icons/finish-icon.png - assets/images/icons/finish-icon.png + - assets/images/icons/expand-icon.png - assets/images/icons/load-more-posts-icon.gif - assets/images/stickers/female-instructor.png - assets/images/stickers/got-it.png