From c22dc3107ae99f6971085d904613c794a7a68f0f Mon Sep 17 00:00:00 2001 From: John Wesley Date: Wed, 17 Apr 2024 10:32:18 -0400 Subject: [PATCH] Add a wide/compact post layout mode --- .../screens/settings/general_settings.dart | 43 + .../screens/settings/settings_controller.dart | 21 + lib/src/widgets/content_item.dart | 765 ++++++++++-------- 3 files changed, 476 insertions(+), 353 deletions(-) diff --git a/lib/src/screens/settings/general_settings.dart b/lib/src/screens/settings/general_settings.dart index 8de7d73..1e04af0 100644 --- a/lib/src/screens/settings/general_settings.dart +++ b/lib/src/screens/settings/general_settings.dart @@ -17,6 +17,8 @@ class GeneralScreen extends StatelessWidget { final currentThemeMode = themeModeSelect.getOption(controller.themeMode); final currentTheme = themeSelect.getOption(controller.accentColor); + final currentPostLayout = postLayoutSelect.getOption(controller.postLayout); + final customLanguageFilterEnabled = !controller.useAccountLangFilter && !isLemmy; @@ -177,6 +179,26 @@ class GeneralScreen extends StatelessWidget { subtitle: const Text( 'When enabled, the instance of a user/magazine will always display instead of an @ button'), ), + ListTile( + title: const Text('Post Layout'), + leading: const Icon(Icons.view_list), + onTap: () async { + controller.updatePostLayout( + await postLayoutSelect.askSelection( + context, + controller.postLayout, + ), + ); + }, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(currentPostLayout.icon), + const SizedBox(width: 4), + Text(currentPostLayout.title), + ], + ), + ), ], ), ); @@ -215,3 +237,24 @@ SelectionMenu themeSelect = SelectionMenu( )) .toList(), ); + +const SelectionMenu postLayoutSelect = SelectionMenu( + 'Post Layout', + [ + SelectionMenuItem( + value: PostLayout.auto, + title: 'Auto', + icon: Icons.auto_mode, + ), + SelectionMenuItem( + value: PostLayout.narrow, + title: 'Narrow', + icon: Icons.smartphone, + ), + SelectionMenuItem( + value: PostLayout.wide, + title: 'Wide', + icon: Icons.tablet, + ), + ], +); diff --git a/lib/src/screens/settings/settings_controller.dart b/lib/src/screens/settings/settings_controller.dart index 78c2a94..e29397b 100644 --- a/lib/src/screens/settings/settings_controller.dart +++ b/lib/src/screens/settings/settings_controller.dart @@ -17,6 +17,8 @@ import 'package:shared_preferences/shared_preferences.dart'; enum ServerSoftware { kbin, mbin, lemmy } +enum PostLayout { auto, narrow, wide } + class Server { final ServerSoftware software; final String? oauthIdentifier; @@ -65,6 +67,8 @@ class SettingsController with ChangeNotifier { late bool _alwaysShowInstance; bool get alwaysShowInstance => _alwaysShowInstance; + late PostLayout _postLayout; + PostLayout get postLayout => _postLayout; late ActionLocation _feedActionBackToTop; ActionLocation get feedActionBackToTop => _feedActionBackToTop; @@ -130,6 +134,11 @@ class SettingsController with ChangeNotifier { _alwaysShowInstance = prefs.getBool("alwaysShowInstance") != null ? prefs.getBool("alwaysShowInstance")! : false; + _postLayout = parseEnum( + PostLayout.values, + PostLayout.auto, + prefs.getString("postLayout"), + ); _feedActionBackToTop = parseEnum( ActionLocation.values, @@ -263,6 +272,18 @@ class SettingsController with ChangeNotifier { await prefs.setBool('alwaysShowInstance', newShowDisplayInstance); } + Future updatePostLayout(PostLayout? newPostLayout) async { + if (newPostLayout == null) return; + if (newPostLayout == _postLayout) return; + + _postLayout = newPostLayout; + + notifyListeners(); + + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString('postLayout', newPostLayout.name); + } + Future updateDefaultFeedType(PostType? newDefaultFeedMode) async { if (newDefaultFeedMode == null) return; if (newDefaultFeedMode == _defaultFeedType) return; diff --git a/lib/src/widgets/content_item.dart b/lib/src/widgets/content_item.dart index 52f3bf7..2a88dcd 100644 --- a/lib/src/widgets/content_item.dart +++ b/lib/src/widgets/content_item.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:interstellar/src/screens/explore/domain_screen.dart'; import 'package:interstellar/src/screens/explore/magazine_screen.dart'; import 'package:interstellar/src/screens/explore/user_screen.dart'; +import 'package:interstellar/src/screens/settings/settings_controller.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/display_name.dart'; import 'package:interstellar/src/widgets/markdown.dart'; @@ -10,6 +11,7 @@ import 'package:interstellar/src/widgets/report_content.dart'; import 'package:interstellar/src/widgets/text_editor.dart'; import 'package:interstellar/src/widgets/video.dart'; import 'package:interstellar/src/widgets/wrapper.dart'; +import 'package:provider/provider.dart'; class ContentItem extends StatefulWidget { final String originInstance; @@ -171,375 +173,432 @@ class _ContentItemState extends State { ) : null; - return Column( - children: [ - if (widget.image != null || (!widget.isPreview && widget.video != null)) - Wrapper( - shouldWrap: !widget.isPreview, - parentBuilder: (child) => Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height / 2, - ), - child: child), - child: (!widget.isPreview && widget.video != null) - ? VideoPlayer(widget.video!) - : Wrapper( - shouldWrap: widget.video == null, - parentBuilder: (child) => InkWell( - onTap: () => _onImageClick(context), - child: child, - ), - child: widget.isPreview - ? Image.network( - widget.image!, - height: 160, - width: double.infinity, - fit: BoxFit.cover, - ) - : Image.network(widget.image!), + return LayoutBuilder(builder: (context, constrains) { + final isWide = switch (context.watch().postLayout) { + PostLayout.auto => constrains.maxWidth > 800, + PostLayout.narrow => false, + PostLayout.wide => true, + }; + + return Column( + children: [ + if ((!isWide && widget.image != null) || + (!widget.isPreview && widget.video != null)) + Wrapper( + shouldWrap: !widget.isPreview, + parentBuilder: (child) => Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height / 2, ), - ), - Container( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.title != null) - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: widget.link != null - ? InkWell( - child: Text( - widget.title!, - style: Theme.of(context) - .textTheme - .titleLarge! - .apply(decoration: TextDecoration.underline), - ), - onTap: () { - openWebpage(context, widget.link!); - }, - ) - : Text( - widget.title!, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - Row( - children: [ - if (!widget.showMagazineFirst && userWidget != null) - userWidget, - if (!widget.showMagazineFirst && - widget.opUserId == widget.userIdOnClick) - const Padding( - padding: EdgeInsets.only(right: 10), child: Text("OP")), - if (widget.showMagazineFirst && magazineWidget != null) - magazineWidget, - if (widget.createdAt != null) - Padding( - padding: const EdgeInsets.only(right: 10), - child: Text( - timeDiffFormat(widget.createdAt!), - style: const TextStyle(fontWeight: FontWeight.w300), + child: child), + child: (!widget.isPreview && widget.video != null) + ? VideoPlayer(widget.video!) + : Wrapper( + shouldWrap: widget.video == null, + parentBuilder: (child) => InkWell( + onTap: () => _onImageClick(context), + child: child, ), + child: widget.isPreview + ? Image.network( + widget.image!, + height: 160, + width: double.infinity, + fit: BoxFit.cover, + ) + : Image.network(widget.image!), ), - if (widget.showMagazineFirst && userWidget != null) - userWidget, - if (widget.showMagazineFirst && - widget.opUserId == widget.userIdOnClick) - const Padding( - padding: EdgeInsets.only(right: 10), child: Text("OP")), - if (!widget.showMagazineFirst && magazineWidget != null) - magazineWidget, - if (widget.domain != null) - Padding( - padding: const EdgeInsets.only(right: 10), - child: IconButton( - tooltip: widget.domain, - onPressed: widget.domainIdOnClick != null - ? () => Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => DomainScreen( - widget.domainIdOnClick!, - ), + ), + Container( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.title != null) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: widget.link != null + ? InkWell( + child: Text( + widget.title!, + style: Theme.of(context) + .textTheme + .titleLarge! + .apply( + decoration: + TextDecoration.underline), ), + onTap: () { + openWebpage(context, widget.link!); + }, ) - : null, - icon: const Icon(Icons.public), - iconSize: 16, - style: const ButtonStyle( - minimumSize: - MaterialStatePropertyAll(Size.fromRadius(16))), - ), - ), - ], - ), - if (widget.body != null) const SizedBox(height: 10), - if (widget.body != null) - widget.isPreview - ? Text( - widget.body!, - maxLines: 4, - overflow: TextOverflow.ellipsis, - ) - : Markdown(widget.body!, widget.originInstance), - const SizedBox(height: 10), - LayoutBuilder(builder: (context, constrains) { - final votingWidgets = [ - if (widget.boosts != null) - Padding( - padding: const EdgeInsets.only(right: 8), - child: Row( + : Text( + widget.title!, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + Row( children: [ - IconButton( - icon: const Icon(Icons.rocket_launch), - color: widget.isBoosted - ? Colors.purple.shade400 - : null, - onPressed: widget.onBoost, - ), - Text(intFormat(widget.boosts!)) - ], - ), - ), - if (widget.upVotes != null || widget.downVotes != null) - Row( - children: [ - if (widget.upVotes != null) - IconButton( - icon: const Icon(Icons.arrow_upward), - color: - widget.isUpVoted ? Colors.green.shade400 : null, - onPressed: widget.onUpVote, - ), - Text(intFormat( - (widget.upVotes ?? 0) - (widget.downVotes ?? 0))), - if (widget.downVotes != null) - IconButton( - icon: const Icon(Icons.arrow_downward), - color: - widget.isDownVoted ? Colors.red.shade400 : null, - onPressed: widget.onDownVote, - ), - ], - ), - ]; - final commentWidgets = [ - if (widget.numComments != null) - Padding( - padding: const EdgeInsets.only(right: 8), - child: Row( - children: [ - const Icon(Icons.comment), - const SizedBox(width: 4), - Text(intFormat(widget.numComments!)) - ], - ), - ), - if (widget.onReply != null) - Padding( - padding: const EdgeInsets.only(right: 8), - child: IconButton( - icon: const Icon(Icons.reply), - onPressed: () => setState(() { - _replyTextController = TextEditingController(); - }), - ), - ), - if (widget.onCollapse != null) - IconButton( - tooltip: widget.isCollapsed ? 'Expand' : 'Collapse', - onPressed: widget.onCollapse, - icon: widget.isCollapsed - ? const Icon(Icons.expand_more) - : const Icon(Icons.expand_less)), - ]; - final menuWidgets = [ - if (widget.openLinkUri != null || - widget.onReport != null || - widget.onEdit != null || - widget.onDelete != null) - MenuAnchor( - builder: (BuildContext context, MenuController controller, - Widget? child) { - return IconButton( - icon: const Icon(Icons.more_vert), - onPressed: () { - if (_menuController.isOpen) { - _menuController.close(); - } else { - _menuController.open(); - } - }, - ); - }, - controller: _menuController, - menuChildren: [ - if (widget.openLinkUri != null) - MenuItemButton( - onPressed: () => - openWebpage(context, widget.openLinkUri!), - child: const Padding( - padding: EdgeInsets.all(12), - child: Text("Open Link")), - ), - if (widget.onReport != null) - MenuItemButton( - onPressed: () async { - final reportReason = await reportContent( - context, widget.contentTypeName); - - if (reportReason != null) { - await widget.onReport!(reportReason); - } - }, - child: const Padding( - padding: EdgeInsets.all(12), - child: Text("Report")), - ), - if (widget.onEdit != null) - MenuItemButton( - onPressed: () => setState(() { - _editTextController = - TextEditingController(text: widget.body); - }), - child: const Padding( - padding: EdgeInsets.all(12), - child: Text("Edit")), - ), - if (widget.onDelete != null) - MenuItemButton( - onPressed: () => showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: Text('Delete ${widget.contentTypeName}'), - actions: [ - OutlinedButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.pop(context); - - widget.onDelete!(); - }, - child: const Text('Delete'), - ), - ], - actionsOverflowAlignment: - OverflowBarAlignment.center, - actionsOverflowButtonSpacing: 8, - actionsOverflowDirection: VerticalDirection.up, + if (!widget.showMagazineFirst && userWidget != null) + userWidget, + if (!widget.showMagazineFirst && + widget.opUserId == widget.userIdOnClick) + const Padding( + padding: EdgeInsets.only(right: 10), + child: Text("OP")), + if (widget.showMagazineFirst && + magazineWidget != null) + magazineWidget, + if (widget.createdAt != null) + Padding( + padding: const EdgeInsets.only(right: 10), + child: Text( + timeDiffFormat(widget.createdAt!), + style: const TextStyle( + fontWeight: FontWeight.w300), ), ), - child: const Padding( - padding: EdgeInsets.all(12), - child: Text("Delete")), - ), - ], - ), - ]; + if (widget.showMagazineFirst && userWidget != null) + userWidget, + if (widget.showMagazineFirst && + widget.opUserId == widget.userIdOnClick) + const Padding( + padding: EdgeInsets.only(right: 10), + child: Text("OP")), + if (!widget.showMagazineFirst && + magazineWidget != null) + magazineWidget, + if (widget.domain != null) + Padding( + padding: const EdgeInsets.only(right: 10), + child: IconButton( + tooltip: widget.domain, + onPressed: widget.domainIdOnClick != null + ? () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => DomainScreen( + widget.domainIdOnClick!, + ), + ), + ) + : null, + icon: const Icon(Icons.public), + iconSize: 16, + style: const ButtonStyle( + minimumSize: MaterialStatePropertyAll( + Size.fromRadius(16))), + ), + ), + ], + ), + if (widget.body != null) const SizedBox(height: 10), + if (widget.body != null) + widget.isPreview + ? Text( + widget.body!, + maxLines: 4, + overflow: TextOverflow.ellipsis, + ) + : Markdown(widget.body!, widget.originInstance), + const SizedBox(height: 10), + LayoutBuilder(builder: (context, constrains) { + final votingWidgets = [ + if (widget.boosts != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.rocket_launch), + color: widget.isBoosted + ? Colors.purple.shade400 + : null, + onPressed: widget.onBoost, + ), + Text(intFormat(widget.boosts!)) + ], + ), + ), + if (widget.upVotes != null || + widget.downVotes != null) + Row( + children: [ + if (widget.upVotes != null) + IconButton( + icon: const Icon(Icons.arrow_upward), + color: widget.isUpVoted + ? Colors.green.shade400 + : null, + onPressed: widget.onUpVote, + ), + Text(intFormat((widget.upVotes ?? 0) - + (widget.downVotes ?? 0))), + if (widget.downVotes != null) + IconButton( + icon: const Icon(Icons.arrow_downward), + color: widget.isDownVoted + ? Colors.red.shade400 + : null, + onPressed: widget.onDownVote, + ), + ], + ), + ]; + final commentWidgets = [ + if (widget.numComments != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + children: [ + const Icon(Icons.comment), + const SizedBox(width: 4), + Text(intFormat(widget.numComments!)) + ], + ), + ), + if (widget.onReply != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + icon: const Icon(Icons.reply), + onPressed: () => setState(() { + _replyTextController = + TextEditingController(); + }), + ), + ), + if (widget.onCollapse != null) + IconButton( + tooltip: + widget.isCollapsed ? 'Expand' : 'Collapse', + onPressed: widget.onCollapse, + icon: widget.isCollapsed + ? const Icon(Icons.expand_more) + : const Icon(Icons.expand_less)), + ]; + final menuWidgets = [ + if (widget.openLinkUri != null || + widget.onReport != null || + widget.onEdit != null || + widget.onDelete != null) + MenuAnchor( + builder: (BuildContext context, + MenuController controller, Widget? child) { + return IconButton( + icon: const Icon(Icons.more_vert), + onPressed: () { + if (_menuController.isOpen) { + _menuController.close(); + } else { + _menuController.open(); + } + }, + ); + }, + controller: _menuController, + menuChildren: [ + if (widget.openLinkUri != null) + MenuItemButton( + onPressed: () => openWebpage( + context, widget.openLinkUri!), + child: const Padding( + padding: EdgeInsets.all(12), + child: Text("Open Link")), + ), + if (widget.onReport != null) + MenuItemButton( + onPressed: () async { + final reportReason = await reportContent( + context, widget.contentTypeName); - return constrains.maxWidth < 300 - ? Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: votingWidgets, - ), - const SizedBox(height: 4), - Row( - children: [ - ...commentWidgets, - const Spacer(), - ...menuWidgets, + if (reportReason != null) { + await widget.onReport!(reportReason); + } + }, + child: const Padding( + padding: EdgeInsets.all(12), + child: Text("Report")), + ), + if (widget.onEdit != null) + MenuItemButton( + onPressed: () => setState(() { + _editTextController = + TextEditingController( + text: widget.body); + }), + child: const Padding( + padding: EdgeInsets.all(12), + child: Text("Edit")), + ), + if (widget.onDelete != null) + MenuItemButton( + onPressed: () => showDialog( + context: context, + builder: (BuildContext context) => + AlertDialog( + title: Text( + 'Delete ${widget.contentTypeName}'), + actions: [ + OutlinedButton( + onPressed: () => + Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.pop(context); + + widget.onDelete!(); + }, + child: const Text('Delete'), + ), + ], + actionsOverflowAlignment: + OverflowBarAlignment.center, + actionsOverflowButtonSpacing: 8, + actionsOverflowDirection: + VerticalDirection.up, + ), + ), + child: const Padding( + padding: EdgeInsets.all(12), + child: Text("Delete")), + ), + ], + ), + ]; + + return constrains.maxWidth < 300 + ? Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: votingWidgets, + ), + const SizedBox(height: 4), + Row( + children: [ + ...commentWidgets, + const Spacer(), + ...menuWidgets, + ], + ), + ], + ) + : Row( + children: [ + ...commentWidgets, + const Spacer(), + ...menuWidgets, + const SizedBox(width: 8), + ...votingWidgets, + ], + ); + }), + if (widget.onReply != null && + _replyTextController != null) + Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + TextEditor(_replyTextController!, + isMarkdown: true), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () => setState(() { + _replyTextController!.dispose(); + _replyTextController = null; + }), + child: const Text('Cancel')), + const SizedBox(width: 8), + FilledButton( + onPressed: () async { + // Wait in case of errors before closing + await widget.onReply!( + _replyTextController!.text); + + setState(() { + _replyTextController!.dispose(); + _replyTextController = null; + }); + }, + child: const Text('Submit')) + ], + ) ], ), - ], - ) - : Row( - children: [ - ...commentWidgets, - const Spacer(), - ...menuWidgets, - const SizedBox(width: 8), - ...votingWidgets, - ], - ); - }), - if (widget.onReply != null && _replyTextController != null) - Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - TextEditor(_replyTextController!, isMarkdown: true), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - OutlinedButton( - onPressed: () => setState(() { - _replyTextController!.dispose(); - _replyTextController = null; - }), - child: const Text('Cancel')), - const SizedBox(width: 8), - FilledButton( - onPressed: () async { - // Wait in case of errors before closing - await widget - .onReply!(_replyTextController!.text); + ), + if (widget.onEdit != null && _editTextController != null) + Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + TextEditor(_editTextController!, + isMarkdown: true), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () => setState(() { + _editTextController!.dispose(); + _editTextController = null; + }), + child: const Text('Cancel')), + const SizedBox(width: 8), + FilledButton( + onPressed: () async { + // Wait in case of errors before closing + await widget + .onEdit!(_editTextController!.text); - setState(() { - _replyTextController!.dispose(); - _replyTextController = null; - }); - }, - child: const Text('Submit')) - ], - ) + setState(() { + _editTextController!.dispose(); + _editTextController = null; + }); + }, + child: const Text('Submit'), + ) + ], + ) + ], + ), + ), ], ), ), - if (widget.onEdit != null && _editTextController != null) - Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - TextEditor(_editTextController!, isMarkdown: true), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - OutlinedButton( - onPressed: () => setState(() { - _editTextController!.dispose(); - _editTextController = null; - }), - child: const Text('Cancel')), - const SizedBox(width: 8), - FilledButton( - onPressed: () async { - // Wait in case of errors before closing - await widget.onEdit!(_editTextController!.text); - - setState(() { - _editTextController!.dispose(); - _editTextController = null; - }); - }, - child: const Text('Submit'), - ) - ], - ) - ], + if (isWide && widget.image != null) + Padding( + padding: const EdgeInsets.only(left: 16), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Wrapper( + shouldWrap: widget.video == null, + parentBuilder: (child) => InkWell( + onTap: () => _onImageClick(context), + child: child, + ), + child: Image.network( + widget.image!, + height: 128, + width: 128, + fit: BoxFit.cover, + ), + ), + ), ), - ), - ], + ], + ), ), - ), - ], - ); + ], + ); + }); } }