diff --git a/flutter/analysis_options.yaml b/flutter/analysis_options.yaml index 0d29021..64222f2 100644 --- a/flutter/analysis_options.yaml +++ b/flutter/analysis_options.yaml @@ -23,6 +23,7 @@ linter: rules: # avoid_print: false # Uncomment to disable the `avoid_print` 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 # https://dart.dev/guides/language/analysis-options diff --git a/flutter/lib/api/api_client.dart b/flutter/lib/api/api_client.dart index 14dc54d..71c628b 100644 --- a/flutter/lib/api/api_client.dart +++ b/flutter/lib/api/api_client.dart @@ -1,8 +1,23 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:simplecloudnotifier/models/key_token_auth.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 { static const String _base = 'https://simplecloudnotifier.de/api/v2'; @@ -23,4 +38,41 @@ class APIClient { 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((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((e) => Message.fromJson(e)).toList(); + + return [npt, messages]; + } } diff --git a/flutter/lib/bottom_fab/fab_with_icons.dart b/flutter/lib/bottom_fab/fab_with_icons.dart index a5f55a1..ff4735e 100644 --- a/flutter/lib/bottom_fab/fab_with_icons.dart +++ b/flutter/lib/bottom_fab/fab_with_icons.dart @@ -5,7 +5,8 @@ import 'package:flutter/material.dart'; class FabWithIcons extends StatefulWidget { FabWithIcons({super.key, required this.icons, required this.onIconTapped}); final List icons; - ValueChanged onIconTapped; + final ValueChanged onIconTapped; + @override State createState() => FabWithIconsState(); } diff --git a/flutter/lib/models/channel.dart b/flutter/lib/models/channel.dart new file mode 100644 index 0000000..9b2789f --- /dev/null +++ b/flutter/lib/models/channel.dart @@ -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 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 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.'), + }; + } +} diff --git a/flutter/lib/models/message.dart b/flutter/lib/models/message.dart new file mode 100644 index 0000000..b61b2b9 --- /dev/null +++ b/flutter/lib/models/message.dart @@ -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 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.'), + }; + } +} diff --git a/flutter/lib/models/subscription.dart b/flutter/lib/models/subscription.dart new file mode 100644 index 0000000..0ad07cb --- /dev/null +++ b/flutter/lib/models/subscription.dart @@ -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 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.'), + }; + } +} diff --git a/flutter/lib/nav_layout.dart b/flutter/lib/nav_layout.dart index dc88777..c5e596e 100644 --- a/flutter/lib/nav_layout.dart +++ b/flutter/lib/nav_layout.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/pages/channel_list/root.dart'; import 'package:simplecloudnotifier/pages/send/root.dart'; import 'bottom_fab/fab_bottom_app_bar.dart'; import 'pages/account/root.dart'; import 'pages/message_list/message_list.dart'; +import 'pages/settings/root.dart'; import 'state/app_theme.dart'; class SCNNavLayout extends StatefulWidget { @@ -19,10 +21,10 @@ class _SCNNavLayoutState extends State { int _selectedIndex = 0; // 4 == FAB static const List _subPages = [ - MessageListPage(title: 'Messages'), - MessageListPage(title: 'Page 2'), + MessageListPage(), + ChannelRootPage(), AccountRootPage(), - MessageListPage(title: 'Page 4'), + SettingsRootPage(), SendRootPage(), ]; diff --git a/flutter/lib/pages/account/login.dart b/flutter/lib/pages/account/login.dart index 36e0241..621c69f 100644 --- a/flutter/lib/pages/account/login.dart +++ b/flutter/lib/pages/account/login.dart @@ -33,12 +33,13 @@ class _AccountLoginPageState extends State { @override Widget build(BuildContext context) { - return Center( + return Padding( + padding: const EdgeInsets.all(16.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - SizedBox( - width: 250, + FractionallySizedBox( + widthFactor: 1.0, child: TextField( controller: _ctrlUserID, decoration: const InputDecoration( @@ -48,8 +49,8 @@ class _AccountLoginPageState extends State { ), ), const SizedBox(height: 16), - SizedBox( - width: 250, + FractionallySizedBox( + widthFactor: 1.0, child: TextField( controller: _ctrlToken, decoration: const InputDecoration( diff --git a/flutter/lib/pages/channel_list/channel_list_item.dart b/flutter/lib/pages/channel_list/channel_list_item.dart new file mode 100644 index 0000000..86dd59b --- /dev/null +++ b/flutter/lib/pages/channel_list/channel_list_item.dart @@ -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), + ); +} diff --git a/flutter/lib/pages/channel_list/root.dart b/flutter/lib/pages/channel_list/root.dart new file mode 100644 index 0000000..aa4909f --- /dev/null +++ b/flutter/lib/pages/channel_list/root.dart @@ -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 createState() => _ChannelRootPageState(); +} + +class _ChannelRootPageState extends State { + final PagingController _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 _fetchPage(int pageKey) async { + final acc = Provider.of(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( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ChannelListItem( + channel: item, + ), + ), + ); + } +} diff --git a/flutter/lib/pages/message_list/message_list.dart b/flutter/lib/pages/message_list/message_list.dart index a29bfcf..35a0679 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -1,19 +1,72 @@ 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 { - final String title; +import '../../models/message.dart'; +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 createState() => _MessageListPageState(); +} + +class _MessageListPageState extends State { + static const _pageSize = 20; //TODO + + final PagingController _pagingController = PagingController(firstPageKey: '@start'); + + @override + void initState() { + _pagingController.addPageRequestListener((pageKey) { + _fetchPage(pageKey); + }); + super.initState(); + } + + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } + + Future _fetchPage(String thisPageToken) async { + final acc = Provider.of(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 Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Text( - title, - style: const TextStyle(fontSize: 24), + return PagedListView( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => MessageListItem( + message: item, ), ), ); } + + void _createChannel() { + //TODO + } } diff --git a/flutter/lib/pages/message_list/message_list_item.dart b/flutter/lib/pages/message_list/message_list_item.dart new file mode 100644 index 0000000..8054bb9 --- /dev/null +++ b/flutter/lib/pages/message_list/message_list_item.dart @@ -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), + ); +} diff --git a/flutter/lib/pages/settings/root.dart b/flutter/lib/pages/settings/root.dart new file mode 100644 index 0000000..67e050c --- /dev/null +++ b/flutter/lib/pages/settings/root.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class SettingsRootPage extends StatefulWidget { + const SettingsRootPage({super.key}); + + @override + State createState() => _SettingsRootPageState(); +} + +class _SettingsRootPageState extends State { + @override + Widget build(BuildContext context) { + return Center( + child: Text('Settings'), + ); + } +} diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index d55e46a..ce09b52 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -82,10 +82,18 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 url: "https://pub.dev" 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: dependency: "direct dev" description: flutter @@ -119,14 +127,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "3.0.0" matcher: dependency: transitive description: @@ -292,6 +308,14 @@ packages: description: flutter source: sdk 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: dependency: transitive description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 249b099..0b17b04 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: shared_preferences: ^2.2.2 qr_flutter: ^4.1.0 url_launcher: ^6.2.4 + infinite_scroll_pagination: ^4.0.0 dependency_overrides: @@ -57,7 +58,7 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # 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 # following page: https://dart.dev/tools/pub/pubspec