channel and message lists

This commit is contained in:
Mike Schwörer 2024-02-18 17:36:58 +01:00
parent 1286a5d848
commit 56d49d8c5e
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
15 changed files with 484 additions and 23 deletions

View File

@ -23,6 +23,7 @@ linter:
rules: rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule # avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
prefer_relative_imports: true,
# Additional information about this file can be found at # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options

View File

@ -1,8 +1,23 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:simplecloudnotifier/models/key_token_auth.dart';
import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/models/user.dart';
import '../models/channel.dart';
import '../models/message.dart';
enum ChannelSelector {
owned(apiKey: 'owned'), // Return all channels of the user
subscribedAny(apiKey: 'subscribed_any'), // Return all channels that the user is subscribing to
allAny(apiKey: 'all_any'), // Return channels that the user owns or is subscribing
subscribed(apiKey: 'subscribed'), // Return all channels that the user is subscribing to (even unconfirmed)
all(apiKey: 'all'); // Return channels that the user owns or is subscribing (even unconfirmed)
const ChannelSelector({required this.apiKey});
final String apiKey;
}
class APIClient { class APIClient {
static const String _base = 'https://simplecloudnotifier.de/api/v2'; static const String _base = 'https://simplecloudnotifier.de/api/v2';
@ -23,4 +38,41 @@ class APIClient {
return User.fromJson(jsonDecode(response.body)); return User.fromJson(jsonDecode(response.body));
} }
static getChannelList(KeyTokenAuth auth, ChannelSelector sel) async {
var url = '$_base/users/${auth.userId}/channels?selector=${sel.apiKey}';
final uri = Uri.parse(url);
final response = await http.get(uri, headers: {'Authorization': 'SCN ${auth.token}'});
if (response.statusCode != 200) {
throw Exception('API request failed');
}
final data = jsonDecode(response.body);
return data['channels'].map<ChannelWithSubscription>((e) => ChannelWithSubscription.fromJson(e)).toList();
}
static getMessageList(KeyTokenAuth auth, String pageToken, int? pageSize) async {
var url = '$_base/messages?next_page_token=$pageToken';
if (pageSize != null) {
url += '&page_size=$pageSize';
}
final uri = Uri.parse(url);
final response = await http.get(uri, headers: {'Authorization': 'SCN ${auth.token}'});
if (response.statusCode != 200) {
throw Exception('API request failed');
}
final data = jsonDecode(response.body);
final npt = data['next_page_token'] as String;
final messages = data['messages'].map<Message>((e) => Message.fromJson(e)).toList();
return [npt, messages];
}
} }

View File

@ -5,7 +5,8 @@ import 'package:flutter/material.dart';
class FabWithIcons extends StatefulWidget { class FabWithIcons extends StatefulWidget {
FabWithIcons({super.key, required this.icons, required this.onIconTapped}); FabWithIcons({super.key, required this.icons, required this.onIconTapped});
final List<IconData> icons; final List<IconData> icons;
ValueChanged<int> onIconTapped; final ValueChanged<int> onIconTapped;
@override @override
State createState() => FabWithIconsState(); State createState() => FabWithIconsState();
} }

View File

