mirror of
https://gitlab.com/mysocialportal/relatica
synced 2024-10-18 13:33:32 +00:00
Initial timeline flows working
This commit is contained in:
parent
f647b68881
commit
bac580935c
13 changed files with 656 additions and 132 deletions
|
@ -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(
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
38
lib/models/TimelineIdentifiers.dart
Normal file
38
lib/models/TimelineIdentifiers.dart
Normal 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}';
|
||||
}
|
||||
}
|
38
lib/models/entry_tree_item.dart
Normal file
38
lib/models/entry_tree_item.dart
Normal 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;
|
||||
}
|
|
@ -14,5 +14,6 @@ enum ErrorType {
|
|||
authentication,
|
||||
localError,
|
||||
missingEndpoint,
|
||||
notFound,
|
||||
parsingError,
|
||||
}
|
||||
|
|
52
lib/models/timeline.dart
Normal file
52
lib/models/timeline.dart
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
73
lib/screens/editor.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,95 +1,80 @@
|
|||
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!;
|
||||
Widget buildTimelineComponent(BuildContext context, TimelineManager manager) {
|
||||
final result =
|
||||
manager.getTimeline(TimelineIdentifiers(timeline: currentType));
|
||||
if (result.isFailure) {
|
||||
return Text('Got an error: ${result.error}');
|
||||
return Center(child: Text('Error getting timeline: ${result.error}'));
|
||||
}
|
||||
|
||||
final items = result.value;
|
||||
|
||||
return ListView.separated(
|
||||
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),
|
||||
|
@ -98,7 +83,7 @@ class HomeScreen extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
HtmlWidget(
|
||||
item.body,
|
||||
item.entry.body,
|
||||
onTapUrl: (url) async {
|
||||
print(url);
|
||||
return true;
|
||||
|
@ -107,27 +92,27 @@ class HomeScreen extends StatelessWidget {
|
|||
print(imageMetadata);
|
||||
},
|
||||
),
|
||||
if (item.links.isNotEmpty)
|
||||
Text('Preview: ${item.links.first.url}'),
|
||||
if (item.mediaAttachments.isNotEmpty)
|
||||
...item.mediaAttachments
|
||||
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: ${item.likes.length}, Dislikes: ${item.dislikes.length}, ')
|
||||
'Engagement -- Likes: ${entry.likes.length}, Dislikes: ${entry.dislikes.length}, Comments:${item.totalChildren} ')
|
||||
],
|
||||
),
|
||||
),
|
||||
//trailing: Text(item.parentId),
|
||||
title: Text(
|
||||
'${item.id} for ${item.author} for post ${item.parentId}'),
|
||||
'${entry.id} for ${item.isMine ? 'Me' : entry.author} for post ${entry.parentId}'),
|
||||
trailing: Text(DateTime.fromMillisecondsSinceEpoch(
|
||||
item.creationTimestamp * 1000)
|
||||
entry.creationTimestamp * 1000)
|
||||
.toIso8601String()),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => Divider(),
|
||||
itemCount: items.length,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
107
lib/services/entry_manager_service.dart
Normal file
107
lib/services/entry_manager_service.dart
Normal 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;
|
||||
}
|
||||
}
|
53
lib/services/timeline_manager.dart
Normal file
53
lib/services/timeline_manager.dart
Normal 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
|
||||
|
||||
}
|
12
lib/utils/app_scrolling_behavior.dart
Normal file
12
lib/utils/app_scrolling_behavior.dart
Normal 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,
|
||||
};
|
||||
}
|
Loading…
Reference in a new issue