diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index 22c6b81..abc9cf8 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ + + + + verifyToken(String uid, String tok) async { + final uri = Uri.parse('$_base/users/$uid'); + final response = await http.get(uri, headers: {'Authorization': 'SCN $tok'}); + + return (response.statusCode == 200); + } +} diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 3651098..63e41b1 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -1,24 +1,36 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'nav_layout.dart'; +import 'state/app_theme.dart'; +import 'state/user_account.dart'; void main() { - runApp(const SCNApp()); + runApp( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => UserAccount()), + ChangeNotifierProvider(create: (context) => AppTheme()), + ], + child: const SCNApp(), + ), + ); } class SCNApp extends StatelessWidget { const SCNApp({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { - return MaterialApp( - title: 'SimpleCloudNotifier', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), - useMaterial3: true, + return Consumer( + builder: (context, appTheme, child) => MaterialApp( + title: 'SimpleCloudNotifier', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, brightness: appTheme.darkMode ? Brightness.dark : Brightness.light), + useMaterial3: true, + ), + home: const SCNNavLayout(), ), - home: const SCNNavLayout(), ); } } diff --git a/flutter/lib/models/key_token_auth.dart b/flutter/lib/models/key_token_auth.dart new file mode 100644 index 0000000..a0be225 --- /dev/null +++ b/flutter/lib/models/key_token_auth.dart @@ -0,0 +1,6 @@ +class KeyTokenAuth { + final String userId; + final String token; + + KeyTokenAuth({required this.userId, required this.token}); +} diff --git a/flutter/lib/models/user.dart b/flutter/lib/models/user.dart new file mode 100644 index 0000000..3dec1e3 --- /dev/null +++ b/flutter/lib/models/user.dart @@ -0,0 +1,87 @@ +class User { + final String userID; + final String? username; + final String timestampCreated; + final String? timestampLastRead; + final String? timestampLastSent; + final int messagesSent; + final int quotaUsed; + final int quotaRemaining; + final int quotaPerDay; + final bool isPro; + final String defaultChannel; + final int maxBodySize; + final int maxTitleLength; + final int defaultPriority; + final int maxChannelNameLength; + final int maxChannelDescriptionLength; + final int maxSenderNameLength; + final int maxUserMessageIDLength; + + const User({ + required this.userID, + required this.username, + required this.timestampCreated, + required this.timestampLastRead, + required this.timestampLastSent, + required this.messagesSent, + required this.quotaUsed, + required this.quotaRemaining, + required this.quotaPerDay, + required this.isPro, + required this.defaultChannel, + required this.maxBodySize, + required this.maxTitleLength, + required this.defaultPriority, + required this.maxChannelNameLength, + required this.maxChannelDescriptionLength, + required this.maxSenderNameLength, + required this.maxUserMessageIDLength, + }); + + factory User.fromJson(Map json) { + return switch (json) { + { + 'user_id': String userID, + 'username': String? username, + 'timestamp_created': String timestampCreated, + 'timestamp_lastread': String? timestampLastRead, + 'timestamp_lastsent': String? timestampLastSent, + 'messages_sent': int messagesSent, + 'quota_used': int quotaUsed, + 'quota_remaining': int quotaRemaining, + 'quota_max': int quotaPerDay, + 'is_pro': bool isPro, + 'default_channel': String defaultChannel, + 'max_body_size': int maxBodySize, + 'max_title_length': int maxTitleLength, + 'default_priority': int defaultPriority, + 'max_channel_name_length': int maxChannelNameLength, + 'max_channel_description_length': int maxChannelDescriptionLength, + 'max_sender_name_length': int maxSenderNameLength, + 'max_user_message_id_length': int maxUserMessageIDLength, + } => + User( + userID: userID, + username: username, + timestampCreated: timestampCreated, + timestampLastRead: timestampLastRead, + timestampLastSent: timestampLastSent, + messagesSent: messagesSent, + quotaUsed: quotaUsed, + quotaRemaining: quotaRemaining, + quotaPerDay: quotaPerDay, + isPro: isPro, + defaultChannel: defaultChannel, + maxBodySize: maxBodySize, + maxTitleLength: maxTitleLength, + defaultPriority: defaultPriority, + maxChannelNameLength: maxChannelNameLength, + maxChannelDescriptionLength: maxChannelDescriptionLength, + maxSenderNameLength: maxSenderNameLength, + maxUserMessageIDLength: maxUserMessageIDLength, + ), + _ => throw const FormatException('Failed to decode User.'), + }; + } +} diff --git a/flutter/lib/nav_layout.dart b/flutter/lib/nav_layout.dart index be2e3e3..3c9a916 100644 --- a/flutter/lib/nav_layout.dart +++ b/flutter/lib/nav_layout.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:provider/provider.dart'; import 'bottom_fab/fab_bottom_app_bar.dart'; +import 'pages/account/root.dart'; import 'pages/message_list/message_list.dart'; +import 'state/app_theme.dart'; class SCNNavLayout extends StatefulWidget { const SCNNavLayout({super.key}); @@ -14,13 +17,11 @@ class SCNNavLayout extends StatefulWidget { class _SCNNavLayoutState extends State { int _selectedIndex = 0; - static const TextStyle optionStyle = TextStyle(fontSize: 30, fontWeight: FontWeight.bold); - static const List _subPages = [ - MessageListPage(title: 'Messages 1'), - MessageListPage(title: 'Messages 2'), - MessageListPage(title: 'Messages 3'), - MessageListPage(title: 'Messages 4'), + MessageListPage(title: 'Messages'), + MessageListPage(title: 'Page 2'), + AccountRootPage(), + MessageListPage(title: 'Page 4'), ]; void _onItemTapped(int index) { @@ -39,9 +40,7 @@ class _SCNNavLayoutState extends State { Widget build(BuildContext context) { return Scaffold( appBar: _buildAppBar(context), - body: Center( - child: _subPages.elementAt(_selectedIndex), - ), + body: _subPages.elementAt(_selectedIndex), bottomNavigationBar: _buildNavBar(context), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, floatingActionButton: _buildFAB(context), @@ -61,8 +60,8 @@ class _SCNNavLayoutState extends State { Widget _buildNavBar(BuildContext context) { return FABBottomAppBar( onTabSelected: _onItemTapped, - color: Colors.grey, - selectedColor: Colors.black, + color: Theme.of(context).disabledColor, + selectedColor: Theme.of(context).primaryColorDark, notchedShape: const AutomaticNotchedShape( RoundedRectangleBorder( borderRadius: BorderRadius.only( @@ -83,10 +82,19 @@ class _SCNNavLayoutState extends State { ); } - AppBar _buildAppBar(BuildContext context) { + 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', diff --git a/flutter/lib/pages/account/choose_auth.dart b/flutter/lib/pages/account/choose_auth.dart new file mode 100644 index 0000000..05749e0 --- /dev/null +++ b/flutter/lib/pages/account/choose_auth.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class AccountChoosePage extends StatelessWidget { + final void Function()? onLogin; + final void Function()? onCreateAccount; + + const AccountChoosePage({super.key, this.onLogin, this.onCreateAccount}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom(textStyle: const TextStyle(fontSize: 20)), + onPressed: () { + onLogin?.call(); + }, + child: const Text('Use existing account'), + ), + const SizedBox(height: 32), + ElevatedButton( + style: ElevatedButton.styleFrom(textStyle: const TextStyle(fontSize: 20)), + onPressed: () { + onCreateAccount?.call(); + }, + child: const Text('Create new account'), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/pages/account/login.dart b/flutter/lib/pages/account/login.dart new file mode 100644 index 0000000..1f974ec --- /dev/null +++ b/flutter/lib/pages/account/login.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:simplecloudnotifier/api/api_client.dart'; + +class AccountLoginPage extends StatefulWidget { + const AccountLoginPage({super.key}); + + @override + State createState() => _AccountLoginPageState(); +} + +class _AccountLoginPageState extends State { + late TextEditingController _ctrlUserID; + late TextEditingController _ctrlToken; + + @override + void initState() { + super.initState(); + _ctrlUserID = TextEditingController(); + _ctrlToken = TextEditingController(); + } + + @override + void dispose() { + _ctrlUserID.dispose(); + _ctrlToken.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 250, + child: TextField( + controller: _ctrlUserID, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'UserID', + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: 250, + child: TextField( + controller: _ctrlToken, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Token', + ), + ), + ), + const SizedBox(height: 16), + ElevatedButton( + style: ElevatedButton.styleFrom(textStyle: const TextStyle(fontSize: 20)), + onPressed: _login, + child: const Text('Login'), + ), + ], + ), + ); + } + + void _login() async { + final msgr = ScaffoldMessenger.of(context); + + final verified = await APIClient.verifyToken(_ctrlUserID.text, _ctrlToken.text); + if (verified) { + msgr.showSnackBar( + const SnackBar( + content: Text('Data ok'), + ), + ); + } else { + msgr.showSnackBar( + const SnackBar( + content: Text('Failed to verify token'), + ), + ); + } + } +} diff --git a/flutter/lib/pages/account/root.dart b/flutter/lib/pages/account/root.dart new file mode 100644 index 0000000..e5feb98 --- /dev/null +++ b/flutter/lib/pages/account/root.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/pages/account/login.dart'; + +import '../../state/user_account.dart'; +import 'choose_auth.dart'; + +class AccountRootPage extends StatefulWidget { + const AccountRootPage({super.key}); + + @override + State createState() => _AccountRootPageState(); +} + +enum _SubPage { chooseAuth, login, main } + +class _AccountRootPageState extends State { + late _SubPage _page; + + @override + void initState() { + super.initState(); + + var prov = Provider.of(context, listen: false); + + _page = (prov.auth != null) ? _SubPage.main : _SubPage.chooseAuth; + + prov.addListener(_onAuthStateChanged); + } + + @override + void dispose() { + Provider.of(context, listen: false).removeListener(_onAuthStateChanged); + super.dispose(); + } + + void _onAuthStateChanged() { + if (Provider.of(context, listen: false).auth != null && _page != _SubPage.main) { + setState(() { + _page = _SubPage.main; + }); + } + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, acc, child) { + switch (_page) { + case _SubPage.main: + return const Center( + child: Text( + 'Logged In', + style: TextStyle(fontSize: 24), + ), + ); + case _SubPage.chooseAuth: + return AccountChoosePage( + onLogin: () => setState(() { + _page = _SubPage.login; + }), + onCreateAccount: () => setState(() { + //TODO + }), + ); + case _SubPage.login: + return const AccountLoginPage(); + } + }, + ); + } +} diff --git a/flutter/lib/state/app_theme.dart b/flutter/lib/state/app_theme.dart new file mode 100644 index 0000000..70f239e --- /dev/null +++ b/flutter/lib/state/app_theme.dart @@ -0,0 +1,16 @@ +import 'package:flutter/foundation.dart'; + +class AppTheme extends ChangeNotifier { + bool _darkmode = false; + bool get darkMode => _darkmode; + + void setDarkMode(bool v) { + _darkmode = v; + notifyListeners(); + } + + void switchDarkMode() { + _darkmode = !_darkmode; + notifyListeners(); + } +} diff --git a/flutter/lib/state/user_account.dart b/flutter/lib/state/user_account.dart new file mode 100644 index 0000000..7cad39c --- /dev/null +++ b/flutter/lib/state/user_account.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; + +import '../models/key_token_auth.dart'; +import '../models/user.dart'; + +class UserAccount extends ChangeNotifier { + User? _user; + User? get user => _user; + + KeyTokenAuth? _auth; + KeyTokenAuth? get auth => _auth; + + void setToken(KeyTokenAuth auth) { + _auth = auth; + _user = user; + notifyListeners(); + } + + void setUser(User user) { + _user = user; + notifyListeners(); + } + + void clearUser() { + _user = null; + notifyListeners(); + } +} diff --git a/flutter/macos/Runner/DebugProfile.entitlements b/flutter/macos/Runner/DebugProfile.entitlements index dddb8a3..08c3ab1 100644 --- a/flutter/macos/Runner/DebugProfile.entitlements +++ b/flutter/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,7 @@ com.apple.security.network.server + com.apple.security.network.client + diff --git a/flutter/macos/Runner/Release.entitlements b/flutter/macos/Runner/Release.entitlements index 852fa1a..ee95ab7 100644 --- a/flutter/macos/Runner/Release.entitlements +++ b/flutter/macos/Runner/Release.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.network.client + diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 45c224b..f667ed0 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -82,6 +82,22 @@ packages: relative: true source: path version: "10.7.0" + http: + dependency: "direct main" + description: + name: http + sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba + url: "https://pub.dev" + source: hosted + version: "1.2.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" lints: dependency: transitive description: @@ -114,6 +130,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" path: dependency: transitive description: @@ -122,6 +146,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.8.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096" + url: "https://pub.dev" + source: hosted + version: "6.1.1" sky_engine: dependency: transitive description: flutter @@ -175,6 +207,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" vector_math: dependency: transitive description: @@ -193,3 +233,4 @@ packages: version: "0.3.0" sdks: dart: ">=3.2.6 <4.0.0" + flutter: ">=1.16.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index dce465f..deea4e0 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -36,6 +36,8 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 + http: ^1.2.0 + provider: ^6.1.1 dependency_overrides: diff --git a/flutter/test/widget_test.dart b/flutter/test/widget_test.dart deleted file mode 100644 index 897e1ae..0000000 --- a/flutter/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:simplecloudnotifier/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}