diff --git a/flutter/lib/api/api_client.dart b/flutter/lib/api/api_client.dart index 71c628b..fdc3cbe 100644 --- a/flutter/lib/api/api_client.dart +++ b/flutter/lib/api/api_client.dart @@ -39,7 +39,7 @@ class APIClient { return User.fromJson(jsonDecode(response.body)); } - static getChannelList(KeyTokenAuth auth, ChannelSelector sel) async { + static Future> getChannelList(KeyTokenAuth auth, ChannelSelector sel) async { var url = '$_base/users/${auth.userId}/channels?selector=${sel.apiKey}'; final uri = Uri.parse(url); @@ -51,10 +51,10 @@ class APIClient { final data = jsonDecode(response.body); - return data['channels'].map((e) => ChannelWithSubscription.fromJson(e)).toList(); + return data['channels'].map((e) => ChannelWithSubscription.fromJson(e)).toList() as List; } - static getMessageList(KeyTokenAuth auth, String pageToken, int? pageSize) async { + static Future<(String, List)> getMessageList(KeyTokenAuth auth, String pageToken, int? pageSize) async { var url = '$_base/messages?next_page_token=$pageToken'; if (pageSize != null) { url += '&page_size=$pageSize'; @@ -71,8 +71,19 @@ class APIClient { final npt = data['next_page_token'] as String; - final messages = data['messages'].map((e) => Message.fromJson(e)).toList(); + final messages = data['messages'].map((e) => Message.fromJson(e)).toList() as List; - return [npt, messages]; + return (npt, messages); + } + + static Future getMessage(KeyTokenAuth auth, String msgid) async { + final uri = Uri.parse('$_base/messages/$msgid'); + final response = await http.get(uri, headers: {'Authorization': 'SCN ${auth.token}'}); + + if (response.statusCode != 200) { + throw Exception('API request failed'); + } + + return Message.fromJson(jsonDecode(response.body)); } } diff --git a/flutter/lib/bottom_fab/anchored_overlay.dart b/flutter/lib/components/bottom_fab/anchored_overlay.dart similarity index 100% rename from flutter/lib/bottom_fab/anchored_overlay.dart rename to flutter/lib/components/bottom_fab/anchored_overlay.dart diff --git a/flutter/lib/bottom_fab/center_about.dart b/flutter/lib/components/bottom_fab/center_about.dart similarity index 100% rename from flutter/lib/bottom_fab/center_about.dart rename to flutter/lib/components/bottom_fab/center_about.dart diff --git a/flutter/lib/bottom_fab/fab_bottom_app_bar.dart b/flutter/lib/components/bottom_fab/fab_bottom_app_bar.dart similarity index 100% rename from flutter/lib/bottom_fab/fab_bottom_app_bar.dart rename to flutter/lib/components/bottom_fab/fab_bottom_app_bar.dart diff --git a/flutter/lib/bottom_fab/fab_with_icons.dart b/flutter/lib/components/bottom_fab/fab_with_icons.dart similarity index 100% rename from flutter/lib/bottom_fab/fab_with_icons.dart rename to flutter/lib/components/bottom_fab/fab_with_icons.dart diff --git a/flutter/lib/components/layout/app_bar.dart b/flutter/lib/components/layout/app_bar.dart new file mode 100644 index 0000000..aad20af --- /dev/null +++ b/flutter/lib/components/layout/app_bar.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/state/app_theme.dart'; + +class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { + const SCNAppBar({Key? key, this.title}) : super(key: key); + + final String? title; + + @override + Widget build(BuildContext context) { + return AppBar( + title: Text(title ?? 'Simple Cloud Notifier 2.0'), + actions: [ + Consumer( + builder: (context, appTheme, child) => IconButton( + icon: Icon(appTheme.darkMode ? FontAwesomeIcons.solidSun : FontAwesomeIcons.solidMoon), + tooltip: 'Debug', + onPressed: () { + appTheme.switchDarkMode(); + }, + ), + ), + IconButton( + icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow), + tooltip: 'Debug', + onPressed: () {}, + ), + IconButton( + icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass), + tooltip: 'Search', + onPressed: () {}, + ), + ], + backgroundColor: Theme.of(context).secondaryHeaderColor, + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} diff --git a/flutter/lib/components/layout/scaffold.dart b/flutter/lib/components/layout/scaffold.dart new file mode 100644 index 0000000..9aff4e7 --- /dev/null +++ b/flutter/lib/components/layout/scaffold.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:simplecloudnotifier/components/layout/app_bar.dart'; + +class SCNScaffold extends StatelessWidget { + const SCNScaffold({Key? key, required this.child, this.title}) : super(key: key); + + final Widget child; + final String? title; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SCNAppBar( + title: title, + ), + body: child, + ); + } +} diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index ea5929f..fa9a19c 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -9,8 +9,14 @@ void main() { runApp( MultiProvider( providers: [ - ChangeNotifierProvider(create: (context) => UserAccount()), - ChangeNotifierProvider(create: (context) => AppTheme()), + ChangeNotifierProvider( + create: (context) => UserAccount(), + lazy: false, + ), + ChangeNotifierProvider( + create: (context) => AppTheme(), + lazy: false, + ), ], child: const SCNApp(), ), diff --git a/flutter/lib/nav_layout.dart b/flutter/lib/nav_layout.dart index c5e596e..30e6081 100644 --- a/flutter/lib/nav_layout.dart +++ b/flutter/lib/nav_layout.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/components/layout/app_bar.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 'components/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 { const SCNNavLayout({super.key}); @@ -43,7 +42,7 @@ class _SCNNavLayoutState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: _buildAppBar(context), + appBar: SCNAppBar(), body: _subPages.elementAt(_selectedIndex), bottomNavigationBar: _buildNavBar(context), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, @@ -86,32 +85,4 @@ class _SCNNavLayoutState extends State { ], ); } - - PreferredSizeWidget _buildAppBar(BuildContext context) { - return AppBar( - title: const Text('Simple Cloud Notifier 2.0'), - actions: [ - Consumer( - builder: (context, appTheme, child) => IconButton( - icon: Icon(appTheme.darkMode ? FontAwesomeIcons.solidSun : FontAwesomeIcons.solidMoon), - tooltip: 'Debug', - onPressed: () { - appTheme.switchDarkMode(); - }, - ), - ), - IconButton( - icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow), - tooltip: 'Debug', - onPressed: () {}, - ), - IconButton( - icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass), - tooltip: 'Search', - onPressed: () {}, - ), - ], - backgroundColor: Theme.of(context).secondaryHeaderColor, - ); - } } diff --git a/flutter/lib/pages/message_list/message_list.dart b/flutter/lib/pages/message_list/message_list.dart index 35a0679..c5d4d58 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -2,9 +2,11 @@ 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 'package:simplecloudnotifier/models/message.dart'; +import 'package:simplecloudnotifier/pages/message_view/message_view.dart'; +import 'package:simplecloudnotifier/state/user_account.dart'; -import '../../models/message.dart'; -import '../../state/user_account.dart'; import 'message_list_item.dart'; class MessageListPage extends StatefulWidget { @@ -15,10 +17,12 @@ class MessageListPage extends StatefulWidget { } class _MessageListPageState extends State { - static const _pageSize = 20; //TODO + static const _pageSize = 128; final PagingController _pagingController = PagingController(firstPageKey: '@start'); + Map? _channels = null; + @override void initState() { _pagingController.addPageRequestListener((pageKey) { @@ -42,7 +46,12 @@ class _MessageListPageState extends State { } try { - final [npt, newItems] = await APIClient.getMessageList(acc.auth!, thisPageToken, _pageSize); + if (_channels == null) { + final channels = await APIClient.getChannelList(acc.auth!, ChannelSelector.allAny); + _channels = Map.fromIterable(channels, key: (e) => e.channelID); + } + + final (npt, newItems) = await APIClient.getMessageList(acc.auth!, thisPageToken, _pageSize); if (npt == '@end') { _pagingController.appendLastPage(newItems); @@ -50,23 +59,31 @@ class _MessageListPageState extends State { _pagingController.appendPage(newItems, npt); } } catch (error) { + print("API-Error: "); //TODO remove me, proper error handling + print(error); //TODO remove me, proper error handling _pagingController.error = error; } } @override Widget build(BuildContext context) { - return PagedListView( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => MessageListItem( - message: item, + return Padding( + padding: EdgeInsets.fromLTRB(8, 4, 8, 4), + child: PagedListView( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => MessageListItem( + message: item, + allChannels: _channels ?? {}, + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => MessageViewPage(messageID: item.messageID)), + ); + }, + ), ), ), ); } - - 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 index 8054bb9..879ff87 100644 --- a/flutter/lib/pages/message_list/message_list_item.dart +++ b/flutter/lib/pages/message_list/message_list_item.dart @@ -1,17 +1,161 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; +import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/message.dart'; +import 'package:intl/intl.dart'; class MessageListItem extends StatelessWidget { + static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); + static final _lineCount = 3; + const MessageListItem({ required this.message, + required this.allChannels, + required this.onPressed, super.key, }); final Message message; + final Map allChannels; + final Null Function() onPressed; @override - Widget build(BuildContext context) => ListTile( - leading: const SizedBox(width: 40, height: 40, child: const Placeholder()), - title: Text(message.messageID), + Widget build(BuildContext context) { + if (showChannel(message)) { + return Card.filled( + margin: EdgeInsets.fromLTRB(0, 4, 0, 4), + shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)), + //clipBehavior: Clip.hardEdge, // nto needed, because our borderRadius is 0 anyway + child: InkWell( + splashColor: Theme.of(context).primaryColor.withAlpha(30), + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 0), + margin: const EdgeInsets.fromLTRB(0, 0, 4, 0), + decoration: BoxDecoration( + color: Theme.of(context).hintColor, + borderRadius: BorderRadius.all(Radius.circular(4)), + ), + child: Text( + resolveChannelName(message), + style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).cardColor, fontSize: 12), + overflow: TextOverflow.clip, + maxLines: 1, + ), + ), + Expanded(child: SizedBox()), + Text( + _dateFormat.format(DateTime.parse(message.timestamp).toLocal()), + style: const TextStyle(fontWeight: FontWeight.normal, fontSize: 11), + overflow: TextOverflow.clip, + maxLines: 1, + ), + ], + ), + SizedBox(height: 4), + Text( + processTitle(message.title), + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + maxLines: 3, + ), + Text( + processContent(message.content), + style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), + overflow: TextOverflow.ellipsis, + maxLines: _lineCount, + ), + ], + ), + ), + ), ); + } else { + return Card.filled( + margin: EdgeInsets.fromLTRB(0, 4, 0, 4), + shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)), + //clipBehavior: Clip.hardEdge, // nto needed, because our borderRadius is 0 anyway + child: InkWell( + splashColor: Theme.of(context).primaryColor.withAlpha(30), + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + processTitle(message.title), + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + maxLines: 3, + ), + ), + Text( + _dateFormat.format(DateTime.parse(message.timestamp).toLocal()), + style: const TextStyle(fontWeight: FontWeight.normal, fontSize: 11), + overflow: TextOverflow.clip, + maxLines: 1, + ), + ], + ), + SizedBox(height: 4), + Text( + processContent(message.content), + style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), + overflow: TextOverflow.ellipsis, + maxLines: _lineCount, + ), + ], + ), + ), + ), + ); + } + } + + processContent(String? v) { + if (v == null) { + return ''; + } + + var lines = v.split('\n'); + if (lines.isEmpty) { + return ''; + } + + return lines.sublist(0, min(_lineCount, lines.length)).join("\n").trim(); + } + + processTitle(String? v) { + if (v == null) { + return ''; + } + + v = v.replaceAll("\n", " "); + v = v.replaceAll("\t", " "); + v = v.replaceAll("\r", ""); + + return v; + } + + String resolveChannelName(Message message) { + return allChannels[message.channelID]?.displayName ?? message.channelInternalName; + } + + showChannel(Message message) { + return message.channelInternalName != 'main'; + } } diff --git a/flutter/lib/pages/message_view/message_view.dart b/flutter/lib/pages/message_view/message_view.dart new file mode 100644 index 0000000..edc59c0 --- /dev/null +++ b/flutter/lib/pages/message_view/message_view.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/api/api_client.dart'; +import 'package:simplecloudnotifier/components/layout/scaffold.dart'; +import 'package:simplecloudnotifier/models/message.dart'; +import 'package:simplecloudnotifier/state/user_account.dart'; + +class MessageViewPage extends StatefulWidget { + const MessageViewPage({super.key, required this.messageID}); + + final String messageID; + + @override + State createState() => _MessageViewPageState(); +} + +class _MessageViewPageState extends State { + late Future? futureMessage; + + @override + void initState() { + super.initState(); + futureMessage = fetchMessage(); + } + + Future fetchMessage() async { + final acc = Provider.of(context, listen: false); + + return await APIClient.getMessage(acc.auth!, widget.messageID); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SCNScaffold( + title: 'Message', + child: FutureBuilder( + future: futureMessage, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Center(child: Text(snapshot.data!.title)); + } else if (snapshot.hasError) { + return Center(child: Text('${snapshot.error}')); //TODO nice error page + } + + return const Center(child: CircularProgressIndicator()); + }, + ), + ); + } +} diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 262a0ed..131e53c 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -82,10 +82,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "4.0.0" flutter_staggered_grid_view: dependency: transitive description: @@ -135,6 +135,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" leak_tracker: dependency: transitive description: @@ -163,10 +171,10 @@ packages: dependency: transitive description: name: lints - sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.0" matcher: dependency: transitive description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 332adc0..178aa9a 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: qr_flutter: ^4.1.0 url_launcher: ^6.2.4 infinite_scroll_pagination: ^4.0.0 + intl: ^0.19.0 dependency_overrides: @@ -58,7 +59,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: ^3.0.1 + flutter_lints: ^4.0.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec