Implement stats panel with top interactors

This commit is contained in:
Hank Grabowski 2022-01-21 10:29:26 -05:00
parent a2b4c1e3da
commit 764a5b9946
8 changed files with 353 additions and 55 deletions

View file

@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/models/time_element.dart';
import 'package:friendica_archive_browser/src/services/friendica_connections.dart';
import 'package:friendica_archive_browser/src/utils/snackbar_status_builder.dart';
import 'package:friendica_archive_browser/src/utils/top_interactors_generator.dart';
import 'package:logging/logging.dart';
import 'package:url_launcher/url_launcher.dart';
class TopInteractorsWidget extends StatefulWidget {
final List<TimeElement> entries;
final FriendicaConnections connections;
const TopInteractorsWidget(this.entries, this.connections, {Key? key})
: super(key: key);
@override
State<TopInteractorsWidget> createState() => _TopInteractionsWidget();
}
class _TopInteractionsWidget extends State<TopInteractorsWidget> {
static final _logger = Logger('$TopInteractorsWidget');
int _currentThreshold = 10;
int _sortIndex = 1;
final _thresholds = [10, 20, 50, 100];
final generator = TopInteractorsGenerator();
@override
void initState() {
super.initState();
}
void _generateStats() {
_logger.finer('Filling list');
generator.clear();
for (final entry in widget.entries) {
generator.processEntry(entry.entry, widget.connections);
}
_logger.finer('List filled');
_calcTopList(false);
}
Future<void> _calcTopList(bool updateState) async {
if (updateState) {
setState(() {});
}
}
@override
Widget build(BuildContext context) {
_logger.fine('Rebuilding Top Interactors');
_generateStats();
final interactors = <InteractorItem>[];
if (_sortIndex == 1) {
interactors.addAll(generator.getTopLikes(_currentThreshold));
} else if (_sortIndex == 2) {
interactors.addAll(generator.getTopDislikes(_currentThreshold));
} else {
interactors.addAll(generator.getTopCommentReshare(_currentThreshold));
}
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text(
'Top',
textAlign: TextAlign.left,
style: Theme.of(context).textTheme.headline6,
),
Padding(
padding: const EdgeInsets.only(left: 5.0, right: 5.0),
child: DropdownButton<int>(
value: _currentThreshold,
items: _thresholds
.map((t) => DropdownMenuItem(value: t, child: Text('$t')))
.toList(),
onChanged: (newValue) async {
_currentThreshold = newValue ?? _thresholds.first;
_calcTopList(true);
}),
),
Text(
'Interactors',
textAlign: TextAlign.right,
style: Theme.of(context).textTheme.headline6,
),
],
),
const SizedBox(height: 10.0),
_buildDataTable(context, interactors),
]),
);
}
Widget _buildDataTable(
BuildContext context, List<InteractorItem> interactors) {
return DataTable(
sortColumnIndex: _sortIndex,
sortAscending: false,
columns: [
const DataColumn(label: Text('Name')),
DataColumn(
label: const Text('Likes'),
numeric: true,
onSort: (column, ascending) => setState(() {
_sortIndex = column;
})),
DataColumn(
label: const Text('Dislikes'),
numeric: true,
onSort: (column, ascending) => setState(() {
_sortIndex = column;
})),
DataColumn(
label: const Text('Reshares'),
numeric: true,
onSort: (column, ascending) => setState(() {
_sortIndex = column;
})),
],
rows: List.generate(
interactors.length,
(index) => DataRow(
color: index.isEven
? MaterialStateProperty.resolveWith(
(states) => Theme.of(context).dividerColor)
: null,
cells: [
DataCell(TextButton(
onPressed: () async {
final url =
interactors[index].contact.profileUrl.toString();
await canLaunch(url)
? await launch(url)
: SnackBarStatusBuilder.buildSnackbar(
context, 'Failed to open $url');
},
child: Text(interactors[index].contact.name))),
DataCell(Text('${interactors[index].likeCount}')),
DataCell(Text('${interactors[index].dislikeCount}')),
DataCell(
Text('${interactors[index].resharedOrCommentedOn}')),
])),
);
}
}

View file

