Add a wide/compact post layout mode

This commit is contained in:
John Wesley 2024-04-17 10:32:18 -04:00
parent fdc77d305b
commit c22dc3107a
3 changed files with 476 additions and 353 deletions

View File

@ -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<String> themeSelect = SelectionMenu(
))
.toList(),
);
const SelectionMenu<PostLayout> 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,
),
],
);

View File

@ -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<void> 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<void> updateDefaultFeedType(PostType? newDefaultFeedMode) async {
if (newDefaultFeedMode == null) return;
if (newDefaultFeedMode == _defaultFeedType) return;

View File

@ -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<ContentItem> {
)
: null;
return Column(
children: <Widget>[
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<SettingsController>().postLayout) {
PostLayout.auto => constrains.maxWidth > 800,
PostLayout.narrow => false,
PostLayout.wide => true,
};
return Column(
children: <Widget>[
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: <Widget>[
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: <Widget>[
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<String>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: Text('Delete ${widget.contentTypeName}'),
actions: <Widget>[
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: <Widget>[
...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<String>(
context: context,
builder: (BuildContext context) =>
AlertDialog(
title: Text(
'Delete ${widget.contentTypeName}'),
actions: <Widget>[
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: <Widget>[
...commentWidgets,
const Spacer(),
...menuWidgets,
],
),
],
)
: Row(
children: <Widget>[
...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: <Widget>[
...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,
),
),
),
),
),
],
],
),
),
),
],
);
],
);
});
}
}