multi account notifs are live

This commit is contained in:
zoe 2022-08-22 13:32:40 +02:00
parent 6efa92c289
commit 5ef7f4e2e9
8 changed files with 345 additions and 47 deletions

View File

@ -1 +1,165 @@
class NotificationModel {}
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:loris/business_logic/account/account.dart';
import 'package:loris/business_logic/timeline/timeline.dart';
import '../../global.dart' as global;
import 'package:http/http.dart' as http;
class NotificationModel implements Comparable {
late String id;
late String time;
late NotificationType type;
late AccountModel account;
late PostModel? post;
late String? accountid;
NotificationModel.fromJson(Map<String, dynamic> json) {
time = json["created_at"];
id = json["id"];
account = AccountModel.fromJson(json["account"]);
type = NotificationType.values.firstWhere(
(element) => element.name == "NotificationType.${json["type"]}");
if (json["status"] != null) {
post = PostModel.fromJson(json["status"]);
}
}
@override
int compareTo(other) {
return time.compareTo(other.time);
}
}
class NotificationData {
// stores latest post ids for each identity
// value is null if there is no latest id
final Map<String, String> latest;
final List<NotificationModel> models;
NotificationData(this.latest, this.models);
}
Future<NotificationData> loadOldNotifications(
Map<String, String>? data,
) async {
List<NotificationModel> models = [];
Map<String, String> map = data ?? {};
for (int i = 0; i < global.settings!.identities.length; i++) {
final idkey = global.settings!.identities.keys.toList()[i];
final id = global.settings!.identities[idkey]!;
Map<String, String> headers = id.getAuthHeaders();
headers.addAll(global.defaultHeaders);
Map<String, String> query = {
"limit": global.settings!.batchSize.toString()
};
if (map[idkey] != null) {
query.addAll({"max_id": map[idkey]!});
}
final uri = Uri(
host: id.instanceUrl,
path: "/api/v1/notifications",
scheme: "https",
queryParameters: query,
);
final response = await http.get(
uri,
headers: headers,
);
// get notifs
if (response.statusCode == 200) {
List<dynamic> json = jsonDecode(response.body);
for (int n = 0; n < json.length; n++) {
NotificationModel model = NotificationModel.fromJson(json[n]);
model.accountid = idkey;
models.add(model);
}
}
}
// massage list
models.sort();
models = models.reversed.toList();
models.removeRange(global.settings!.batchSize, models.length);
for (NotificationModel m in models) {
if (map[m.accountid!] == null) {
map.addAll({
m.accountid!: m.id,
});
} else if (m.id.compareTo(map[m.accountid]!) < 0) {
map.addAll({m.accountid!: m.id});
}
}
return NotificationData(map, models);
}
enum NotificationType {
follow,
followRequest,
mention,
reblog,
favourite,
poll,
status,
}
extension NotificationTypeExtension on NotificationType {
String get name {
switch (this) {
case NotificationType.followRequest:
return "follow_request";
default:
return toString();
}
}
IconData get icon {
switch (this) {
case NotificationType.follow:
return Icons.person_add;
case NotificationType.followRequest:
return Icons.person_add;
case NotificationType.mention:
return Icons.forum;
case NotificationType.favourite:
return Icons.favorite;
case NotificationType.poll:
return Icons.poll;
case NotificationType.reblog:
return Icons.repeat_on;
case NotificationType.status:
return Icons.forum;
default:
return Icons.question_mark;
}
}
String get actionName {
switch (this) {
case NotificationType.favourite:
return "liked-your-post".i18n();
case NotificationType.follow:
return "followed-you".i18n();
case NotificationType.followRequest:
return "requested-to-folow-you".i18n();
case NotificationType.mention:
return "mentioned-you".i18n();
case NotificationType.reblog:
return "reblogged-your-post".i18n();
case NotificationType.status:
return "made-a-status".i18n();
case NotificationType.poll:
return "poll-has-ended".i18n();
default:
return "interacted-with-you".i18n();
}
}
}

View File

@ -29,7 +29,15 @@
"content-width-percentage-label": "content width",
"content-width-percentage-description": "determines what the regular width of content should be in percent (requires reloading the timeline)",
"max-content-width-label": "max content width",
"content-max-width-description": "determines what the maximum width of content should be, values below 375 get ignored because they would be too small (requires reloading the timeline)"
"content-max-width-description": "determines what the maximum width of content should be, values below 375 get ignored because they would be too small (requires reloading the timeline)",
"liked-your-post": "liked your post",
"mentioned-you": "mentioned you",
"reblogged-your-post": "reblogged your post",
"followed-you": "followed you",
"requested-to-folow-you": "requested to follow you",
"made-a-status": "made a post",
"poll-has-ended": "poll has ended",
"interacted-with-you": "interacted with you"
}

View File

