Refactor TimelineManager and EntryManager to Riverpod providers

merge-requests/67/merge
Hank Grabowski 2024-12-01 14:57:47 -05:00
rodzic b8b2ea7d26
commit fc6e17f133
29 zmienionych plików z 2470 dodań i 1666 usunięć

Wyświetl plik

@ -1,10 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:provider/provider.dart';
import '../../riverpod_controllers/hashtag_service.dart';
import '../../services/entry_manager_service.dart';
import '../../utils/active_profile_selector.dart';
class HashtagAutocompleteOptions extends ConsumerWidget {
const HashtagAutocompleteOptions({
@ -20,12 +17,14 @@ class HashtagAutocompleteOptions extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final manager = context
.read<ActiveProfileSelector<EntryManagerService>>()
.activeEntry
.value;
final postTreeHashtags =
manager.getPostTreeHashtags(id).getValueOrElse(() => [])..sort();
//TODO Replace with new Riverpod hashtags services
// final manager = context
// .read<ActiveProfileSelector<EntryManagerService>>()
// .activeEntry
// .value;
// final postTreeHashtags =
// manager.getPostTreeHashtags(id).getValueOrElse(() => [])..sort();
final postTreeHashtags = [];
final hashtagsFromService =
ref.watch(hashtagServiceProvider(searchString: query));
final hashtags = [...postTreeHashtags, ...hashtagsFromService];

Wyświetl plik

@ -1,10 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/connection.dart';
import '../../services/connections_manager.dart';
import '../../services/entry_manager_service.dart';
import '../../utils/active_profile_selector.dart';
import '../image_control.dart';
class MentionAutocompleteOptions extends StatelessWidget {
@ -21,28 +17,30 @@ class MentionAutocompleteOptions extends StatelessWidget {
@override
Widget build(BuildContext context) {
final entryManager = context
.read<ActiveProfileSelector<EntryManagerService>>()
.activeEntry
.value;
final connectionManager = context
.read<ActiveProfileSelector<ConnectionsManager>>()
.activeEntry
.value;
final postTreeUsers = entryManager
.getPostTreeConnectionIds(id)
.getValueOrElse(() => [])
.map((id) => connectionManager.getById(id))
.where((result) => result.isSuccess)
.map((result) => result.value)
.toList()
..sort((u1, u2) => u1.name.compareTo(u2.name));
final knownUsers = connectionManager.getKnownUsersByName(query);
final users = [...postTreeUsers, ...knownUsers];
//TODO Replace with new Riverpod mentions service
// final entryManager = context
// .read<ActiveProfileSelector<EntryManagerService>>()
// .activeEntry
// .value;
//
// final connectionManager = context
// .read<ActiveProfileSelector<ConnectionsManager>>()
// .activeEntry
// .value;
//
// final postTreeUsers = entryManager
// .getPostTreeConnectionIds(id)
// .getValueOrElse(() => [])
// .map((id) => connectionManager.getById(id))
// .where((result) => result.isSuccess)
// .map((result) => result.value)
// .toList()
// ..sort((u1, u2) => u1.name.compareTo(u2.name));
//
// final knownUsers = connectionManager.getKnownUsersByName(query);
//
// final users = [...postTreeUsers, ...knownUsers];
final users = [];
if (users.isEmpty) return const SizedBox.shrink();

Wyświetl plik

@ -6,13 +6,14 @@ import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import '../globals.dart';
import '../models/auth/profile.dart';
import '../models/exec_error.dart';
import '../models/user_notification.dart';
import '../riverpod_controllers/entry_tree_item_services.dart';
import '../riverpod_controllers/notification_services.dart';
import '../routes.dart';
import '../services/auth_service.dart';
import '../services/connections_manager.dart';
import '../services/timeline_manager.dart';
import '../utils/active_profile_selector.dart';
import '../utils/dateutils.dart';
import '../utils/snackbar_builder.dart';
@ -29,16 +30,18 @@ class NotificationControl extends ConsumerWidget {
required this.notification,
});
Future<void> _goToStatus(BuildContext context) async {
final manager =
getIt<ActiveProfileSelector<TimelineManager>>().activeEntry.value;
final existingPostData = manager.getPostTreeEntryBy(notification.iid);
Future<void> _goToStatus(
BuildContext context, WidgetRef ref, Profile profile) async {
final existingPostData =
ref.read(postTreeEntryByIdProvider(profile, notification.iid));
if (existingPostData.isSuccess) {
context
.push('/post/view/${existingPostData.value.id}/${notification.iid}');
return;
}
final loadedPost = await manager.refreshStatusChain(notification.iid);
final loadedPost = await ref
.read(timelineUpdaterProvider(profile).notifier)
.refreshStatusChain(notification.iid);
if (loadedPost.isSuccess) {
if (context.mounted) {
context.push('/post/view/${loadedPost.value.id}/${notification.iid}');
@ -105,7 +108,7 @@ class NotificationControl extends ConsumerWidget {
case NotificationType.reblog:
case NotificationType.status:
onTapCallFunction = () async {
await _goToStatus(context);
await _goToStatus(context, ref, profile);
};
break;
case NotificationType.direct_message:

Wyświetl plik

@ -6,17 +6,17 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:relatica/riverpod_controllers/timeline_entry_filter_services.dart';
import 'package:result_monad/result_monad.dart';
import '../../globals.dart';
import '../../models/auth/profile.dart';
import '../../models/filters/timeline_entry_filter.dart';
import '../../models/flattened_tree_item.dart';
import '../../models/timeline_entry.dart';
import '../../riverpod_controllers/entry_tree_item_services.dart';
import '../../riverpod_controllers/settings_services.dart';
import '../../riverpod_controllers/timeline_entry_filter_services.dart';
import '../../services/auth_service.dart';
import '../../services/timeline_manager.dart';
import '../../utils/active_profile_selector.dart';
import '../../utils/clipboard_utils.dart';
import '../../utils/filter_runner.dart';
import '../../utils/html_to_edit_text_helper.dart';
@ -104,7 +104,7 @@ class _StatusControlState extends ConsumerState<FlattenedTreeEntryControl> {
if (filteringInfo.isFiltered && !showFilteredPost) {
body = buildHiddenBody(context, filteringInfo);
} else {
body = buildMainWidgetBody(context);
body = buildMainWidgetBody(context, profile);
}
final decoration = isPost
@ -145,7 +145,7 @@ class _StatusControlState extends ConsumerState<FlattenedTreeEntryControl> {
);
}
Widget buildMainWidgetBody(BuildContext context) {
Widget buildMainWidgetBody(BuildContext context, Profile profile) {
return Padding(
padding: const EdgeInsets.all(5.0),
child: Column(
@ -165,7 +165,7 @@ class _StatusControlState extends ConsumerState<FlattenedTreeEntryControl> {
showFilteredPost = false;
}),
icon: const Icon(Icons.hide_source)),
buildMenuControl(context),
buildMenuControl(context, profile),
],
),
const VerticalPadding(
@ -280,7 +280,7 @@ class _StatusControlState extends ConsumerState<FlattenedTreeEntryControl> {
itemCount: items.length));
}
Widget buildMenuControl(BuildContext context) {
Widget buildMenuControl(BuildContext context, Profile profile) {
const editStatus = 'Edit';
const deleteStatus = 'Delete';
const divider = 'Divider';
@ -326,7 +326,7 @@ class _StatusControlState extends ConsumerState<FlattenedTreeEntryControl> {
}
break;
case deleteStatus:
deleteEntry();
deleteEntry(profile);
break;
case openExternal:
await openUrlStringInSystembrowser(
@ -376,17 +376,16 @@ class _StatusControlState extends ConsumerState<FlattenedTreeEntryControl> {
});
}
Future<void> deleteEntry() async {
Future<void> deleteEntry(Profile profile) async {
setState(() {
isProcessing = true;
});
final confirm =
await showYesNoDialog(context, 'Delete ${isPost ? "Post" : "Comment"}');
if (confirm == true) {
await getIt<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.transformAsync(
(tm) async => await tm.deleteEntryById(item.timelineEntry.id))
await ref
.read(timelineUpdaterProvider(profile).notifier)
.deleteEntryById(item.timelineEntry.id)
.match(onSuccess: (_) {
isProcessing = false;
if (context.canPop()) {

Wyświetl plik

@ -2,15 +2,17 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import '../../globals.dart';
import '../../models/auth/profile.dart';
import '../../models/exec_error.dart';
import '../../models/timeline_entry.dart';
import '../../riverpod_controllers/timeline_entry_services.dart';
import '../../services/auth_service.dart';
import '../../services/feature_version_checker.dart';
import '../../services/fediverse_server_validator.dart';
import '../../services/timeline_manager.dart';
import '../../utils/active_profile_selector.dart';
import '../../utils/dateutils.dart';
import '../../utils/interaction_availability_util.dart';
import '../../utils/snackbar_builder.dart';
@ -50,20 +52,18 @@ class _InteractionsBarControlState
int get likes => widget.entry.engagementSummary.favoritesCount;
Future<void> toggleFavorited() async {
Future<void> toggleFavorited(Profile profile) async {
setState(() {
isProcessing = true;
});
final newState = !isFavorited;
_logger.fine('Trying to toggle favorite from $isFavorited to $newState');
final result = await getIt<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.andThenAsync(
(tm) async => await tm.toggleFavorited(widget.entry.id, newState));
final result = await ref
.read(timelineEntryManagerProvider(profile, widget.entry.id).notifier)
.toggleFavorited(newState);
result.match(onSuccess: (update) {
setState(() {
_logger.fine(
'Success toggling! $isFavorited -> ${update.entry.isFavorited}');
_logger.fine('Success toggling! $isFavorited -> ${update.isFavorited}');
});
}, onError: (error) {
buildSnackbar(context, 'Error toggling like status: $error');
@ -73,7 +73,7 @@ class _InteractionsBarControlState
});
}
Future<void> resharePost() async {
Future<void> resharePost(Profile profile) async {
setState(() {
isProcessing = true;
});
@ -99,9 +99,9 @@ class _InteractionsBarControlState
final id = widget.entry.id;
_logger.fine('Trying to reshare $id');
final result = await getIt<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.andThenAsync((tm) async => await tm.resharePost(id));
final result = await await ref
.read(timelineEntryManagerProvider(profile, widget.entry.id).notifier)
.resharePost();
result.match(onSuccess: (update) {
setState(() {
@ -139,15 +139,15 @@ class _InteractionsBarControlState
}
}
Future<void> unResharePost() async {
Future<void> unResharePost(Profile profile) async {
setState(() {
isProcessing = true;
});
final id = widget.entry.id;
_logger.fine('Trying to un-reshare $id');
final result = await getIt<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.andThenAsync((tm) async => await tm.unResharePost(id));
final result = await await ref
.read(timelineEntryManagerProvider(profile, widget.entry.id).notifier)
.unResharePost();
result.match(onSuccess: (update) {
setState(() {
_logger.fine('Success un-resharing post by ${widget.entry.author}');
@ -181,7 +181,7 @@ class _InteractionsBarControlState
);
}
Widget buildLikeButton() {
Widget buildLikeButton(Profile profile) {
final canReact = widget.entry.getCanReact(ref);
final tooltip =
canReact.canDo ? 'Press to toggle like/unlike' : canReact.reason;
@ -190,7 +190,7 @@ class _InteractionsBarControlState
likes,
true,
tooltip,
canReact.canDo ? () async => await toggleFavorited() : null,
canReact.canDo ? () async => await toggleFavorited(profile) : null,
);
}
@ -207,7 +207,7 @@ class _InteractionsBarControlState
);
}
Widget buildReshareButton() {
Widget buildReshareButton(Profile profile) {
final reshareable = widget.entry.getIsReshareable(ref, widget.isMine);
final canReshare = reshareable.canDo;
late final String tooltip;
@ -222,8 +222,9 @@ class _InteractionsBarControlState
true,
tooltip,
canReshare && !isProcessing
? () async =>
youReshared ? await unResharePost() : await resharePost()
? () async => youReshared
? await unResharePost(profile)
: await resharePost(profile)
: null,
);
}
@ -231,12 +232,13 @@ class _InteractionsBarControlState
@override
Widget build(BuildContext context) {
_logger.finest('Building: ${widget.entry.toShortString()}');
final profile = context.watch<AccountsService>().currentProfile;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
buildLikeButton(),
buildLikeButton(profile),
buildCommentButton(),
if (widget.entry.parentId.isEmpty) buildReshareButton(),
if (widget.entry.parentId.isEmpty) buildReshareButton(profile),
],
);
}

Wyświetl plik

@ -4,17 +4,15 @@ import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../models/entry_tree_item.dart';
import '../../models/flattened_tree_item.dart';
import '../../models/timeline_entry.dart';
import '../../riverpod_controllers/entry_tree_item_services.dart';
import '../../riverpod_controllers/timeline_entry_services.dart';
import '../../services/auth_service.dart';
import '../../services/timeline_manager.dart';
import '../../utils/active_profile_selector.dart';
import '../../utils/entry_tree_item_flattening.dart';
import 'flattened_tree_entry_control.dart';
class PostControl extends ConsumerStatefulWidget {
final EntryTreeItem originalItem;
final String id;
final String scrollToId;
final bool openRemote;
final bool showStatusOpenButton;
@ -22,7 +20,7 @@ class PostControl extends ConsumerStatefulWidget {
const PostControl({
super.key,
required this.originalItem,
required this.id,
required this.scrollToId,
required this.openRemote,
required this.showStatusOpenButton,
@ -40,10 +38,6 @@ class _PostControlState extends ConsumerState<PostControl> {
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
EntryTreeItem get item => widget.originalItem;
TimelineEntry get entry => item.entry;
@override
void initState() {
super.initState();
@ -52,17 +46,19 @@ class _PostControlState extends ConsumerState<PostControl> {
@override
Widget build(BuildContext context) {
final profile = context.watch<AccountsService>().currentProfile;
context.watch<ActiveProfileSelector<TimelineManager>>();
_logger.finest('Building ${item.entry.toShortString()}');
//TODO Handle it not existing
final item = ref.watch(entryTreeManagerProvider(profile, widget.id)).value;
ref.watch(timelineEntryManagerProvider(profile, widget.id));
if (!widget.isRoot) {
return FlattenedTreeEntryControl(
originalItem: item.flatten(topLevelOnly: true).first,
originalItem:
item.flatten(topLevelOnly: true, profile: profile, ref: ref).first,
openRemote: widget.openRemote,
showStatusOpenButton: widget.showStatusOpenButton,
);
}
final items = widget.originalItem.flatten();
final items = item.flatten(profile: profile, ref: ref);
return buildListView(context, items);
}

Wyświetl plik

@ -1,31 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:relatica/controls/padding.dart';
import 'package:relatica/globals.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../models/auth/profile.dart';
import '../../models/timeline_identifiers.dart';
import '../../riverpod_controllers/timeline_services.dart';
import '../../services/auth_service.dart';
import '../../services/network_status_service.dart';
import '../../services/timeline_manager.dart';
import '../../utils/active_profile_selector.dart';
import 'post_control.dart';
class TimelinePanel extends StatelessWidget {
class TimelinePanel extends ConsumerWidget {
static final _logger = Logger('$TimelinePanel');
final TimelineIdentifiers timeline;
final controller = ItemScrollController();
TimelinePanel({super.key, required this.timeline});
Future<void> update(BuildContext context, TimelineManager manager) async {
Future<void> update(
BuildContext context, Profile profile, WidgetRef ref) async {
final confirm =
await showYesNoDialog(context, 'Reload timeline from scratch?');
if (confirm == true) {
await manager.updateTimeline(
timeline,
TimelineRefreshType.refresh,
);
await ref
.read(timelineManagerProvider(profile, timeline).notifier)
.updateTimeline(TimelineRefreshType.refresh);
}
}
@ -34,19 +36,16 @@ class TimelinePanel extends StatelessWidget {
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
_logger.finer('Build');
final nss = getIt<NetworkStatusService>();
final manager = context
.watch<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.value;
final items = manager.getTimeline(timeline);
final profile = context.watch<AccountsService>().currentProfile;
final postIds = ref.watch(timelineManagerProvider(profile, timeline)).posts;
return ValueListenableBuilder(
valueListenable: nss.timelineLoadingStatus,
builder: (BuildContext context, bool loading, Widget? _) {
if (items.isEmpty && loading) {
if (postIds.isEmpty && loading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -57,7 +56,7 @@ class TimelinePanel extends StatelessWidget {
);
}
if (items.isEmpty) {
if (postIds.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -65,8 +64,10 @@ class TimelinePanel extends StatelessWidget {
const Text('No posts loaded'),
const VerticalPadding(),
ElevatedButton(
onPressed: () => manager.updateTimeline(
timeline, TimelineRefreshType.refresh),
onPressed: () => ref
.read(
timelineManagerProvider(profile, timeline).notifier)
.updateTimeline(TimelineRefreshType.refresh),
child: const Text('Load Posts'))
],
),
@ -75,43 +76,46 @@ class TimelinePanel extends StatelessWidget {
return RefreshIndicator(
onRefresh: () async {
update(context, manager);
update(context, profile, ref);
return;
},
child: ScrollablePositionedList.separated(
itemScrollController: controller,
physics: const AlwaysScrollableScrollPhysics(),
separatorBuilder: (context, index) =>
index == 0 || index == items.length
index == 0 || index == postIds.length
? const SizedBox()
: const Divider(),
itemBuilder: (context, index) {
if (index == 0) {
return TextButton(
onPressed: () async => await manager.updateTimeline(
timeline, TimelineRefreshType.loadNewer),
onPressed: () async => await ref
.read(
timelineManagerProvider(profile, timeline).notifier)
.updateTimeline(TimelineRefreshType.loadNewer),
child: const Text('Load newer posts'));
}
if (index == items.length + 1) {
if (index == postIds.length + 1) {
return TextButton(
onPressed: () async => await manager.updateTimeline(
timeline, TimelineRefreshType.loadOlder),
onPressed: () async => await ref
.read(
timelineManagerProvider(profile, timeline).notifier)
.updateTimeline(TimelineRefreshType.loadOlder),
child: const Text('Load older posts'));
}
final itemIndex = index - 1;
final item = items[itemIndex];
TimelinePanel._logger.finest(
'Building item: $itemIndex: ${item.entry.toShortString()}');
final id = postIds[itemIndex];
TimelinePanel._logger.finest('Building item: $itemIndex: $id');
return PostControl(
originalItem: item,
scrollToId: item.id,
id: id,
scrollToId: id,
openRemote: false,
showStatusOpenButton: true,
isRoot: false,
);
},
itemCount: items.length + 2,
itemCount: postIds.length + 2,
),
);
},

Wyświetl plik

@ -17,7 +17,6 @@ import 'models/auth/profile.dart';
import 'models/instance_info.dart';
import 'services/auth_service.dart';
import 'services/connections_manager.dart';
import 'services/entry_manager_service.dart';
import 'services/feature_version_checker.dart';
import 'services/fediverse_server_validator.dart';
import 'services/follow_requests_manager.dart';
@ -26,7 +25,6 @@ import 'services/network_status_service.dart';
import 'services/persistent_info_service.dart';
import 'services/reshared_via_service.dart';
import 'services/secrets_service.dart';
import 'services/timeline_manager.dart';
import 'utils/active_profile_selector.dart';
final _logger = Logger('DI_Init');
@ -90,18 +88,6 @@ Future<void> dependencyInjectionInitialization() async {
getIt.registerSingleton<ActiveProfileSelector<GalleryService>>(
ActiveProfileSelector((p) => GalleryService(p))
..subscribeToProfileSwaps());
getIt.registerSingleton<ActiveProfileSelector<EntryManagerService>>(
ActiveProfileSelector((p) => EntryManagerService(p))
..subscribeToProfileSwaps());
getIt.registerSingleton<ActiveProfileSelector<TimelineManager>>(
ActiveProfileSelector((p) => TimelineManager(
p,
getIt<ActiveProfileSelector<ICirclesRepo>>().getForProfile(p).value,
getIt<ActiveProfileSelector<EntryManagerService>>()
.getForProfile(p)
.value,
))
..subscribeToProfileSwaps());
getIt.registerSingleton<ActiveProfileSelector<FollowRequestsManager>>(
ActiveProfileSelector((p) => FollowRequestsManager(p))
..subscribeToProfileSwaps());
@ -134,12 +120,6 @@ void clearCaches() {
_logger.severe('Error clearing IConnections Repo: $error'),
);
getIt<ActiveProfileSelector<EntryManagerService>>().activeEntry.match(
onSuccess: (service) => service.clear(),
onError: (error) =>
_logger.severe('Error clearing EntryManagerService Repo: $error'),
);
getIt<ActiveProfileSelector<FollowRequestsManager>>().activeEntry.match(
onSuccess: (manager) => manager.clear(),
onError: (error) =>
@ -151,10 +131,4 @@ void clearCaches() {
onError: (error) =>
_logger.severe('Error clearing GalleryService Repo: $error'),
);
getIt<ActiveProfileSelector<TimelineManager>>().activeEntry.match(
onSuccess: (manager) => manager.clear(),
onError: (error) =>
_logger.severe('Error clearing TimelineManager Repo: $error'),
);
}

Wyświetl plik

@ -17,10 +17,8 @@ import 'riverpod_controllers/settings_services.dart';
import 'routes.dart';
import 'services/auth_service.dart';
import 'services/connections_manager.dart';
import 'services/entry_manager_service.dart';
import 'services/follow_requests_manager.dart';
import 'services/gallery_service.dart';
import 'services/timeline_manager.dart';
import 'update_timer_initialization.dart';
import 'utils/active_profile_selector.dart';
import 'utils/app_scrolling_behavior.dart';
@ -117,17 +115,10 @@ class _AppState extends fr.ConsumerState<App> {
create: (_) => getIt<ActiveProfileSelector<ConnectionsManager>>(),
lazy: true,
),
ChangeNotifierProvider<ActiveProfileSelector<EntryManagerService>>(
create: (_) => getIt<ActiveProfileSelector<EntryManagerService>>(),
lazy: true,
),
ChangeNotifierProvider<ActiveProfileSelector<GalleryService>>(
create: (_) => getIt<ActiveProfileSelector<GalleryService>>(),
lazy: true,
),
ChangeNotifierProvider<ActiveProfileSelector<TimelineManager>>(
create: (_) => getIt<ActiveProfileSelector<TimelineManager>>(),
),
ChangeNotifierProvider<ActiveProfileSelector<FollowRequestsManager>>(
create: (_) =>
getIt<ActiveProfileSelector<FollowRequestsManager>>(),

Wyświetl plik

@ -1,86 +1,37 @@
import 'timeline_entry.dart';
import 'package:flutter/foundation.dart';
class EntryTreeItem {
final TimelineEntry entry;
final String id;
final bool isMine;
bool isOrphaned;
final _children = <String, EntryTreeItem>{};
final _children = <String>{};
EntryTreeItem(this.entry,
EntryTreeItem(this.id,
{this.isMine = true,
this.isOrphaned = false,
Map<String, EntryTreeItem>? initialChildren}) {
Iterable<String>? initialChildren}) {
_children.addAll(initialChildren ?? {});
}
factory EntryTreeItem.empty() => EntryTreeItem(TimelineEntry());
List<String> get children => List.unmodifiable(_children);
EntryTreeItem copy({required TimelineEntry entry}) => EntryTreeItem(
entry,
isMine: isMine,
isOrphaned: isOrphaned,
initialChildren: _children,
);
String get id => entry.id;
void addOrUpdate(EntryTreeItem child) {
_children[child.id] = child;
}
EntryTreeItem? getChildById(String id) {
if (_children.containsKey(id)) {
return _children[id]!;
}
for (final c in _children.values) {
final result = c.getChildById(id);
if (result != null) {
return result;
}
}
return null;
}
void removeChildById(String id) {
if (_children.containsKey(id)) {
_children.remove(id);
}
for (final c in _children.values) {
c.removeChildById(id);
}
return;
}
int get totalChildren {
int t = _children.length;
for (final c in _children.values) {
t += c.totalChildren;
}
return t;
}
List<EntryTreeItem> get children => List.unmodifiable(_children.values);
factory EntryTreeItem.empty() => EntryTreeItem('');
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is EntryTreeItem &&
runtimeType == other.runtimeType &&
entry == other.entry &&
id == other.id &&
isMine == other.isMine &&
isOrphaned == other.isOrphaned &&
_children == other._children;
setEquals(_children, other._children);
@override
int get hashCode =>
entry.hashCode ^
id.hashCode ^
isMine.hashCode ^
isOrphaned.hashCode ^
_children.hashCode;
Object.hashAll(_children);
}

Wyświetl plik

@ -1,13 +1,20 @@
import 'entry_tree_item.dart';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'timeline_identifiers.dart';
const defaultLowestId = 9223372036854775807;
const defaultHighestId = 0;
enum InsertionType {
beginning,
end,
}
class Timeline {
final TimelineIdentifiers id;
final List<EntryTreeItem> _posts = [];
final Map<String, EntryTreeItem> _postsById = {};
final TimelineIdentifiers timelineId;
final List<String> _postIds = [];
int _lowestStatusId = defaultLowestId;
int _highestStatusId = defaultHighestId;
@ -15,17 +22,23 @@ class Timeline {
int get lowestStatusId => _lowestStatusId;
Timeline(this.id, {List<EntryTreeItem>? initialPosts}) {
if (initialPosts != null) {
addOrUpdate(initialPosts);
Timeline(this.timelineId, {List<String>? initialPostIds}) {
if (initialPostIds != null) {
_postIds.addAll(initialPostIds);
}
}
List<EntryTreeItem> get posts => List.unmodifiable(_posts);
List<String> get posts => UnmodifiableListView(_postIds);
void addOrUpdate(List<EntryTreeItem> newPosts) {
for (final p in newPosts) {
final id = int.parse(p.id);
Timeline addOrUpdate(
List<String> newIds, {
required InsertionType insertionType,
}) {
final newIdsSet = Set.from(newIds);
final updatedIds = _postIds.where((i) => !newIdsSet.contains(i)).toList();
for (final idString in newIds) {
final id = int.parse(idString);
if (_lowestStatusId > id) {
_lowestStatusId = id;
}
@ -33,48 +46,44 @@ class Timeline {
if (_highestStatusId < id) {
_highestStatusId = id;
}
_postsById[p.id] = p;
}
_posts.clear();
_posts.addAll(_postsById.values);
_posts.sort((p1, p2) {
final id1 = num.parse(p1.id);
final id2 = num.parse(p2.id);
return id2.compareTo(id1);
// return p2.entry.backdatedTimestamp.compareTo(p1.entry.backdatedTimestamp);
});
}
bool tryUpdateComment(EntryTreeItem comment) {
var changed = false;
final parentId = comment.entry.parentId;
for (final p in _posts) {
final parent =
p.id == parentId ? p : p.getChildById(comment.entry.parentId);
if (parent != null) {
parent.addOrUpdate(comment);
changed = true;
switch (insertionType) {
case InsertionType.beginning:
updatedIds.insertAll(0, newIds);
break;
case InsertionType.end:
updatedIds.addAll(newIds);
break;
}
final existingIds = <String>{};
for (int i = 0; i < updatedIds.length; i++) {
final id = updatedIds[i];
if (existingIds.contains(id)) {
updatedIds.removeAt(i);
} else {
existingIds.add(id);
}
}
return changed;
updatedIds.sort((p1, p2) {
final id1 = num.parse(p1);
final id2 = num.parse(p2);
return id2.compareTo(id1);
});
return Timeline(timelineId, initialPostIds: updatedIds)
.._lowestStatusId = lowestStatusId
.._highestStatusId = highestStatusId;
}
void removeTimelineEntry(String id) {
if (_postsById.containsKey(id)) {
final post = _postsById.remove(id);
_posts.remove(post);
return;
}
for (final p in _posts) {
p.removeChildById(id);
}
_postIds.remove(id);
}
void clear() {
_posts.clear();
_postsById.clear();
_postIds.clear();
_lowestStatusId = defaultLowestId;
_highestStatusId = defaultHighestId;
}
@ -82,8 +91,11 @@ class Timeline {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Timeline && runtimeType == other.runtimeType && id == other.id;
other is Timeline &&
runtimeType == other.runtimeType &&
timelineId == other.timelineId &&
listEquals(posts, other.posts);
@override
int get hashCode => id.hashCode;
int get hashCode => timelineId.hashCode ^ Object.hashAll(_postIds);
}

Wyświetl plik

@ -75,8 +75,9 @@ class TimelineIdentifiers {
other is TimelineIdentifiers &&
runtimeType == other.runtimeType &&
timeline == other.timeline &&
auxData == other.auxData;
auxData == other.auxData &&
label == other.label;
@override
int get hashCode => timeline.hashCode ^ auxData.hashCode;
int get hashCode => timeline.hashCode ^ auxData.hashCode ^ label.hashCode;
}

Wyświetl plik

@ -0,0 +1,547 @@
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:result_monad/result_monad.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../friendica_client/friendica_client.dart';
import '../friendica_client/paging_data.dart';
import '../models/auth/profile.dart';
import '../models/entry_tree_item.dart';
import '../models/exec_error.dart';
import '../models/image_entry.dart';
import '../models/media_attachment_uploads/media_upload_attachment.dart';
import '../models/media_attachment_uploads/new_entry_media_items.dart';
import '../models/timeline_entry.dart';
import '../models/timeline_identifiers.dart';
import '../models/visibility.dart';
import '../utils/media_upload_attachment_helper.dart';
import 'timeline_entry_services.dart';
part 'entry_tree_item_services.g.dart';
@Riverpod(keepAlive: true)
Map<String, String> _parentPostIds(_ParentPostIdsRef ref, Profile profile) {
return {};
}
@Riverpod(keepAlive: true)
Map<String, _Node> _postNodes(_PostNodesRef ref, Profile profile) {
return {};
}
@Riverpod(keepAlive: true)
Map<String, EntryTreeItem> _entryTreeItems(
_EntryTreeItemsRef ref, Profile profile) =>
{};
final _pteLogger = Logger('PostTreeEntryByIdProvider');
@riverpod
Result<EntryTreeItem, ExecError> postTreeEntryById(
PostTreeEntryByIdRef ref, Profile profile, String id) {
_pteLogger.finest('Building for $id for $profile');
final isPostNode = ref.watch(_postNodesProvider(profile)).containsKey(id);
_pteLogger.finest('$id ${isPostNode ? "is a post" : "is a comment"}');
final postId =
isPostNode ? id : ref.watch(_parentPostIdsProvider(profile))[id];
if (postId == null) {
_pteLogger.finest('No post entry found for $id for $profile');
return buildErrorResult(
type: ErrorType.notFound, message: 'No post entry found for id: $id');
}
final entry = ref.watch(entryTreeManagerProvider(profile, postId));
_pteLogger.finest('Result from ETM: $entry');
return entry;
}
final _etmLogger = Logger('EntryTreeManagerProvider');
@Riverpod(keepAlive: true)
class EntryTreeManager extends _$EntryTreeManager {
late Profile userProfile;
var entryId = '';
@override
Result<EntryTreeItem, ExecError> build(Profile profile, String id) {
_etmLogger.finest('Building for $id for $profile');
// ref.cacheFor(const Duration(hours: 1));
entryId = id;
userProfile = profile;
final entries = ref.watch(_entryTreeItemsProvider(profile));
final entry = entries[id];
if (entry == null) {
_etmLogger.finest('EntryTreeItem for $id for $profile not found');
return buildErrorResult(
type: ErrorType.notFound,
message: '$id not found',
);
}
_etmLogger.finest('Return entry for $id for $profile');
return Result.ok(entry);
}
Result<EntryTreeItem, ExecError> upsert(EntryTreeItem entry) {
_etmLogger.finest('Upserting entry for $id for $profile');
if (entry.id != entryId) {
return buildErrorResult(
type: ErrorType.argumentError,
message:
'Trying to add an entry to a provider that does not match the id: $id',
);
} else {
ref.read(_entryTreeItemsProvider(userProfile))[entryId] = entry;
}
if (state.isFailure || entry != state.value) {
state = Result.ok(entry);
ref.invalidateSelf(); //TODO Confirm need to invalidate (I don't think I do any longer)
}
return state;
}
void remove() {
_etmLogger.finest('Removing for $entryId for $profile');
ref.read(_entryTreeItemsProvider(userProfile)).remove(entryId);
}
}
final _tluLogger = Logger('TimelineUpdater');
@riverpod
class TimelineUpdater extends _$TimelineUpdater {
@override
bool build(Profile userProfile) {
return true;
}
FutureResult<List<EntryTreeItem>, ExecError> updateTimeline(
TimelineIdentifiers type, int maxId, int sinceId) async {
_tluLogger.fine(() => 'Updating timeline');
final client = TimelineClient(userProfile);
final itemsResult = await client.getTimeline(
type: type,
page: PagingData(
maxId: maxId > 0 ? maxId : null,
sinceId: sinceId > 0 ? sinceId : null,
),
);
if (itemsResult.isFailure) {
_tluLogger.severe('Error getting timeline: ${itemsResult.error}');
return itemsResult.errorCast();
}
itemsResult.value.sort((t1, t2) => t1.id.compareTo(t2.id));
final updatedPosts = await _processNewItems(
itemsResult.value,
userProfile.userId,
);
return Result.ok(updatedPosts);
}
FutureResult<EntryTreeItem, ExecError> refreshStatusChain(String id) async {
_tluLogger.fine('Refreshing post: $id');
final client = StatusesClient(userProfile);
final postResult = client.getPostOrComment(id, fullContext: false);
final contextResult = client.getPostOrComment(id, fullContext: true);
final results = await Future.wait([postResult, contextResult]);
final hadError = results.map((r) => r.isFailure).reduce((e, s) => e && s);
final entries = results
.map((r) => r.getValueOrElse(() => []))
.expand((i) => i)
.toList();
if (entries.isNotEmpty) {
_cleanupEntriesForId(id);
//ID may be wrong, have to think about if this always needs to be a post ID. Maybe this field is superfluous
await _processNewItems(entries, client.profile.userId);
}
if (hadError) {
return Result.error(results.firstWhere((r) => r.isFailure).error);
} else {
final resultFromProvider =
ref.read(postTreeEntryByIdProvider(userProfile, id));
return resultFromProvider;
}
}
FutureResult<bool, ExecError> deleteEntryById(String id) async {
// TODO confirm that the entry no longer appears in the UI even if the timeline or chain has the ID
_tluLogger.finest('Delete entry: $id');
final result = await StatusesClient(userProfile)
.deleteEntryById(id)
.withResult((_) => _cleanupEntriesForId(id));
return result.execErrorCast();
}
void _cleanupEntriesForId(String id) {
final parentPostIds = ref.read(_parentPostIdsProvider(userProfile));
final postNodes = ref.read(_postNodesProvider(userProfile));
if (parentPostIds.containsKey(id)) {
final parentPostId = parentPostIds.remove(id);
final parentPostNode = postNodes[parentPostId];
parentPostNode?.removeChildById(id);
}
ref.read(entryTreeManagerProvider(userProfile, id).notifier).remove();
if (postNodes.containsKey(id)) {
postNodes.remove(id);
}
}
Future<List<EntryTreeItem>> _processNewItems(
List<TimelineEntry> items,
String currentId,
) async {
_tluLogger.fine('Processing new items: ${items.map((e) => e.id).toList()}');
items.sort((i1, i2) => int.parse(i1.id).compareTo(int.parse(i2.id)));
final allSeenItems = [...items];
for (final item in items) {
_tluLogger.finest('Upserting entry for ${item.id}');
ref
.read(timelineEntryManagerProvider(userProfile, item.id).notifier)
.upsert(item);
}
final orphans = <TimelineEntry>[];
for (final item in items) {
if (item.parentId.isEmpty) {
continue;
}
ref.read(timelineEntryManagerProvider(userProfile, item.parentId)).match(
onSuccess: (parent) {
if (parent.parentId.isEmpty) {
ref.read(_parentPostIdsProvider(userProfile))[item.id] = parent.id;
}
}, onError: (_) {
orphans.add(item);
});
}
for (final o in orphans) {
await StatusesClient(userProfile)
.getPostOrComment(o.id, fullContext: true)
.andThenSuccessAsync((items) async {
final parentPostId = items.firstWhere((e) => e.parentId.isEmpty).id;
ref.read(_parentPostIdsProvider(userProfile))[o.id] = parentPostId;
allSeenItems.addAll(items);
for (final item in items) {
ref
.read(timelineEntryManagerProvider(userProfile, item.id).notifier)
.upsert(item);
ref.read(_parentPostIdsProvider(userProfile))[item.id] = parentPostId;
}
});
}
allSeenItems.sort((i1, i2) {
if (i1.parentId.isEmpty && i2.parentId.isNotEmpty) {
return -1;
}
if (i2.parentId.isEmpty && i1.parentId.isNotEmpty) {
return 1;
}
return int.parse(i1.id).compareTo(int.parse(i2.id));
});
final postNodesToReturn = <_Node>{};
var lastCount = 0;
final postNodes = ref.read(_postNodesProvider(userProfile));
final parentPostIds = ref.read(_parentPostIdsProvider(userProfile));
while (allSeenItems.isNotEmpty) {
final seenItemsCopy = [...allSeenItems];
for (final item in seenItemsCopy) {
if (item.parentId.isEmpty) {
final postNode = postNodes.putIfAbsent(item.id, () => _Node(item.id));
postNodesToReturn.add(postNode);
allSeenItems.remove(item);
} else {
final parentParentPostId = postNodes.containsKey(item.parentId)
? item.parentId
: parentPostIds[item.parentId];
if (postNodes[parentParentPostId] == null) {
_tluLogger.severe(
'Error finding parent ${item.parentId} for entry ${item.id}');
continue;
}
final parentPostNode = postNodes[parentParentPostId]!;
postNodesToReturn.add(parentPostNode);
parentPostIds[item.id] = parentPostNode.id;
if (parentPostNode.getChildById(item.id) == null) {
final newNode = _Node(item.id);
final injectionNode = parentPostNode.id == item.parentId
? parentPostNode
: parentPostNode.getChildById(item.parentId);
if (injectionNode == null) {
continue;
} else {
injectionNode.addChild(newNode);
}
}
allSeenItems.remove(item);
}
}
if (allSeenItems.isNotEmpty && allSeenItems.length == lastCount) {
_tluLogger.severe(
'Had ${allSeenItems.length} items left over after all iterations');
break;
}
lastCount = allSeenItems.length;
}
final updatedPosts = postNodesToReturn
.map((node) => _nodeToTreeItem(node, currentId))
.toList();
_tluLogger.finest('Completed processing new items');
return updatedPosts;
}
EntryTreeItem _nodeToTreeItem(_Node node, String currentId) {
final childenEntries = <String>[];
for (final c in node.children) {
childenEntries.add(c.id);
_nodeToTreeItem(c, currentId);
}
final entryId = node.id;
final isMine = ref
.read(timelineEntryManagerProvider(userProfile, entryId))
.fold(onSuccess: (t) => t.authorId, onError: (_) => '') ==
currentId;
final rval = EntryTreeItem(
entryId,
isMine: isMine,
initialChildren: childenEntries,
);
ref
.read(entryTreeManagerProvider(userProfile, node.id).notifier)
.upsert(rval);
return rval;
}
}
final _swLogger = Logger('StatusWriter');
@riverpod
class StatusWriter extends _$StatusWriter {
@override
bool build(Profile userProfile) {
return true;
}
// TODO move to RP timeline entry services
FutureResult<TimelineEntry, ExecError> createNewStatus(
String text, {
String spoilerText = '',
String inReplyToId = '',
required NewEntryMediaItems mediaItems,
required List<ImageEntry> existingMediaItems,
required Visibility visibility,
}) async {
_swLogger.info('Creating new post: $text');
final mediaIds = existingMediaItems.map((m) => m.scales.first.id).toList();
for (final item in mediaItems.attachments) {
if (item.isExistingServerItem) {
continue;
}
final result =
await _uploadMediaItems(item, mediaItems.albumName, visibility)
.withResult((newId) => mediaIds.add(newId));
if (result.isFailure) {
return Result.error(ExecError(
type: ErrorType.localError,
message: 'Error uploading image: ${result.error}'));
}
}
final result = await StatusesClient(userProfile)
.createNewStatus(
text: text,
spoilerText: spoilerText,
inReplyToId: inReplyToId,
mediaIds: mediaIds,
visibility: visibility)
.withResult((item) async {
ref
.read(timelineEntryManagerProvider(userProfile, item.id).notifier)
.upsert(item);
})
.withResult((item) async {
if (inReplyToId.isNotEmpty) {
late final String rootPostId;
if (ref
.read(_postNodesProvider(userProfile))
.containsKey(inReplyToId)) {
rootPostId = inReplyToId;
} else {
rootPostId =
ref.read(_parentPostIdsProvider(userProfile))[inReplyToId]!;
}
await ref
.read(timelineUpdaterProvider(userProfile).notifier)
.refreshStatusChain(rootPostId);
}
})
.withResult((status) => _swLogger.finest('${status.id} status created'))
.withError((error) => _swLogger.finest('Error creating post: $error'));
return result.execErrorCast();
}
FutureResult<TimelineEntry, ExecError> editStatus(
String statusId,
String text, {
String spoilerText = '',
required NewEntryMediaItems mediaItems,
required List<ImageEntry> existingMediaItems,
required Visibility newMediaItemVisibility,
}) async {
_swLogger.info('Editing post: $text');
final mediaIds = existingMediaItems
.map((m) => m.scales.isEmpty ? m.id : m.scales.first.id)
.toList();
for (final item in mediaItems.attachments) {
if (item.isExistingServerItem) {
continue;
}
final result = await _uploadMediaItems(
item, mediaItems.albumName, newMediaItemVisibility)
.withResult((newId) => mediaIds.add(newId));
if (result.isFailure) {
return Result.error(ExecError(
type: ErrorType.localError,
message: 'Error uploading image: ${result.error}'));
}
}
final result = await StatusesClient(userProfile)
.editStatus(
id: statusId,
text: text,
spoilerText: spoilerText,
mediaIds: mediaIds)
.withResult((item) async {
ref
.read(timelineEntryManagerProvider(userProfile, item.id).notifier)
.upsert(item);
})
.withResult((item) async {
final inReplyToId = item.parentId;
if (inReplyToId.isNotEmpty) {
late final String rootPostId;
if (ref
.read(_postNodesProvider(userProfile))
.containsKey(inReplyToId)) {
rootPostId = inReplyToId;
} else {
rootPostId =
ref.read(_parentPostIdsProvider(userProfile))[inReplyToId]!;
}
await ref
.read(timelineUpdaterProvider(userProfile).notifier)
.refreshStatusChain(rootPostId);
}
})
.withResult((status) => _swLogger.finest('${status.id} status created'))
.withError((error) => _swLogger.finest('Error creating post: $error'));
return result.execErrorCast();
}
FutureResult<String, ExecError> _uploadMediaItems(
MediaUploadAttachment item,
String albumName,
Visibility visibility,
) async {
final String extension = p.extension(item.localFilePath);
late final String filename;
if (item.remoteFilename.isEmpty) {
filename = p.basename(item.localFilePath);
} else {
if (item.remoteFilename.toLowerCase().endsWith(extension.toLowerCase())) {
filename = item.remoteFilename;
} else {
filename = "${item.remoteFilename}$extension";
}
}
final uploadResult =
await MediaUploadAttachmentHelper.getUploadableImageBytes(
item.localFilePath,
)
.andThenAsync(
(imageBytes) async =>
await RemoteFileClient(userProfile).uploadFileAsAttachment(
bytes: imageBytes,
album: albumName,
description: item.description,
fileName: filename,
visibility: visibility,
),
)
.transform((v) => v.scales.first.id);
return uploadResult.execErrorCast();
}
}
class _Node {
final String id;
final _children = <String, _Node>{};
List<_Node> get children => _children.values.toList();
_Node(this.id, {Map<String, _Node>? initialChildren}) {
if (initialChildren != null) {
_children.addAll(initialChildren);
}
}
void addChild(_Node node) {
_children[node.id] = node;
}
void removeChildById(String id) {
if (_children.containsKey(id)) {
_children.remove(id);
}
for (final c in _children.values) {
c.removeChildById(id);
}
}
_Node? getChildById(String id) {
if (_children.containsKey(id)) {
return _children[id]!;
}
for (final c in _children.values) {
final result = c.getChildById(id);
if (result != null) {
return result;
}
}
return null;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _Node && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}

Wyświetl plik

@ -3,9 +3,6 @@ import 'dart:io';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/auth/profile.dart';
import '../services/entry_manager_service.dart';
part 'globals_services.g.dart';
@Riverpod(keepAlive: true)
@ -15,8 +12,3 @@ SharedPreferences sharedPreferences(SharedPreferencesRef ref) =>
@Riverpod(keepAlive: true)
Directory applicationSupportDirectory(ApplicationSupportDirectoryRef ref) =>
throw UnimplementedError();
@Riverpod(keepAlive: true)
EntryManagerService entryManagerService(
EntryManagerServiceRef ref, Profile profile) =>
EntryManagerService(profile);

Wyświetl plik

@ -37,154 +37,5 @@ final applicationSupportDirectoryProvider = Provider<Directory>.internal(
);
typedef ApplicationSupportDirectoryRef = ProviderRef<Directory>;
String _$entryManagerServiceHash() =>
r'c3fdb3153999b0b9053b3a4aff0e6834b5a76d12';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [entryManagerService].
@ProviderFor(entryManagerService)
const entryManagerServiceProvider = EntryManagerServiceFamily();
/// See also [entryManagerService].
class EntryManagerServiceFamily extends Family<EntryManagerService> {
/// See also [entryManagerService].
const EntryManagerServiceFamily();
/// See also [entryManagerService].
EntryManagerServiceProvider call(
Profile profile,
) {
return EntryManagerServiceProvider(
profile,
);
}
@override
EntryManagerServiceProvider getProviderOverride(
covariant EntryManagerServiceProvider provider,
) {
return call(
provider.profile,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'entryManagerServiceProvider';
}
/// See also [entryManagerService].
class EntryManagerServiceProvider extends Provider<EntryManagerService> {
/// See also [entryManagerService].
EntryManagerServiceProvider(
Profile profile,
) : this._internal(
(ref) => entryManagerService(
ref as EntryManagerServiceRef,
profile,
),
from: entryManagerServiceProvider,
name: r'entryManagerServiceProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$entryManagerServiceHash,
dependencies: EntryManagerServiceFamily._dependencies,
allTransitiveDependencies:
EntryManagerServiceFamily._allTransitiveDependencies,
profile: profile,
);
EntryManagerServiceProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.profile,
}) : super.internal();
final Profile profile;
@override
Override overrideWith(
EntryManagerService Function(EntryManagerServiceRef provider) create,
) {
return ProviderOverride(
origin: this,
override: EntryManagerServiceProvider._internal(
(ref) => create(ref as EntryManagerServiceRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
profile: profile,
),
);
}
@override
ProviderElement<EntryManagerService> createElement() {
return _EntryManagerServiceProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is EntryManagerServiceProvider && other.profile == profile;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, profile.hashCode);
return _SystemHash.finish(hash);
}
}
mixin EntryManagerServiceRef on ProviderRef<EntryManagerService> {
/// The parameter `profile` of this provider.
Profile get profile;
}
class _EntryManagerServiceProviderElement
extends ProviderElement<EntryManagerService> with EntryManagerServiceRef {
_EntryManagerServiceProviderElement(super.provider);
@override
Profile get profile => (origin as EntryManagerServiceProvider).profile;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

Wyświetl plik

@ -1,10 +1,12 @@
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../friendica_client/friendica_client.dart';
import '../models/auth/profile.dart';
import '../models/exec_error.dart';
import '../models/timeline_entry.dart';
import 'rp_provider_extension.dart';
import 'reshared_via_services.dart';
part 'timeline_entry_services.g.dart';
@ -13,28 +15,35 @@ Map<String, TimelineEntry> _timelineEntries(
_TimelineEntriesRef ref, Profile profile) =>
{};
@riverpod
final _logger = Logger('TimelineEntryManagerProvider');
//TODO Test using cacheFor to let it expire
@Riverpod(keepAlive: true)
class TimelineEntryManager extends _$TimelineEntryManager {
late Profile userProfile;
var entryId = '';
@override
Result<TimelineEntry, ExecError> build(Profile profile, String id) {
ref.cacheFor(const Duration(hours: 1));
_logger.finest('Building for $id for $profile');
//ref.cacheFor(const Duration(hours: 1));
entryId = id;
userProfile = profile;
final entry = ref.watch(_timelineEntriesProvider(profile))[id];
if (entry == null) {
_logger.finest('Entry not found for $id for $profile');
return buildErrorResult(
type: ErrorType.notFound,
message: '$id not found',
);
}
_logger.finest('For $id for $profile returning: \n$entry');
return Result.ok(entry);
}
Result<TimelineEntry, ExecError> upsert(TimelineEntry entry) {
_logger.fine('Upserting entry for ${entry.id} for $userProfile');
if (entry.id != entryId) {
return buildErrorResult(
type: ErrorType.argumentError,
@ -47,13 +56,54 @@ class TimelineEntryManager extends _$TimelineEntryManager {
if (state.isFailure || entry != state.value) {
state = Result.ok(entry);
ref.invalidateSelf();
ref.invalidateSelf(); //Confirm I need to do this, I don't think I do
}
return state;
}
FutureResult<TimelineEntry, ExecError> toggleFavorited(bool newStatus) async {
final interactionClient = InteractionsClient(userProfile);
final result = await interactionClient
.changeFavoriteStatus(entryId, newStatus)
.withResult((update) {
_logger.fine('Updating $entryId for $userProfile to: \n$update');
ref.read(_timelineEntriesProvider(userProfile))[entryId] = update;
});
if (result.isSuccess) {
state = result.execErrorCast();
}
return state;
}
FutureResult<TimelineEntry, ExecError> resharePost() async {
final client = StatusesClient(profile);
final result = await client.resharePost(entryId).withResult((item) async {
ref
.read(resharedViaProvider(userProfile, entryId).notifier)
.upsertResharedVia(profile.id);
}).withResult((update) =>
ref.read(_timelineEntriesProvider(userProfile))[entryId] = update);
state = result.execErrorCast();
return state;
}
FutureResult<TimelineEntry, ExecError> unResharePost() async {
final client = StatusesClient(profile);
final result = await client.unResharePost(entryId).withResult((item) async {
ref
.read(resharedViaProvider(userProfile, entryId).notifier)
.upsertRemovedSharer(profile.id);
}).withResult((update) =>
ref.read(_timelineEntriesProvider(userProfile))[entryId] = update);
state = result.execErrorCast();
return state;
}
void remove() {
ref.read(_timelineEntriesProvider(userProfile)).remove(entryId);
ref.invalidateSelf();
}
}

Wyświetl plik

@ -157,10 +157,10 @@ class _TimelineEntriesProviderElement
}
String _$timelineEntryManagerHash() =>
r'8e8cf823e4721ff139a78084a17cd06a712e0a44';
r'2d0a706f65beeff92606c826d85550a533d6a56e';
abstract class _$TimelineEntryManager
extends BuildlessAutoDisposeNotifier<Result<TimelineEntry, ExecError>> {
extends BuildlessNotifier<Result<TimelineEntry, ExecError>> {
late final Profile profile;
late final String id;
@ -217,7 +217,7 @@ class TimelineEntryManagerFamily
}
/// See also [TimelineEntryManager].
class TimelineEntryManagerProvider extends AutoDisposeNotifierProviderImpl<
class TimelineEntryManagerProvider extends NotifierProviderImpl<
TimelineEntryManager, Result<TimelineEntry, ExecError>> {
/// See also [TimelineEntryManager].
TimelineEntryManagerProvider(
@ -284,7 +284,7 @@ class TimelineEntryManagerProvider extends AutoDisposeNotifierProviderImpl<
}
@override
AutoDisposeNotifierProviderElement<TimelineEntryManager,
NotifierProviderElement<TimelineEntryManager,
Result<TimelineEntry, ExecError>> createElement() {
return _TimelineEntryManagerProviderElement(this);
}
@ -307,7 +307,7 @@ class TimelineEntryManagerProvider extends AutoDisposeNotifierProviderImpl<
}
mixin TimelineEntryManagerRef
on AutoDisposeNotifierProviderRef<Result<TimelineEntry, ExecError>> {
on NotifierProviderRef<Result<TimelineEntry, ExecError>> {
/// The parameter `profile` of this provider.
Profile get profile;
@ -315,9 +315,9 @@ mixin TimelineEntryManagerRef
String get id;
}
class _TimelineEntryManagerProviderElement
extends AutoDisposeNotifierProviderElement<TimelineEntryManager,
Result<TimelineEntry, ExecError>> with TimelineEntryManagerRef {
class _TimelineEntryManagerProviderElement extends NotifierProviderElement<
TimelineEntryManager,
Result<TimelineEntry, ExecError>> with TimelineEntryManagerRef {
_TimelineEntryManagerProviderElement(super.provider);
@override

Wyświetl plik

@ -0,0 +1,71 @@
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../models/auth/profile.dart';
import '../models/timeline.dart';
import '../models/timeline_identifiers.dart';
import 'entry_tree_item_services.dart';
part 'timeline_services.g.dart';
final _tmpLogger = Logger('TimelineManagerProvider');
enum TimelineRefreshType {
refresh,
loadOlder,
loadNewer,
}
@Riverpod(keepAlive: true)
class TimelineManager extends _$TimelineManager {
@override
Timeline build(Profile userProfile, TimelineIdentifiers timelineId) {
_tmpLogger.info('Building for $userProfile for $timelineId');
return Timeline(timelineId);
}
Future<void> updateTimeline(
TimelineRefreshType refreshType,
) async {
_tmpLogger.info(
'Updating w/$refreshType for timeline $timelineId for profile $userProfile ');
late final int lowestId;
late final int highestId;
late final InsertionType insertionType;
switch (refreshType) {
case TimelineRefreshType.refresh:
lowestId = 0;
highestId = 0;
insertionType = InsertionType.end;
break;
case TimelineRefreshType.loadOlder:
lowestId = state.lowestStatusId;
highestId = 0;
insertionType = InsertionType.end;
break;
case TimelineRefreshType.loadNewer:
lowestId = 0;
highestId = state.highestStatusId;
insertionType = InsertionType.beginning;
break;
}
(await ref
.read(timelineUpdaterProvider(userProfile).notifier)
.updateTimeline(timelineId, lowestId, highestId))
.match(onSuccess: (posts) {
_tmpLogger
.finest('Posts returned for adding to $timelineId: ${posts.length}');
final oldState = state;
final newState = state = state.addOrUpdate(
posts.map((p) => p.id).toList(),
insertionType: insertionType,
);
_tmpLogger.finest(
'Old post count: ${oldState.posts.length}, New Post count: ${newState.posts.length}');
_tmpLogger.finest('Old state == New state? ${oldState == newState}');
}, onError: (error) {
_tmpLogger.severe('Error updating timeline: $timelineId}');
});
}
}

Wyświetl plik

@ -0,0 +1,196 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timeline_services.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$timelineManagerHash() => r'b4b0676646b2efc9ce54e2790b4188683c586767';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$TimelineManager extends BuildlessNotifier<Timeline> {
late final Profile userProfile;
late final TimelineIdentifiers timelineId;
Timeline build(
Profile userProfile,
TimelineIdentifiers timelineId,
);
}
/// See also [TimelineManager].
@ProviderFor(TimelineManager)
const timelineManagerProvider = TimelineManagerFamily();
/// See also [TimelineManager].
class TimelineManagerFamily extends Family<Timeline> {
/// See also [TimelineManager].
const TimelineManagerFamily();
/// See also [TimelineManager].
TimelineManagerProvider call(
Profile userProfile,
TimelineIdentifiers timelineId,
) {
return TimelineManagerProvider(
userProfile,
timelineId,
);
}
@override
TimelineManagerProvider getProviderOverride(
covariant TimelineManagerProvider provider,
) {
return call(
provider.userProfile,
provider.timelineId,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'timelineManagerProvider';
}
/// See also [TimelineManager].
class TimelineManagerProvider
extends NotifierProviderImpl<TimelineManager, Timeline> {
/// See also [TimelineManager].
TimelineManagerProvider(
Profile userProfile,
TimelineIdentifiers timelineId,
) : this._internal(
() => TimelineManager()
..userProfile = userProfile
..timelineId = timelineId,
from: timelineManagerProvider,
name: r'timelineManagerProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$timelineManagerHash,
dependencies: TimelineManagerFamily._dependencies,
allTransitiveDependencies:
TimelineManagerFamily._allTransitiveDependencies,
userProfile: userProfile,
timelineId: timelineId,
);
TimelineManagerProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.userProfile,
required this.timelineId,
}) : super.internal();
final Profile userProfile;
final TimelineIdentifiers timelineId;
@override
Timeline runNotifierBuild(
covariant TimelineManager notifier,
) {
return notifier.build(
userProfile,
timelineId,
);
}
@override
Override overrideWith(TimelineManager Function() create) {
return ProviderOverride(
origin: this,
override: TimelineManagerProvider._internal(
() => create()
..userProfile = userProfile
..timelineId = timelineId,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
userProfile: userProfile,
timelineId: timelineId,
),
);
}
@override
NotifierProviderElement<TimelineManager, Timeline> createElement() {
return _TimelineManagerProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is TimelineManagerProvider &&
other.userProfile == userProfile &&
other.timelineId == timelineId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, userProfile.hashCode);
hash = _SystemHash.combine(hash, timelineId.hashCode);
return _SystemHash.finish(hash);
}
}
mixin TimelineManagerRef on NotifierProviderRef<Timeline> {
/// The parameter `userProfile` of this provider.
Profile get userProfile;
/// The parameter `timelineId` of this provider.
TimelineIdentifiers get timelineId;
}
class _TimelineManagerProviderElement
extends NotifierProviderElement<TimelineManager, Timeline>
with TimelineManagerRef {
_TimelineManagerProviderElement(super.provider);
@override
Profile get userProfile => (origin as TimelineManagerProvider).userProfile;
@override
TimelineIdentifiers get timelineId =>
(origin as TimelineManagerProvider).timelineId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

Wyświetl plik

@ -18,6 +18,7 @@ import '../controls/standard_appbar.dart';
import '../controls/timeline/status_header_control.dart';
import '../controls/visibility_dialog.dart';
import '../globals.dart';
import '../models/auth/profile.dart';
import '../models/exec_error.dart';
import '../models/image_entry.dart';
import '../models/link_preview_data.dart';
@ -26,11 +27,12 @@ import '../models/timeline_entry.dart';
import '../models/timeline_grouping_list_data.dart';
import '../models/visibility.dart';
import '../riverpod_controllers/circles_repo_services.dart';
import '../riverpod_controllers/entry_tree_item_services.dart';
import '../riverpod_controllers/timeline_entry_services.dart';
import '../serializers/friendica/link_preview_friendica_extensions.dart';
import '../services/auth_service.dart';
import '../services/connections_manager.dart';
import '../services/feature_version_checker.dart';
import '../services/timeline_manager.dart';
import '../utils/active_profile_selector.dart';
import '../utils/html_to_edit_text_helper.dart';
import '../utils/opengraph_preview_grabber.dart';
@ -78,11 +80,9 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
void initState() {
super.initState();
if (isComment) {
final manager = context
.read<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.value;
manager.getEntryById(widget.parentId).match(onSuccess: (entry) {
final profile = context.read<AccountsService>().currentProfile;
ref.read(timelineEntryManagerProvider(profile, widget.parentId)).match(
onSuccess: (entry) {
spoilerController.text = entry.spoilerText;
parentEntry = entry;
visibility = entry.visibility;
@ -101,10 +101,9 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
void restoreStatusData() async {
_logger.finer('Attempting to load status for editing');
loaded = false;
final result = await getIt<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.andThenAsync((manager) async => manager.getEntryById(widget.id));
result.match(onSuccess: (entry) {
final profile = context.read<AccountsService>().currentProfile;
ref.read(timelineEntryManagerProvider(profile, widget.id)).match(
onSuccess: (entry) {
_logger.finer('Loading status ${widget.id} information into fields');
contentController.text = htmlToSimpleText(entry.body);
spoilerController.text = entry.spoilerText;
@ -144,8 +143,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
existingMediaItems.isEmpty &&
newMediaItems.attachments.isEmpty;
Future<void> createStatus(
BuildContext context, TimelineManager manager) async {
Future<void> createStatus(BuildContext context, Profile profile) async {
if (isSubmitting) {
return;
}
@ -159,15 +157,16 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
isSubmitting = true;
});
final result = await manager
final result = await ref
.read(statusWriterProvider(profile).notifier)
.createNewStatus(
bodyText,
spoilerText: spoilerController.text,
inReplyToId: widget.parentId,
newMediaItems: newMediaItems,
existingMediaItems: existingMediaItems,
visibility: visibility,
)
bodyText,
spoilerText: spoilerController.text,
inReplyToId: widget.parentId,
mediaItems: newMediaItems,
existingMediaItems: existingMediaItems,
visibility: visibility,
)
.withError((error) {
buildSnackbar(context, 'Error posting: $error');
logError(error, _logger);
@ -186,7 +185,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
}
}
Future<void> editStatus(BuildContext context, TimelineManager manager) async {
Future<void> editStatus(BuildContext context, Profile profile) async {
if (isSubmitting) {
return;
}
@ -200,16 +199,16 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
isSubmitting = true;
});
final result = await manager
final result = await ref
.read(statusWriterProvider(profile).notifier)
.editStatus(
widget.id,
bodyText,
spoilerText: spoilerController.text,
inReplyToId: widget.parentId,
newMediaItems: newMediaItems,
existingMediaItems: existingMediaItems,
newMediaItemVisibility: visibility,
)
widget.id,
bodyText,
spoilerText: spoilerController.text,
mediaItems: newMediaItems,
existingMediaItems: existingMediaItems,
newMediaItemVisibility: visibility,
)
.withError((error) {
buildSnackbar(context, 'Error updating $statusType: $error');
logError(error, _logger);
@ -231,11 +230,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
@override
Widget build(BuildContext context) {
_logger.finer('Build editor $isComment $parentEntry');
final manager = context
.read<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.value;
final profile = context.watch<AccountsService>().currentProfile;
final vc = getIt<FriendicaVersionChecker>();
final canEdit = vc.canUseFeature(RelaticaFeatures.statusEditing);
final canSpoilerText = vc.canUseFeature(RelaticaFeatures.postSpoilerText) ||
@ -298,7 +293,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
MediaUploadsControl(
entryMediaItems: newMediaItems,
),
buildButtonBar(context, manager),
buildButtonBar(context, profile),
],
),
),
@ -415,19 +410,19 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
);
}
Widget buildButtonBar(BuildContext context, TimelineManager manager) {
Widget buildButtonBar(BuildContext context, Profile profile) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!widget.forEditing)
ElevatedButton(
onPressed:
isSubmitting ? null : () => createStatus(context, manager),
isSubmitting ? null : () => createStatus(context, profile),
child: const Text('Submit'),
),
if (widget.forEditing)
ElevatedButton(
onPressed: isSubmitting ? null : () => editStatus(context, manager),
onPressed: isSubmitting ? null : () => editStatus(context, profile),
child: const Text('Edit'),
),
const HorizontalPadding(),

Wyświetl plik

@ -17,10 +17,9 @@ import '../models/timeline_grouping_list_data.dart';
import '../models/timeline_identifiers.dart';
import '../riverpod_controllers/circles_repo_services.dart';
import '../riverpod_controllers/focus_mode.dart';
import '../riverpod_controllers/timeline_services.dart';
import '../services/auth_service.dart';
import '../services/network_status_service.dart';
import '../services/timeline_manager.dart';
import '../utils/active_profile_selector.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@ -34,20 +33,20 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
TimelineIdentifiers currentTimeline = TimelineIdentifiers.home();
void updateTimeline(TimelineManager manager) {
void updateTimeline(Profile profile) {
_logger.finer('Updating timeline: $currentTimeline');
Future.delayed(const Duration(milliseconds: 100), () async {
await manager.updateTimeline(
currentTimeline, TimelineRefreshType.refresh);
await ref
.read(timelineManagerProvider(profile, currentTimeline).notifier)
.updateTimeline(TimelineRefreshType.refresh);
});
}
@override
void initState() {
super.initState();
getIt<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.andThenSuccess((m) => updateTimeline(m));
final profile = context.read<AccountsService>().currentProfile;
updateTimeline(profile);
}
@override
@ -113,11 +112,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
TimelineType.local,
];
final manager = context
.watch<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.value;
final circles = ref.watch(timelineGroupingListProvider(
profile,
GroupingType.circle,
@ -210,7 +204,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
return;
}
currentTimeline = value;
updateTimeline(manager);
updateTimeline(profile);
});
});
}

Wyświetl plik

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:provider/provider.dart';
import '../controls/linear_status_indicator.dart';
@ -6,11 +7,11 @@ import '../controls/responsive_max_width.dart' show ResponsiveMaxWidth;
import '../controls/standard_appbar.dart';
import '../controls/timeline/post_control.dart';
import '../globals.dart';
import '../riverpod_controllers/entry_tree_item_services.dart';
import '../services/auth_service.dart';
import '../services/network_status_service.dart';
import '../services/timeline_manager.dart';
import '../utils/active_profile_selector.dart';
class PostScreen extends StatefulWidget {
class PostScreen extends ConsumerStatefulWidget {
final String id;
final String goToId;
@ -22,39 +23,39 @@ class PostScreen extends StatefulWidget {
});
@override
State<PostScreen> createState() => _PostScreenState();
ConsumerState<PostScreen> createState() => _PostScreenState();
}
class _PostScreenState extends State<PostScreen> {
class _PostScreenState extends ConsumerState<PostScreen> {
bool firstDraw = true;
@override
void initState() {
super.initState();
Future.delayed(const Duration(milliseconds: 500), () async {
getIt<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.andThenSuccess(
(m) => m.refreshStatusChain(widget.id),
);
final profile = context.read<AccountsService>().currentProfile;
Future.delayed(const Duration(milliseconds: 100), () async {
await ref
.read(timelineUpdaterProvider(profile).notifier)
.refreshStatusChain(widget.id);
});
}
@override
Widget build(BuildContext context) {
final nss = getIt<NetworkStatusService>();
final manager = context
.watch<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.value;
final body = manager.getPostTreeEntryBy(widget.id).fold(
final profile = context.watch<AccountsService>().currentProfile;
final entryResult =
ref.watch(postTreeEntryByIdProvider(profile, widget.id));
final body = entryResult.fold(
onSuccess: (post) => RefreshIndicator(
onRefresh: () async {
manager.refreshStatusChain(widget.id);
await ref
.read(timelineUpdaterProvider(profile).notifier)
.refreshStatusChain(widget.id);
return;
},
child: PostControl(
originalItem: post,
id: post.id,
scrollToId: widget.goToId,
openRemote: true,
showStatusOpenButton: false,

Wyświetl plik

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
@ -17,22 +18,22 @@ import '../models/connection.dart';
import '../models/search_results.dart';
import '../models/search_types.dart';
import '../models/timeline_entry.dart';
import '../riverpod_controllers/entry_tree_item_services.dart';
import '../routes.dart';
import '../services/auth_service.dart';
import '../services/connections_manager.dart';
import '../services/entry_manager_service.dart';
import '../services/network_status_service.dart';
import '../utils/active_profile_selector.dart';
import '../utils/snackbar_builder.dart';
class SearchScreen extends StatefulWidget {
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
class _SearchScreenState extends ConsumerState<SearchScreen> {
static const limit = 50;
static final _logger = Logger('$SearchScreen');
var searchTextController = TextEditingController();
@ -281,7 +282,7 @@ class _SearchScreenState extends State<SearchScreen> {
child: const Text('Load more results'),
);
}
return buildStatusListTile(statuses[index]);
return buildStatusListTile(profile, statuses[index]);
},
itemCount: statuses.length + 1,
);
@ -294,7 +295,7 @@ class _SearchScreenState extends State<SearchScreen> {
return ListView(physics: const AlwaysScrollableScrollPhysics(), children: [
if (searchResult.statuses.isNotEmpty)
buildStatusListTile(searchResult.statuses.first),
buildStatusListTile(profile, searchResult.statuses.first),
if (searchResult.accounts.isNotEmpty)
buildConnectionListTile(searchResult.accounts.first),
]);
@ -340,11 +341,11 @@ class _SearchScreenState extends State<SearchScreen> {
);
}
Widget buildStatusListTile(TimelineEntry status) {
Widget buildStatusListTile(Profile profile, TimelineEntry status) {
return SearchResultStatusControl(status, () async {
final result = await getIt<ActiveProfileSelector<EntryManagerService>>()
.activeEntry
.andThenAsync((em) async => em.refreshStatusChain(status.id));
final result = await ref
.read(timelineUpdaterProvider(profile).notifier)
.refreshStatusChain(status.id);
if (context.mounted) {
result.match(
onSuccess: (entry) =>

Wyświetl plik

@ -1,44 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:provider/provider.dart';
import '../controls/linear_status_indicator.dart';
import '../controls/standard_appbar.dart';
import '../controls/timeline/timeline_panel.dart';
import '../globals.dart';
import '../models/auth/profile.dart';
import '../models/timeline_identifiers.dart';
import '../riverpod_controllers/timeline_services.dart';
import '../services/auth_service.dart';
import '../services/network_status_service.dart';
import '../services/timeline_manager.dart';
import '../utils/active_profile_selector.dart';
class UserPostsScreen extends StatefulWidget {
class UserPostsScreen extends ConsumerStatefulWidget {
final String userId;
const UserPostsScreen({super.key, required this.userId});
@override
State<UserPostsScreen> createState() => _UserPostsScreenState();
ConsumerState<UserPostsScreen> createState() => _UserPostsScreenState();
}
class _UserPostsScreenState extends State<UserPostsScreen> {
class _UserPostsScreenState extends ConsumerState<UserPostsScreen> {
late final TimelineIdentifiers timeline;
@override
void initState() {
super.initState();
final profile = context.read<AccountsService>().currentProfile;
timeline = TimelineIdentifiers.profile(widget.userId);
getIt<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.andThenSuccess((m) => updateTimeline(m));
updateTimeline(profile);
}
void updateTimeline(TimelineManager manager) {
void updateTimeline(Profile profile) {
Future.delayed(const Duration(milliseconds: 100), () async {
await manager.updateTimeline(timeline, TimelineRefreshType.refresh);
await ref
.read(timelineManagerProvider(profile, timeline).notifier)
.updateTimeline(TimelineRefreshType.refresh);
});
}
@override
Widget build(BuildContext context) {
final nss = getIt<NetworkStatusService>();
final profile = context.watch<AccountsService>().currentProfile;
ref.watch(timelineManagerProvider(profile, timeline));
return Scaffold(
appBar: StandardAppBar.build(

Wyświetl plik

@ -1,650 +0,0 @@
import 'package:flutter/material.dart' hide Visibility;
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:result_monad/result_monad.dart';
import '../friendica_client/friendica_client.dart';
import '../friendica_client/paging_data.dart';
import '../globals.dart';
import '../models/auth/profile.dart';
import '../models/entry_tree_item.dart';
import '../models/exec_error.dart';
import '../models/image_entry.dart';
import '../models/media_attachment_uploads/new_entry_media_items.dart';
import '../models/timeline_entry.dart';
import '../models/timeline_identifiers.dart';
import '../models/visibility.dart';
import '../utils/active_profile_selector.dart';
import '../utils/media_upload_attachment_helper.dart';
import 'reshared_via_service.dart';
class EntryManagerService extends ChangeNotifier {
static final _logger = Logger('$EntryManagerService');
final _entries = <String, TimelineEntry>{};
final _parentPostIds = <String, String>{};
final _postNodes = <String, _Node>{};
final _postThreadHashtags = <String, Set<String>>{};
final _postTreeConnections = <String, Set<String>>{};
final Profile profile;
EntryManagerService(this.profile);
void clear() {
_entries.clear();
_parentPostIds.clear();
_postNodes.clear();
}
_Node? _getPostRootNode(String id) {
final fromPosts = _postNodes[id];
if (fromPosts != null) {
return fromPosts;
}
final parentId = _parentPostIds[id];
if (parentId == null) {
return null;
}
return _postNodes[parentId];
}
Result<EntryTreeItem, ExecError> getPostTreeEntryBy(String id) {
_logger.finest('Getting post: $id');
final idForCall = id;
final postNode = _getPostRootNode(idForCall);
if (postNode == null) {
return Result.error(ExecError(
type: ErrorType.notFound,
message: 'Unknown post id: $id',
));
}
return Result.ok(_nodeToTreeItem(postNode, profile.userId));
}
Result<List<String>, ExecError> getPostTreeHashtags(String id) {
final postId = _getPostRootNode(id)?.id ?? '';
if (postId.isEmpty) {
return buildErrorResult(
type: ErrorType.notFound,
message: 'Root Post ID not found for $id',
);
}
final hashtags = _postThreadHashtags[postId]?.toList() ?? [];
return Result.ok(hashtags);
}
Result<List<String>, ExecError> getPostTreeConnectionIds(String id) {
final postId = _getPostRootNode(id)?.id ?? '';
if (postId.isEmpty) {
return buildErrorResult(
type: ErrorType.notFound,
message: 'Root Post ID not found for $id',
);
}
final hashtags = _postTreeConnections[postId]?.toList() ?? [];
return Result.ok(hashtags);
}
Result<TimelineEntry, ExecError> getEntryById(String id) {
if (_entries.containsKey(id)) {
return Result.ok(_entries[id]!);
}
return Result.error(ExecError(
type: ErrorType.notFound,
message: 'Timeline entry not found: $id',
));
}
FutureResult<bool, ExecError> deleteEntryById(String id) async {
_logger.finest('Delete entry: $id');
final result = await StatusesClient(profile).deleteEntryById(id);
if (result.isFailure) {
return result.errorCast();
}
_cleanupEntriesForId(id);
notifyListeners();
return Result.ok(true);
}
FutureResult<bool, ExecError> createNewStatus(
String text, {
String spoilerText = '',
String inReplyToId = '',
required NewEntryMediaItems mediaItems,
required List<ImageEntry> existingMediaItems,
required Visibility visibility,
}) async {
_logger.finest('Creating new post: $text');
final mediaIds = existingMediaItems.map((m) => m.scales.first.id).toList();
for (final item in mediaItems.attachments) {
if (item.isExistingServerItem) {
continue;
}
final String extension = p.extension(item.localFilePath);
late final String filename;
if (item.remoteFilename.isEmpty) {
filename = p.basename(item.localFilePath);
} else {
if (item.remoteFilename
.toLowerCase()
.endsWith(extension.toLowerCase())) {
filename = item.remoteFilename;
} else {
filename = "${item.remoteFilename}$extension";
}
}
final uploadResult =
await MediaUploadAttachmentHelper.getUploadableImageBytes(
item.localFilePath,
).andThenAsync(
(imageBytes) async =>
await RemoteFileClient(profile).uploadFileAsAttachment(
bytes: imageBytes,
album: mediaItems.albumName,
description: item.description,
fileName: filename,
visibility: visibility,
),
);
if (uploadResult.isSuccess) {
mediaIds.add(uploadResult.value.scales.first.id);
} else {
return Result.error(ExecError(
type: ErrorType.localError,
message: 'Error uploading image: ${uploadResult.error}'));
}
}
final result = await StatusesClient(profile)
.createNewStatus(
text: text,
spoilerText: spoilerText,
inReplyToId: inReplyToId,
mediaIds: mediaIds,
visibility: visibility)
.andThenSuccessAsync((item) async {
await processNewItems([item], profile.username, null);
return item;
}).andThenSuccessAsync((item) async {
if (inReplyToId.isNotEmpty) {
late final String rootPostId;
if (_postNodes.containsKey(inReplyToId)) {
rootPostId = inReplyToId;
} else {
rootPostId = _parentPostIds[inReplyToId]!;
}
await refreshStatusChain(rootPostId);
}
return item;
});
return result.mapValue((status) {
_logger.finest('${status.id} status created');
return true;
}).mapError(
(error) {
_logger.finest('Error creating post: $error');
return ExecError(
type: ErrorType.localError,
message: error.toString(),
);
},
);
}
FutureResult<bool, ExecError> editStatus(
String id,
String text, {
String spoilerText = '',
required NewEntryMediaItems mediaItems,
required List<ImageEntry> existingMediaItems,
required Visibility newMediaItemVisibility,
}) async {
_logger.finest('Editing post: $text');
final idForCall = id;
final mediaIds = existingMediaItems
.map((m) => m.scales.isEmpty ? m.id : m.scales.first.id)
.toList();
for (final item in mediaItems.attachments) {
if (item.isExistingServerItem) {
continue;
}
final String extension = p.extension(item.localFilePath);
late final String filename;
if (item.remoteFilename.isEmpty) {
filename = p.basename(item.localFilePath);
} else {
if (item.remoteFilename
.toLowerCase()
.endsWith(extension.toLowerCase())) {
filename = item.remoteFilename;
} else {
filename = "${item.remoteFilename}$extension";
}
}
final uploadResult =
await MediaUploadAttachmentHelper.getUploadableImageBytes(
item.localFilePath,
).andThenAsync(
(imageBytes) async => await RemoteFileClient(profile)
.uploadFileAsAttachment(
bytes: imageBytes,
album: mediaItems.albumName,
description: item.description,
fileName: filename,
visibility: newMediaItemVisibility),
);
if (uploadResult.isSuccess) {
mediaIds.add(uploadResult.value.scales.first.id);
} else {
return Result.error(ExecError(
type: ErrorType.localError,
message: 'Error uploading image: ${uploadResult.error}'));
}
}
final result = await StatusesClient(profile)
.editStatus(
id: idForCall,
text: text,
spoilerText: spoilerText,
mediaIds: mediaIds)
.andThenSuccessAsync((item) async {
await processNewItems([item], profile.username, null);
return item;
}).andThenSuccessAsync((item) async {
final inReplyToId = item.parentId;
if (inReplyToId.isNotEmpty) {
late final String rootPostId;
if (_postNodes.containsKey(inReplyToId)) {
rootPostId = inReplyToId;
} else {
rootPostId = _parentPostIds[inReplyToId]!;
}
await refreshStatusChain(rootPostId);
}
return item;
});
return result.mapValue((status) {
_logger.finest('${status.id} status created');
return true;
}).mapError(
(error) {
_logger.finest('Error creating post: $error');
return ExecError(
type: ErrorType.localError,
message: error.toString(),
);
},
);
}
FutureResult<List<EntryTreeItem>, ExecError> updateTimeline(
TimelineIdentifiers type, int maxId, int sinceId) async {
_logger.fine(() => 'Updating timeline');
final client = TimelineClient(profile);
final itemsResult = await client.getTimeline(
type: type,
page: PagingData(
maxId: maxId > 0 ? maxId : null,
sinceId: sinceId > 0 ? sinceId : null,
),
);
if (itemsResult.isFailure) {
_logger.severe('Error getting timeline: ${itemsResult.error}');
return itemsResult.errorCast();
}
itemsResult.value.sort((t1, t2) => t1.id.compareTo(t2.id));
final updatedPosts =
await processNewItems(itemsResult.value, profile.userId, client);
_logger.finest(() {
final postCount = _entries.values.where((e) => e.parentId.isEmpty).length;
final commentCount = _entries.length - postCount;
final orphanCount = _entries.values
.where(
(e) => e.parentId.isNotEmpty && !_entries.containsKey(e.parentId))
.length;
return 'End of update # posts: $postCount, #comments: $commentCount, #orphans: $orphanCount';
});
return Result.ok(updatedPosts);
}
Future<List<EntryTreeItem>> processNewItems(
List<TimelineEntry> items,
String currentId,
FriendicaClient? client,
) async {
items.sort((i1, i2) => int.parse(i1.id).compareTo(int.parse(i2.id)));
final allSeenItems = [...items];
for (final item in items) {
_entries[item.id] = item;
}
final orphans = <TimelineEntry>[];
for (final item in items) {
if (item.parentId.isEmpty) {
continue;
}
final parent = _entries[item.parentId];
if (parent == null) {
orphans.add(item);
} else {
if (parent.parentId.isEmpty) {
_parentPostIds[item.id] = parent.id;
}
}
}
for (final o in orphans) {
await StatusesClient(profile)
.getPostOrComment(o.id, fullContext: true)
.andThenSuccessAsync((items) async {
final parentPostId = items.firstWhere((e) => e.parentId.isEmpty).id;
_parentPostIds[o.id] = parentPostId;
allSeenItems.addAll(items);
for (final item in items) {
_entries[item.id] = item;
_parentPostIds[item.id] = parentPostId;
}
});
}
allSeenItems.sort((i1, i2) {
if (i1.parentId.isEmpty && i2.parentId.isNotEmpty) {
return -1;
}
if (i2.parentId.isEmpty && i1.parentId.isNotEmpty) {
return 1;
}
return int.parse(i1.id).compareTo(int.parse(i2.id));
});
final postNodesToReturn = <_Node>{};
var lastCount = 0;
while (allSeenItems.isNotEmpty) {
final seenItemsCopy = [...allSeenItems];
for (final item in seenItemsCopy) {
if (item.parentId.isEmpty) {
final postNode =
_postNodes.putIfAbsent(item.id, () => _Node(item.id));
final pth = _postThreadHashtags.putIfAbsent(item.id, () => {});
final ptc = _postTreeConnections.putIfAbsent(item.id, () => {});
pth.addAll(item.tags);
ptc.add(item.authorId);
ptc.add(item.parentAuthorId);
postNodesToReturn.add(postNode);
allSeenItems.remove(item);
} else {
final parentParentPostId = _postNodes.containsKey(item.parentId)
? item.parentId
: _parentPostIds[item.parentId];
if (_postNodes[parentParentPostId] == null) {
_logger.severe(
'Error finding parent ${item.parentId} for entry ${item.id}');
continue;
}
final pth =
_postThreadHashtags.putIfAbsent(parentParentPostId!, () => {});
final ptc =
_postTreeConnections.putIfAbsent(parentParentPostId, () => {});
pth.addAll(item.tags);
ptc.add(item.authorId);
ptc.add(item.parentAuthorId);
final parentPostNode = _postNodes[parentParentPostId]!;
postNodesToReturn.add(parentPostNode);
_parentPostIds[item.id] = parentPostNode.id;
if (parentPostNode.getChildById(item.id) == null) {
final newNode = _Node(item.id);
final injectionNode = parentPostNode.id == item.parentId
? parentPostNode
: parentPostNode.getChildById(item.parentId);
if (injectionNode == null) {
continue;
} else {
injectionNode.addChild(newNode);
}
}
allSeenItems.remove(item);
}
}
if (allSeenItems.isNotEmpty && allSeenItems.length == lastCount) {
_logger.severe(
'Had ${allSeenItems.length} items left over after all iterations');
break;
}
lastCount = allSeenItems.length;
}
final updatedPosts = postNodesToReturn
.map((node) => _nodeToTreeItem(node, currentId))
.toList();
_logger.finest(
'Completed processing new items ${client == null ? 'sub level' : 'top level'}');
return updatedPosts;
}
FutureResult<EntryTreeItem, ExecError> refreshStatusChain(String id) async {
_logger.finest('Refreshing post: $id');
final client = StatusesClient(profile);
final result = await client
.getPostOrComment(id, fullContext: false)
.andThenAsync((rootItems) async => await client
.getPostOrComment(id, fullContext: true)
.andThenSuccessAsync(
(contextItems) async => [...rootItems, ...contextItems]))
.withResult((items) async {
_cleanupEntriesForId(id);
await processNewItems(items, client.profile.username, null);
});
return result.mapValue((_) {
_logger.finest('$id post updated');
return _nodeToTreeItem(_getPostRootNode(id)!, client.profile.userId);
}).mapError(
(error) {
_logger.finest('$id error updating: $error');
return ExecError(
type: ErrorType.localError,
message: error.toString(),
);
},
);
}
FutureResult<EntryTreeItem, ExecError> resharePost(String id) async {
_logger.finest('Resharing post: $id');
final resharedViaService = getIt<ActiveProfileSelector<ReshareViaService>>()
.getForProfile(profile)
.fold(
onSuccess: (s) => s,
onError: (error) {
_logger.severe('Error getting reshared via service: $error');
return null;
});
final client = StatusesClient(profile);
final idForCall = id;
final result =
await client.resharePost(idForCall).andThenSuccessAsync((item) async {
resharedViaService?.upsertResharedVia(postId: id, resharerId: profile.id);
await processNewItems([item], client.profile.username, null);
});
return result
.mapValue((_) {
_logger.finest('$id post updated after reshare');
return _nodeToTreeItem(_postNodes[id]!, client.profile.userId);
})
.withResult((_) => notifyListeners())
.mapError(
(error) {
_logger.finest('$id error updating: $error');
return ExecError(
type: ErrorType.localError,
message: error.toString(),
);
},
);
}
FutureResult<EntryTreeItem, ExecError> unResharePost(String id) async {
_logger.finest('Unresharing post: $id');
final resharedViaService = getIt<ActiveProfileSelector<ReshareViaService>>()
.getForProfile(profile)
.fold(
onSuccess: (s) => s,
onError: (error) {
_logger.severe('Error getting reshared via service: $error');
return null;
});
final client = StatusesClient(profile);
final idForCall = id;
final result =
await client.unResharePost(idForCall).andThenSuccessAsync((item) async {
resharedViaService?.upsertRemovedSharer(
postId: id, resharerId: profile.id);
await processNewItems([item], client.profile.username, null);
});
if (result.isFailure) {
return Result.error(result.error);
}
return result
.mapValue((_) {
_logger.finest('$id post updated after reshare');
return _nodeToTreeItem(_postNodes[id]!, client.profile.userId);
})
.withResult((_) => notifyListeners())
.mapError(
(error) {
_logger.finest('$id error updating: $error');
return ExecError(
type: ErrorType.localError,
message: error.toString(),
);
},
);
}
FutureResult<EntryTreeItem, ExecError> toggleFavorited(
String id, bool newStatus) async {
final interactionClient = InteractionsClient(profile);
final postsClient = StatusesClient(profile);
final idForCall = id;
final result =
await interactionClient.changeFavoriteStatus(idForCall, newStatus);
if (result.isFailure) {
return result.errorCast();
}
final updateResult =
await postsClient.getPostOrComment(id, fullContext: false);
if (updateResult.isFailure) {
return updateResult.errorCast();
}
final update = updateResult.value.first;
_entries[update.id] = update;
final node = update.parentId.isEmpty
? _postNodes[update.id]!
: _postNodes[_parentPostIds[update.id]]!;
notifyListeners();
return Result.ok(_nodeToTreeItem(node, interactionClient.profile.userId));
}
EntryTreeItem _nodeToTreeItem(_Node node, String currentId) {
final childenEntries = <String, EntryTreeItem>{};
for (final c in node.children) {
childenEntries[c.id] = _nodeToTreeItem(c, currentId);
}
final entry = _entries[node.id]!;
final isMine = entry.authorId == currentId;
return EntryTreeItem(
_entries[node.id]!,
isMine: isMine,
initialChildren: childenEntries,
);
}
void _cleanupEntriesForId(String id) {
if (_parentPostIds.containsKey(id)) {
final parentPostId = _parentPostIds.remove(id);
final parentPostNode = _postNodes[parentPostId];
parentPostNode?.removeChildById(id);
}
if (_entries.containsKey(id)) {
_entries.remove(id);
}
if (_postNodes.containsKey(id)) {
_postNodes.remove(id);
}
}
}
class _Node {
final String id;
final _children = <String, _Node>{};
List<_Node> get children => _children.values.toList();
_Node(this.id, {Map<String, _Node>? initialChildren}) {
if (initialChildren != null) {
_children.addAll(initialChildren);
}
}
void addChild(_Node node) {
_children[node.id] = node;
}
void removeChildById(String id) {
if (_children.containsKey(id)) {
_children.remove(id);
}
for (final c in _children.values) {
c.removeChildById(id);
}
}
_Node? getChildById(String id) {
if (_children.containsKey(id)) {
return _children[id]!;
}
for (final c in _children.values) {
final result = c.getChildById(id);
if (result != null) {
return result;
}
}
return null;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _Node && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}

Wyświetl plik

@ -1,216 +0,0 @@
import 'package:flutter/material.dart' hide Visibility;
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart';
import '../data/interfaces/circles_repo_intf.dart';
import '../models/auth/profile.dart';
import '../models/entry_tree_item.dart';
import '../models/exec_error.dart';
import '../models/image_entry.dart';
import '../models/media_attachment_uploads/new_entry_media_items.dart';
import '../models/timeline.dart';
import '../models/timeline_entry.dart';
import '../models/timeline_identifiers.dart';
import '../models/visibility.dart';
import 'entry_manager_service.dart';
enum TimelineRefreshType {
refresh,
loadOlder,
loadNewer,
}
class TimelineManager extends ChangeNotifier {
static final _logger = Logger('$TimelineManager');
final ICirclesRepo circlesRepo;
final EntryManagerService entryManagerService;
var circlesNotInitialized = true;
final Profile profile;
final cachedTimelines = <TimelineIdentifiers, Timeline>{};
TimelineManager(this.profile, this.circlesRepo, this.entryManagerService);
void clear() {
circlesNotInitialized = true;
cachedTimelines.clear();
entryManagerService.clear();
circlesRepo.clear();
notifyListeners();
}
FutureResult<bool, ExecError> createNewStatus(
String text, {
String spoilerText = '',
String inReplyToId = '',
required NewEntryMediaItems newMediaItems,
required List<ImageEntry> existingMediaItems,
required Visibility visibility,
}) async {
final result = await entryManagerService.createNewStatus(
text,
spoilerText: spoilerText,
inReplyToId: inReplyToId,
mediaItems: newMediaItems,
existingMediaItems: existingMediaItems,
visibility: visibility,
);
if (result.isSuccess) {
_logger.finest('Notifying listeners of new status created');
notifyListeners();
}
return result;
}
FutureResult<bool, ExecError> editStatus(
String id,
String text, {
String spoilerText = '',
String inReplyToId = '',
required NewEntryMediaItems newMediaItems,
required List<ImageEntry> existingMediaItems,
required Visibility newMediaItemVisibility,
}) async {
final result = await entryManagerService.editStatus(id, text,
spoilerText: spoilerText,
mediaItems: newMediaItems,
existingMediaItems: existingMediaItems,
newMediaItemVisibility: newMediaItemVisibility);
if (result.isSuccess) {
_logger.finest('Notifying listeners of updated status');
notifyListeners();
}
return result;
}
Result<TimelineEntry, ExecError> getEntryById(String id) {
_logger.finest('Getting entry for $id');
return entryManagerService.getEntryById(id);
}
FutureResult<bool, ExecError> deleteEntryById(String id) async {
_logger.finest('Delete entry for $id');
final result = await entryManagerService.deleteEntryById(id);
if (result.isSuccess) {
for (final t in cachedTimelines.values) {
t.removeTimelineEntry(id);
}
}
notifyListeners();
return result;
}
Result<EntryTreeItem, ExecError> getPostTreeEntryBy(String id) {
_logger.finest('Getting post for $id');
return entryManagerService.getPostTreeEntryBy(id);
}
// refresh timeline gets statuses newer than the newest in that timeline
List<EntryTreeItem> getTimeline(TimelineIdentifiers type) {
_logger.finest('Getting timeline $type');
return cachedTimelines.putIfAbsent(type, () => Timeline(type)).posts;
}
///
/// id is the id of a post or comment in the chain, including the original post.
FutureResult<EntryTreeItem, ExecError> refreshStatusChain(String id) async {
_logger.finest('Refreshing post $id');
final result = await entryManagerService.refreshStatusChain(id);
if (result.isSuccess) {
for (final t in cachedTimelines.values) {
t.addOrUpdate([result.value]);
}
notifyListeners();
}
return result;
}
FutureResult<EntryTreeItem, ExecError> resharePost(String id) async {
final result = await entryManagerService.resharePost(id);
final empty = EntryTreeItem.empty();
if (result.isSuccess) {
for (final t in cachedTimelines.values) {
if (t.posts.firstWhere((p) => p.id == id, orElse: () => empty) !=
empty) {
t.addOrUpdate([result.value]);
}
}
notifyListeners();
}
return result;
}
FutureResult<EntryTreeItem, ExecError> unResharePost(String id) async {
final result = await entryManagerService.unResharePost(id);
final empty = EntryTreeItem.empty();
if (result.isSuccess) {
for (final t in cachedTimelines.values) {
if (t.posts.firstWhere((p) => p.id == id, orElse: () => empty) !=
empty) {
t.addOrUpdate([result.value]);
}
}
notifyListeners();
}
return result;
}
Future<void> updateTimeline(
TimelineIdentifiers type,
TimelineRefreshType refreshType,
) async {
_logger.finest('Updating w/$refreshType for timeline $type ');
final timeline = cachedTimelines.putIfAbsent(type, () => Timeline(type));
late final int lowestId;
late final int highestId;
switch (refreshType) {
case TimelineRefreshType.refresh:
timeline.clear();
lowestId = 0;
highestId = 0;
break;
case TimelineRefreshType.loadOlder:
lowestId = timeline.lowestStatusId;
highestId = 0;
break;
case TimelineRefreshType.loadNewer:
lowestId = 0;
highestId = timeline.highestStatusId;
break;
}
(await entryManagerService.updateTimeline(type, lowestId, highestId)).match(
onSuccess: (posts) {
_logger.finest('Posts returned for adding to $type: ${posts.length}');
timeline.addOrUpdate(posts);
notifyListeners();
}, onError: (error) {
_logger.severe('Error getting timeline: $type}');
});
}
FutureResult<EntryTreeItem, ExecError> toggleFavorited(
String id, bool newStatus) async {
_logger.finer('Attempting toggling favorite $id to $newStatus');
final result = await entryManagerService.toggleFavorited(id, newStatus);
if (result.isFailure) {
_logger.info('Error toggling favorite $id: ${result.error}');
return result;
}
final update = result.value;
for (final timeline in cachedTimelines.values) {
update.entry.parentId.isEmpty
? timeline.addOrUpdate([update])
: timeline.tryUpdateComment(update);
}
notifyListeners();
return result;
}
// Should put backing store on timelines and entity manager so can recover from restart faster
// Have a purge caches button to start that over from scratch
// Should have a contacts manager with backing store as well
// If our own has delete
}

Wyświetl plik

@ -1,14 +1,27 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/auth/profile.dart';
import '../models/entry_tree_item.dart';
import '../models/flattened_tree_item.dart';
import '../models/timeline_entry.dart';
import '../riverpod_controllers/entry_tree_item_services.dart';
import '../riverpod_controllers/timeline_entry_services.dart';
extension FlatteningExtensions on EntryTreeItem {
static const baseLevel = 0;
List<FlattenedTreeItem> flatten(
{int level = baseLevel, bool topLevelOnly = false}) {
{int level = baseLevel,
bool topLevelOnly = false,
required Profile profile,
required WidgetRef ref}) {
final items = <FlattenedTreeItem>[];
// TODO handle if entries aren't in manager
final entryForItem =
ref.read(timelineEntryManagerProvider(profile, id)).value;
final myEntry = FlattenedTreeItem(
timelineEntry: entry,
timelineEntry: entryForItem,
isMine: isMine,
level: level,
);
@ -18,16 +31,19 @@ extension FlatteningExtensions on EntryTreeItem {
return items;
}
final sortedChildren = [...children];
sortedChildren.sort((c1, c2) =>
c1.entry.creationTimestamp.compareTo(c2.entry.creationTimestamp));
final sortedChildren = children.map((id) {
final tree = ref.read(entryTreeManagerProvider(profile, id)).value;
final entry = ref.read(timelineEntryManagerProvider(profile, id)).value;
return _EntryTreeItemWithEntity(entry, tree);
}).toList();
for (final child in sortedChildren) {
int childLevel = level + 1;
if (child.entry.authorId == entry.authorId && level != baseLevel) {
if (child.entry.authorId == entryForItem.authorId && level != baseLevel) {
childLevel = level;
}
final childItems = child.flatten(level: childLevel);
final childItems =
child.tree.flatten(level: childLevel, profile: profile, ref: ref);
childItems.sort((c1, c2) {
if (c2.level == c1.level) {
return c1.timelineEntry.creationTimestamp
@ -51,3 +67,10 @@ extension FlatteningExtensions on EntryTreeItem {
return items;
}
}
class _EntryTreeItemWithEntity {
final TimelineEntry entry;
final EntryTreeItem tree;
_EntryTreeItemWithEntity(this.entry, this.tree);
}

Wyświetl plik

@ -1,271 +1,268 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relatica/globals.dart';
import 'package:relatica/models/entry_tree_item.dart';
import 'package:relatica/models/timeline_entry.dart';
import 'package:relatica/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]),
);
});
});
//TODO Maketests work with Riverpod
// group('Flattening Tests', () {
// test('Single entry no children', () {
// final entry = TimelineEntry.randomBuilt();
// final treeItem = EntryTreeItem(entry.id);
// 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]),
// );
// });
// });
}