Refactor post/comment views to use a more flattened structure.

- Will allow doing comment paging, limiting depth, etc.
This commit is contained in:
Hank Grabowski 2023-01-07 10:30:16 -06:00
parent 45467295b8
commit 1f5232891f
7 changed files with 449 additions and 67 deletions

View file

@ -1,46 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import '../../models/entry_tree_item.dart';
import '../../models/flattened_tree_item.dart';
import '../../models/timeline_entry.dart';
import '../../services/timeline_manager.dart';
import '../../utils/url_opening_utils.dart';
import '../media_attachment_viewer_control.dart';
import '../padding.dart';
import 'interactions_bar_control.dart';
import 'status_header_control.dart';
class StatusControl extends StatefulWidget {
final EntryTreeItem originalItem;
class FlattenedTreeEntryControl extends StatefulWidget {
final FlattenedTreeItem originalItem;
final bool openRemote;
final bool showStatusOpenButton;
const StatusControl(
const FlattenedTreeEntryControl(
{super.key,
required this.originalItem,
required this.openRemote,
required this.showStatusOpenButton});
@override
State<StatusControl> createState() => _StatusControlState();
State<FlattenedTreeEntryControl> createState() => _StatusControlState();
}
class _StatusControlState extends State<StatusControl> {
static final _logger = Logger('$StatusControl');
class _StatusControlState extends State<FlattenedTreeEntryControl> {
static final _logger = Logger('$FlattenedTreeEntryControl');
var showContent = true;
var showComments = false;
EntryTreeItem get item => widget.originalItem;
FlattenedTreeItem get item => widget.originalItem;
TimelineEntry get entry => item.entry;
TimelineEntry get entry => item.timelineEntry;
bool get isPublic => item.entry.isPublic;
bool get isPublic => entry.isPublic;
bool get isPost => item.entry.parentId.isEmpty;
bool get isPost => entry.parentId.isEmpty;
bool get hasComments => entry.engagementSummary.repliesCount > 0;
@ -52,11 +50,14 @@ class _StatusControlState extends State<StatusControl> {
@override
Widget build(BuildContext context) {
final manager = context.watch<TimelineManager>();
_logger.finest('Building ${item.entry.toShortString()}');
final padding = isPost ? 8.0 : 8.0;
_logger.finest('Building ${entry.toShortString()}');
const otherPadding = 8.0;
final leftPadding = otherPadding + (widget.originalItem.level * 15.0);
final color = widget.originalItem.level.isOdd
? Theme.of(context).splashColor
: Theme.of(context).cardColor;
final body = Padding(
padding: EdgeInsets.all(padding),
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
@ -95,28 +96,18 @@ class _StatusControlState extends State<StatusControl> {
const VerticalPadding(
height: 5,
),
if (isPost && hasComments)
TextButton(
onPressed: () async {
setState(() {
showComments = !showComments;
});
if (showComments) {
await manager.refreshStatusChain(item.id);
}
},
child:
Text(showComments ? 'Hide Comments' : 'Load & Show Comments'),
),
if (item.totalChildren > 0 && showComments)
buildChildComments(context),
],
),
);
return isPost
? body
: Card(color: Theme.of(context).splashColor, child: body);
return Padding(
padding: EdgeInsets.only(
left: leftPadding,
right: otherPadding,
top: otherPadding,
bottom: otherPadding,
),
child: isPost ? body : Card(color: color, child: body),
);
}
Widget buildBody(BuildContext context) {
@ -145,31 +136,4 @@ class _StatusControlState extends State<StatusControl> {
},
itemCount: items.length));
}
Widget buildChildComments(BuildContext context) {
final comments = widget.originalItem.children;
if (comments.isEmpty) {
return Text('No comments');
}
return Padding(
padding: EdgeInsets.only(left: 5.0, top: 5.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
children: comments
.map((c) => StatusControl(
originalItem: c,
openRemote: false,
showStatusOpenButton: false,
))
.toList(),
),
),
],
));
}
}

View file

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import '../../models/entry_tree_item.dart';
import '../../models/timeline_entry.dart';
import '../../services/timeline_manager.dart';
import '../../utils/entry_tree_item_flattening.dart';
import 'flattened_tree_entry_control.dart';
class PostControl extends StatefulWidget {
final EntryTreeItem originalItem;
final bool openRemote;
final bool showStatusOpenButton;
const PostControl(
{super.key,
required this.originalItem,
required this.openRemote,
required this.showStatusOpenButton});
@override
State<PostControl> createState() => _PostControlState();
}
class _PostControlState extends State<PostControl> {
static final _logger = Logger('$PostControl');
var showContent = true;
var showComments = false;
EntryTreeItem get item => widget.originalItem;
TimelineEntry get entry => item.entry;
bool get isPublic => item.entry.isPublic;
bool get hasComments => entry.engagementSummary.repliesCount > 0;
@override
void initState() {
showContent = entry.spoilerText.isEmpty;
}
@override
Widget build(BuildContext context) {
final manager = context.watch<TimelineManager>();
_logger.finest('Building ${item.entry.toShortString()}');
final items = widget.originalItem.flatten(topLevelOnly: !showComments);
final widgets = <Widget>[];
for (var i = 0; i < items.length; i++) {
final itemWidget = FlattenedTreeEntryControl(
originalItem: items[i],
openRemote: widget.openRemote,
showStatusOpenButton: widget.showStatusOpenButton,
);
widgets.add(itemWidget);
if (i == 0 && hasComments) {
widgets.add(
TextButton(
onPressed: () async {
setState(() {
showComments = !showComments;
});
if (showComments) {
await manager.refreshStatusChain(entry.id);
}
},
child:
Text(showComments ? 'Hide Comments' : 'Load & Show Comments'),
),
);
}
}
return Column(children: widgets);
}
}