@ -0,0 +1,100 @@
import 'package:simplecloudnotifier/models/subscription.dart';
class Channel {
final String channelID;
final String ownerUserID;
final String internalName;
final String displayName;
final String? descriptionName;
final String? subscribeKey;
final String timestampCreated;
final String? timestampLastSent;
final int messagesSent;
const Channel({
required this.channelID,
required this.ownerUserID,
required this.internalName,
required this.displayName,
required this.descriptionName,
required this.subscribeKey,
required this.timestampCreated,
required this.timestampLastSent,
required this.messagesSent,
});
factory Channel.fromJson(Map<String, dynamic> json) {
return switch (json) {
{
'channel_id': String channelID,
'owner_user_id': String ownerUserID,
'internal_name': String internalName,
'display_name': String displayName,
'description_name': String? descriptionName,
'subscribe_key': String? subscribeKey,
'timestamp_created': String timestampCreated,
'timestamp_lastsent': String? timestampLastSent,
'messages_sent': int messagesSent,
} =>
Channel(
channelID: channelID,
ownerUserID: ownerUserID,
internalName: internalName,
displayName: displayName,
descriptionName: descriptionName,
subscribeKey: subscribeKey,
timestampCreated: timestampCreated,
timestampLastSent: timestampLastSent,
messagesSent: messagesSent,
),
_ => throw const FormatException('Failed to decode Channel.'),
};
}
}
class ChannelWithSubscription extends Channel {
final Subscription subscription;
ChannelWithSubscription({
required super.channelID,
required super.ownerUserID,
required super.internalName,
required super.displayName,
required super.descriptionName,
required super.subscribeKey,
required super.timestampCreated,
required super.timestampLastSent,
required super.messagesSent,
required this.subscription,
});
factory ChannelWithSubscription.fromJson(Map<String, dynamic> json) {
return switch (json) {
{
'channel_id': String channelID,
'owner_user_id': String ownerUserID,
'internal_name': String internalName,
'display_name': String displayName,
'description_name': String? descriptionName,
'subscribe_key': String? subscribeKey,
'timestamp_created': String timestampCreated,
'timestamp_lastsent': String? timestampLastSent,
'messages_sent': int messagesSent,
'subscription': dynamic subscription,
} =>
ChannelWithSubscription(
channelID: channelID,
ownerUserID: ownerUserID,
internalName: internalName,
displayName: displayName,
descriptionName: descriptionName,
subscribeKey: subscribeKey,
timestampCreated: timestampCreated,
timestampLastSent: timestampLastSent,
messagesSent: messagesSent,
subscription: Subscription.fromJson(subscription),
),
_ => throw const FormatException('Failed to decode Channel.'),
};
}
}

View File

@ -0,0 +1,67 @@
class Message {
final String messageID;
final String senderUserID;
final String channelInternalName;
final String channelID;
final String? senderName;
final String senderIP;
final String timestamp;
final String title;
final String? content;
final int priority;
final String? userMessageID;
final String usedKeyID;
final bool trimmed;
const Message({
required this.messageID,
required this.senderUserID,
required this.channelInternalName,
required this.channelID,
required this.senderName,
required this.senderIP,
required this.timestamp,
required this.title,
required this.content,
required this.priority,
required this.userMessageID,
required this.usedKeyID,
required this.trimmed,
});
factory Message.fromJson(Map<String, dynamic> json) {
return switch (json) {
{
'message_id': String messageID,
'sender_user_id': String senderUserID,
'channel_internal_name': String channelInternalName,
'channel_id': String channelID,
'sender_name': String? senderName,
'sender_ip': String senderIP,
'timestamp': String timestamp,
'title': String title,
'content': String? content,
'priority': int priority,
'usr_message_id': String? userMessageID,
'used_key_id': String usedKeyID,
'trimmed': bool trimmed,
} =>
Message(
messageID: messageID,
senderUserID: senderUserID,
channelInternalName: channelInternalName,
channelID: channelID,
senderName: senderName,
senderIP: senderIP,
timestamp: timestamp,
title: title,
content: content,
priority: priority,
userMessageID: userMessageID,
usedKeyID: usedKeyID,
trimmed: trimmed,
),
_ => throw const FormatException('Failed to decode Message.'),
};
}
}

View File