@ -52,19 +52,6 @@ class _WordFrequencyWidgetState extends State<WordFrequencyWidget> {
_generateWordMap();
_logger.finer('Top elements count: ${topElements.length}');
final rowElements = <Widget>[];
for (var i = 0; i < topElements.length; i++) {
final element = topElements[i];
final background = i % 2 == 0 ? null : Theme.of(context).dividerColor;
final row = Container(
color: background,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(element.word), Text('${element.count}')],
));
rowElements.add(row);
}
return Padding(
padding: const EdgeInsets.all(8.0),
@ -101,14 +88,30 @@ class _WordFrequencyWidgetState extends State<WordFrequencyWidget> {
],
),
const SizedBox(height: 10.0),
SizedBox(
width: 200,
child: Column(
children: rowElements,
),
),
_buildDataTable(context),
],
),
);
}
Widget _buildDataTable(BuildContext context) {
return DataTable(
sortAscending: false,
columns: const [
DataColumn(label: Text('Word')),
DataColumn(label: Text('Count'), numeric: true),
],
rows: List.generate(
topElements.length,
(index) => DataRow(
color: index.isEven
? MaterialStateProperty.resolveWith(
(states) => Theme.of(context).dividerColor)
: null,
cells: [
DataCell(Text(topElements[index].word)),
DataCell(Text('${topElements[index].count}')),
])),
);
}
}

View file