View file

@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
import '../../models/TimelineIdentifiers.dart';
import '../../services/timeline_manager.dart';
import 'status_control.dart';
import 'post_control.dart';
class TimelinePanel extends StatelessWidget {
static final _logger = Logger('$TimelinePanel');
@ -43,7 +43,7 @@ class TimelinePanel extends StatelessWidget {
final item = items[itemIndex];
_logger.finest(
'Building item: $itemIndex: ${item.entry.toShortString()}');
return StatusControl(
return PostControl(
originalItem: item,
openRemote: false,
showStatusOpenButton: true,

View file

@ -0,0 +1,15 @@
import 'timeline_entry.dart';
class FlattenedTreeItem {
final TimelineEntry timelineEntry;
final bool isMine;
final int level;
FlattenedTreeItem({
required this.timelineEntry,
required this.isMine,
required this.level,
});
}

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../controls/timeline/status_control.dart';
import '../controls/timeline/post_control.dart';
import '../services/timeline_manager.dart';
class PostScreen extends StatelessWidget {
@ -19,7 +19,7 @@ class PostScreen extends StatelessWidget {
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: StatusControl(
child: PostControl(
originalItem: post,
openRemote: true,
showStatusOpenButton: true,

View file

@ -0,0 +1,53 @@
import '../models/entry_tree_item.dart';
import '../models/flattened_tree_item.dart';
extension FlatteningExtensions on EntryTreeItem {
static const BaseLevel = 0;
List<FlattenedTreeItem> flatten(
{int level = BaseLevel, bool topLevelOnly = false}) {
final items = <FlattenedTreeItem>[];
final myEntry = FlattenedTreeItem(
timelineEntry: entry,
isMine: isMine,
level: level,
);
items.add(myEntry);
if (topLevelOnly) {
return items;
}
final sortedChildren = [...children];
sortedChildren.sort((c1, c2) =>
c1.entry.creationTimestamp.compareTo(c2.entry.creationTimestamp));
for (final child in sortedChildren) {
int childLevel = level + 1;
if (child.entry.authorId == entry.authorId && level != BaseLevel) {
childLevel = level;
}
final childItems = child.flatten(level: childLevel);
childItems.sort((c1, c2) {
if (c2.level == c1.level) {
return c1.timelineEntry.creationTimestamp
.compareTo(c2.timelineEntry.creationTimestamp);
}
if (c1.level == childLevel) {
return -1;
}
if (c2.level == childLevel) {
return 1;
}
return 0;
});
items.addAll(childItems);
}
return items;
}
}

View file

@ -0,0 +1,271 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:friendica_portal/globals.dart';
import 'package:friendica_portal/models/entry_tree_item.dart';
import 'package:friendica_portal/models/timeline_entry.dart';
import 'package:friendica_portal/utils/entry_tree_item_flattening.dart';
void main() {
group('Flattening Tests', () {
test('Single entry no children', () {
final entry = TimelineEntry.randomBuilt();
final treeItem = EntryTreeItem(entry);
final flattened = treeItem.flatten();
expect(flattened.length, equals(1));
expect(flattened.first.isMine, equals(treeItem.isMine));
expect(flattened.first.timelineEntry, equals(treeItem.entry));
});
test('Entry with two children', () {
final post = TimelineEntry(id: '0');
final children = {
'1': EntryTreeItem(
TimelineEntry(id: '1'),
),
'2': EntryTreeItem(
TimelineEntry(id: '2'),
),
};
final treeItem = EntryTreeItem(post, initialChildren: children);
final flattened = treeItem.flatten();
expect(flattened.length, equals(3));
expect(
flattened.map((e) => int.parse(e.timelineEntry.id)).toList(),
equals([0, 1, 2]),
);
expect(
flattened.map((e) => e.level).toList(),
equals([0, 1, 1]),
);
});
test('Entry with nesting children different authors', () {
final post = TimelineEntry(id: '0');
final children = {
'1': EntryTreeItem(TimelineEntry(id: '1', authorId: randomId()),
initialChildren: {
'2': EntryTreeItem(
TimelineEntry(id: '2', authorId: randomId()),
),
}),
};
final treeItem = EntryTreeItem(post, initialChildren: children);
final flattened = treeItem.flatten();
expect(flattened.length, equals(3));
expect(
flattened.map((e) => int.parse(e.timelineEntry.id)).toList(),
equals([0, 1, 2]),
);
expect(
flattened.map((e) => e.level).toList(),
equals([0, 1, 2]),
);
});
test('Entry with nesting children same authors', () {
final post = TimelineEntry(id: '0');
final children = {
'1': EntryTreeItem(TimelineEntry(id: '1'), initialChildren: {
'2': EntryTreeItem(
TimelineEntry(id: '2'),
),
}),
};
final treeItem = EntryTreeItem(post, initialChildren: children);
final flattened = treeItem.flatten();
expect(flattened.length, equals(3));
expect(
flattened.map((e) => int.parse(e.timelineEntry.id)).toList(),
equals([0, 1, 2]),
);
expect(
flattened.map((e) => e.level).toList(),
equals([0, 1, 1]),
);
});
test('Entry fully nested children', () {
var stamp = 0;
final post =
TimelineEntry(id: '0', authorId: 'a0', creationTimestamp: stamp++);
final children = {
'1': EntryTreeItem(
TimelineEntry(id: '1', creationTimestamp: stamp++),
initialChildren: {
'1.1': EntryTreeItem(
TimelineEntry(
id: '1.1',
authorId: randomId(),
creationTimestamp: stamp++,
),
),
'1.2': EntryTreeItem(
TimelineEntry(
id: '1.2',
authorId: randomId(),
creationTimestamp: stamp++,
),
),
'1.3': EntryTreeItem(
TimelineEntry(
id: '1.3',
authorId: randomId(),
creationTimestamp: stamp++,
),
),
},
),
'2': EntryTreeItem(
TimelineEntry(
id: '2',
authorId: randomId(),
creationTimestamp: stamp++,
),
initialChildren: {
'2.1': EntryTreeItem(
TimelineEntry(
id: '2.1',
authorId: randomId(),
creationTimestamp: stamp++,
),
),
'2.2': EntryTreeItem(
TimelineEntry(
id: '2.2',
authorId: randomId(),
creationTimestamp: stamp++,
),
initialChildren: {
'2.2.1': EntryTreeItem(
TimelineEntry(
id: '2.2.1',
authorId: 'a1',
creationTimestamp: stamp++,
),
initialChildren: {
'2.2.1.1': EntryTreeItem(
TimelineEntry(
id: '2.2.1.1',
creationTimestamp: stamp++,
),
),
'2.2.1.2': EntryTreeItem(
TimelineEntry(
id: '2.2.1.2',
authorId: 'a1',
creationTimestamp: stamp++,
),
),
},
),
'2.2.2': EntryTreeItem(
TimelineEntry(
id: '2.2.2',
authorId: 'a2',
creationTimestamp: stamp++,
),
initialChildren: {
'2.2.2.1': EntryTreeItem(
TimelineEntry(
id: '2.2.2.1',
creationTimestamp: (stamp++) + 100,
),
),
'2.2.2.2': EntryTreeItem(
TimelineEntry(
id: '2.2.2.2',
authorId: 'a2',
creationTimestamp: stamp++,
),
),
'2.2.2.3': EntryTreeItem(
TimelineEntry(
id: '2.2.2.3',
authorId: 'a2',
creationTimestamp: stamp++,
),
),
'2.2.2.4': EntryTreeItem(
TimelineEntry(
id: '2.2.2.4',
authorId: 'a0',
creationTimestamp: stamp++,
),
),
},
),
},
),
'2.3': EntryTreeItem(
TimelineEntry(
id: '2.3',
authorId: randomId(),
creationTimestamp: stamp++,
),
),
},
),
'3': EntryTreeItem(
TimelineEntry(
id: '3',
authorId: 'a0',
creationTimestamp: stamp++,
),
initialChildren: {
'3.1': EntryTreeItem(TimelineEntry(
id: '3.1',
authorId: randomId(),
creationTimestamp: stamp++,
)),
'3.2': EntryTreeItem(
TimelineEntry(
id: '3.2',
authorId: 'a0',
creationTimestamp: stamp++,
),
),
'3.3': EntryTreeItem(
TimelineEntry(
id: '3.3',
authorId: randomId(),
creationTimestamp: stamp++,
),
),
},
),
};
final treeItem = EntryTreeItem(post, initialChildren: children);
final flattened = treeItem.flatten();
expect(flattened.length, equals(21));
expect(
flattened.map((e) => e.timelineEntry.id).toList(),
equals([
'0',
'1',
'1.1',
'1.2',
'1.3',
'2',
'2.1',
'2.2',
'2.2.1',
'2.2.1.2',
'2.2.1.1',
'2.2.2',
'2.2.2.2',
'2.2.2.3',
'2.2.2.4',
'2.2.2.1',
'2.3',
'3',
'3.2',
'3.1',
'3.3',
]),
);
expect(
flattened.map((e) => e.level).toList(),
equals([0, 1, 2, 2, 2, 1, 2, 2, 3, 3, 4, 3, 3, 3, 4, 4, 2, 1, 1, 2, 2]),
);
});
});
}