From e96be86314601756466da38443fa906f6500168a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Sun, 13 Apr 2025 01:51:52 +0200 Subject: [PATCH] Finish implementing send page --- flutter/TODO.md | 2 +- .../channel_scanner_result_messagesend.dart | 10 +- flutter/lib/pages/send/send.dart | 323 ++++++++++++++---- flutter/lib/pages/settings/root.dart | 2 +- flutter/lib/state/app_auth.dart | 2 +- flutter/lib/state/globals.dart | 6 + flutter/lib/utils/ui.dart | 40 ++- 7 files changed, 300 insertions(+), 85 deletions(-) diff --git a/flutter/TODO.md b/flutter/TODO.md index ed98fa2..f0941c3 100644 --- a/flutter/TODO.md +++ b/flutter/TODO.md @@ -21,7 +21,7 @@ - [ ] read + migrate old SharedPrefs (or not? - who uses SCN even??) - [ ] Account-Page - [ ] Logout - - [ ] Send-page + - [x] Send-page - [ ] Still @ERROR on scn-init, but no logs? - better persist error (write in SharedPrefs at error_$date=txt ?), also perhaps print first error line in scn-init notification? diff --git a/flutter/lib/pages/channel_scanner/channel_scanner_result_messagesend.dart b/flutter/lib/pages/channel_scanner/channel_scanner_result_messagesend.dart index 8d02287..15593b1 100644 --- a/flutter/lib/pages/channel_scanner/channel_scanner_result_messagesend.dart +++ b/flutter/lib/pages/channel_scanner/channel_scanner_result_messagesend.dart @@ -178,11 +178,12 @@ class _ChannelScannerResultMessageSendState extends State createState() => _SendRootPageState(); @@ -16,18 +22,28 @@ class SendRootPage extends StatefulWidget { class _SendRootPageState extends State { late TextEditingController _msgTitle; late TextEditingController _msgContent; + late TextEditingController _channelName; + late TextEditingController _senderName; + + int _priority = 0; + + bool _expanded = false; @override void initState() { super.initState(); _msgTitle = TextEditingController(); _msgContent = TextEditingController(); + _channelName = TextEditingController(); + _senderName = TextEditingController(); } @override void dispose() { _msgTitle.dispose(); _msgContent.dispose(); + _channelName.dispose(); + _senderName.dispose(); super.dispose(); } @@ -38,52 +54,162 @@ class _SendRootPageState extends State { return SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildQRCode(context, acc), - const SizedBox(height: 16), - FractionallySizedBox( - widthFactor: 1.0, - child: TextField( - controller: _msgTitle, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Title', - ), - ), - ), - const SizedBox(height: 16), - FractionallySizedBox( - widthFactor: 1.0, - child: TextField( - controller: _msgContent, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Text', - ), - minLines: 2, - maxLines: null, - keyboardType: TextInputType.multiline, - ), - ), - const SizedBox(height: 16), - FilledButton( - style: FilledButton.styleFrom(textStyle: const TextStyle(fontSize: 20)), - onPressed: _send, - child: const Text('Send'), - ), - const SizedBox(height: 32), - ], - ), + child: _expanded ? _buildExpanded(context, acc) : _buildSimple(context, acc), ), ); }, ); } - void _send() { - //... + Widget _buildSimple(BuildContext context, AppAuth acc) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildQRCode(context, acc), + const SizedBox(height: 16), + FractionallySizedBox( + widthFactor: 1.0, + child: TextField( + controller: _msgTitle, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Title', + ), + ), + ), + const SizedBox(height: 16), + FractionallySizedBox( + widthFactor: 1.0, + child: TextField( + controller: _msgContent, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Text', + ), + minLines: 2, + maxLines: null, + keyboardType: TextInputType.multiline, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: UI.button( + text: 'Send', + onPressed: () { + _sendSimple(acc); + }, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + ), + ), + const SizedBox(width: 8), + UI.buttonIconOnly( + icon: FontAwesomeIcons.layerPlus, + onPressed: _openExpanded, + square: true, + color: Theme.of(context).colorScheme.secondary, + iconColor: Theme.of(context).colorScheme.onSecondary, + ), + ], + ), + const SizedBox(height: 32), + ], + ); + } + + Widget _buildExpanded(BuildContext context, AppAuth acc) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FractionallySizedBox( + widthFactor: 1.0, + child: TextField( + controller: _channelName, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Channel', + ), + ), + ), + const SizedBox(height: 16), + FractionallySizedBox( + widthFactor: 1.0, + child: TextField( + controller: _msgTitle, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Title', + ), + ), + ), + const SizedBox(height: 16), + FractionallySizedBox( + widthFactor: 1.0, + child: TextField( + controller: _senderName, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'SenderName', + ), + ), + ), + const SizedBox(height: 16), + SegmentedButton( + showSelectedIcon: false, + segments: const >[ + ButtonSegment(value: 0, label: Text('Low Priority')), + ButtonSegment(value: 1, label: Text('Normal')), + ButtonSegment(value: 2, label: Text('High Priority')), + ], + selected: {_priority}, + onSelectionChanged: (Set newSelection) { + setState(() { + _priority = newSelection.isEmpty ? 1 : newSelection.first; + }); + }, + ), + const SizedBox(height: 16), + FractionallySizedBox( + widthFactor: 1.0, + child: TextField( + controller: _msgContent, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Text', + ), + minLines: 6, + maxLines: null, + keyboardType: TextInputType.multiline, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: UI.button( + text: 'Send', + onPressed: () { + _sendExpanded(acc); + }, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + ), + ), + const SizedBox(width: 8), + UI.buttonIconOnly( + icon: FontAwesomeIcons.squareDashed, + onPressed: _closeExpanded, + square: true, + color: Theme.of(context).colorScheme.secondary, + iconColor: Theme.of(context).colorScheme.onSecondary, + ), + ], + ), + const SizedBox(height: 32), + ], + ); } Widget _buildQRCode(BuildContext context, AppAuth acc) { @@ -94,39 +220,82 @@ class _SendRootPageState extends State { return FutureBuilder( future: acc.loadUser(force: false), builder: ((context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - if (snapshot.hasError) { - return Text('Error: ${snapshot.error}'); //TODO better error display - } - var url = (acc.tokenSend == null) ? 'https://simplecloudnotifier.de?preset_user_id=${acc.userID}' : 'https://simplecloudnotifier.de?preset_user_id=${acc.userID}&preset_user_key=${acc.tokenSend}'; - return GestureDetector( - onTap: () { - _openWeb(url); - }, - child: QrImageView( - data: url, - version: QrVersions.auto, - size: 300.0, - eyeStyle: QrEyeStyle( - eyeShape: QrEyeShape.square, - color: Theme.of(context).textTheme.bodyLarge?.color, - ), - dataModuleStyle: QrDataModuleStyle( - dataModuleShape: QrDataModuleShape.square, - color: Theme.of(context).textTheme.bodyLarge?.color, - ), - ), + if (snapshot.connectionState == ConnectionState.active || snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox( + width: 300.0, + height: 300.0, + child: Center(child: CircularProgressIndicator()), ); } - return const SizedBox( - width: 300.0, - height: 300.0, - child: Center(child: CircularProgressIndicator()), + if (snapshot.hasError) { + return Text('Error: ${snapshot.error}'); //TODO better error display + } + if (snapshot.connectionState != ConnectionState.done) { + return Text('...'); //? + } + + var url = (acc.tokenSend == null) ? 'https://simplecloudnotifier.de?preset_user_id=${acc.userID}' : 'https://simplecloudnotifier.de?preset_user_id=${acc.userID}&preset_user_key=${acc.tokenSend}'; + + return GestureDetector( + onTap: () { + _openWeb(url); + }, + child: QrImageView( + data: url, + version: QrVersions.auto, + size: 300.0, + eyeStyle: QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + ), ); }), ); } + void _sendSimple(AppAuth acc) async { + if (!acc.isAuth()) { + Toaster.error("Error", 'Must be logged in to send messages'); + return; + } + + try { + await APIClient.sendMessage(acc.userID!, acc.tokenSend!, _msgContent.text); + Toaster.success("Success", 'Message sent'); + setState(() { + _msgTitle.clear(); + _msgContent.clear(); + }); + } catch (e, stackTrace) { + Toaster.error("Error", 'Failed to send message: ${e.toString()}'); + ApplicationLog.error('Failed to send message', trace: stackTrace); + } + } + + void _sendExpanded(AppAuth acc) async { + if (!acc.isAuth()) { + Toaster.error("Error", 'Must be logged in to send messages'); + return; + } + + try { + await APIClient.sendMessage(acc.userID!, acc.tokenSend!, _msgContent.text, channel: _channelName.text, senderName: _senderName.text, priority: _priority); + Toaster.success("Success", 'Message sent'); + setState(() { + _msgTitle.clear(); + _msgContent.clear(); + }); + } catch (e, stackTrace) { + Toaster.error("Error", 'Failed to send message: ${e.toString()}'); + ApplicationLog.error('Failed to send message', trace: stackTrace); + } + } + void _openWeb(String url) async { try { final Uri uri = Uri.parse(url); @@ -142,4 +311,24 @@ class _SendRootPageState extends State { ApplicationLog.error('Failed to open URL: ' + exc.toString(), additional: 'URL: ${url}', trace: trace); } } + + void _closeExpanded() { + setState(() { + _expanded = false; + _channelName.clear(); + _priority = 1; + _senderName.clear(); + }); + } + + void _openExpanded() { + final userAcc = Provider.of(context, listen: false); + + setState(() { + _expanded = true; + _channelName.text = userAcc.getUserOrNull()?.defaultChannel ?? 'main'; + _priority = 1; + _senderName.text = Globals().deviceName; + }); + } } diff --git a/flutter/lib/pages/settings/root.dart b/flutter/lib/pages/settings/root.dart index 3fed0c9..6cb457e 100644 --- a/flutter/lib/pages/settings/root.dart +++ b/flutter/lib/pages/settings/root.dart @@ -11,7 +11,7 @@ class _SettingsRootPageState extends State { @override Widget build(BuildContext context) { return Center( - child: Text('Settings'), + child: Text('(coming soon...)'), //TODO ); } } diff --git a/flutter/lib/state/app_auth.dart b/flutter/lib/state/app_auth.dart index 038feed..cb74d8b 100644 --- a/flutter/lib/state/app_auth.dart +++ b/flutter/lib/state/app_auth.dart @@ -34,7 +34,7 @@ class AppAuth extends ChangeNotifier implements TokenSource { } bool isAuth() { - return _userID != null && _tokenAdmin != null; + return _userID != null && _tokenAdmin != null && _tokenSend != null; } void set(User user, Client client, String tokenAdmin, String tokenSend) { diff --git a/flutter/lib/state/globals.dart b/flutter/lib/state/globals.dart index dfeea2d..314ddb1 100644 --- a/flutter/lib/state/globals.dart +++ b/flutter/lib/state/globals.dart @@ -25,6 +25,7 @@ class Globals { String hostname = ''; String clientType = ''; String deviceModel = ''; + String deviceName = ''; late SharedPreferences sharedPrefs; @@ -48,18 +49,23 @@ class Globals { if (Platform.isAndroid) { this.clientType = 'ANDROID'; this.deviceModel = (await DeviceInfoPlugin().androidInfo).model; + this.deviceName = (await DeviceInfoPlugin().androidInfo).name; } else if (Platform.isIOS) { this.clientType = 'IOS'; this.deviceModel = (await DeviceInfoPlugin().iosInfo).model; + this.deviceName = (await DeviceInfoPlugin().iosInfo).name; } else if (Platform.isLinux) { this.clientType = 'LINUX'; this.deviceModel = (await DeviceInfoPlugin().linuxInfo).prettyName; + this.deviceName = (await DeviceInfoPlugin().linuxInfo).name; } else if (Platform.isWindows) { this.clientType = 'WINDOWS'; this.deviceModel = (await DeviceInfoPlugin().windowsInfo).productName; + this.deviceName = (await DeviceInfoPlugin().windowsInfo).computerName; } else if (Platform.isMacOS) { this.clientType = 'MACOS'; this.deviceModel = (await DeviceInfoPlugin().macOsInfo).model; + this.deviceName = (await DeviceInfoPlugin().macOsInfo).computerName; } else { this.clientType = '?'; } diff --git a/flutter/lib/utils/ui.dart b/flutter/lib/utils/ui.dart index 379c8ed..3a9bcb1 100644 --- a/flutter/lib/utils/ui.dart +++ b/flutter/lib/utils/ui.dart @@ -49,19 +49,35 @@ class UI { } } - static Widget buttonIconOnly({ - required void Function() onPressed, - required IconData icon, - double? iconSize = null, - }) { - return IconButton( - icon: FaIcon(icon), - iconSize: iconSize ?? 18, - padding: EdgeInsets.all(4), - constraints: BoxConstraints(), - style: ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap), - onPressed: onPressed, + static Widget buttonIconOnly({required void Function() onPressed, required IconData icon, double? iconSize = null, bool? square, Color? color = null, Color? iconColor = null}) { + final style = ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + backgroundColor: (color != null) ? WidgetStateProperty.resolveWith((states) => color) : null, + padding: (square ?? false) ? WidgetStateProperty.resolveWith((states) => EdgeInsets.all(10)) : null, + shape: (square ?? false) ? WidgetStateProperty.resolveWith((states) => RoundedRectangleBorder(borderRadius: BorderRadius.circular(DefaultBorderRadius))) : null, ); + + if (color != null) { + return IconButton.filled( + icon: FaIcon(icon), + iconSize: iconSize ?? 18, + padding: EdgeInsets.all(4), + constraints: BoxConstraints(), + style: style, + onPressed: onPressed, + color: iconColor, + ); + } else { + return IconButton( + icon: FaIcon(icon), + iconSize: iconSize ?? 18, + padding: EdgeInsets.all(4), + constraints: BoxConstraints(), + style: style, + onPressed: onPressed, + color: iconColor, + ); + } } static Widget buttonCard({required BuildContext context, required Widget child, required void Function() onTap, EdgeInsets? margin = null}) {