diff --git a/lib/pages/profile_tab.dart b/lib/pages/profile_tab.dart index 7b3b8e4..9b522a8 100644 --- a/lib/pages/profile_tab.dart +++ b/lib/pages/profile_tab.dart @@ -43,6 +43,8 @@ class UserProfileTab extends HookWidget { return Scaffold( extendBodyBehindAppBar: true, + // TODO: this is not visible in light mode when the sliver app bar + // in UserProfile is folded appBar: AppBar( backgroundColor: Colors.transparent, shadowColor: Colors.transparent, diff --git a/lib/pages/user.dart b/lib/pages/user.dart index 822153f..b82238e 100644 --- a/lib/pages/user.dart +++ b/lib/pages/user.dart @@ -9,34 +9,29 @@ import '../widgets/user_profile.dart'; class UserPage extends HookWidget { final int userId; final String instanceUrl; - final Future _userView; + final Future _userDetails; UserPage({@required this.userId, @required this.instanceUrl}) : assert(userId != null), assert(instanceUrl != null), - _userView = LemmyApi(instanceUrl) - .v1 - .getUserDetails( - userId: userId, savedOnly: true, sort: SortType.active) - .then((res) => res.user); + _userDetails = LemmyApi(instanceUrl).v1.getUserDetails( + userId: userId, savedOnly: true, sort: SortType.active); + UserPage.fromName({@required this.instanceUrl, @required String username}) : assert(instanceUrl != null), assert(username != null), userId = null, - _userView = LemmyApi(instanceUrl) - .v1 - .getUserDetails( - username: username, savedOnly: true, sort: SortType.active) - .then((res) => res.user); + _userDetails = LemmyApi(instanceUrl).v1.getUserDetails( + username: username, savedOnly: true, sort: SortType.active); @override Widget build(BuildContext context) { - final userViewSnap = useFuture(_userView); + final userDetailsSnap = useFuture(_userDetails); final body = () { - if (userViewSnap.hasData) { - return UserProfile.fromUserView(userViewSnap.data); - } else if (userViewSnap.hasError) { + if (userDetailsSnap.hasData) { + return UserProfile.fromUserDetails(userDetailsSnap.data); + } else if (userDetailsSnap.hasError) { return Center(child: Text('Could not find that user.')); } else { return Center(child: CircularProgressIndicator()); @@ -49,15 +44,15 @@ class UserPage extends HookWidget { backgroundColor: Colors.transparent, shadowColor: Colors.transparent, actions: [ - if (userViewSnap.hasData) ...[ + if (userDetailsSnap.hasData) ...[ IconButton( icon: Icon(Icons.email), onPressed: () {}, // TODO: go to messaging page ), IconButton( icon: Icon(Icons.share), - onPressed: () => Share.text( - 'Share user', userViewSnap.data.actorId, 'text/plain'), + onPressed: () => Share.text('Share user', + userDetailsSnap.data.user.actorId, 'text/plain'), ) ] ], diff --git a/lib/widgets/user_profile.dart b/lib/widgets/user_profile.dart index ddba340..3a8b06f 100644 --- a/lib/widgets/user_profile.dart +++ b/lib/widgets/user_profile.dart @@ -5,28 +5,117 @@ import 'package:intl/intl.dart'; import 'package:lemmy_api_client/lemmy_api_client.dart'; import 'package:timeago/timeago.dart' as timeago; +import '../hooks/stores.dart'; import '../util/extensions/api.dart'; import '../util/goto.dart'; import '../util/intl.dart'; import '../util/text_color.dart'; import 'badge.dart'; +import 'fullscreenable_image.dart'; +import 'markdown_text.dart'; +import 'sortable_infinite_list.dart'; /// Shared widget of UserPage and ProfileTab class UserProfile extends HookWidget { - final Future _userView; + final Future _userDetails; final String instanceUrl; - // TODO: add `.fromUser` constructor UserProfile({@required int userId, @required this.instanceUrl}) - : _userView = LemmyApi(instanceUrl) - .v1 - .getUserDetails( - userId: userId, savedOnly: true, sort: SortType.active) - .then((res) => res.user); + : _userDetails = LemmyApi(instanceUrl).v1.getUserDetails( + userId: userId, savedOnly: false, sort: SortType.active); - UserProfile.fromUserView(UserView userView) - : _userView = Future.value(userView), - instanceUrl = userView.instanceUrl; + UserProfile.fromUserDetails(UserDetails userDetails) + : _userDetails = Future.value(userDetails), + instanceUrl = userDetails.user.instanceUrl; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final userDetailsSnap = useFuture(_userDetails, preserveState: false); + + if (!userDetailsSnap.hasData) { + return const Center(child: CircularProgressIndicator()); + } else if (userDetailsSnap.hasError) { + return Center( + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.error), + Padding( + padding: const EdgeInsets.all(8), + child: Text('ERROR: ${userDetailsSnap.error}'), + ) + ]), + ); + } + + final userView = userDetailsSnap.data.user; + + return DefaultTabController( + length: 3, + child: NestedScrollView( + headerSliverBuilder: (_, __) => [ + SliverAppBar( + pinned: true, + expandedHeight: 265, + toolbarHeight: 0, + forceElevated: true, + elevation: 0, + backgroundColor: theme.cardColor, + brightness: theme.brightness, + iconTheme: theme.iconTheme, + flexibleSpace: + FlexibleSpaceBar(background: _UserOverview(userView)), + bottom: TabBar( + labelColor: theme.textTheme.bodyText1.color, + tabs: [ + Tab(text: 'Posts'), + Tab(text: 'Comments'), + Tab(text: 'About'), + ], + ), + ), + ], + body: TabBarView(children: [ + // TODO: first batch is already fetched on render + // TODO: comment and post come from the same endpoint, could be shared + InfinitePostList( + fetcher: (page, batchSize, sort) => LemmyApi(instanceUrl) + .v1 + .getUserDetails( + userId: userView.id, + savedOnly: false, + sort: SortType.active, + page: page, + limit: batchSize, + ) + .then((val) => val.posts), + ), + InfiniteCommentList( + fetcher: (page, batchSize, sort) => LemmyApi(instanceUrl) + .v1 + .getUserDetails( + userId: userView.id, + savedOnly: false, + sort: SortType.active, + page: page, + limit: batchSize, + ) + .then((val) => val.comments), + ), + _AboutTab(userDetailsSnap.data), + ]), + ), + ); + } +} + +/// Content in the sliver flexible space +/// Renders general info about the given user. +/// Such as his nickname, no. of posts, no. of posts, +/// banner, avatar etc. +class _UserOverview extends HookWidget { + final UserView userView; + + const _UserOverview(this.userView); @override Widget build(BuildContext context) { @@ -34,185 +123,153 @@ class UserProfile extends HookWidget { final colorOnTopOfAccentColor = textColorBasedOnBackground(theme.accentColor); - final userViewSnap = useFuture(_userView, preserveState: false); - - final bio = () { - if (userViewSnap.hasData) { - if (userViewSnap.data.bio != null) { - return Padding( - padding: const EdgeInsets.all(10), - child: Text(userViewSnap.data.bio), - ); - } else { - return Center( - child: Text( - 'no bio', - style: const TextStyle(fontStyle: FontStyle.italic), - ), - ); - } - } else { - return Center(child: CircularProgressIndicator()); - } - }(); - - Widget tabs() => DefaultTabController( - length: 3, - child: Column( - children: [ - TabBar( - labelColor: theme.textTheme.bodyText1.color, - tabs: [ - Tab(text: 'Posts'), - Tab(text: 'Comments'), - Tab(text: 'About'), - ], - ), - Expanded( - child: TabBarView( - children: [ - Center( - child: Text( - 'Posts', - style: const TextStyle(fontSize: 36), - ), - ), - Center( - child: Text( - 'Comments', - style: const TextStyle(fontSize: 36), - ), - ), - bio, - ], - ), - ) - ], - ), - ); - - return Center( - child: Stack( - children: [ - if (userViewSnap.data?.banner != null) - CachedNetworkImage( - imageUrl: userViewSnap.data.banner, + return Stack( + children: [ + if (userView.banner != null) + // TODO: for some reason doesnt react to presses + FullscreenableImage( + url: userView.banner, + child: CachedNetworkImage( + imageUrl: userView.banner, errorWidget: (_, __, ___) => SizedBox.shrink(), - ) - else - Container( + ), + ) + else + Container( + width: double.infinity, + height: 200, + color: theme.accentColor, + ), + Container( + height: 200, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: FractionalOffset.topCenter, + end: FractionalOffset.bottomCenter, + colors: [ + Colors.black26, + Colors.transparent, + ], + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: SizedBox( width: double.infinity, height: double.infinity, - color: theme.primaryColor, - ), - Container( - height: 200, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: FractionalOffset.topCenter, - end: FractionalOffset.bottomCenter, - colors: [ - Colors.black26, - Colors.transparent, - ], - ), - ), - ), - SafeArea( - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: SizedBox( - width: double.infinity, - height: double.infinity, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.only( - topRight: Radius.circular(40), - topLeft: Radius.circular(40), - ), - color: theme.scaffoldBackgroundColor, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topRight: Radius.circular(40), + topLeft: Radius.circular(40), ), + color: theme.scaffoldBackgroundColor, ), ), ), ), - SafeArea( - child: Column( - children: [ - if (userViewSnap.data?.avatar != null) - SizedBox( - width: 80, - height: 80, - child: Container( - // clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - boxShadow: [ - BoxShadow(blurRadius: 6, color: Colors.black54) - ], - borderRadius: BorderRadius.all(Radius.circular(15)), - border: Border.all(color: Colors.white, width: 3), - ), - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), + ), + SafeArea( + child: Column( + children: [ + if (userView.avatar != null) + SizedBox( + width: 80, + height: 80, + child: Container( + // clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + boxShadow: [ + BoxShadow(blurRadius: 6, color: Colors.black54) + ], + borderRadius: BorderRadius.all(Radius.circular(15)), + border: Border.all(color: Colors.white, width: 3), + ), + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(12)), + child: FullscreenableImage( + url: userView.avatar, child: CachedNetworkImage( - imageUrl: userViewSnap.data.avatar, + imageUrl: userView.avatar, errorWidget: (_, __, ___) => SizedBox.shrink(), ), ), ), ), - Padding( - padding: userViewSnap.data?.avatar != null - ? const EdgeInsets.only(top: 8.0) - : const EdgeInsets.only(top: 70), - child: Padding( - padding: EdgeInsets.only( - top: userViewSnap.data?.avatar == null ? 10 : 0), - child: Text( - userViewSnap.data?.preferredUsername ?? - userViewSnap.data?.name ?? - '', - style: theme.textTheme.headline6, - ), + ), + Padding( + padding: userView.avatar != null + ? const EdgeInsets.only(top: 8.0) + : const EdgeInsets.only(top: 70), + child: Padding( + padding: + EdgeInsets.only(top: userView.avatar == null ? 10 : 0), + child: Text( + userView.preferredUsername ?? userView.name, + style: theme.textTheme.headline6, ), ), - Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '@${userViewSnap.data?.name ?? ''}@', + ), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '@${userView.name}@', + style: theme.textTheme.caption, + ), + InkWell( + onTap: () => goToInstance(context, userView.instanceUrl), + child: Text( + '${userView.instanceUrl}', style: theme.textTheme.caption, ), - InkWell( - onTap: () => goToInstance(context, instanceUrl), - child: Text( - '$instanceUrl', - style: theme.textTheme.caption, - ), - ) - ], - ), + ) + ], ), - Padding( - padding: const EdgeInsets.only(top: 15), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Badge( + ), + Padding( + padding: const EdgeInsets.only(top: 15), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Badge( + child: Row( + children: [ + Icon( + Icons.comment, // TODO: should be article icon + size: 15, + color: colorOnTopOfAccentColor, + ), + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text( + '${compactNumber(userView.numberOfPosts)}' + ' Post${pluralS(userView.numberOfPosts)}', + style: TextStyle(color: colorOnTopOfAccentColor), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Badge( child: Row( children: [ Icon( - Icons.comment, // TODO: should be article icon + Icons.comment, size: 15, color: colorOnTopOfAccentColor, ), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( - ''' -${userViewSnap.hasData ? compactNumber(userViewSnap.data.numberOfPosts) : '-'} Post${pluralS(userViewSnap.data?.numberOfPosts ?? 0)}''', + '${compactNumber(userView.numberOfComments)}' + ' Comment${pluralS(userView.numberOfComments)}', style: TextStyle(color: colorOnTopOfAccentColor), ), @@ -220,68 +277,153 @@ ${userViewSnap.hasData ? compactNumber(userViewSnap.data.numberOfPosts) : '-'} P ], ), ), - Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Badge( - child: Row( - children: [ - Icon( - Icons.comment, - size: 15, - color: colorOnTopOfAccentColor, - ), - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Text( - ''' -${userViewSnap.hasData ? compactNumber(userViewSnap.data.numberOfComments) : '-'} Comment${pluralS(userViewSnap.data?.numberOfComments ?? 0)}''', - style: - TextStyle(color: colorOnTopOfAccentColor), - ), - ), - ], - ), - ), - ), - ], - ), + ), + ], ), - Padding( - padding: const EdgeInsets.only(top: 15), - child: Text( - ''' -Joined ${userViewSnap.hasData ? timeago.format(userViewSnap.data.published) : ''}''', - style: theme.textTheme.bodyText1, - ), + ), + Padding( + padding: const EdgeInsets.only(top: 15), + child: Text( + 'Joined ${timeago.format(userView.published)}', + style: theme.textTheme.bodyText1, ), - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.cake, - size: 13, + ), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cake, + size: 13, + ), + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text( + DateFormat('MMM dd, yyyy').format(userView.published), + style: theme.textTheme.bodyText1, ), - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Text( - userViewSnap.hasData - ? DateFormat('MMM dd, yyyy') - .format(userViewSnap.data.published) - : '', - style: theme.textTheme.bodyText1, - ), - ), - ], - ), + ), + ], ), - Expanded(child: tabs()) - ], - ), + ), + // Expanded(child: tabs()) + ], ), - ], - ), + ), + ], + ); + } +} + +class _AboutTab extends HookWidget { + final UserDetails userDetails; + + const _AboutTab(this.userDetails); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final instanceUrl = userDetails.user.instanceUrl; + + final accStore = useAccountsStore(); + + final isOwnedAccount = accStore.loggedInInstances.contains(instanceUrl) && + accStore.tokens[instanceUrl].containsKey(userDetails.user.name); + + const wallPadding = EdgeInsets.symmetric(horizontal: 15); + + final divider = Padding( + padding: EdgeInsets.symmetric( + horizontal: wallPadding.horizontal / 2, vertical: 10), + child: Divider(), + ); + + communityTile(String name, String icon, int id) => ListTile( + dense: true, + onTap: () => goToCommunity.byId(context, instanceUrl, id), + title: Text('!$name'), + leading: icon != null + ? CachedNetworkImage( + height: 40, + width: 40, + imageUrl: icon, + errorWidget: (_, __, ___) => SizedBox(width: 40, height: 40), + imageBuilder: (context, imageProvider) => Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage( + fit: BoxFit.cover, + image: imageProvider, + ), + ), + )) + : SizedBox(width: 40), + ); + + return ListView( + padding: EdgeInsets.symmetric(vertical: 20), + children: [ + if (isOwnedAccount) + ListTile( + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.edit), + SizedBox(width: 10), + Text('edit profile'), + ], + ), + onTap: () {}, // TODO: go to account editing + ), + if (userDetails.user.bio != null) ...[ + Padding( + padding: wallPadding, + child: + MarkdownText(userDetails.user.bio, instanceUrl: instanceUrl)), + divider, + ], + if (userDetails.moderates.isNotEmpty) ...[ + ListTile( + title: Center( + child: Text( + 'Moderates:', + style: theme.textTheme.headline6.copyWith(fontSize: 18), + ), + ), + ), + for (final comm + in userDetails.moderates + ..sort((a, b) => a.communityName.compareTo(b.communityName))) + communityTile( + comm.communityName, comm.communityIcon, comm.communityId), + divider + ], + ListTile( + title: Center( + child: Text( + 'Subscribed:', + style: theme.textTheme.headline6.copyWith(fontSize: 18), + ), + ), + ), + if (userDetails.follows.isNotEmpty) + for (final comm + in userDetails.follows + ..sort((a, b) => a.communityName.compareTo(b.communityName))) + communityTile( + comm.communityName, comm.communityIcon, comm.communityId) + else + Padding( + padding: const EdgeInsets.only(top: 8), + child: Center( + child: Text( + 'this user does not subscribe to any community', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ), + ) + ], ); } }