@ -18,6 +18,8 @@ class FriendicaTimelineEntry {
final String parentAuthor;
final String parentAuthorId;
final int creationTimestamp;
final int backdatedTimestamp;
@ -58,6 +60,7 @@ class FriendicaTimelineEntry {
this.author = '',
this.authorId = '',
this.parentAuthor = '',
this.parentAuthorId = '',
this.externalLink = '',
this.locationData = const LocationData(),
this.likes = const <FriendicaContact>[],
@ -80,6 +83,7 @@ class FriendicaTimelineEntry {
author = 'Random author ${randomId()}',
authorId = 'Random authorId ${randomId()}',
parentAuthor = 'Random parent author ${randomId()}',
parentAuthorId = 'Random parent author id ${randomId()}',
locationData = LocationData.randomBuilt(),
likes = const <FriendicaContact>[],
dislikes = const <FriendicaContact>[],
@ -105,6 +109,7 @@ class FriendicaTimelineEntry {
String? author,
String? authorId,
String? parentAuthor,
String? parentAuthorId,
LocationData? locationData,
List<FriendicaMediaAttachment>? mediaAttachments,
List<FriendicaContact>? likes,
@ -124,6 +129,7 @@ class FriendicaTimelineEntry {
author: author ?? this.author,
authorId: authorId ?? this.authorId,
parentAuthor: parentAuthor ?? this.parentAuthor,
parentAuthorId: parentAuthorId ?? this.parentAuthorId,
locationData: locationData ?? this.locationData,
mediaAttachments: mediaAttachments ?? this.mediaAttachments,
likes: likes ?? this.likes,
@ -186,6 +192,7 @@ class FriendicaTimelineEntry {
final isReshare = json.containsKey('retweeted_status');
final parentId = json['in_reply_to_status_id_str'] ?? '';
final parentAuthor = json['in_reply_to_screen_name'] ?? '';
final parentAuthorId = json['in_reply_to_user_id_str'] ?? '';
final body = json['friendica_html'] ?? '';
final author = json['user']['name'];
final authorId = json['user']['id_str'];
@ -225,6 +232,7 @@ class FriendicaTimelineEntry {
isReshare: isReshare,
id: id,
parentId: parentId,
parentAuthorId: parentAuthorId,
author: author,
authorId: authorId,
parentAuthor: parentAuthor,

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:friendica_archive_browser/src/components/heatmap_widget.dart';
import 'package:friendica_archive_browser/src/components/timechart_widget.dart';
import 'package:friendica_archive_browser/src/components/top_interactactors_widget.dart';
import 'package:friendica_archive_browser/src/components/word_frequency_widget.dart';
import 'package:friendica_archive_browser/src/friendica/components/filter_control_component.dart';
import 'package:friendica_archive_browser/src/friendica/models/model_utils.dart';
@ -47,26 +48,18 @@ class _StatsScreenState extends State<StatsScreen> {
case StatType.post:
newItems = (await archiveDataService!.getPosts()).fold(
onSuccess: (posts) => posts.map((e) => TimeElement(
timeInMS: e.entry.creationTimestamp * 1000,
hasImages: e.entry.hasImages(),
hasVideos: e.entry.hasVideos(),
title: e.entry.title,
text: e.entry.body)),
timeInMS: e.entry.creationTimestamp * 1000, entry: e.entry)),
onError: (error) {
_logger.severe('Error getting posts: $error');
return [];
});
break;
case StatType.comment:
newItems = (await archiveDataService!.getOrphanedComments()).fold(
newItems = (await archiveDataService!.getAllComments()).fold(
onSuccess: (comments) => comments.map((e) => TimeElement(
timeInMS: e.entry.creationTimestamp * 1000,
hasImages: e.entry.hasImages(),
hasVideos: e.entry.hasVideos(),
title: e.entry.title,
text: e.entry.body)),
timeInMS: e.entry.creationTimestamp * 1000, entry: e.entry)),
onError: (error) {
_logger.severe('Error getting oprhaned comments: $error');
_logger.severe('Error getting comments: $error');
return [];
});
break;
@ -154,6 +147,8 @@ class _StatsScreenState extends State<StatsScreen> {
child: Column(children: [
..._buildGraphScreens(context, items),
const Divider(),
TopInteractorsWidget(items, archiveDataService!.connections),
const Divider(),
WordFrequencyWidget(items),
]),
));

View file

@ -1,18 +1,20 @@
import 'package:friendica_archive_browser/src/friendica/models/friendica_timeline_entry.dart';
class TimeElement {
final DateTime timestamp;
final bool hasImages;
final bool hasVideos;
final String text;
final String title;
final FriendicaTimelineEntry entry;
TimeElement(
{int timeInMS = 0,
this.hasImages = false,
this.hasVideos = false,
this.text = '',
this.title = ''})
TimeElement({int timeInMS = 0, required this.entry})
: timestamp = DateTime.fromMillisecondsSinceEpoch(timeInMS);
bool get hasImages => entry.hasImages();
bool get hasVideos => entry.hasVideos();
String get text => entry.body;
String get title => entry.title;
bool hasText(String phrase) =>
text.contains(phrase) || title.contains(phrase);
}

View file

@ -16,7 +16,8 @@ class FriendicaArchiveService extends ChangeNotifier {
final Map<String, ImageEntry> _imagesByRequestUrl = {};
final List<FriendicaEntryTreeItem> _postEntries = [];
final List<FriendicaEntryTreeItem> _orphanedCommentEntries = [];
final FriendicaConnections _connections = FriendicaConnections();
final List<FriendicaEntryTreeItem> _allComments = [];
final FriendicaConnections connections = FriendicaConnections();
String _ownersName = '';
FriendicaArchiveService({required this.pathMappingService});
@ -33,23 +34,32 @@ class FriendicaArchiveService extends ChangeNotifier {
}
void clearCaches() {
_connections.clearCaches();
connections.clearCaches();
_imagesByRequestUrl.clear();
_orphanedCommentEntries.clear();
_allComments.clear();
_postEntries.clear();
}
FutureResult<List<FriendicaEntryTreeItem>, ExecError> getPosts() async {
if (_postEntries.isEmpty && _orphanedCommentEntries.isEmpty) {
if (_postEntries.isEmpty && _allComments.isEmpty) {
_loadEntries();
}
return Result.ok(_postEntries);
}
FutureResult<List<FriendicaEntryTreeItem>, ExecError> getAllComments() async {
if (_postEntries.isEmpty && _allComments.isEmpty) {
_loadEntries();
}
return Result.ok(_allComments);
}
FutureResult<List<FriendicaEntryTreeItem>, ExecError>
getOrphanedComments() async {
if (_postEntries.isEmpty && _orphanedCommentEntries.isEmpty) {
if (_postEntries.isEmpty && _allComments.isEmpty) {
_loadEntries();
}
@ -75,21 +85,21 @@ class FriendicaArchiveService extends ChangeNotifier {
if (jsonFile.existsSync()) {
final json = jsonDecode(jsonFile.readAsStringSync()) as List<dynamic>;
final entries =
json.map((j) => FriendicaTimelineEntry.fromJson(j, _connections));
json.map((j) => FriendicaTimelineEntry.fromJson(j, connections));
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>{};
final postTreeEntries = <FriendicaEntryTreeItem>[];
for (final entry in topLevelEntries) {
entryTrees[entry.id] = FriendicaEntryTreeItem(entry, false);
final treeEntry = FriendicaEntryTreeItem(entry, false);
entryTrees[entry.id] = treeEntry;
postTreeEntries.add(treeEntry);
}
final commentsWithParents = commentEntries
.where((element) => ids.contains(element.parentId))
.toList();
print(commentsWithParents.length);
final commentTreeEntries = <FriendicaEntryTreeItem>[];
commentEntries.sort(
(c1, c2) => c1.creationTimestamp.compareTo(c2.creationTimestamp));
for (final entry in commentEntries) {
@ -97,11 +107,14 @@ class FriendicaArchiveService extends ChangeNotifier {
final treeEntry = FriendicaEntryTreeItem(entry, parent == null);
parent?.addChild(treeEntry);
entryTrees[entry.id] = treeEntry;
commentTreeEntries.add(treeEntry);
}
_postEntries.clear();
_postEntries
.addAll(entryTrees.values.where((element) => !element.isOrphaned));
_postEntries.addAll(postTreeEntries);
_allComments.clear();
_allComments.addAll(commentTreeEntries);
_orphanedCommentEntries.clear();
_orphanedCommentEntries

View file

@ -0,0 +1,117 @@
import 'package:friendica_archive_browser/src/friendica/models/friendica_contact.dart';
import 'package:friendica_archive_browser/src/friendica/models/friendica_timeline_entry.dart';
import 'package:friendica_archive_browser/src/services/friendica_connections.dart';
class TopInteractorsGenerator {
final _interactors = <String, InteractorItem>{};
final _processedEntryIds = <String>{};
void clear() {
_interactors.clear();
_processedEntryIds.clear();
}
void processEntry(
FriendicaTimelineEntry item, FriendicaConnections contacts) {
if (_processedEntryIds.contains(item.id)) {
return;
}
_processedEntryIds.add(item.id);
if (item.parentAuthorId.isNotEmpty) {
final interactorItem =
_getInteractorItemById(item.parentAuthorId, contacts);
_interactors[item.parentAuthorId] =
interactorItem.incrementResharedOrCommentedOn();
}
for (final like in item.likes) {
final interactorItem =
_interactors[like.id] ?? InteractorItem(contact: like);
_interactors[like.id] = interactorItem.incrementLike();
}
for (final dislike in item.dislikes) {
final interactorItem =
_interactors[dislike.id] ?? InteractorItem(contact: dislike);
_interactors[dislike.id] = interactorItem.incrementDislike();
}
}
List<InteractorItem> getTopCommentReshare(int threshold) {
final forResult = List.of(_interactors.values);
forResult.sort((i1, i2) =>
i2.resharedOrCommentedOn.compareTo(i1.resharedOrCommentedOn));
return forResult.take(threshold).toList();
}
List<InteractorItem> getTopLikes(int threshold) {
final forResult = List.of(_interactors.values);
forResult.sort((i1, i2) => i2.likeCount.compareTo(i1.likeCount));
return forResult.take(threshold).toList();
}
List<InteractorItem> getTopDislikes(int threshold) {
final forResult = List.of(_interactors.values);
forResult.sort((i1, i2) => i2.dislikeCount.compareTo(i1.dislikeCount));
return forResult.take(threshold).toList();
}
InteractorItem _getInteractorItemById(
String id, FriendicaConnections contacts) {
if (_interactors.containsKey(id)) {
return _interactors[id]!;
}
final contact = contacts.getById(id).fold(
onSuccess: (contact) => contact,
onError: (error) => FriendicaContact(
status: ConnectionStatus.none,
name: '',
id: id,
profileUrl: Uri(),
network: 'network'));
return InteractorItem(contact: contact);
}
}
class InteractorItem {
final FriendicaContact contact;
final int resharedOrCommentedOn;
final int likeCount;
final int dislikeCount;
InteractorItem(
{required this.contact,
this.resharedOrCommentedOn = 0,
this.likeCount = 0,
this.dislikeCount = 0});
@override
String toString() {
return 'InteractorItem{contact: $contact, resharedOrCommentedOn: $resharedOrCommentedOn, likeCount: $likeCount, dislikeCount: $dislikeCount}';
}
InteractorItem copy(
{FriendicaContact? contact,
int? resharedOrCommentedOn,
int? likeCount,
int? dislikeCount}) {
return InteractorItem(
contact: contact ?? this.contact,
resharedOrCommentedOn:
resharedOrCommentedOn ?? this.resharedOrCommentedOn,
likeCount: likeCount ?? this.likeCount,
dislikeCount: dislikeCount ?? this.dislikeCount);
}
InteractorItem incrementResharedOrCommentedOn() =>
copy(resharedOrCommentedOn: this.resharedOrCommentedOn + 1);
InteractorItem incrementLike() => copy(likeCount: this.likeCount + 1);
InteractorItem incrementDislike() =>
copy(dislikeCount: this.dislikeCount + 1);
}

View file

@ -1,5 +1,7 @@
import 'dart:math';
import 'package:html/parser.dart' show parse;
class WordMapGenerator {
final _words = <String, int>{};
final int minimumWordSize;
@ -16,7 +18,14 @@ class WordMapGenerator {
}
void processEntry(String text) {
final wordsFromText = text
final topLevelText = parse(text)
.body
?.nodes
.where((element) => element.nodeType == 3)
.join(' ') ??
text;
final wordsFromText = topLevelText
.toLowerCase()
.replaceAll(RegExp(r'[^\w]+'), ' ')
.replaceAll(RegExp(r'[_]+'), ' ')