@ -0,0 +1,43 @@
class Subscription {
final String subscriptionID;
final String subscriberUserID;
final String channelOwnerUserID;
final String channelID;
final String channelInternalName;
final String timestampCreated;
final bool confirmed;
const Subscription({
required this.subscriptionID,
required this.subscriberUserID,
required this.channelOwnerUserID,
required this.channelID,
required this.channelInternalName,
required this.timestampCreated,
required this.confirmed,
});
factory Subscription.fromJson(Map<String, dynamic> json) {
return switch (json) {
{
'subscription_id': String subscriptionID,
'subscriber_user_id': String subscriberUserID,
'channel_owner_user_id': String channelOwnerUserID,
'channel_id': String channelID,
'channel_internal_name': String channelInternalName,
'timestamp_created': String timestampCreated,
'confirmed': bool confirmed,
} =>
Subscription(
subscriptionID: subscriptionID,
subscriberUserID: subscriberUserID,
channelOwnerUserID: channelOwnerUserID,
channelID: channelID,
channelInternalName: channelInternalName,
timestampCreated: timestampCreated,
confirmed: confirmed,
),
_ => throw const FormatException('Failed to decode Subscription.'),
};
}
}

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/pages/channel_list/root.dart';
import 'package:simplecloudnotifier/pages/send/root.dart'; import 'package:simplecloudnotifier/pages/send/root.dart';
import 'bottom_fab/fab_bottom_app_bar.dart'; import 'bottom_fab/fab_bottom_app_bar.dart';
import 'pages/account/root.dart'; import 'pages/account/root.dart';
import 'pages/message_list/message_list.dart'; import 'pages/message_list/message_list.dart';
import 'pages/settings/root.dart';
import 'state/app_theme.dart'; import 'state/app_theme.dart';
class SCNNavLayout extends StatefulWidget { class SCNNavLayout extends StatefulWidget {
@ -19,10 +21,10 @@ class _SCNNavLayoutState extends State<SCNNavLayout> {
int _selectedIndex = 0; // 4 == FAB int _selectedIndex = 0; // 4 == FAB
static const List<Widget> _subPages = <Widget>[ static const List<Widget> _subPages = <Widget>[
MessageListPage(title: 'Messages'), MessageListPage(),
MessageListPage(title: 'Page 2'), ChannelRootPage(),
AccountRootPage(), AccountRootPage(),
MessageListPage(title: 'Page 4'), SettingsRootPage(),
SendRootPage(), SendRootPage(),
]; ];

View File

@ -33,12 +33,13 @@ class _AccountLoginPageState extends State<AccountLoginPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Padding(
padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
SizedBox( FractionallySizedBox(
width: 250, widthFactor: 1.0,
child: TextField( child: TextField(
controller: _ctrlUserID, controller: _ctrlUserID,
decoration: const InputDecoration( decoration: const InputDecoration(
@ -48,8 +49,8 @@ class _AccountLoginPageState extends State<AccountLoginPage> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox( FractionallySizedBox(
width: 250, widthFactor: 1.0,
child: TextField( child: TextField(
controller: _ctrlToken, controller: _ctrlToken,
decoration: const InputDecoration( decoration: const InputDecoration(

View File

@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import '../../models/channel.dart';
class ChannelListItem extends StatelessWidget {
const ChannelListItem({
required this.channel,
super.key,
});
final Channel channel;
@override
Widget build(BuildContext context) => ListTile(
leading: const SizedBox(width: 40, height: 40, child: const Placeholder()),
title: Text(channel.internalName),
);
}

View File

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import '../../state/user_account.dart';
import 'channel_list_item.dart';
class ChannelRootPage extends StatefulWidget {
const ChannelRootPage({super.key});
@override
State<ChannelRootPage> createState() => _ChannelRootPageState();
}
class _ChannelRootPageState extends State<ChannelRootPage> {
final PagingController<int, Channel> _pagingController = PagingController(firstPageKey: 0);
late UserAccount userAcc;
@override
void initState() {
_pagingController.addPageRequestListener((pageKey) {
_fetchPage(pageKey);
});
super.initState();
}
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
Future<void> _fetchPage(int pageKey) async {
final acc = Provider.of<UserAccount>(context, listen: false);
if (acc.auth == null) {
_pagingController.error = 'Not logged in';
return;
}
try {
final items = await APIClient.getChannelList(acc.auth!, ChannelSelector.all);
_pagingController.appendLastPage(items);
} catch (error) {
_pagingController.error = error;
}
}
@override
Widget build(BuildContext context) {
return PagedListView<int, Channel>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Channel>(
itemBuilder: (context, item, index) => ChannelListItem(
channel: item,
),
),
);
}
}

View File

@ -1,19 +1,72 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
class MessageListPage extends StatelessWidget { import '../../models/message.dart';
final String title; import '../../state/user_account.dart';
import 'message_list_item.dart';
const MessageListPage({super.key, required this.title}); class MessageListPage extends StatefulWidget {
const MessageListPage({super.key});
@override
State<MessageListPage> createState() => _MessageListPageState();
}
class _MessageListPageState extends State<MessageListPage> {
static const _pageSize = 20; //TODO
final PagingController<String, Message> _pagingController = PagingController(firstPageKey: '@start');
@override
void initState() {
_pagingController.addPageRequestListener((pageKey) {
_fetchPage(pageKey);
});
super.initState();
}
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
Future<void> _fetchPage(String thisPageToken) async {
final acc = Provider.of<UserAccount>(context, listen: false);
if (acc.auth == null) {
_pagingController.error = 'Not logged in';
return;
}
try {
final [npt, newItems] = await APIClient.getMessageList(acc.auth!, thisPageToken, _pageSize);
if (npt == '@end') {
_pagingController.appendLastPage(newItems);
} else {
_pagingController.appendPage(newItems, npt);
}
} catch (error) {
_pagingController.error = error;
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return PagedListView<String, Message>(
body: Center( pagingController: _pagingController,
child: Text( builderDelegate: PagedChildBuilderDelegate<Message>(
title, itemBuilder: (context, item, index) => MessageListItem(
style: const TextStyle(fontSize: 24), message: item,
), ),
), ),
); );
} }
void _createChannel() {
//TODO
}
} }

View File

@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:simplecloudnotifier/models/message.dart';
class MessageListItem extends StatelessWidget {
const MessageListItem({
required this.message,
super.key,
});
final Message message;
@override
Widget build(BuildContext context) => ListTile(
leading: const SizedBox(width: 40, height: 40, child: const Placeholder()),
title: Text(message.messageID),
);
}

View File

@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
class SettingsRootPage extends StatefulWidget {
const SettingsRootPage({super.key});
@override
State<SettingsRootPage> createState() => _SettingsRootPageState();
}
class _SettingsRootPageState extends State<SettingsRootPage> {
@override
Widget build(BuildContext context) {
return Center(
child: Text('Settings'),
);
}
}

View File

@ -82,10 +82,18 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_lints name: flutter_lints
sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.3" version: "3.0.1"
flutter_staggered_grid_view:
dependency: transitive
description:
name: flutter_staggered_grid_view
sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -119,14 +127,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.2" version: "4.0.2"
infinite_scroll_pagination:
dependency: "direct main"
description:
name: infinite_scroll_pagination
sha256: b68bce20752fcf36c7739e60de4175494f74e99e9a69b4dd2fe3a1dd07a7f16a
url: "https://pub.dev"
source: hosted
version: "4.0.0"
lints: lints:
dependency: transitive dependency: transitive
description: description:
name: lints name: lints
sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "3.0.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -292,6 +308,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.99"
sliver_tools:
dependency: transitive
description:
name: sliver_tools
sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6
url: "https://pub.dev"
source: hosted
version: "0.2.12"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:

View File

@ -41,6 +41,7 @@ dependencies:
shared_preferences: ^2.2.2 shared_preferences: ^2.2.2
qr_flutter: ^4.1.0 qr_flutter: ^4.1.0
url_launcher: ^6.2.4 url_launcher: ^6.2.4
infinite_scroll_pagination: ^4.0.0
dependency_overrides: dependency_overrides:
@ -57,7 +58,7 @@ dev_dependencies:
# activated in the `analysis_options.yaml` file located at the root of your # activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^2.0.0 flutter_lints: ^3.0.1
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec