Initial timeline flows working

This commit is contained in:
Hank Grabowski 2022-11-17 11:04:14 -05:00
parent f647b68881
commit bac580935c
13 changed files with 656 additions and 132 deletions

View file

@ -1,14 +1,17 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart';
import 'models/TimelineIdentifiers.dart';
import 'models/credentials.dart';
import 'models/exec_error.dart';
import 'models/timeline_entry.dart';
import 'serializers/mastodon/timeline_entry_mastodon_extensions.dart';
class FriendicaClient {
static final _logger = Logger('$FriendicaClient');
final Credentials _credentials;
late final String _authHeader;
@ -26,6 +29,7 @@ class FriendicaClient {
FutureResult<List<TimelineEntry>, ExecError> getUserTimeline(
{String userId = '', int page = 1, int count = 10}) async {
_logger.finest(() => 'Getting user timeline for $userId');
final baseUrl = 'https://$serverName/api/statuses/user_timeline?';
final pagingData = 'count=$count&page=$page';
final url = userId.isEmpty
@ -39,14 +43,14 @@ class FriendicaClient {
.mapError((error) => error as ExecError);
}
FutureResult<List<TimelineEntry>, ExecError> getHomeTimeline(
{int page = 1, int count = 10}) async {
//
//final baseUrl = 'https://$serverName/api/statuses/home_timeline';
final baseUrl = 'https://$serverName/api/v1/timelines/public?limit=2';
final pagingData = '';
// final pagingData = 'count=$count&page=$page';
final url = '$baseUrl$pagingData';
FutureResult<List<TimelineEntry>, ExecError> getTimeline(
{required TimelineIdentifiers type, int limit = 20}) async {
_logger.finest(() => 'Getting home timeline limit $limit');
final String timelinePath = _typeToTimelinePath(type);
final String timelineQPs = _typeToTimelineQueryParameters(type);
final baseUrl = 'https://$serverName/api/v1/timelines/$timelinePath';
final pagingData = 'limit=$limit';
final url = '$baseUrl?$pagingData&$timelineQPs';
final request = Uri.parse(url);
return (await _getApiListRequest(request).andThenSuccessAsync(
(postsJson) async => postsJson
@ -55,23 +59,103 @@ class FriendicaClient {
.mapError((error) => error as ExecError);
}
FutureResult<List<TimelineEntry>, ExecError> getPostOrComment(String id,
{bool fullContext = false}) async {
_logger.finest(
() => 'Getting entry for status $id, full context? $fullContext');
return (await runCatchingAsync(() async {
final baseUrl = 'https://$serverName/api/v1/statuses/$id';
final url = fullContext ? '$baseUrl/context' : baseUrl;
final request = Uri.parse(url);
return (await _getApiRequest(request).andThenSuccessAsync((json) async {
if (fullContext) {
final ancestors = json['ancestors'] as List<dynamic>;
final descendants = json['descendants'] as List<dynamic>;
final items = [
...ancestors
.map((a) => TimelineEntryMastodonExtensions.fromJson(a)),
...descendants
.map((d) => TimelineEntryMastodonExtensions.fromJson(d))
];
return items;
} else {
return [TimelineEntryMastodonExtensions.fromJson(json)];
}
}));
}))
.mapError((error) => ExecError(
type: ErrorType.parsingError,
message: error.toString(),
));
}
FutureResult<TimelineEntry, ExecError> createNewPost(String text) async {
_logger.finest(() => 'Creating post');
final url = Uri.parse('https://$serverName/api/v1/statuses');
final body = {'status': text, 'spoiler_text': 'For Testing Only'};
print(body);
final result = await _postUrl(url, body);
if (result.isFailure) {
return result.errorCast();
}
final responseText = result.value;
return runCatching<TimelineEntry>(() {
final json = jsonDecode(responseText);
return Result.ok(TimelineEntryMastodonExtensions.fromJson(json));
}).mapError((error) {
return ExecError(type: ErrorType.parsingError, message: error.toString());
});
}
FutureResult<String, ExecError> getMyProfile() async {
_logger.finest(() => 'Getting logged in user profile');
final request = Uri.parse('https://$serverName/api/friendica/profile/show');
return (await _getApiRequest(request))
.mapValue((value) => value.toString());
}
FutureResult<HttpClientResponse, ExecError> getUrl(Uri url) async {
FutureResult<String, ExecError> _getUrl(Uri url) async {
try {
final request = await HttpClient().getUrl(url);
request.headers.add('authorization', _authHeader);
final response = await request.close();
final response = await http.get(
url,
headers: {
'Authorization': _authHeader,
'Content-Type': 'application/json; charset=UTF-8'
},
);
if (response.statusCode != 200) {
return Result.error(ExecError(
type: ErrorType.authentication,
message: '${response.statusCode}: ${response.reasonPhrase}'));
}
return Result.ok(response);
return Result.ok(response.body);
} catch (e) {
return Result.error(
ExecError(type: ErrorType.localError, message: e.toString()));
}
}
FutureResult<String, ExecError> _postUrl(
Uri url, Map<String, dynamic> body) async {
try {
final response = await http.post(
url,
headers: {
'Authorization': _authHeader,
'Content-Type': 'application/json; charset=UTF-8'
},
body: jsonEncode(body),
);
if (response.statusCode != 200) {
return Result.error(ExecError(
type: ErrorType.authentication,
message: '${response.statusCode}: ${response.reasonPhrase}'));
}
return Result.ok(response.body);
} catch (e) {
return Result.error(
ExecError(type: ErrorType.localError, message: e.toString()));
@ -79,19 +163,43 @@ class FriendicaClient {
}
FutureResult<List<dynamic>, ExecError> _getApiListRequest(Uri url) async {
return (await getUrl(url)
.andThenSuccessAsync((response) async =>
await response.transform(utf8.decoder).join(''))
.andThenSuccessAsync(
(jsonText) async => jsonDecode(jsonText) as List<dynamic>))
return (await _getUrl(url).andThenSuccessAsync(
(jsonText) async => jsonDecode(jsonText) as List<dynamic>))
.mapError((error) => error as ExecError);
}
FutureResult<dynamic, ExecError> _getApiRequest(Uri url) async {
return (await getUrl(url)
.andThenSuccessAsync((response) async =>
await response.transform(utf8.decoder).join(''))
return (await _getUrl(url)
.andThenSuccessAsync((jsonText) async => jsonDecode(jsonText)))
.mapError((error) => error as ExecError);
}
String _typeToTimelinePath(TimelineIdentifiers type) {
switch (type.timeline) {
case TimelineType.home:
return 'home';
case TimelineType.global:
return 'public';
case TimelineType.local:
return 'public';
case TimelineType.tag:
case TimelineType.profile:
case TimelineType.self:
throw UnimplementedError('These types are not supported yet');
}
}
String _typeToTimelineQueryParameters(TimelineIdentifiers type) {
switch (type.timeline) {
case TimelineType.home:
case TimelineType.global:
return '';
case TimelineType.local:
return 'local=true';
case TimelineType.tag:
case TimelineType.profile:
case TimelineType.self:
throw UnimplementedError('These types are not supported yet');
}
}
}

View file

@ -1,20 +1,34 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:result_monad/result_monad.dart';
import 'globals.dart';
import 'models/TimelineIdentifiers.dart';
import 'routes.dart';
import 'screens/sign_in.dart';
import 'services/auth_service.dart';
import 'services/entry_manager_service.dart';
import 'services/secrets_service.dart';
import 'services/timeline_manager.dart';
import 'utils/app_scrolling_behavior.dart';
void main() async {
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((event) {
final logName = event.loggerName.isEmpty ? 'ROOT' : event.loggerName;
final msg =
'${event.level.name} - $logName @ ${event.time}: ${event.message}';
print(msg);
});
WidgetsFlutterBinding.ensureInitialized();
final authService = AuthService();
final secretsService = SecretsService();
final entryManagerService = EntryManagerService();
getIt.registerSingleton<EntryManagerService>(entryManagerService);
getIt.registerSingleton<SecretsService>(secretsService);
getIt.registerSingleton<AuthService>(authService);
getIt.registerLazySingleton<TimelineManager>(() => TimelineManager());
await secretsService.initialize().andThenSuccessAsync((credentials) async {
if (credentials.isEmpty) {
return;
@ -24,6 +38,9 @@ void main() async {
if (wasLoggedIn) {
final result = await authService.signIn(credentials);
print('Startup login result: $result');
if (result.isSuccess) {
await entryManagerService.updateTimeline(TimelineIdentifiers.home());
}
} else {
print('Was not logged in');
}
@ -43,13 +60,21 @@ class App extends StatelessWidget {
ChangeNotifierProvider<AuthService>(
create: (_) => getIt<AuthService>(),
lazy: true,
)
),
ChangeNotifierProvider<EntryManagerService>(
create: (_) => getIt<EntryManagerService>(),
lazy: true,
),
ChangeNotifierProvider<TimelineManager>(
create: (_) => getIt<TimelineManager>(),
),
],
child: MaterialApp.router(
theme: ThemeData(
primarySwatch: Colors.indigo,
),
debugShowCheckedModeBanner: false,
scrollBehavior: AppScrollingBehavior(),
routerDelegate: appRouter.routerDelegate,
routeInformationProvider: appRouter.routeInformationProvider,
routeInformationParser: appRouter.routeInformationParser,

View file

@ -0,0 +1,38 @@
enum TimelineType {
home,
global,
local,
tag,
profile,
self,
}
class TimelineIdentifiers {
final TimelineType timeline;
final String auxData;
TimelineIdentifiers({required this.timeline, this.auxData = ''});
String toHumanKey() {
return auxData.isEmpty ? timeline.name : '${timeline.name}_$auxData';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TimelineIdentifiers &&
runtimeType == other.runtimeType &&
timeline == other.timeline;
@override
int get hashCode => timeline.hashCode;
factory TimelineIdentifiers.home() =>
TimelineIdentifiers(timeline: TimelineType.home);
@override
String toString() {
return auxData.isEmpty
? 'TimelineIdentifiers{timeline: $timeline)'
: 'TimelineIdentifiers{timeline: $timeline, auxData: $auxData}';
}
}

View file

@ -0,0 +1,38 @@
import 'timeline_entry.dart';
class EntryTreeItem {
final TimelineEntry entry;
final bool isMine;
bool isOrphaned;
final _children = <String, EntryTreeItem>{};
EntryTreeItem(this.entry, {this.isMine = true, this.isOrphaned = false});
String get id => entry.id;
void addChild(EntryTreeItem child) {
_children[child.id] = child;
}
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);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is EntryTreeItem &&
runtimeType == other.runtimeType &&
entry == other.entry;
@override
int get hashCode => entry.hashCode;
}

View file

@ -14,5 +14,6 @@ enum ErrorType {
authentication,
localError,
missingEndpoint,
notFound,
parsingError,
}

52
lib/models/timeline.dart Normal file
View file

@ -0,0 +1,52 @@
import 'TimelineIdentifiers.dart';
import 'entry_tree_item.dart';
class Timeline {
final TimelineIdentifiers id;
final List<EntryTreeItem> _posts = [];
final Set<EntryTreeItem> _postsSet = {};
int _lowestStatusId = 0;
int _highestStatusId = 0;
Timeline(this.id, {List<EntryTreeItem>? initialPosts}) {
if (initialPosts != null) {
addPosts(initialPosts);
}
}
List<EntryTreeItem> get posts => List.unmodifiable(_posts);
void addPosts(List<EntryTreeItem> newPosts) {
for (final p in newPosts) {
final id = int.parse(p.id);
if (_lowestStatusId > id) {
_lowestStatusId = id;
}
if (_highestStatusId < id) {
_highestStatusId = id;
}
_postsSet.add(p);
}
_posts.clear();
_posts.addAll(_postsSet);
_posts.sort((p1, p2) {
return p2.entry.backdatedTimestamp.compareTo(p1.entry.backdatedTimestamp);
});
}
void clear() {
_posts.clear();
_postsSet.clear();
_lowestStatusId = 0;
_highestStatusId = 0;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Timeline && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}

View file

@ -135,4 +135,14 @@ class TimelineEntry {
String toString() {
return 'TimelineEntry{id: $id, isReshare: $isReshare, parentId: $parentId, creationTimestamp: $creationTimestamp, modificationTimestamp: $modificationTimestamp, backdatedTimeStamp: $backdatedTimestamp, post: $body, title: $title, author: $author, parentAuthor: $parentAuthor externalLink:$externalLink}';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TimelineEntry &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
}

View file

@ -1,6 +1,7 @@
import 'package:go_router/go_router.dart';
import 'globals.dart';
import 'screens/editor.dart';
import 'screens/home.dart';
import 'screens/sign_in.dart';
import 'screens/splash.dart';
@ -58,4 +59,25 @@ final appRouter = GoRouter(
name: ScreenPaths.splash,
builder: (context, state) => SplashScreen(),
),
GoRoute(
path: '/post',
redirect: (context, state) {
print('post state redirect');
if (state.location == '/post') {
return '/post/new';
}
return null;
},
routes: [
GoRoute(
path: 'new',
builder: (context, state) => EditorScreen(),
),
GoRoute(
path: 'edit/:id',
builder: (context, state) =>
EditorScreen(id: state.params['id'] ?? 'Not Found'),
),
])
]);

73
lib/screens/editor.dart Normal file
View file

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../routes.dart';
import '../services/auth_service.dart';
import '../utils/snackbar_builder.dart';
class EditorScreen extends StatefulWidget {
final String id;
const EditorScreen({super.key, this.id = ''});
@override
State<EditorScreen> createState() => _EditorScreenState();
}
class _EditorScreenState extends State<EditorScreen> {
final controller = TextEditingController();
@override
Widget build(BuildContext context) {
final clientResult = context.read<AuthService>().currentClient;
if (clientResult.isFailure) {
context.goNamed(ScreenPaths.home);
}
final client = clientResult.value;
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(widget.id.isEmpty ? 'New Post' : 'Edit Post'),
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
TextFormField(
maxLines: 10,
controller: controller,
),
ElevatedButton(
onPressed: () async {
if (controller.text.isEmpty) {
buildSnackbar(
context, "Can't submit an empty post/comment");
return;
}
final result = await client.createNewPost(controller.text);
if (result.isFailure) {
buildSnackbar(context, 'Error posting: ${result.error}');
return;
}
context.pop();
},
child: Text('Submit'),
),
ElevatedButton(
onPressed: () {
context.pop();
},
child: Text('Cancel'),
),
],
),
),
),
);
}
}

View file

@ -1,133 +1,118 @@
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.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 '../controls/padding.dart';
import '../friendica_client.dart';
import '../globals.dart';
import '../models/exec_error.dart';
import '../models/timeline_entry.dart';
import '../services/auth_service.dart';
import '../models/TimelineIdentifiers.dart';
import '../services/timeline_manager.dart';
class HomeScreen extends StatefulWidget {
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _logger = Logger('$HomeScreen');
class HomeScreen extends StatelessWidget {
final postText = TextEditingController();
var currentType = TimelineType.home;
final types = [
TimelineType.home,
TimelineType.global,
TimelineType.local,
];
@override
Widget build(BuildContext context) {
final clientResult = context.read<AuthService>().currentClient;
final body = clientResult.fold(onSuccess: (client) {
return Column(
children: [
TextFormField(
controller: postText,
maxLines: 4,
decoration: InputDecoration(
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).backgroundColor,
),
borderRadius: BorderRadius.circular(5.0),
),
),
),
const VerticalPadding(),
ElevatedButton(onPressed: null, child: const Text('Post')),
const VerticalPadding(),
Expanded(child: buildTimelineComponent(context, client))
],
);
}, onError: (error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error getting client: $error '),
],
),
);
});
_logger.finest('Build');
final tm = context.watch<TimelineManager>();
return Scaffold(
appBar: AppBar(
title: Text('Home'),
actions: [
IconButton(
onPressed: () {
getIt<AuthService>().signOut();
context.push('/post/new');
},
icon: Icon(Icons.logout),
)
icon: Icon(Icons.add),
),
],
),
body: Column(
children: [
DropdownButton<TimelineType>(
value: currentType,
items: types
.map((e) => DropdownMenuItem<TimelineType>(
value: e,
child: Text(e.name),
))
.toList(),
onChanged: (value) {
setState(() {
currentType = value!;
});
}),
Expanded(child: buildTimelineComponent(context, tm))
],
),
body: body,
);
}
Widget buildTimelineComponent(BuildContext context, FriendicaClient client) {
return FutureBuilder<Result<List<TimelineEntry>, ExecError>>(
future: client.getHomeTimeline(page: 1, count: 50),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return Text('Loading');
}
if (snapshot.hasError) {
return Text('Got an error: ${snapshot.error}');
}
if (snapshot.data == null) {
return Text('Got null data');
}
final result = snapshot.data!;
if (result.isFailure) {
return Text('Got an error: ${result.error}');
}
final items = result.value;
return ListView.separated(
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
subtitle: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HtmlWidget(
item.body,
onTapUrl: (url) async {
print(url);
return true;
},
onTapImage: (imageMetadata) {
print(imageMetadata);
},
),
if (item.links.isNotEmpty)
Text('Preview: ${item.links.first.url}'),
if (item.mediaAttachments.isNotEmpty)
...item.mediaAttachments
.map((a) => Text('Media: ${a.uri}')),
Text(
'Engagement -- Likes: ${item.likes.length}, Dislikes: ${item.dislikes.length}, ')
],
Widget buildTimelineComponent(BuildContext context, TimelineManager manager) {
final result =
manager.getTimeline(TimelineIdentifiers(timeline: currentType));
if (result.isFailure) {
return Center(child: Text('Error getting timeline: ${result.error}'));
}
final items = result.value;
return RefreshIndicator(
onRefresh: () async {
await manager.refreshTimeline(TimelineIdentifiers.home());
},
child: ListView.separated(
itemBuilder: (context, index) {
final item = items[index];
final entry = item.entry;
return ListTile(
subtitle: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HtmlWidget(
item.entry.body,
onTapUrl: (url) async {
print(url);
return true;
},
onTapImage: (imageMetadata) {
print(imageMetadata);
},
),
),
//trailing: Text(item.parentId),
title: Text(
'${item.id} for ${item.author} for post ${item.parentId}'),
trailing: Text(DateTime.fromMillisecondsSinceEpoch(
item.creationTimestamp * 1000)
.toIso8601String()),
);
},
separatorBuilder: (context, index) => Divider(),
itemCount: items.length,
if (entry.links.isNotEmpty)
Text('Preview: ${entry.links.first.url}'),
if (entry.mediaAttachments.isNotEmpty)
...entry.mediaAttachments
.map((a) => Text('Media: ${a.uri}')),
Text(
'Engagement -- Likes: ${entry.likes.length}, Dislikes: ${entry.dislikes.length}, Comments:${item.totalChildren} ')
],
),
),
//trailing: Text(item.parentId),
title: Text(
'${entry.id} for ${item.isMine ? 'Me' : entry.author} for post ${entry.parentId}'),
trailing: Text(DateTime.fromMillisecondsSinceEpoch(
entry.creationTimestamp * 1000)
.toIso8601String()),
);
});
},
separatorBuilder: (context, index) => Divider(),
itemCount: items.length,
),
);
}
}

View file

@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart';
import '../friendica_client.dart';
import '../globals.dart';
import '../models/TimelineIdentifiers.dart';
import '../models/entry_tree_item.dart';
import '../models/exec_error.dart';
import '../models/timeline_entry.dart';
import 'auth_service.dart';
class EntryManagerService extends ChangeNotifier {
static final _logger = Logger('$EntryManagerService');
final Map<String, EntryTreeItem> _posts = {};
final List<EntryTreeItem> _orphanedComments = [];
final Map<String, EntryTreeItem> _allComments = {};
FutureResult<List<EntryTreeItem>, ExecError> updateTimeline(
TimelineIdentifiers type) async {
_logger.fine(() => 'Updating timeline');
final auth = getIt<AuthService>();
final clientResult = auth.currentClient;
if (clientResult.isFailure) {
_logger.severe('Error getting Friendica client: ${clientResult.error}');
return clientResult.errorCast();
}
final client = clientResult.value;
final itemsResult = await client.getTimeline(type: type);
final myHandle = client.credentials.username;
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, myHandle, client);
_orphanedComments.removeWhere((element) => !element.isOrphaned);
_logger.finest(() =>
'End of update # posts: ${_posts.length}, #comments: ${_allComments.length}, #orphans: ${_orphanedComments.length}');
notifyListeners();
return Result.ok(updatedPosts);
}
Future<List<EntryTreeItem>> processNewItems(
List<TimelineEntry> items,
String myHandle,
FriendicaClient? client,
) async {
final updatedPosts = <EntryTreeItem>[];
for (final t in items) {
late EntryTreeItem entry;
final isMine = t.author == myHandle;
if (t.parentAuthor.isEmpty) {
entry = _posts.putIfAbsent(
t.id, () => EntryTreeItem(t, isMine: isMine, isOrphaned: false));
updatedPosts.add(entry);
} else {
final parent = _posts[t.parentId];
bool newEntry = false;
entry = _allComments.putIfAbsent(t.id, () {
newEntry = true;
return EntryTreeItem(t, isMine: isMine, isOrphaned: parent == null);
});
parent?.addChild(entry);
if (newEntry && entry.isOrphaned) {
_orphanedComments.add(entry);
}
}
}
final orphansToProcess = [];
for (final c in _orphanedComments) {
final post = _posts[c.entry.parentId];
if (post != null) {
c.isOrphaned = false;
post.addChild(c);
continue;
}
final comment = _allComments[c.entry.parentId];
if (comment != null) {
c.isOrphaned = false;
comment.addChild(c);
continue;
}
if (client != null) {
orphansToProcess.add(c);
}
}
for (final o in orphansToProcess) {
await client
?.getPostOrComment(o.id, fullContext: true)
.andThenSuccessAsync((items) async {
final moreUpdatedPosts = await processNewItems(items, myHandle, null);
updatedPosts.addAll(moreUpdatedPosts);
});
}
_logger.finest(
'Completed processing new items ${client == null ? 'sub level' : 'top level'}');
return updatedPosts;
}
}

View file

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart';
import '../globals.dart';
import '../models/TimelineIdentifiers.dart';
import '../models/entry_tree_item.dart';
import '../models/exec_error.dart';
import '../models/timeline.dart';
import 'entry_manager_service.dart';
class TimelineManager extends ChangeNotifier {
static final _logger = Logger('$TimelineManager');
final cachedTimelines = <TimelineIdentifiers, Timeline>{};
// refresh timeline gets statuses newer than the newest in that timeline
Result<List<EntryTreeItem>, ExecError> getTimeline(TimelineIdentifiers type) {
final posts = cachedTimelines[type]?.posts;
if (posts != null) {
return Result.ok(posts);
}
refreshTimeline(type);
return Result.ok([]);
}
Future<void> refreshTimeline(TimelineIdentifiers type) async {
(await getIt<EntryManagerService>().updateTimeline(type)).match(
onSuccess: (posts) {
final timeline = cachedTimelines.putIfAbsent(type, () => Timeline(type));
timeline.addPosts(posts);
notifyListeners();
}, onError: (error) {
_logger.severe('Error getting timeline: $type}');
});
notifyListeners();
}
// All statuses get dumped into the entity mangager and get full assembled posts out of it
// Timeline keeps track of posts level only so can query timeline manager for those
// 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
// Timeline view is new control that knows how to load timeline, scrolling around with refresh and get more
// Timeline Item view displays itself and children
// Has "Add Comment" value
// Has like/dislke
// Has reshare/quote reshare (if can get that working somehow)
// If our own has delete
}

View file

@ -0,0 +1,12 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class AppScrollingBehavior extends MaterialScrollBehavior {
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
};
}