Implement treed post/comment entries

This commit is contained in:
Hank Grabowski 2022-01-19 09:44:55 -05:00
parent eb9ecd39e5
commit aaa41c9131
9 changed files with 264 additions and 174 deletions

View file

@ -66,7 +66,9 @@ class FriendicaArchiveBrowser extends StatelessWidget {
create: (context) => friendicaArchiveService),
Provider(create: (context) => pathMappingService),
],
child: Home(settingsController: settingsController),
child: Home(
settingsController: settingsController,
archiveService: friendicaArchiveService),
),
);
},

View file

@ -1,7 +1,7 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
import 'package:friendica_archive_browser/src/friendica/models/friendica_timeline_entry.dart';
import 'package:friendica_archive_browser/src/friendica/models/friendica_entry_tree_item.dart';
import 'package:friendica_archive_browser/src/friendica/models/location_data.dart';
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
@ -14,11 +14,14 @@ import 'package:url_launcher/url_launcher.dart';
import 'link_elements_component.dart';
import 'media_timeline_component.dart';
class PostCard extends StatelessWidget {
static final _logger = Logger("$PostCard");
final FriendicaTimelineEntry post;
class TreeEntryCard extends StatelessWidget {
static final _logger = Logger("$TreeEntryCard");
final FriendicaEntryTreeItem treeEntry;
final bool isTopLevel;
const PostCard({Key? key, required this.post}) : super(key: key);
const TreeEntryCard(
{Key? key, required this.treeEntry, this.isTopLevel = true})
: super(key: key);
@override
Widget build(BuildContext context) {
@ -31,10 +34,16 @@ class PostCard extends StatelessWidget {
Provider.of<SettingsController>(context).dateTimeFormatter;
final mapper = Provider.of<PathMappingService>(context);
final title = post.title.isEmpty ? 'Post' : post.title;
final entry = treeEntry.entry;
final title = entry.title.isNotEmpty
? entry.title
: entry.parentId.isEmpty
? 'Post'
: 'Comment on post by ${entry.parentAuthor}';
final dateStamp = ' At ' +
formatter.format(
DateTime.fromMillisecondsSinceEpoch(post.creationTimestamp * 1000)
DateTime.fromMillisecondsSinceEpoch(entry.creationTimestamp * 1000)
.toLocal());
return Padding(
@ -62,15 +71,15 @@ class PostCard extends StatelessWidget {
child: IconButton(
onPressed: () async => await copyToClipboard(
context: context,
text: post.toHumanString(mapper, formatter),
text: entry.toHumanString(mapper, formatter),
snackbarMessage: 'Copied Post to clipboard'),
icon: const Icon(Icons.copy)),
),
]),
if (post.post.isNotEmpty) ...[
if (entry.post.isNotEmpty) ...[
const SizedBox(height: spacingHeight),
HtmlWidget(
post.post,
entry.post,
onTapUrl: (url) async {
bool canLaunchResult = await canLaunch(url);
if (!canLaunchResult) {
@ -87,16 +96,22 @@ class PostCard extends StatelessWidget {
},
)
],
if (post.locationData.hasData())
post.locationData.toWidget(spacingHeight),
if (post.links.isNotEmpty) ...[
if (entry.locationData.hasData())
entry.locationData.toWidget(spacingHeight),
if (entry.links.isNotEmpty) ...[
const SizedBox(height: spacingHeight),
LinkElementsComponent(links: post.links)
LinkElementsComponent(links: entry.links)
],
if (post.mediaAttachments.isNotEmpty) ...[
if (entry.mediaAttachments.isNotEmpty) ...[
const SizedBox(height: spacingHeight),
MediaTimelineComponent(mediaAttachments: post.mediaAttachments)
]
MediaTimelineComponent(mediaAttachments: entry.mediaAttachments)
],
if (treeEntry.children.isNotEmpty)
Column(
children: treeEntry.children
.map((e) => TreeEntryCard(treeEntry: e))
.toList(),
)
],
),
);

View file

@ -0,0 +1,19 @@
import 'package:friendica_archive_browser/src/friendica/models/friendica_timeline_entry.dart';
class FriendicaEntryTreeItem {
final FriendicaTimelineEntry entry;
final bool isOrphaned;
final _children = <String, FriendicaEntryTreeItem>{};
FriendicaEntryTreeItem(this.entry, this.isOrphaned);
String get id => entry.id;
void addChild(FriendicaEntryTreeItem child) {
_children[child.id] = child;
}
List<FriendicaEntryTreeItem> get children =>
List.unmodifiable(_children.values);
}

View file

@ -11,6 +11,12 @@ import 'timeline_type.dart';
class FriendicaTimelineEntry {
static final _logger = Logger('$FriendicaTimelineEntry');
final String id;
final String parentId;
final String parentAuthor;
final int creationTimestamp;
final int backdatedTimestamp;
@ -32,12 +38,15 @@ class FriendicaTimelineEntry {
final TimelineType timelineType;
FriendicaTimelineEntry(
{this.creationTimestamp = 0,
{this.id = '',
this.parentId = '',
this.creationTimestamp = 0,
this.backdatedTimestamp = 0,
this.modificationTimestamp = 0,
this.post = '',
this.title = '',
this.author = '',
this.parentAuthor = '',
this.locationData = const LocationData(),
required this.timelineType,
List<FriendicaMediaAttachment>? mediaAttachments,
@ -49,9 +58,12 @@ class FriendicaTimelineEntry {
: creationTimestamp = DateTime.now().millisecondsSinceEpoch,
backdatedTimestamp = DateTime.now().millisecondsSinceEpoch,
modificationTimestamp = DateTime.now().millisecondsSinceEpoch,
id = randomId(),
parentId = randomId(),
post = 'Random post text ${randomId()}',
title = 'Random title ${randomId()}',
author = 'Random author ${randomId()}',
parentAuthor = 'Random parent author ${randomId()}',
locationData = LocationData.randomBuilt(),
timelineType = TimelineType.active,
links = [
@ -67,9 +79,12 @@ class FriendicaTimelineEntry {
{int? creationTimestamp,
int? backdatedTimestamp,
int? modificationTimestamp,
String? id,
String? parentId,
String? post,
String? title,
String? author,
String? parentAuthor,
LocationData? locationData,
List<FriendicaMediaAttachment>? mediaAttachments,
TimelineType? timelineType,
@ -79,9 +94,12 @@ class FriendicaTimelineEntry {
backdatedTimestamp: backdatedTimestamp ?? this.backdatedTimestamp,
modificationTimestamp:
modificationTimestamp ?? this.modificationTimestamp,
id: id ?? this.id,
parentId: parentId ?? this.parentId,
post: post ?? this.post,
title: title ?? this.title,
author: author ?? this.author,
parentAuthor: parentAuthor ?? this.parentAuthor,
locationData: locationData ?? this.locationData,
mediaAttachments: mediaAttachments ?? this.mediaAttachments,
timelineType: timelineType ?? this.timelineType,
@ -90,7 +108,7 @@ class FriendicaTimelineEntry {
@override
String toString() {
return 'FacebookPost{creationTimestamp: $creationTimestamp, modificationTimestamp: $modificationTimestamp, backdatedTimeStamp: $backdatedTimestamp, timelineType: $timelineType, post: $post, title: $title, author: $author, mediaAttachments: $mediaAttachments, links: $links}';
return 'FacebookPost{id: $id, parentId: $parentId, creationTimestamp: $creationTimestamp, modificationTimestamp: $modificationTimestamp, backdatedTimeStamp: $backdatedTimestamp, timelineType: $timelineType, post: $post, title: $title, author: $author, parentAuthor: $parentAuthor mediaAttachments: $mediaAttachments, links: $links}';
}
String toHumanString(PathMappingService mapper, DateFormat formatter) {
@ -104,6 +122,8 @@ class FriendicaTimelineEntry {
'Author: $author',
post,
'',
if (parentId.isEmpty)
"Comment on post/comment by ${parentAuthor.isNotEmpty ? parentAuthor : 'unknown author'}",
if (links.isNotEmpty) 'Links:',
...links.map((e) => e.toString()),
'',
@ -135,6 +155,9 @@ class FriendicaTimelineEntry {
return 0;
})
: 0;
final id = json['id_str'] ?? '';
final parentId = json['in_reply_to_status_id_str'] ?? '';
final parentAuthor = json['in_reply_to_screen_name'] ?? '';
final post = json['friendica_html'] ?? '';
final author = json['user']['name'];
final title = json['friendica_title'] ?? '';
@ -152,7 +175,10 @@ class FriendicaTimelineEntry {
backdatedTimestamp: backdatedTimestamp,
locationData: actualLocationData,
post: post,
id: id,
parentId: parentId,
author: author,
parentAuthor: parentAuthor,
title: title,
links: links,
mediaAttachments: mediaAttachments,

View file

@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/components/filter_control_component.dart';
import 'package:friendica_archive_browser/src/friendica/components/tree_entry_card.dart';
import 'package:friendica_archive_browser/src/friendica/models/friendica_entry_tree_item.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
import 'package:friendica_archive_browser/src/screens/error_screen.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart';
import '../../screens/loading_status_screen.dart';
import '../../screens/standin_status_screen.dart';
class EntriesScreen extends StatelessWidget {
static final _logger = Logger('$EntriesScreen');
final FutureResult<List<FriendicaEntryTreeItem>, ExecError> Function()
populator;
const EntriesScreen({Key? key, required this.populator}) : super(key: key);
@override
Widget build(BuildContext context) {
_logger.info('Build FriendicaEntriesScreen');
return FutureBuilder<Result<List<FriendicaEntryTreeItem>, ExecError>>(
future: populator(),
builder: (context, snapshot) {
_logger.info('FriendicaEntriesScreen Future builder called');
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done) {
return const LoadingStatusScreen(title: 'Loading entries');
}
final postsResult = snapshot.requireData;
if (postsResult.isFailure) {
return ErrorScreen(
title: 'Error getting entries', error: postsResult.error);
}
final allPosts = postsResult.value;
final posts = allPosts;
if (posts.isEmpty) {
return const StandInStatusScreen(title: 'No entries were found');
}
_logger.fine('Build Entries ListView');
return _FriendicaEntriesScreenWidget(posts: posts);
});
}
}
class _FriendicaEntriesScreenWidget extends StatelessWidget {
static final _logger = Logger('$_FriendicaEntriesScreenWidget');
final List<FriendicaEntryTreeItem> posts;
const _FriendicaEntriesScreenWidget({Key? key, required this.posts})
: super(key: key);
@override
Widget build(BuildContext context) {
_logger.fine('Redrawing');
return FilterControl<FriendicaEntryTreeItem, dynamic>(
allItems: posts,
imagesOnlyFilterFunction: (post) => post.entry.hasImages(),
videosOnlyFilterFunction: (post) => post.entry.hasVideos(),
textSearchFilterFunction: (post, text) =>
post.entry.title.contains(text) || post.entry.post.contains(text),
itemToDateTimeFunction: (post) => DateTime.fromMillisecondsSinceEpoch(
post.entry.creationTimestamp * 1000),
dateRangeFilterFunction: (post, start, stop) =>
timestampInRange(post.entry.creationTimestamp * 1000, start, stop),
builder: (context, items) {
if (items.isEmpty) {
return const StandInStatusScreen(
title: 'No posts meet filter criteria');
}
return ScrollConfiguration(
behavior:
ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: ListView.separated(
primary: false,
physics: const RangeMaintainingScrollPhysics(),
restorationId: 'friendicaEntriesListView',
itemCount: items.length,
itemBuilder: (context, index) {
_logger.finer('Rendering Friendica List Item');
return TreeEntryCard(treeEntry: items[index]);
},
separatorBuilder: (context, index) {
return const Divider(
color: Colors.black,
thickness: 0.2,
);
}),
);
});
}
}

View file

@ -5,7 +5,8 @@ import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/components/geo/geo_extensions.dart';
import 'package:friendica_archive_browser/src/friendica/components/geo/map_bounds.dart';
import 'package:friendica_archive_browser/src/friendica/components/geo/marker_data.dart';
import 'package:friendica_archive_browser/src/friendica/components/post_card.dart';
import 'package:friendica_archive_browser/src/friendica/components/tree_entry_card.dart';
import 'package:friendica_archive_browser/src/friendica/models/friendica_entry_tree_item.dart';
import 'package:friendica_archive_browser/src/friendica/models/friendica_timeline_entry.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
import 'package:friendica_archive_browser/src/friendica/services/facebook_archive_service.dart';
@ -237,7 +238,8 @@ class _GeospatialViewState extends State<GeospatialView> {
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: ListView.separated(
itemBuilder: (context, index) => PostCard(post: postsInList[index]),
itemBuilder: (context, index) => TreeEntryCard(
treeEntry: FriendicaEntryTreeItem(postsInList[index], false)),
separatorBuilder: (context, index) => const Divider(height: 1),
itemCount: postsInList.length),
);

View file

@ -1,131 +0,0 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/friendica/components/filter_control_component.dart';
import 'package:friendica_archive_browser/src/friendica/components/post_card.dart';
import 'package:friendica_archive_browser/src/friendica/models/friendica_timeline_entry.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
import 'package:friendica_archive_browser/src/screens/error_screen.dart';
import 'package:friendica_archive_browser/src/services/friendica_archive_service.dart';
import 'package:friendica_archive_browser/src/settings/settings_controller.dart';
import 'package:friendica_archive_browser/src/utils/exec_error.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import '../../screens/loading_status_screen.dart';
import '../../screens/standin_status_screen.dart';
class PostsScreen extends StatelessWidget {
static final _logger = Logger('$PostsScreen');
const PostsScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
_logger.info('Build FriendicaPostListView');
final service = Provider.of<FriendicaArchiveService>(context);
final username = Provider.of<SettingsController>(context).facebookName;
return FutureBuilder<Result<List<FriendicaTimelineEntry>, ExecError>>(
future: service.getPosts(),
builder: (context, snapshot) {
_logger.info('FriendicaPostListView Future builder called');
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done) {
return const LoadingStatusScreen(title: 'Loading posts');
}
final postsResult = snapshot.requireData;
if (postsResult.isFailure) {
return ErrorScreen(
title: 'Error getting posts', error: postsResult.error);
}
final allPosts = postsResult.value;
final posts = allPosts;
// final filteredPosts = username.isEmpty
// ? allPosts
// : allPosts.where((p) =>
// p.title != username ||
// p.post.isNotEmpty ||
// p.mediaAttachments.isNotEmpty ||
// p.links.isNotEmpty);
//
// final posts = username.isEmpty
// ? filteredPosts.toList()
// : filteredPosts.map((p) {
// var newTitle = p.title;
// if (p.title == username) {
// newTitle = 'You posted';
// } else {
// newTitle = p.title
// .replaceAll(username, 'You')
// .replaceAll(wholeWordRegEx('his'), 'your')
// .replaceAll(wholeWordRegEx('her'), 'your');
// }
// if (newTitle == p.title) {
// return p;
// } else {
// return p.copy(title: newTitle);
// }
// }).toList();
if (posts.isEmpty) {
return const StandInStatusScreen(title: 'No posts were found');
}
_logger.fine('Build Posts ListView');
return _FriendicaPostsScreenWidget(posts: posts);
});
}
}
class _FriendicaPostsScreenWidget extends StatelessWidget {
static final _logger = Logger('$_FriendicaPostsScreenWidget');
final List<FriendicaTimelineEntry> posts;
const _FriendicaPostsScreenWidget({Key? key, required this.posts})
: super(key: key);
@override
Widget build(BuildContext context) {
_logger.fine('Redrawing');
return FilterControl<FriendicaTimelineEntry, dynamic>(
allItems: posts,
imagesOnlyFilterFunction: (post) => post.hasImages(),
videosOnlyFilterFunction: (post) => post.hasVideos(),
textSearchFilterFunction: (post, text) =>
post.title.contains(text) || post.post.contains(text),
itemToDateTimeFunction: (post) =>
DateTime.fromMillisecondsSinceEpoch(post.creationTimestamp * 1000),
dateRangeFilterFunction: (post, start, stop) =>
timestampInRange(post.creationTimestamp * 1000, start, stop),
builder: (context, items) {
if (items.isEmpty) {
return const StandInStatusScreen(
title: 'No posts meet filter criteria');
}
return ScrollConfiguration(
behavior:
ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: ListView.separated(
primary: false,
physics: const RangeMaintainingScrollPhysics(),
restorationId: 'friendicaPostsListView',
itemCount: items.length,
itemBuilder: (context, index) {
_logger.finer('Rendering Friendica List Item');
return PostCard(post: items[index]);
},
separatorBuilder: (context, index) {
return const Divider(
color: Colors.black,
thickness: 0.2,
);
}),
);
});
}
}

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/services/friendica_archive_service.dart';
import 'friendica/screens/photo_album_browser_screen.dart';
import 'friendica/screens/posts_screen.dart';
import 'friendica/screens/entries_screen.dart';
import 'friendica/screens/stats_screen.dart';
import 'friendica/services/facebook_archive_reader.dart';
import 'settings/settings_controller.dart';
@ -9,8 +9,13 @@ import 'settings/settings_view.dart';
class Home extends StatefulWidget {
final SettingsController settingsController;
final FriendicaArchiveService archiveService;
const Home({Key? key, required this.settingsController}) : super(key: key);
const Home(
{Key? key,
required this.settingsController,
required this.archiveService})
: super(key: key);
@override
_HomeState createState() => _HomeState();
@ -25,9 +30,18 @@ class _HomeState extends State<Home> {
@override
void initState() {
_pageData.addAll([
AppPageData('Posts', Icons.home, () => const PostsScreen()),
AppPageData('Photos', Icons.photo_library,
() => const PhotoAlbumsBrowserScreen()),
AppPageData(
'Posts',
Icons.home,
() => EntriesScreen(
populator: widget.archiveService.getPosts,
)),
AppPageData(
'Orphan\nComments',
Icons.comment,
() => EntriesScreen(
populator: widget.archiveService.getOrphanedComments,
)),
AppPageData('Stats', Icons.bar_chart, () => const StatsScreen()),
AppPageData('Settings', Icons.settings, () => _buildSettingsView()),
]);
@ -114,6 +128,10 @@ class AppPageData {
AppPageData(this.label, this.icon, widgetBuilder)
: _widgetBuilder = widgetBuilder,
navRailDestination =
NavigationRailDestination(icon: Icon(icon), label: Text(label));
navRailDestination = NavigationRailDestination(
icon: Icon(icon),
label: Text(
label,
textAlign: TextAlign.center,
));
}

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:friendica_archive_browser/src/friendica/models/friendica_entry_tree_item.dart';
import 'package:friendica_archive_browser/src/friendica/models/friendica_timeline_entry.dart';
import 'package:friendica_archive_browser/src/friendica/models/timeline_type.dart';
import 'package:friendica_archive_browser/src/friendica/services/path_mapping_service.dart';
@ -13,21 +14,32 @@ import 'package:result_monad/result_monad.dart';
class FriendicaArchiveService extends ChangeNotifier {
final PathMappingService pathMappingService;
final Map<String, ImageEntry> _imagesByRequestUrl = {};
final List<FriendicaTimelineEntry> _posts = [];
final List<FriendicaEntryTreeItem> _postEntries = [];
final List<FriendicaEntryTreeItem> _orphanedCommentEntries = [];
FriendicaArchiveService({required this.pathMappingService});
void clearCaches() {
_imagesByRequestUrl.clear();
_posts.clear();
_orphanedCommentEntries.clear();
_postEntries.clear();
}
FutureResult<List<FriendicaTimelineEntry>, ExecError> getPosts() async {
if (_posts.isEmpty) {
_loadPosts();
FutureResult<List<FriendicaEntryTreeItem>, ExecError> getPosts() async {
if (_postEntries.isEmpty && _orphanedCommentEntries.isEmpty) {
_loadEntries();
}
return Result.ok(_posts);
return Result.ok(_postEntries);
}
FutureResult<List<FriendicaEntryTreeItem>, ExecError>
getOrphanedComments() async {
if (_postEntries.isEmpty && _orphanedCommentEntries.isEmpty) {
_loadEntries();
}
return Result.ok(_orphanedCommentEntries);
}
Result<ImageEntry, ExecError> getImageByUrl(String url) {
@ -43,19 +55,43 @@ class FriendicaArchiveService extends ChangeNotifier {
String get _baseArchiveFolder => pathMappingService.rootFolder;
void _loadPosts() {
void _loadEntries() {
final entriesJsonPath = p.join(_baseArchiveFolder, 'postsAndComments.json');
final jsonFile = File(entriesJsonPath);
if (jsonFile.existsSync()) {
final json = jsonDecode(jsonFile.readAsStringSync()) as List<dynamic>;
final postEntries = json
final entries = json
.map((j) => FriendicaTimelineEntry.fromJson(j, TimelineType.active));
_posts.clear();
_posts.addAll(postEntries);
}
final topLevelEntries =
entries.where((element) => element.parentId.isEmpty);
final ids = entries.map((e) => e.id).toSet();
final commentEntries =
entries.where((element) => element.parentId.isNotEmpty).toList();
final entryTrees = <String, FriendicaEntryTreeItem>{};
for (final entry in topLevelEntries) {
entryTrees[entry.id] = FriendicaEntryTreeItem(entry, false);
}
final uniqueUsers = _posts.map((e) => e.author).toSet();
print(uniqueUsers);
final commentsWithParents = commentEntries
.where((element) => ids.contains(element.parentId))
.toList();
print(commentsWithParents.length);
commentEntries.sort(
(c1, c2) => c1.creationTimestamp.compareTo(c2.creationTimestamp));
for (final entry in commentEntries) {
final parent = entryTrees[entry.parentId];
final treeEntry = FriendicaEntryTreeItem(entry, parent == null);
parent?.addChild(treeEntry);
entryTrees[entry.id] = treeEntry;
}
_postEntries.clear();
_postEntries
.addAll(entryTrees.values.where((element) => !element.isOrphaned));
_orphanedCommentEntries.clear();
_orphanedCommentEntries
.addAll(entryTrees.values.where((element) => element.isOrphaned));
}
}
void _loadImages() {