@ -2,10 +2,12 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:loris/business_logic/notifications/notifs.dart';
import 'package:loris/pages/notifications/single_notif.dart';
import '../../business_logic/websocket.dart' as websocket;
final notifStream = StreamController<int>();
final notifStream = StreamController<int>.broadcast();
class Notifications extends StatefulWidget {
const Notifications({Key? key}) : super(key: key);
@ -16,16 +18,39 @@ class Notifications extends StatefulWidget {
class _NotificationsState extends State<Notifications> {
List<Widget> notifs = [];
Map<String, String> maxIdData = {};
final ScrollController _controller = ScrollController();
Future<void> loadMore() async {
final data = await loadOldNotifications(maxIdData);
final models = data.models;
maxIdData = data.latest;
List<Widget> widgets = [];
for (int i = 0; i < models.length; i++) {
widgets.add(
SingleNotif(
model: models[i],
),
);
}
if (mounted) {
setState(() {
notifs.addAll(widgets);
});
cleanChildren();
}
}
@override
void initState() {
super.initState();
for (int i = 0; i < websocket.map.length; i++) {
final keyI = websocket.map.keys.toList()[i];
websocket.map[keyI]!["home"]!.stream.listen((event) async {
Map<String, dynamic> json = jsonDecode(event);
if (json["event"] == "notification") {
SingleNotif notif = SingleNotif(
content: jsonDecode(json["payload"]),
model: NotificationModel.fromJson(jsonDecode(json["payload"])),
);
if (mounted) {
setState(() {
@ -36,16 +61,50 @@ class _NotificationsState extends State<Notifications> {
}
});
}
super.initState();
loadMore();
}
void cleanChildren() {
setState(() {
notifs.removeWhere((element) {
return element.runtimeType != SingleNotif;
});
notifs.add(
TextButton.icon(
onPressed: () {
loadMore();
},
icon: const Icon(Icons.more_horiz),
label: Text(
"load-older-notifications".i18n(),
),
),
);
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.separated(
itemBuilder: ((context, index) => notifs[index]),
separatorBuilder: (context, index) => const SizedBox(
height: 8,
),
itemCount: notifs.length);
return Column(
children: [
Container(),
Expanded(
child: ListView.separated(
controller: _controller,
addSemanticIndexes: true,
itemBuilder: ((context, index) => notifs[index]),
separatorBuilder: (context, index) => const SizedBox(
height: 8,
),
itemCount: notifs.length),
),
],
);
}
}

View File

@ -1,14 +1,77 @@
import 'package:flutter/material.dart';
import 'package:loris/business_logic/notifications/notifs.dart';
import 'package:loris/partials/post.dart';
import '../../global.dart' as global;
class SingleNotif extends StatelessWidget {
const SingleNotif({
required this.content,
required this.model,
Key? key,
}) : super(key: key);
final Map<String, dynamic> content;
final NotificationModel model;
@override
Widget build(BuildContext context) {
return Text(content["id"].toString());
return Padding(
padding: const EdgeInsets.all(8),
child: Align(
child: Container(
width:
(MediaQuery.of(context).size.width * global.settings!.postWidth) -
56,
constraints: BoxConstraints(
maxWidth: global.settings!.maxPostWidth,
minWidth: 375,
),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(color: Theme.of(context).colorScheme.secondary),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Row(
children: [
ProfilePic(url: model.account.avatar),
const SizedBox(
width: 8,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
model.account.displayName,
style: Theme.of(context).textTheme.displaySmall,
),
SelectableText(
"${model.account.acct} ${model.type.actionName}",
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
const Spacer(),
Icon(
model.type.icon,
size: 64,
),
],
),
const SizedBox(
height: 8,
),
(model.post != null)
? (Post(
model: model.post!,
))
: const SizedBox(
width: 0,
height: 0,
),
],
),
),
),
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:localization/localization.dart';
import 'package:loris/partials/thread.dart';
import '../../business_logic/timeline/timeline.dart' as tl;
import '../../global.dart' as global;
import 'package:loris/partials/loadingbox.dart';
class Timeline extends StatefulWidget {
const Timeline({Key? key}) : super(key: key);
@ -220,19 +221,3 @@ class TimelineState extends State<Timeline> {
}
}
}
class LoadingBox extends StatelessWidget {
const LoadingBox({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox.fromSize(
size: Size(double.infinity, MediaQuery.of(context).size.height),
child: Center(
child: SizedBox.fromSize(
size: const Size(128, 128),
child: const CircularProgressIndicator(),
),
));
}
}

View File

@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
class LoadingBox extends StatelessWidget {
const LoadingBox({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox.fromSize(
size: Size(double.infinity, MediaQuery.of(context).size.height),
child: Center(
child: SizedBox.fromSize(
size: const Size(128, 128),
child: const CircularProgressIndicator(),
),
));
}
}

View File

@ -30,6 +30,11 @@ class _MainScaffoldState extends State<MainScaffold> {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
websocket.reloadWebsockets();
@ -51,10 +56,7 @@ class _MainScaffoldState extends State<MainScaffold> {
onPressed: () {},
child: const Icon(Icons.person_add),
),
FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.clear_all),
),
null,
null,
];
return Scaffold(

View File

@ -19,6 +19,7 @@ class _PostState extends State<Post> {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DisplayName(account: widget.model.account),
@ -54,11 +55,7 @@ class DisplayName extends StatelessWidget {
}
return Row(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ProfilePic(
url: account.avatar,
)),
ProfilePic(url: account.avatar),
const SizedBox(
width: 8,
),
@ -126,15 +123,18 @@ class ProfilePic extends StatelessWidget {
Widget build(BuildContext context) {
const double width = 64;
if (url.isNotEmpty) {
return Image.network(
fit: BoxFit.cover,
url,
errorBuilder: (context, error, stackTrace) => const Icon(
Icons.cruelty_free,
size: width,
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
fit: BoxFit.cover,
url,
errorBuilder: (context, error, stackTrace) => const Icon(
Icons.cruelty_free,
size: width,
),
height: width,
width: width,
),
height: width,
width: width,
);
} else {
return const Icon(