diff --git a/lib/community/widgets/community_header.dart b/lib/community/widgets/community_header.dart index 5255d5a6a..2ea0e8166 100644 --- a/lib/community/widgets/community_header.dart +++ b/lib/community/widgets/community_header.dart @@ -1,16 +1,16 @@ +import 'package:extended_image/extended_image.dart'; import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:thunder/feed/bloc/feed_bloc.dart'; import 'package:thunder/feed/utils/utils.dart'; import 'package:thunder/shared/avatars/community_avatar.dart'; -import 'package:thunder/shared/full_name_widgets.dart'; import 'package:thunder/shared/icon_text.dart'; -import 'package:thunder/utils/instance.dart'; +import 'package:thunder/utils/colors.dart'; import 'package:thunder/utils/numbers.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class CommunityHeader extends StatefulWidget { final bool showCommunitySidebar; @@ -28,11 +28,22 @@ class CommunityHeader extends StatefulWidget { State createState() => _CommunityHeaderState(); } -class _CommunityHeaderState extends State { +class _CommunityHeaderState extends State with SingleTickerProviderStateMixin { + late AnimationController _bannerImageFadeInController; + late bool _hasBanner; + + @override + void initState() { + _bannerImageFadeInController = AnimationController(vsync: this, duration: const Duration(milliseconds: 250), lowerBound: 0.0, upperBound: 1.0); + _hasBanner = widget.getCommunityResponse.communityView.community.banner?.isNotEmpty == true; + + super.initState(); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); final FeedBloc feedBloc = context.watch(); + final AppLocalizations l10n = AppLocalizations.of(context)!; return Material( elevation: widget.showCommunitySidebar ? 5.0 : 0, @@ -47,112 +58,127 @@ class _CommunityHeaderState extends State { }, child: Stack( children: [ - if (widget.getCommunityResponse.communityView.community.banner == null) Positioned.fill(child: Container(color: theme.colorScheme.background)), - if (widget.getCommunityResponse.communityView.community.banner != null) - Positioned.fill( - child: Row( - children: [ - Expanded(flex: 1, child: Container()), - Expanded( - flex: 3, - child: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: CachedNetworkImageProvider(widget.getCommunityResponse.communityView.community.banner!), - fit: BoxFit.cover, - ), - ), - ), - ), - ], + Positioned.fill(child: Container(color: getBackgroundColor(context))), + if (_hasBanner) + SizedBox( + height: 100, + width: MediaQuery.sizeOf(context).width, + child: ExtendedImage.network( + widget.getCommunityResponse.communityView.community.banner!, + fit: BoxFit.cover, + loadStateChanged: (ExtendedImageState state) { + switch (state.extendedImageLoadState) { + case LoadState.loading: + _bannerImageFadeInController.reset(); + return const SizedBox.shrink(); + case LoadState.failed: + _bannerImageFadeInController.reset(); + return const SizedBox.shrink(); + case LoadState.completed: + if (state.wasSynchronouslyLoaded) return state.completedWidget; + + _bannerImageFadeInController.forward(); + + return FadeTransition( + opacity: _bannerImageFadeInController, + child: state.completedWidget, + ); + } + }, ), ), - if (widget.getCommunityResponse.communityView.community.banner != null) - Positioned.fill( - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - theme.colorScheme.background, - theme.colorScheme.background, - theme.colorScheme.background.withOpacity(0.9), - theme.colorScheme.background.withOpacity(0.6), - theme.colorScheme.background.withOpacity(0.3), - ], - ), + Positioned( + left: 25, + top: _hasBanner ? 60 : 10, + child: Column( + children: [ + CommunityAvatar( + community: widget.getCommunityResponse.communityView.community, + radius: 25, + showCommunityStatus: true, + showBorder: true, ), - ), + ], ), + ), Column( + mainAxisAlignment: MainAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only(top: 16.0, left: 24.0, right: 24.0, bottom: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + SizedBox(height: _hasBanner ? 125 : 15), + ConstrainedBox( + constraints: BoxConstraints(minHeight: _hasBanner ? 0 : 45), + child: Row( children: [ - Row( - children: [ - CommunityAvatar( - community: widget.getCommunityResponse.communityView.community, - radius: 45.0, - showCommunityStatus: true, - ), - const SizedBox(width: 20.0), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.getCommunityResponse.communityView.community.title, - style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), + SizedBox( + width: MediaQuery.sizeOf(context).width * 0.75, + child: Padding( + padding: EdgeInsets.only(left: _hasBanner ? 25 : 100), + child: Wrap( + runSpacing: 10, + children: [ + Container( + decoration: BoxDecoration( + color: getBackgroundColorAlt(context), + borderRadius: BorderRadius.circular(6), ), - CommunityFullNameWidget( - context, - widget.getCommunityResponse.communityView.community.name, - widget.getCommunityResponse.communityView.community.title, - fetchInstanceNameFromUrl(widget.getCommunityResponse.communityView.community.actorId) ?? 'N/A', - // Override because we're showing right above - useDisplayName: false, + padding: const EdgeInsets.only(left: 4, right: 4), + child: IconText( + icon: const Icon(Icons.people_rounded, size: 15), + text: formatNumberToK(widget.getCommunityResponse.communityView.counts.subscribers), ), - const SizedBox(height: 8.0), - Wrap( - children: [ - IconText( - icon: const Icon(Icons.people_rounded), - text: formatNumberToK(widget.getCommunityResponse.communityView.counts.subscribers), - ), - const SizedBox(width: 8.0), - IconText( - icon: const Icon(Icons.calendar_month_rounded), - text: formatNumberToK(widget.getCommunityResponse.communityView.counts.usersActiveMonth), - ), - const SizedBox(width: 8.0), - IconText( - icon: Icon(getSortIcon(feedBloc.state)), - text: getSortName(feedBloc.state), - ), - ], + ), + const SizedBox(width: 8.0), + Container( + decoration: BoxDecoration( + color: getBackgroundColorAlt(context), + borderRadius: BorderRadius.circular(6), ), - ], - ), + padding: const EdgeInsets.only(left: 4, right: 4), + child: IconText( + icon: const Icon(Icons.calendar_month_rounded, size: 15), + text: formatNumberToK(widget.getCommunityResponse.communityView.counts.usersActiveMonth), + ), + ), + const SizedBox(width: 8.0), + Container( + decoration: BoxDecoration( + color: getBackgroundColorAlt(context), + borderRadius: BorderRadius.circular(6), + ), + padding: const EdgeInsets.only(left: 4, right: 4), + child: IconText( + icon: Icon(getSortIcon(feedBloc.state), size: 15), + text: getSortName(feedBloc.state), + ), + ), + ], ), - Padding( - padding: const EdgeInsets.all(9.0), - child: Icon( - Icons.info_outline_rounded, - size: 25, - shadows: [Shadow(color: theme.colorScheme.background, blurRadius: 10.0), Shadow(color: theme.colorScheme.background, blurRadius: 20.0)], + ), + ), + const Spacer(), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(6), + onTap: () => widget.onToggle(!widget.showCommunitySidebar), + child: Container( + decoration: BoxDecoration( + color: getBackgroundColorAlt(context), + borderRadius: BorderRadius.circular(6), + ), + padding: const EdgeInsets.only(left: 4, right: 4), + child: IconText( + icon: const Icon(Icons.info_outline_rounded, size: 15), + text: l10n.about, ), ), - ], + ), ), + const SizedBox(width: 25), ], ), ), + const SizedBox(height: 15), ], ), ], diff --git a/lib/feed/utils/utils.dart b/lib/feed/utils/utils.dart index c42ecdfeb..354b7c3fb 100644 --- a/lib/feed/utils/utils.dart +++ b/lib/feed/utils/utils.dart @@ -11,12 +11,41 @@ import 'package:thunder/community/bloc/community_bloc.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/feed/feed.dart'; import 'package:thunder/instance/bloc/instance_bloc.dart'; +import 'package:thunder/shared/avatars/community_avatar.dart'; +import 'package:thunder/shared/avatars/user_avatar.dart'; +import 'package:thunder/shared/full_name_widgets.dart'; import 'package:thunder/shared/pages/loading_page.dart'; import 'package:thunder/shared/sort_picker.dart'; import 'package:thunder/community/widgets/community_drawer.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; +import 'package:thunder/utils/instance.dart'; import 'package:thunder/utils/swipe.dart'; +Widget getAppBarAvatar(FeedState state) { + if (state.status == FeedStatus.initial) { + return const SizedBox.shrink(); + } + + if ((state.communityId != null || state.communityName != null) && state.fullCommunityView!.communityView.community.icon?.isNotEmpty == true) { + return CommunityAvatar( + community: state.fullCommunityView!.communityView.community, + radius: 15, + showCommunityStatus: true, + showBorder: true, + ); + } + + if ((state.userId != null || state.username != null) && state.fullPersonView!.personView.person.avatar!.isNotEmpty == true) { + return UserAvatar( + person: state.fullPersonView!.personView.person, + radius: 15, + showBorder: true, + ); + } + + return const SizedBox.shrink(); +} + String getAppBarTitle(FeedState state) { if (state.status == FeedStatus.initial) { return ''; @@ -33,6 +62,36 @@ String getAppBarTitle(FeedState state) { return (state.postListingType != null) ? (destinations.firstWhere((destination) => destination.listingType == state.postListingType).label) : ''; } +Widget getAppBarSubtitle(BuildContext context, FeedState state) { + if (state.status == FeedStatus.initial) { + return const SizedBox.shrink(); + } + + if (state.communityId != null || state.communityName != null) { + return CommunityFullNameWidget( + context, + state.fullCommunityView!.communityView.community.name, + state.fullCommunityView!.communityView.community.title, + fetchInstanceNameFromUrl(state.fullCommunityView!.communityView.community.actorId) ?? '', + // Override because we're showing right above + useDisplayName: false, + ); + } + + if (state.userId != null || state.username != null) { + return UserFullNameWidget( + context, + state.fullPersonView!.personView.person.name, + state.fullPersonView!.personView.person.displayName, + fetchInstanceNameFromUrl(state.fullPersonView!.personView.person.actorId) ?? '', + // Override because we're showing right above + useDisplayName: false, + ); + } + + return const SizedBox.shrink(); +} + String getSortName(FeedState state) { if (state.status == FeedStatus.initial) { return ''; diff --git a/lib/feed/view/feed_page.dart b/lib/feed/view/feed_page.dart index 724cfcd9d..24ba9dc33 100644 --- a/lib/feed/view/feed_page.dart +++ b/lib/feed/view/feed_page.dart @@ -170,8 +170,9 @@ class FeedView extends StatefulWidget { class _FeedViewState extends State { final ScrollController _scrollController = ScrollController(); - /// Boolean which indicates whether the title on the app bar should be shown - bool showAppBarTitle = false; + /// Boolean which indicates whether the page has been scrolled up + /// This is used to determine what to show on the app bar + bool isPageScrolled = false; /// Boolean which indicates whether the community sidebar should be shown bool showCommunitySidebar = false; @@ -199,10 +200,10 @@ class _FeedViewState extends State { _scrollController.addListener(() { // Updates the [showAppBarTitle] value when the user has scrolled past a given threshold - if (_scrollController.position.pixels > 100.0 && showAppBarTitle == false) { - setState(() => showAppBarTitle = true); - } else if (_scrollController.position.pixels < 100.0 && showAppBarTitle == true) { - setState(() => showAppBarTitle = false); + if (_scrollController.position.pixels > 100.0 && isPageScrolled == false) { + setState(() => isPageScrolled = true); + } else if (_scrollController.position.pixels < 100.0 && isPageScrolled == true) { + setState(() => isPageScrolled = false); } // Fetches new posts when the user has scrolled past 70% list @@ -325,7 +326,7 @@ class _FeedViewState extends State { top: hideTopBarOnScroll, // Don't apply to top of screen to allow for the status bar colour to extend child: BlocConsumer( listenWhen: (previous, current) { - if (current.status == FeedStatus.initial) setState(() => showAppBarTitle = false); + if (current.status == FeedStatus.initial) setState(() => isPageScrolled = false); if (previous.scrollId != current.scrollId) _scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut); if (previous.dismissReadId != current.dismissReadId) dismissRead(); if (current.dismissBlockedUserId != null || current.dismissBlockedCommunityId != null) dismissBlockedUsersAndCommunities(current.dismissBlockedUserId, current.dismissBlockedCommunityId); @@ -368,7 +369,7 @@ class _FeedViewState extends State { controller: _scrollController, slivers: [ FeedPageAppBar( - showAppBarTitle: (state.feedType == FeedType.general && state.status != FeedStatus.initial) ? true : showAppBarTitle, + showSecondaryTitle: (state.feedType == FeedType.general && state.status != FeedStatus.initial) ? true : isPageScrolled, scaffoldStateKey: widget.scaffoldStateKey, ), // Display loading indicator until the feed is fetched diff --git a/lib/feed/widgets/feed_page_app_bar.dart b/lib/feed/widgets/feed_page_app_bar.dart index 081ced48b..4411e7ea8 100644 --- a/lib/feed/widgets/feed_page_app_bar.dart +++ b/lib/feed/widgets/feed_page_app_bar.dart @@ -32,10 +32,11 @@ import 'package:thunder/thunder/bloc/thunder_bloc.dart'; /// Holds the app bar for the feed page. The app bar actions changes depending on the type of feed (general, community, user) class FeedPageAppBar extends StatefulWidget { - const FeedPageAppBar({super.key, this.showAppBarTitle = true, this.scaffoldStateKey}); + const FeedPageAppBar({super.key, this.showSecondaryTitle = true, this.scaffoldStateKey}); - /// Whether to show the app bar title - final bool showAppBarTitle; + /// Whether to show the alternative information in the app bar. + /// Usually implies that the user has scrolled up. + final bool showSecondaryTitle; /// The scaffold key of the parent scaffold holding the drawer. /// This is used to determine if we are in a pushed navigation stack. @@ -56,6 +57,7 @@ class _FeedPageAppBarState extends State { final AccountState accountState = context.read().state; person = accountState.reload ? accountState.personView?.person : person; + bool showUserAvatar = widget.scaffoldStateKey != null && thunderBloc.state.useProfilePictureForDrawer && authState.isLoggedIn; return SliverAppBar( pinned: !thunderBloc.state.hideTopBarOnScroll, @@ -63,9 +65,13 @@ class _FeedPageAppBarState extends State { centerTitle: false, toolbarHeight: 70.0, surfaceTintColor: thunderBloc.state.hideTopBarOnScroll ? Colors.transparent : null, - title: FeedAppBarTitle(visible: widget.showAppBarTitle), - leadingWidth: widget.scaffoldStateKey != null && thunderBloc.state.useProfilePictureForDrawer && authState.isLoggedIn ? 50 : null, - leading: widget.scaffoldStateKey != null && thunderBloc.state.useProfilePictureForDrawer && authState.isLoggedIn + title: FeedAppBarTitle( + showSecondaryTitle: widget.showSecondaryTitle, + isUserAvatarShown: showUserAvatar, + ), + titleSpacing: 0, + leadingWidth: showUserAvatar ? 50 : null, + leading: showUserAvatar ? Padding( padding: const EdgeInsets.only(left: 16.0), child: Semantics( @@ -120,35 +126,52 @@ class _FeedPageAppBarState extends State { /// The title of the app bar. This shows the title (feed type, community, user) and the sort type class FeedAppBarTitle extends StatelessWidget { - const FeedAppBarTitle({super.key, this.visible = true}); + const FeedAppBarTitle({super.key, this.showSecondaryTitle = true, required this.isUserAvatarShown}); + + /// Whether to show the alternative information in the app bar. + /// Usually implies that the user has scrolled up. + final bool showSecondaryTitle; - /// Whether to show the title. When the user scrolls down the title will be hidden - final bool visible; + /// Whether the user avatar is being displayed in the app bar. + /// If so, we need to make some visual adjustments. + final bool isUserAvatarShown; @override Widget build(BuildContext context) { final theme = Theme.of(context); final FeedBloc feedBloc = context.watch(); - return AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: visible ? 1.0 : 0.0, - child: ListTile( - title: Text( - getAppBarTitle(feedBloc.state), - style: theme.textTheme.titleLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Row( + return ListTile( + minLeadingWidth: 0, + leading: AnimatedSize( + duration: const Duration(milliseconds: 250), + alignment: Alignment.centerRight, + child: showSecondaryTitle + ? Padding( + padding: EdgeInsets.only(left: isUserAvatarShown ? 10 : 0), + child: getAppBarAvatar(feedBloc.state), + ) + : const SizedBox(width: 0, height: 70), + ), + title: Text( + getAppBarTitle(feedBloc.state), + style: theme.textTheme.titleLarge, + softWrap: false, + overflow: TextOverflow.fade, + ), + subtitle: AnimatedCrossFade( + duration: const Duration(milliseconds: 250), + crossFadeState: showSecondaryTitle ? CrossFadeState.showSecond : CrossFadeState.showFirst, + firstChild: getAppBarSubtitle(context, feedBloc.state), + secondChild: Row( children: [ Icon(getSortIcon(feedBloc.state), size: 13), const SizedBox(width: 4), Text(getSortName(feedBloc.state)), ], ), - contentPadding: const EdgeInsets.symmetric(horizontal: 0), ), + contentPadding: const EdgeInsets.symmetric(horizontal: 0), ); } } diff --git a/lib/modlog/widgets/modlog_feed_page_app_bar.dart b/lib/modlog/widgets/modlog_feed_page_app_bar.dart index 7c1b9df2e..882f8cd5f 100644 --- a/lib/modlog/widgets/modlog_feed_page_app_bar.dart +++ b/lib/modlog/widgets/modlog_feed_page_app_bar.dart @@ -36,6 +36,7 @@ class ModlogFeedPageAppBar extends StatelessWidget { toolbarHeight: 70.0, surfaceTintColor: state.hideTopBarOnScroll ? Colors.transparent : null, title: ModlogFeedAppBarTitle(visible: showAppBarTitle, lemmyClient: lemmyClient), + titleSpacing: 0, leading: IconButton( icon: (!kIsWeb && Platform.isIOS ? Icon( diff --git a/lib/shared/avatars/avatar_border.dart b/lib/shared/avatars/avatar_border.dart new file mode 100644 index 000000000..5725de0ab --- /dev/null +++ b/lib/shared/avatars/avatar_border.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:thunder/utils/colors.dart'; + +/// A border that can be wrapped around User/Commnity avatars +class AvatarBorder extends StatelessWidget { + /// The width of the border + final double? width; + + /// The color of the border + final Color? color; + + /// The child widget + final Widget child; + + const AvatarBorder({super.key, this.width, this.color, required this.child}); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + color: getBackgroundColor(context), + shape: BoxShape.circle, + border: Border.all( + color: color ?? theme.colorScheme.onBackground, + width: width ?? 2, + ), + ), + child: child, + ); + } +} diff --git a/lib/shared/avatars/community_avatar.dart b/lib/shared/avatars/community_avatar.dart index 005d00937..5bc408d6a 100644 --- a/lib/shared/avatars/community_avatar.dart +++ b/lib/shared/avatars/community_avatar.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:thunder/shared/avatars/avatar_border.dart'; +import 'package:thunder/shared/conditional_parent_widget.dart'; /// A community avatar. Displays the associated community icon if available. /// @@ -24,7 +26,18 @@ class CommunityAvatar extends StatelessWidget { /// The image format to request from the instance final String? format; - const CommunityAvatar({super.key, this.community, this.radius = 12.0, this.showCommunityStatus = false, this.thumbnailSize, this.format}); + /// Whether or not to display a border around the avatar + final bool showBorder; + + const CommunityAvatar({ + super.key, + this.community, + this.radius = 12.0, + this.showCommunityStatus = false, + this.thumbnailSize, + this.format, + this.showBorder = false, + }); @override Widget build(BuildContext context) { @@ -45,7 +58,13 @@ class CommunityAvatar extends StatelessWidget { ), ); - if (community?.icon?.isNotEmpty != true) return placeholderIcon; + if (community?.icon?.isNotEmpty != true) { + return ConditionalParentWidget( + condition: showBorder, + parentBuilder: (child) => AvatarBorder(child: child), + child: placeholderIcon, + ); + } Uri imageUri = Uri.parse(community!.icon!); bool isPictrsImageEndpoint = imageUri.toString().contains('/pictrs/image/'); @@ -54,34 +73,38 @@ class CommunityAvatar extends StatelessWidget { if (isPictrsImageEndpoint && format != null) queryParameters['format'] = format; Uri thumbnailUri = Uri.https(imageUri.host, imageUri.path, queryParameters); - return CachedNetworkImage( - imageUrl: thumbnailUri.toString(), - imageBuilder: (context, imageProvider) { - return Stack( - children: [ - CircleAvatar( - backgroundColor: Colors.transparent, - foregroundImage: imageProvider, - maxRadius: radius, - ), - if (community?.postingRestrictedToMods == true && showCommunityStatus) - Positioned( - bottom: -2.0, - right: -2.0, - child: Tooltip( - message: l10n.onlyModsCanPostInCommunity, - child: Container( - padding: const EdgeInsets.all(4.0), - decoration: BoxDecoration(color: theme.colorScheme.surface, shape: BoxShape.circle), - child: Icon(Icons.lock, color: theme.colorScheme.error, size: 18.0, semanticLabel: l10n.onlyModsCanPostInCommunity), + return ConditionalParentWidget( + condition: showBorder, + parentBuilder: (child) => AvatarBorder(child: child), + child: CachedNetworkImage( + imageUrl: thumbnailUri.toString(), + imageBuilder: (context, imageProvider) { + return Stack( + children: [ + CircleAvatar( + backgroundColor: Colors.transparent, + foregroundImage: imageProvider, + maxRadius: radius, + ), + if (community?.postingRestrictedToMods == true && showCommunityStatus) + Positioned( + bottom: -2.0, + right: -2.0, + child: Tooltip( + message: l10n.onlyModsCanPostInCommunity, + child: Container( + padding: EdgeInsets.all(radius * 0.15), + decoration: BoxDecoration(color: theme.colorScheme.surface, shape: BoxShape.circle), + child: Icon(Icons.lock, color: theme.colorScheme.error, size: radius * 0.5, semanticLabel: l10n.onlyModsCanPostInCommunity), + ), ), ), - ), - ], - ); - }, - placeholder: (context, url) => placeholderIcon, - errorWidget: (context, url, error) => placeholderIcon, + ], + ); + }, + placeholder: (context, url) => placeholderIcon, + errorWidget: (context, url, error) => placeholderIcon, + ), ); } } diff --git a/lib/shared/avatars/user_avatar.dart b/lib/shared/avatars/user_avatar.dart index 5f31f30c3..0e9d2cd10 100644 --- a/lib/shared/avatars/user_avatar.dart +++ b/lib/shared/avatars/user_avatar.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:thunder/shared/avatars/avatar_border.dart'; +import 'package:thunder/shared/conditional_parent_widget.dart'; /// A user avatar. Displays the associated user icon if available. /// @@ -20,7 +22,17 @@ class UserAvatar extends StatelessWidget { /// The image format to request from the instance final String? format; - const UserAvatar({super.key, this.person, this.radius = 16.0, this.thumbnailSize, this.format}); + /// Whether or not to display a border around the avatar + final bool showBorder; + + const UserAvatar({ + super.key, + this.person, + this.radius = 16.0, + this.thumbnailSize, + this.format, + this.showBorder = false, + }); @override Widget build(BuildContext context) { @@ -40,7 +52,13 @@ class UserAvatar extends StatelessWidget { ), ); - if (person?.avatar?.isNotEmpty != true) return placeholderIcon; + if (person?.avatar?.isNotEmpty != true) { + return ConditionalParentWidget( + condition: showBorder, + parentBuilder: (child) => AvatarBorder(child: child), + child: placeholderIcon, + ); + } Uri imageUri = Uri.parse(person!.avatar!); bool isPictrsImageEndpoint = imageUri.toString().contains('/pictrs/image/'); @@ -49,17 +67,21 @@ class UserAvatar extends StatelessWidget { if (isPictrsImageEndpoint && format != null) queryParameters['format'] = format; Uri thumbnailUri = Uri.https(imageUri.host, imageUri.path, queryParameters); - return CachedNetworkImage( - imageUrl: thumbnailUri.toString(), - imageBuilder: (context, imageProvider) { - return CircleAvatar( - backgroundColor: Colors.transparent, - foregroundImage: imageProvider, - maxRadius: radius, - ); - }, - placeholder: (context, url) => placeholderIcon, - errorWidget: (context, url, error) => placeholderIcon, + return ConditionalParentWidget( + condition: showBorder, + parentBuilder: (child) => AvatarBorder(child: child), + child: CachedNetworkImage( + imageUrl: thumbnailUri.toString(), + imageBuilder: (context, imageProvider) { + return CircleAvatar( + backgroundColor: Colors.transparent, + foregroundImage: imageProvider, + maxRadius: radius, + ); + }, + placeholder: (context, url) => placeholderIcon, + errorWidget: (context, url, error) => placeholderIcon, + ), ); } } diff --git a/lib/user/pages/user_page.dart b/lib/user/pages/user_page.dart index fbc0623d4..f2ecef563 100644 --- a/lib/user/pages/user_page.dart +++ b/lib/user/pages/user_page.dart @@ -4,12 +4,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:lemmy_api_client/v3.dart'; import 'package:share_plus/share_plus.dart'; import 'package:swipeable_page_route/swipeable_page_route.dart'; import 'package:thunder/account/bloc/account_bloc.dart'; import 'package:thunder/account/utils/profiles.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; +import 'package:thunder/shared/full_name_widgets.dart'; import 'package:thunder/shared/primitive_wrapper.dart'; import 'package:thunder/shared/snackbar.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; @@ -18,6 +20,7 @@ import 'package:thunder/user/pages/user_page_success.dart'; import 'package:thunder/shared/error_message.dart'; import 'package:thunder/user/bloc/user_bloc_old.dart'; import 'package:thunder/user/pages/user_settings_page.dart'; +import 'package:thunder/utils/instance.dart'; class UserPage extends StatefulWidget { final int? userId; @@ -41,24 +44,25 @@ class UserPage extends StatefulWidget { class _UserPageState extends State { UserBloc? userBloc; - String? userActorId; + Person? person; @override Widget build(BuildContext context) { final ThunderState state = context.read().state; final bool reduceAnimations = state.reduceAnimations; + final ThemeData theme = Theme.of(context); return BlocProvider( create: (BuildContext context) => UserBloc(lemmyClient: LemmyClient.instance), child: BlocListener( listener: (context, state) { - if (userActorId == null && state.personView?.person.actorId != null) { - setState(() => userActorId = state.personView!.person.actorId); + if (person == null && state.personView?.person != null) { + setState(() => person = state.personView!.person); } }, child: Scaffold( appBar: AppBar( - scrolledUnderElevation: 0, + titleSpacing: 0, leading: widget.isAccountUser ? Padding( padding: const EdgeInsets.fromLTRB(0.0, 4.0, 4.0, 4.0), @@ -72,6 +76,24 @@ class _UserPageState extends State { ), ) : null, + title: person == null + ? const SizedBox.shrink() + : ListTile( + title: Text( + person!.name, + style: theme.textTheme.titleLarge, + softWrap: false, + overflow: TextOverflow.fade, + ), + subtitle: UserFullNameWidget( + context, + person!.name, + person!.displayName, + fetchInstanceNameFromUrl(person!.actorId) ?? '', + // Override because we're showing right above + useDisplayName: false, + ), + ), actions: [ Padding( padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 4.0), @@ -84,11 +106,11 @@ class _UserPageState extends State { tooltip: AppLocalizations.of(context)!.refresh, ), ), - if (!widget.isAccountUser && userActorId != null) + if (!widget.isAccountUser && person?.actorId != null) Padding( padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 4.0), child: IconButton( - onPressed: () => Share.share(userActorId!), + onPressed: () => Share.share(person!.actorId), icon: Icon( Icons.share_rounded, semanticLabel: AppLocalizations.of(context)!.share, diff --git a/lib/user/widgets/user_header.dart b/lib/user/widgets/user_header.dart index 96d6e79bc..ec3f2fa82 100644 --- a/lib/user/widgets/user_header.dart +++ b/lib/user/widgets/user_header.dart @@ -1,16 +1,15 @@ +import 'package:extended_image/extended_image.dart'; import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lemmy_api_client/v3.dart'; -import 'package:auto_size_text/auto_size_text.dart'; import 'package:thunder/feed/feed.dart'; import 'package:thunder/shared/avatars/user_avatar.dart'; -import 'package:thunder/shared/full_name_widgets.dart'; import 'package:thunder/shared/icon_text.dart'; -import 'package:thunder/utils/instance.dart'; +import 'package:thunder/utils/colors.dart'; import 'package:thunder/utils/numbers.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class UserHeader extends StatefulWidget { final bool showUserSidebar; @@ -28,11 +27,22 @@ class UserHeader extends StatefulWidget { State createState() => _UserHeaderState(); } -class _UserHeaderState extends State { +class _UserHeaderState extends State with SingleTickerProviderStateMixin { + late AnimationController _bannerImageFadeInController; + late bool _hasBanner; + + @override + void initState() { + _bannerImageFadeInController = AnimationController(vsync: this, duration: const Duration(milliseconds: 250), lowerBound: 0.0, upperBound: 1.0); + _hasBanner = widget.getPersonDetailsResponse.personView.person.banner?.isNotEmpty == true; + + super.initState(); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); final FeedBloc feedBloc = context.watch(); + final AppLocalizations l10n = AppLocalizations.of(context)!; return Material( elevation: widget.showUserSidebar ? 5.0 : 0, @@ -47,115 +57,128 @@ class _UserHeaderState extends State { }, child: Stack( children: [ - if (widget.getPersonDetailsResponse.personView.person.banner == null) Positioned.fill(child: Container(color: theme.colorScheme.background)), - if (widget.getPersonDetailsResponse.personView.person.banner != null) - Positioned.fill( - child: Row( - children: [ - Expanded(flex: 1, child: Container()), - Expanded( - flex: 3, - child: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: CachedNetworkImageProvider(widget.getPersonDetailsResponse.personView.person.banner!), - fit: BoxFit.cover, - ), - ), - ), - ), - ], + Positioned.fill(child: Container(color: getBackgroundColor(context))), + if (_hasBanner) + SizedBox( + height: 100, + width: MediaQuery.sizeOf(context).width, + child: ExtendedImage.network( + widget.getPersonDetailsResponse.personView.person.banner!, + fit: BoxFit.cover, + loadStateChanged: (ExtendedImageState state) { + switch (state.extendedImageLoadState) { + case LoadState.loading: + _bannerImageFadeInController.reset(); + return const SizedBox.shrink(); + case LoadState.failed: + _bannerImageFadeInController.reset(); + return const SizedBox.shrink(); + case LoadState.completed: + if (state.wasSynchronouslyLoaded) return state.completedWidget; + + _bannerImageFadeInController.forward(); + + return FadeTransition( + opacity: _bannerImageFadeInController, + child: state.completedWidget, + ); + } + }, ), ), - if (widget.getPersonDetailsResponse.personView.person.banner != null) - Positioned.fill( - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - theme.colorScheme.background, - theme.colorScheme.background, - theme.colorScheme.background.withOpacity(0.9), - theme.colorScheme.background.withOpacity(0.6), - theme.colorScheme.background.withOpacity(0.3), - ], - ), + Positioned( + left: 25, + top: _hasBanner ? 60 : 10, + child: Column( + children: [ + UserAvatar( + person: widget.getPersonDetailsResponse.personView.person, + radius: 25, + showBorder: true, ), - ), + ], ), + ), Column( + mainAxisAlignment: MainAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only(top: 16.0, left: 24.0, right: 24.0, bottom: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + SizedBox(height: _hasBanner ? 125 : 15), + ConstrainedBox( + constraints: BoxConstraints(minHeight: _hasBanner ? 0 : 45), + child: Row( children: [ - Row( - children: [ - UserAvatar( - person: widget.getPersonDetailsResponse.personView.person, - radius: 45.0, - ), - const SizedBox(width: 20.0), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - AutoSizeText( - widget.getPersonDetailsResponse.personView.person.displayName ?? widget.getPersonDetailsResponse.personView.person.name, - style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, + SizedBox( + width: MediaQuery.sizeOf(context).width * 0.75, + child: Padding( + padding: EdgeInsets.only(left: _hasBanner ? 25 : 100), + child: Wrap( + runSpacing: 10, + children: [ + Container( + decoration: BoxDecoration( + color: getBackgroundColorAlt(context), + borderRadius: BorderRadius.circular(6), + ), + padding: const EdgeInsets.only(left: 4, right: 4), + child: IconText( + icon: const Icon(Icons.wysiwyg_rounded, size: 15), + text: formatNumberToK(widget.getPersonDetailsResponse.personView.counts.postCount), + ), + ), + const SizedBox(width: 8.0), + Container( + decoration: BoxDecoration( + color: getBackgroundColorAlt(context), + borderRadius: BorderRadius.circular(6), ), - UserFullNameWidget( - context, - widget.getPersonDetailsResponse.personView.person.name, - widget.getPersonDetailsResponse.personView.person.displayName, - fetchInstanceNameFromUrl(widget.getPersonDetailsResponse.personView.person.actorId), - autoSize: true, - // Override because we're showing display name above - useDisplayName: false, + padding: const EdgeInsets.only(left: 4, right: 4), + child: IconText( + icon: const Icon(Icons.chat_rounded, size: 15), + text: formatNumberToK(widget.getPersonDetailsResponse.personView.counts.commentCount), ), - const SizedBox(height: 8.0), - Wrap( - children: [ - IconText( - icon: const Icon(Icons.wysiwyg_rounded), - text: formatNumberToK(widget.getPersonDetailsResponse.personView.counts.postCount), - ), - const SizedBox(width: 8.0), - IconText( - icon: const Icon(Icons.chat_rounded), - text: formatNumberToK(widget.getPersonDetailsResponse.personView.counts.commentCount), - ), - if (feedBloc.state.feedType == FeedType.user) ...[ - const SizedBox(width: 8.0), - IconText( - icon: Icon(getSortIcon(feedBloc.state)), - text: getSortName(feedBloc.state), - ), - ], - ], + ), + if (feedBloc.state.feedType == FeedType.user) ...[ + const SizedBox(width: 8.0), + Container( + decoration: BoxDecoration( + color: getBackgroundColorAlt(context), + borderRadius: BorderRadius.circular(6), + ), + padding: const EdgeInsets.only(left: 4, right: 4), + child: IconText( + icon: Icon(getSortIcon(feedBloc.state), size: 15), + text: getSortName(feedBloc.state), + ), ), ], - ), + ], ), - Padding( - padding: const EdgeInsets.all(9.0), - child: Icon( - Icons.info_outline_rounded, - size: 25, - shadows: [Shadow(color: theme.colorScheme.background, blurRadius: 10.0), Shadow(color: theme.colorScheme.background, blurRadius: 20.0)], + ), + ), + const Spacer(), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(6), + onTap: () => widget.onToggle(!widget.showUserSidebar), + child: Container( + decoration: BoxDecoration( + color: getBackgroundColorAlt(context), + borderRadius: BorderRadius.circular(6), + ), + padding: const EdgeInsets.only(left: 4, right: 4), + child: IconText( + icon: const Icon(Icons.info_outline_rounded, size: 15), + text: l10n.about, ), ), - ], + ), ), + const SizedBox(width: 25), ], ), ), + const SizedBox(height: 15), ], ), ], diff --git a/lib/utils/colors.dart b/lib/utils/colors.dart index 3862d0424..7b532bdef 100644 --- a/lib/utils/colors.dart +++ b/lib/utils/colors.dart @@ -10,6 +10,13 @@ Color getBackgroundColor(BuildContext context) { return darkTheme ? theme.dividerColor.darken(5) : theme.dividerColor.lighten(20); } +/// Gets a tinted background color that looks good in light and dark mode, and looks good on top of the backgroud color +Color getBackgroundColorAlt(BuildContext context) { + final bool darkTheme = context.read().state.useDarkTheme; + final ThemeData theme = Theme.of(context); + return darkTheme ? theme.dividerColor.darken(10) : theme.dividerColor.lighten(15); +} + /// Retrieves the color based on the depth of the comment in the comment tree Color getCommentLevelColor(BuildContext context, int level) { // TODO: make this themeable