mirror of
https://gitlab.com/mysocialportal/relatica
synced 2024-10-18 15:53:32 +00:00
First cut of entire timeline elements rendering
This commit is contained in:
parent
bac580935c
commit
1524cc217a
22 changed files with 547 additions and 44 deletions
61
lib/controls/timeline/interactions_bar_control.dart
Normal file
61
lib/controls/timeline/interactions_bar_control.dart
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../globals.dart';
|
||||||
|
import '../../models/timeline_entry.dart';
|
||||||
|
import '../../services/entry_manager_service.dart';
|
||||||
|
import '../../utils/snackbar_builder.dart';
|
||||||
|
|
||||||
|
class InteractionsBarControl extends StatefulWidget {
|
||||||
|
final TimelineEntry entry;
|
||||||
|
const InteractionsBarControl({super.key, required this.entry});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<InteractionsBarControl> createState() => _InteractionsBarControlState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InteractionsBarControlState extends State<InteractionsBarControl> {
|
||||||
|
bool isFavorited = false;
|
||||||
|
int reshares = 0;
|
||||||
|
int comments = 0;
|
||||||
|
int likes = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
isFavorited = widget.entry.isFavorited;
|
||||||
|
comments = widget.entry.engagementSummary.repliesCount;
|
||||||
|
reshares = widget.entry.engagementSummary.rebloggedCount;
|
||||||
|
likes = widget.entry.engagementSummary.favoritesCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleFavorited() async {
|
||||||
|
final newState = !isFavorited;
|
||||||
|
print('Trying to toggle favorite from $isFavorited to $newState');
|
||||||
|
final result = await getIt<EntryManagerService>()
|
||||||
|
.toggleFavorited(widget.entry.id, newState);
|
||||||
|
result.match(onSuccess: (update) {
|
||||||
|
setState(() {
|
||||||
|
print('Success toggling! $isFavorited -> ${update.entry.isFavorited}');
|
||||||
|
isFavorited = update.entry.isFavorited;
|
||||||
|
});
|
||||||
|
}, onError: (error) {
|
||||||
|
buildSnackbar(context, 'Error toggling like status: $error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('$likes likes, $reshares reshares, $comments comments'),
|
||||||
|
IconButton(
|
||||||
|
onPressed: toggleFavorited,
|
||||||
|
icon: isFavorited
|
||||||
|
? Icon(Icons.thumb_up)
|
||||||
|
: Icon(Icons.thumb_up_outlined)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
188
lib/controls/timeline/status_control.dart
Normal file
188
lib/controls/timeline/status_control.dart
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../../globals.dart';
|
||||||
|
import '../../models/attachment_media_type_enum.dart';
|
||||||
|
import '../../models/connection.dart';
|
||||||
|
import '../../models/entry_tree_item.dart';
|
||||||
|
import '../../models/timeline_entry.dart';
|
||||||
|
import '../../screens/image_viewer_screen.dart';
|
||||||
|
import '../../services/connections_manager.dart';
|
||||||
|
import '../../utils/dateutils.dart';
|
||||||
|
import '../../utils/snackbar_builder.dart';
|
||||||
|
import '../padding.dart';
|
||||||
|
import 'interactions_bar_control.dart';
|
||||||
|
|
||||||
|
class StatusControl extends StatelessWidget {
|
||||||
|
final EntryTreeItem item;
|
||||||
|
|
||||||
|
TimelineEntry get entry => item.entry;
|
||||||
|
|
||||||
|
const StatusControl({super.key, required this.item});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
buildHeader(context),
|
||||||
|
const VerticalPadding(
|
||||||
|
height: 5,
|
||||||
|
),
|
||||||
|
buildBody(context),
|
||||||
|
const VerticalPadding(
|
||||||
|
height: 5,
|
||||||
|
),
|
||||||
|
buildMediaBar(context),
|
||||||
|
const VerticalPadding(
|
||||||
|
height: 5,
|
||||||
|
),
|
||||||
|
InteractionsBarControl(entry: entry),
|
||||||
|
const VerticalPadding(
|
||||||
|
height: 5,
|
||||||
|
),
|
||||||
|
buildChildComments(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildHeader(BuildContext context) {
|
||||||
|
final author = getIt<ConnectionsManager>()
|
||||||
|
.getById(entry.authorId)
|
||||||
|
.getValueOrElse(() => Connection());
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: author.avatarUrl.toString(),
|
||||||
|
width: 32.0,
|
||||||
|
),
|
||||||
|
const HorizontalPadding(),
|
||||||
|
Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
author.name,
|
||||||
|
style: Theme.of(context).textTheme.bodyText1,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
ElapsedDateUtils.epochSecondsToString(entry.backdatedTimestamp),
|
||||||
|
style: Theme.of(context).textTheme.caption,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildBody(BuildContext context) {
|
||||||
|
return HtmlWidget(
|
||||||
|
entry.body,
|
||||||
|
onTapUrl: (url) async {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null) {
|
||||||
|
buildSnackbar(context, 'Bad link: $url');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
buildSnackbar(
|
||||||
|
context,
|
||||||
|
'Attempting to launch video: $url',
|
||||||
|
);
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
buildSnackbar(context, 'Unable to launch video: $url');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onTapImage: (imageMetadata) {
|
||||||
|
print(imageMetadata);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildMediaBar(BuildContext context) {
|
||||||
|
final items = entry.mediaAttachments;
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
width: 250.0,
|
||||||
|
height: 250.0,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
|
||||||
|
if (item.explicitType == AttachmentMediaType.video) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (await canLaunchUrl(item.uri)) {
|
||||||
|
buildSnackbar(
|
||||||
|
context,
|
||||||
|
'Attempting to launch video: ${item.uri}',
|
||||||
|
);
|
||||||
|
await launchUrl(item.uri);
|
||||||
|
} else {
|
||||||
|
buildSnackbar(
|
||||||
|
context, 'Unable to launch video: ${item.uri}');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(item.description.isNotEmpty
|
||||||
|
? item.description
|
||||||
|
: 'Video'));
|
||||||
|
}
|
||||||
|
if (item.explicitType != AttachmentMediaType.image) {
|
||||||
|
return Text('${item.explicitType}: ${item.uri}');
|
||||||
|
}
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (context) {
|
||||||
|
return ImageViewerScreen(attachment: item);
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
width: 250.0,
|
||||||
|
height: 250.0,
|
||||||
|
imageUrl: item.thumbnailUri.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// return Text(item.toString());
|
||||||
|
},
|
||||||
|
separatorBuilder: (context, index) {
|
||||||
|
return HorizontalPadding();
|
||||||
|
},
|
||||||
|
itemCount: items.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildChildComments(BuildContext context) {
|
||||||
|
final comments = item.children;
|
||||||
|
if (comments.isEmpty) {
|
||||||
|
return Text('No comments');
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(left: 20.0, top: 5.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.subdirectory_arrow_right),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: comments.map((c) => StatusControl(item: c)).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
|
@ -109,6 +109,25 @@ class FriendicaClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FutureResult<TimelineEntry, ExecError> changeFavoriteStatus(
|
||||||
|
String id, bool status) async {
|
||||||
|
final action = status ? 'favourite' : 'unfavourite';
|
||||||
|
final url = Uri.parse('https://$serverName/api/v1/statuses/$id/$action');
|
||||||
|
final result = await _postUrl(url, {});
|
||||||
|
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 {
|
FutureResult<String, ExecError> getMyProfile() async {
|
||||||
_logger.finest(() => 'Getting logged in user profile');
|
_logger.finest(() => 'Getting logged in user profile');
|
||||||
final request = Uri.parse('https://$serverName/api/friendica/profile/show');
|
final request = Uri.parse('https://$serverName/api/friendica/profile/show');
|
||||||
|
|
|
@ -8,6 +8,7 @@ import 'models/TimelineIdentifiers.dart';
|
||||||
import 'routes.dart';
|
import 'routes.dart';
|
||||||
import 'screens/sign_in.dart';
|
import 'screens/sign_in.dart';
|
||||||
import 'services/auth_service.dart';
|
import 'services/auth_service.dart';
|
||||||
|
import 'services/connections_manager.dart';
|
||||||
import 'services/entry_manager_service.dart';
|
import 'services/entry_manager_service.dart';
|
||||||
import 'services/secrets_service.dart';
|
import 'services/secrets_service.dart';
|
||||||
import 'services/timeline_manager.dart';
|
import 'services/timeline_manager.dart';
|
||||||
|
@ -25,10 +26,12 @@ void main() async {
|
||||||
final authService = AuthService();
|
final authService = AuthService();
|
||||||
final secretsService = SecretsService();
|
final secretsService = SecretsService();
|
||||||
final entryManagerService = EntryManagerService();
|
final entryManagerService = EntryManagerService();
|
||||||
|
final timelineManager = TimelineManager();
|
||||||
|
getIt.registerLazySingleton<ConnectionsManager>(() => ConnectionsManager());
|
||||||
getIt.registerSingleton<EntryManagerService>(entryManagerService);
|
getIt.registerSingleton<EntryManagerService>(entryManagerService);
|
||||||
getIt.registerSingleton<SecretsService>(secretsService);
|
getIt.registerSingleton<SecretsService>(secretsService);
|
||||||
getIt.registerSingleton<AuthService>(authService);
|
getIt.registerSingleton<AuthService>(authService);
|
||||||
getIt.registerLazySingleton<TimelineManager>(() => TimelineManager());
|
getIt.registerSingleton<TimelineManager>(timelineManager);
|
||||||
await secretsService.initialize().andThenSuccessAsync((credentials) async {
|
await secretsService.initialize().andThenSuccessAsync((credentials) async {
|
||||||
if (credentials.isEmpty) {
|
if (credentials.isEmpty) {
|
||||||
return;
|
return;
|
||||||
|
@ -39,7 +42,8 @@ void main() async {
|
||||||
final result = await authService.signIn(credentials);
|
final result = await authService.signIn(credentials);
|
||||||
print('Startup login result: $result');
|
print('Startup login result: $result');
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
await entryManagerService.updateTimeline(TimelineIdentifiers.home());
|
print('Getting timeline for ${result.value.credentials.handle}');
|
||||||
|
timelineManager.getTimeline(TimelineIdentifiers.home());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print('Was not logged in');
|
print('Was not logged in');
|
||||||
|
|
|
@ -9,17 +9,21 @@ class Connection {
|
||||||
|
|
||||||
final String network;
|
final String network;
|
||||||
|
|
||||||
|
final Uri avatarUrl;
|
||||||
|
|
||||||
Connection(
|
Connection(
|
||||||
{this.status = ConnectionStatus.none,
|
{this.status = ConnectionStatus.none,
|
||||||
this.name = '',
|
this.name = '',
|
||||||
this.id = '',
|
this.id = '',
|
||||||
profileUrl,
|
Uri? profileUrl,
|
||||||
this.network = ''})
|
this.network = '',
|
||||||
: profileUrl = profileUrl ?? Uri();
|
Uri? avatarUrl})
|
||||||
|
: profileUrl = profileUrl ?? Uri(),
|
||||||
|
avatarUrl = avatarUrl ?? Uri();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'Connection{status: $status, name: $name, id: $id, profileUrl: $profileUrl, network: $network}';
|
return 'Connection{status: $status, name: $name, id: $id, profileUrl: $profileUrl, network: $network, avatar: $avatarUrl}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,12 @@ class EntryTreeItem {
|
||||||
|
|
||||||
EntryTreeItem(this.entry, {this.isMine = true, this.isOrphaned = false});
|
EntryTreeItem(this.entry, {this.isMine = true, this.isOrphaned = false});
|
||||||
|
|
||||||
|
EntryTreeItem copy({required TimelineEntry entry}) => EntryTreeItem(
|
||||||
|
entry,
|
||||||
|
isMine: isMine,
|
||||||
|
isOrphaned: isOrphaned,
|
||||||
|
);
|
||||||
|
|
||||||
String get id => entry.id;
|
String get id => entry.id;
|
||||||
|
|
||||||
void addChild(EntryTreeItem child) {
|
void addChild(EntryTreeItem child) {
|
||||||
|
|
|
@ -34,6 +34,8 @@ class TimelineEntry {
|
||||||
|
|
||||||
final LocationData locationData;
|
final LocationData locationData;
|
||||||
|
|
||||||
|
final bool isFavorited;
|
||||||
|
|
||||||
final List<LinkData> links;
|
final List<LinkData> links;
|
||||||
|
|
||||||
final List<Connection> likes;
|
final List<Connection> likes;
|
||||||
|
@ -59,6 +61,7 @@ class TimelineEntry {
|
||||||
this.parentAuthorId = '',
|
this.parentAuthorId = '',
|
||||||
this.externalLink = '',
|
this.externalLink = '',
|
||||||
this.locationData = const LocationData(),
|
this.locationData = const LocationData(),
|
||||||
|
this.isFavorited = false,
|
||||||
this.links = const [],
|
this.links = const [],
|
||||||
this.likes = const [],
|
this.likes = const [],
|
||||||
this.dislikes = const [],
|
this.dislikes = const [],
|
||||||
|
@ -81,6 +84,7 @@ class TimelineEntry {
|
||||||
parentAuthor = 'Random parent author ${randomId()}',
|
parentAuthor = 'Random parent author ${randomId()}',
|
||||||
parentAuthorId = 'Random parent author id ${randomId()}',
|
parentAuthorId = 'Random parent author id ${randomId()}',
|
||||||
locationData = LocationData.randomBuilt(),
|
locationData = LocationData.randomBuilt(),
|
||||||
|
isFavorited = DateTime.now().second ~/ 2 == 0 ? true : false,
|
||||||
links = [],
|
links = [],
|
||||||
likes = [],
|
likes = [],
|
||||||
dislikes = [],
|
dislikes = [],
|
||||||
|
@ -102,6 +106,7 @@ class TimelineEntry {
|
||||||
String? parentAuthor,
|
String? parentAuthor,
|
||||||
String? parentAuthorId,
|
String? parentAuthorId,
|
||||||
LocationData? locationData,
|
LocationData? locationData,
|
||||||
|
bool? isFavorited,
|
||||||
List<LinkData>? links,
|
List<LinkData>? links,
|
||||||
List<Connection>? likes,
|
List<Connection>? likes,
|
||||||
List<Connection>? dislikes,
|
List<Connection>? dislikes,
|
||||||
|
@ -123,6 +128,7 @@ class TimelineEntry {
|
||||||
parentAuthor: parentAuthor ?? this.parentAuthor,
|
parentAuthor: parentAuthor ?? this.parentAuthor,
|
||||||
parentAuthorId: parentAuthorId ?? this.parentAuthorId,
|
parentAuthorId: parentAuthorId ?? this.parentAuthorId,
|
||||||
locationData: locationData ?? this.locationData,
|
locationData: locationData ?? this.locationData,
|
||||||
|
isFavorited: isFavorited ?? this.isFavorited,
|
||||||
links: links ?? this.links,
|
links: links ?? this.links,
|
||||||
likes: likes ?? this.likes,
|
likes: likes ?? this.likes,
|
||||||
dislikes: dislikes ?? this.dislikes,
|
dislikes: dislikes ?? this.dislikes,
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
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:go_router/go_router.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../controls/timeline/status_control.dart';
|
||||||
import '../models/TimelineIdentifiers.dart';
|
import '../models/TimelineIdentifiers.dart';
|
||||||
import '../services/timeline_manager.dart';
|
import '../services/timeline_manager.dart';
|
||||||
|
|
||||||
|
@ -67,48 +67,15 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||||
return Center(child: Text('Error getting timeline: ${result.error}'));
|
return Center(child: Text('Error getting timeline: ${result.error}'));
|
||||||
}
|
}
|
||||||
final items = result.value;
|
final items = result.value;
|
||||||
|
print('items count = ${items.length}');
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await manager.refreshTimeline(TimelineIdentifiers.home());
|
await manager.refreshTimeline(TimelineIdentifiers.home());
|
||||||
},
|
},
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = items[index];
|
print('Building item: $index');
|
||||||
final entry = item.entry;
|
return StatusControl(item: items[index]);
|
||||||
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);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
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(),
|
separatorBuilder: (context, index) => Divider(),
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
|
|
28
lib/screens/image_viewer_screen.dart
Normal file
28
lib/screens/image_viewer_screen.dart
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/media_attachment.dart';
|
||||||
|
|
||||||
|
class ImageViewerScreen extends StatelessWidget {
|
||||||
|
final MediaAttachment attachment;
|
||||||
|
|
||||||
|
const ImageViewerScreen({super.key, required this.attachment});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
color: Theme.of(context).backgroundColor,
|
||||||
|
height: MediaQuery.of(context).size.height,
|
||||||
|
child: InteractiveViewer(
|
||||||
|
child: CachedNetworkImage(imageUrl: attachment.uri.toString()),
|
||||||
|
maxScale: 10.0,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
19
lib/serializers/mastodon/connection_mastodon_extensions.dart
Normal file
19
lib/serializers/mastodon/connection_mastodon_extensions.dart
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import '../../models/connection.dart';
|
||||||
|
|
||||||
|
extension ConnectionMastodonExtensions on Connection {
|
||||||
|
static Connection fromJson(Map<String, dynamic> json) {
|
||||||
|
final name = json['display_name'] ?? '';
|
||||||
|
final id = json['id'] ?? '';
|
||||||
|
final profileUrl = Uri.parse(json['url'] ?? '');
|
||||||
|
const network = 'Mastodon';
|
||||||
|
final avatar = Uri.tryParse(json['avatar_static'] ?? '') ?? Uri();
|
||||||
|
|
||||||
|
return Connection(
|
||||||
|
name: name,
|
||||||
|
id: id,
|
||||||
|
profileUrl: profileUrl,
|
||||||
|
network: network,
|
||||||
|
avatarUrl: avatar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,14 @@
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
import '../../globals.dart';
|
||||||
import '../../models/engagement_summary.dart';
|
import '../../models/engagement_summary.dart';
|
||||||
import '../../models/link_data.dart';
|
import '../../models/link_data.dart';
|
||||||
import '../../models/location_data.dart';
|
import '../../models/location_data.dart';
|
||||||
import '../../models/media_attachment.dart';
|
import '../../models/media_attachment.dart';
|
||||||
import '../../models/timeline_entry.dart';
|
import '../../models/timeline_entry.dart';
|
||||||
|
import '../../services/connections_manager.dart';
|
||||||
import '../../utils/dateutils.dart';
|
import '../../utils/dateutils.dart';
|
||||||
|
import 'connection_mastodon_extensions.dart';
|
||||||
|
|
||||||
final _logger = Logger('TimelineEntryMastodonExtensions');
|
final _logger = Logger('TimelineEntryMastodonExtensions');
|
||||||
|
|
||||||
|
@ -26,13 +29,14 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
|
||||||
final parentAuthor = json['in_reply_to_account_id'] ?? '';
|
final parentAuthor = json['in_reply_to_account_id'] ?? '';
|
||||||
final parentAuthorId = json['in_reply_to_account_id'] ?? '';
|
final parentAuthorId = json['in_reply_to_account_id'] ?? '';
|
||||||
final body = json['content'] ?? '';
|
final body = json['content'] ?? '';
|
||||||
final author = json['account']['acct'];
|
final author = json['account']['display_name'];
|
||||||
final authorId = json['account']['id'];
|
final authorId = json['account']['id'];
|
||||||
const title = '';
|
const title = '';
|
||||||
final externalLink = json['uri'] ?? '';
|
final externalLink = json['uri'] ?? '';
|
||||||
final actualLocationData = LocationData();
|
final actualLocationData = LocationData();
|
||||||
final modificationTimestamp = timestamp;
|
final modificationTimestamp = timestamp;
|
||||||
final backdatedTimestamp = timestamp;
|
final backdatedTimestamp = timestamp;
|
||||||
|
final isFavorited = json['favourited'] ?? false;
|
||||||
final linkData = json['card'] == null
|
final linkData = json['card'] == null
|
||||||
? <LinkData>[]
|
? <LinkData>[]
|
||||||
: [LinkData.fromMastodonJson(json['card'])];
|
: [LinkData.fromMastodonJson(json['card'])];
|
||||||
|
@ -47,6 +51,9 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
|
||||||
rebloggedCount: rebloggedCount,
|
rebloggedCount: rebloggedCount,
|
||||||
repliesCount: repliesCount,
|
repliesCount: repliesCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final connection = ConnectionMastodonExtensions.fromJson(json['account']);
|
||||||
|
getIt<ConnectionsManager>().addConnection(connection);
|
||||||
return TimelineEntry(
|
return TimelineEntry(
|
||||||
creationTimestamp: timestamp,
|
creationTimestamp: timestamp,
|
||||||
modificationTimestamp: modificationTimestamp,
|
modificationTimestamp: modificationTimestamp,
|
||||||
|
@ -57,6 +64,7 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
|
||||||
id: id,
|
id: id,
|
||||||
parentId: parentId,
|
parentId: parentId,
|
||||||
parentAuthorId: parentAuthorId,
|
parentAuthorId: parentAuthorId,
|
||||||
|
isFavorited: isFavorited,
|
||||||
externalLink: externalLink,
|
externalLink: externalLink,
|
||||||
author: author,
|
author: author,
|
||||||
authorId: authorId,
|
authorId: authorId,
|
||||||
|
|
56
lib/services/connections_manager.dart
Normal file
56
lib/services/connections_manager.dart
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import 'package:result_monad/result_monad.dart';
|
||||||
|
|
||||||
|
import '../models/connection.dart';
|
||||||
|
|
||||||
|
class ConnectionsManager {
|
||||||
|
final _connectionsById = <String, Connection>{};
|
||||||
|
final _connectionsByName = <String, Connection>{};
|
||||||
|
final _connectionsByProfileUrl = <Uri, Connection>{};
|
||||||
|
|
||||||
|
int get length => _connectionsById.length;
|
||||||
|
|
||||||
|
void clearCaches() {
|
||||||
|
_connectionsById.clear();
|
||||||
|
_connectionsByName.clear();
|
||||||
|
_connectionsByProfileUrl.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool addConnection(Connection connection) {
|
||||||
|
if (_connectionsById.containsKey(connection.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_connectionsById[connection.id] = connection;
|
||||||
|
_connectionsByName[connection.name] = connection;
|
||||||
|
_connectionsByProfileUrl[connection.profileUrl] = connection;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool addAllConnections(Iterable<Connection> newConnections) {
|
||||||
|
bool result = true;
|
||||||
|
|
||||||
|
for (final connection in newConnections) {
|
||||||
|
result &= addConnection(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<Connection, String> getById(String id) {
|
||||||
|
final result = _connectionsById[id];
|
||||||
|
|
||||||
|
return result != null ? Result.ok(result) : Result.error('$id not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<Connection, String> getByName(String name) {
|
||||||
|
final result = _connectionsByName[name];
|
||||||
|
|
||||||
|
return result != null ? Result.ok(result) : Result.error('$name not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<Connection, String> getByProfileUrl(Uri url) {
|
||||||
|
final result = _connectionsByProfileUrl[url];
|
||||||
|
|
||||||
|
return result != null ? Result.ok(result) : Result.error('$url not found');
|
||||||
|
}
|
||||||
|
}
|
|
@ -104,4 +104,52 @@ class EntryManagerService extends ChangeNotifier {
|
||||||
'Completed processing new items ${client == null ? 'sub level' : 'top level'}');
|
'Completed processing new items ${client == null ? 'sub level' : 'top level'}');
|
||||||
return updatedPosts;
|
return updatedPosts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FutureResult<EntryTreeItem, ExecError> toggleFavorited(
|
||||||
|
String id, bool newStatus,
|
||||||
|
{bool notify = false}) async {
|
||||||
|
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 result = await client.changeFavoriteStatus(id, newStatus);
|
||||||
|
if (result.isFailure) {
|
||||||
|
return result.errorCast();
|
||||||
|
}
|
||||||
|
|
||||||
|
final update = result.value;
|
||||||
|
late EntryTreeItem rval;
|
||||||
|
if (_posts.containsKey(update.id)) {
|
||||||
|
rval = _posts[update.id]!.copy(entry: update);
|
||||||
|
_posts[update.id] = rval;
|
||||||
|
_updateChildrenEntities(rval, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_allComments.containsKey(update.id)) {
|
||||||
|
rval = _allComments[update.id]!.copy(entry: update);
|
||||||
|
_allComments[update.id] = rval;
|
||||||
|
_updateChildrenEntities(rval, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notify) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
return Result.ok(rval);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateChildrenEntities(EntryTreeItem item, TimelineEntry entry) {
|
||||||
|
final updates = item.children.where((element) => element.id == entry.id);
|
||||||
|
for (final u in updates) {
|
||||||
|
final newItem = u.copy(entry: entry);
|
||||||
|
item.children.remove(u);
|
||||||
|
item.children.add(newItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final c in item.children) {
|
||||||
|
_updateChildrenEntities(c, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ class TimelineManager extends ChangeNotifier {
|
||||||
(await getIt<EntryManagerService>().updateTimeline(type)).match(
|
(await getIt<EntryManagerService>().updateTimeline(type)).match(
|
||||||
onSuccess: (posts) {
|
onSuccess: (posts) {
|
||||||
final timeline = cachedTimelines.putIfAbsent(type, () => Timeline(type));
|
final timeline = cachedTimelines.putIfAbsent(type, () => Timeline(type));
|
||||||
|
_logger.finest('Posts returned for adding to $type: ${posts.length}');
|
||||||
timeline.addPosts(posts);
|
timeline.addPosts(posts);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}, onError: (error) {
|
}, onError: (error) {
|
||||||
|
|
|
@ -38,3 +38,23 @@ class OffsetDateTimeUtils {
|
||||||
1000);
|
1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ElapsedDateUtils {
|
||||||
|
static String epochSecondsToString(int epochSeconds) {
|
||||||
|
final epoch = DateTime.fromMillisecondsSinceEpoch(epochSeconds * 1000);
|
||||||
|
final elapsed = DateTime.now().difference(epoch);
|
||||||
|
if (elapsed.inDays > 0) {
|
||||||
|
return '${elapsed.inDays} days ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsed.inHours > 0) {
|
||||||
|
return '${elapsed.inHours} hours ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsed.inMinutes > 0) {
|
||||||
|
return '${elapsed.inMinutes} minutes ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'seconds ago';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
#include <desktop_window/desktop_window_plugin.h>
|
#include <desktop_window/desktop_window_plugin.h>
|
||||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) desktop_window_registrar =
|
g_autoptr(FlPluginRegistrar) desktop_window_registrar =
|
||||||
|
@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
desktop_window
|
desktop_window
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
@ -10,6 +10,7 @@ import flutter_secure_storage_macos
|
||||||
import path_provider_macos
|
import path_provider_macos
|
||||||
import shared_preferences_macos
|
import shared_preferences_macos
|
||||||
import sqflite
|
import sqflite
|
||||||
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
DesktopWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWindowPlugin"))
|
DesktopWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWindowPlugin"))
|
||||||
|
@ -17,4 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|
56
pubspec.lock
56
pubspec.lock
|
@ -588,6 +588,62 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.1"
|
version: "1.3.1"
|
||||||
|
url_launcher:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: url_launcher
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.6"
|
||||||
|
url_launcher_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_android
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.21"
|
||||||
|
url_launcher_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_ios
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.17"
|
||||||
|
url_launcher_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_linux
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
|
url_launcher_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_macos
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
|
url_launcher_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_platform_interface
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
|
url_launcher_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_web
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.13"
|
||||||
|
url_launcher_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_windows
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
uuid:
|
uuid:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -28,6 +28,7 @@ dependencies:
|
||||||
shared_preferences: ^2.0.15
|
shared_preferences: ^2.0.15
|
||||||
uuid: ^3.0.6
|
uuid: ^3.0.6
|
||||||
time_machine: ^0.9.17
|
time_machine: ^0.9.17
|
||||||
|
url_launcher: ^6.1.6
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
@ -8,10 +8,13 @@
|
||||||
|
|
||||||
#include <desktop_window/desktop_window_plugin.h>
|
#include <desktop_window/desktop_window_plugin.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
DesktopWindowPluginRegisterWithRegistrar(
|
DesktopWindowPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("DesktopWindowPlugin"));
|
registry->GetRegistrarForPlugin("DesktopWindowPlugin"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
desktop_window
|
desktop_window
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
Loading…
Reference in a new issue