From 45be2c80ff486f647400f9a27cd6bc482f138c4d Mon Sep 17 00:00:00 2001 From: Andrew nuark G Date: Wed, 12 Apr 2023 03:03:48 +0700 Subject: [PATCH] Base user workflow (create/update) --- lib/api/api_client.dart | 64 ++++++ lib/api/model/user_model.dart | 27 +++ .../create_table_item_bottomsheet.dart | 2 +- .../bottomsheets/create_user_bottomsheet.dart | 217 ++++++++++++++++++ .../bottomsheets/open_table_bottomsheet.dart | 8 +- lib/pages/home_page.dart | 5 +- lib/pages/home_panels/users_list_panel.dart | 211 ++++++++++++++++- lib/utils.dart | 11 + 8 files changed, 528 insertions(+), 17 deletions(-) create mode 100644 lib/api/model/user_model.dart create mode 100644 lib/pages/bottomsheets/create_user_bottomsheet.dart create mode 100644 lib/utils.dart diff --git a/lib/api/api_client.dart b/lib/api/api_client.dart index 80a8abf..33eaf80 100644 --- a/lib/api/api_client.dart +++ b/lib/api/api_client.dart @@ -6,6 +6,7 @@ import 'package:http/browser_client.dart'; import 'package:http/http.dart'; import 'package:tuuli_app/api/model/table_field_model.dart'; import 'package:tuuli_app/api/model/tables_list_model.dart'; +import 'package:tuuli_app/api/model/user_model.dart'; class ErrorOrData { final T? data; @@ -292,6 +293,69 @@ class ApiClient { return ErrorOrData(ignored, error); } + FutureErrorOrData createUser( + TableModel table, String username, String password) async { + bool? ignored; + Exception? error; + + final response = await post( + '/api/createUser', + body: { + "username": username, + "password": password, + }, + headers: { + 'Content-Type': 'application/json', + }, + ); + if (response.statusCode == 200) { + final body = json.decode(await response.stream.bytesToString()); + if (body['error'] != null) { + error = Exception(body['error']); + } else { + ignored = true; + } + } else if (response.statusCode == 422) { + error = Exception('Invalid request parameters'); + } else { + error = Exception('HTTP ${response.statusCode}'); + } + + return ErrorOrData(ignored, error); + } + + FutureErrorOrData updateUser( + TableModel table, int userId, String password, String accessToken) async { + bool? ignored; + Exception? error; + + final response = await post( + '/api/updateUser', + body: { + "user_id": userId, + "password": password, + "access_token": accessToken, + }, + headers: { + 'Content-Type': 'application/json', + }, + ); + if (response.statusCode == 200) { + final body = json.decode(await response.stream.bytesToString()); + if (body['error'] != null) { + error = Exception(body['error']); + } else { + ignored = true; + } + } else if (response.statusCode == 422) { + error = Exception('Invalid request parameters'); + } else { + error = Exception('HTTP ${response.statusCode}'); + } + + return ErrorOrData(ignored, error); + } + // REGION: HTTP Methods implementation Future get( diff --git a/lib/api/model/user_model.dart b/lib/api/model/user_model.dart new file mode 100644 index 0000000..35b88a9 --- /dev/null +++ b/lib/api/model/user_model.dart @@ -0,0 +1,27 @@ +class UserModel { + final int id; + final String username; + final String? password; + final String? accessToken; + + UserModel({ + required this.id, + required this.username, + this.password, + this.accessToken, + }); + + factory UserModel.fromJson(Map json) => UserModel( + id: json["id"], + username: json["username"], + password: json["password"], + accessToken: json["access_token"], + ); + + Map toJson() => { + "id": id, + "username": username, + "password": password, + "access_token": accessToken, + }; +} diff --git a/lib/pages/bottomsheets/create_table_item_bottomsheet.dart b/lib/pages/bottomsheets/create_table_item_bottomsheet.dart index c979b13..45c5621 100644 --- a/lib/pages/bottomsheets/create_table_item_bottomsheet.dart +++ b/lib/pages/bottomsheets/create_table_item_bottomsheet.dart @@ -47,7 +47,7 @@ class _CreateTableItemBottomSheetState formKey: _formKey, children: [ Text( - "Create new item", + widget.existingItem == null ? "Create new item" : "Update item", style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 16), diff --git a/lib/pages/bottomsheets/create_user_bottomsheet.dart b/lib/pages/bottomsheets/create_user_bottomsheet.dart new file mode 100644 index 0000000..886ef86 --- /dev/null +++ b/lib/pages/bottomsheets/create_user_bottomsheet.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_fast_forms/flutter_fast_forms.dart'; +import 'package:get/get.dart'; +import 'package:tuuli_app/api/api_client.dart'; +import 'package:tuuli_app/api/model/table_field_model.dart'; +import 'package:tuuli_app/api/model/tables_list_model.dart'; +import 'package:tuuli_app/api/model/user_model.dart'; +import 'package:tuuli_app/utils.dart'; +import 'package:uuid/uuid.dart'; +import 'package:uuid/uuid_util.dart'; + +class CreateUserResult { + final String username; + final String password; + + CreateUserResult(this.username, this.password); +} + +class UpdateUserResult { + final String username; + final String password; + final String accessToken; + + UpdateUserResult(this.username, this.password, this.accessToken); +} + +// TODO: Add a way to change user's group +class CreateUserBottomSheet extends StatefulWidget { + final UserModel? existingUser; + + const CreateUserBottomSheet({ + super.key, + this.existingUser, + }); + + @override + State createState() => _CreateUserBottomSheetState(); +} + +class _CreateUserBottomSheetState extends State { + final _formKey = GlobalKey(); + + String? newUsername; + String? newPassword; + String? newAccessToken; + + bool obscurePassword = true; + bool obscureToken = true; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: FastForm( + formKey: _formKey, + children: [ + Text( + widget.existingUser == null ? "Create new user" : "Update user", + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + Card( + margin: const EdgeInsets.all(8), + child: Container( + padding: const EdgeInsets.all(8), + child: FastTextField( + name: "Username", + labelText: "Username", + validator: (value) { + if (value == null || value.isEmpty) { + return "Please enter a value"; + } + return null; + }, + readOnly: widget.existingUser != null, + initialValue: widget.existingUser?.username, + onChanged: (value) { + newUsername = value; + }, + ), + ), + ), + Card( + margin: const EdgeInsets.all(8), + child: Container( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Flexible( + child: FastTextField( + name: "Password", + labelText: "Password", + validator: (value) { + if (value == null || value.isEmpty) { + return "Please enter a value"; + } + return null; + }, + obscureText: obscurePassword, + onChanged: (value) { + newPassword = value; + }, + ), + ), + IconButton( + icon: Icon( + obscurePassword + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + obscurePassword = !obscurePassword; + }); + }, + ), + ], + ), + ), + ), + if (widget.existingUser != null) + Card( + margin: const EdgeInsets.all(8), + child: Container( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Flexible( + child: FastTextField( + name: "Access token", + labelText: "Access token", + validator: (value) { + if (value == null || value.isEmpty) { + return "Please enter a value"; + } + return null; + }, + obscureText: obscureToken, + initialValue: newAccessToken ?? + widget.existingUser?.accessToken, + readOnly: true, + onChanged: (value) { + newAccessToken = value; + }, + ), + ), + IconButton( + icon: const Icon(Icons.shuffle), + onPressed: () { + setState(() { + newAccessToken = randomHexString(64); + }); + }, + ), + IconButton( + icon: Icon( + obscurePassword + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + obscureToken = !obscureToken; + }); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Cancel"), + ), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + Navigator.of(context).pop(widget.existingUser == null + ? CreateUserResult( + newUsername!, + newPassword!, + ) + : UpdateUserResult( + newUsername!, + newPassword!, + newAccessToken!, + )); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Please fill in all fields"), + ), + ); + }, + child: + Text(widget.existingUser == null ? "Create" : "Update"), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/bottomsheets/open_table_bottomsheet.dart b/lib/pages/bottomsheets/open_table_bottomsheet.dart index bccfae2..44b95db 100644 --- a/lib/pages/bottomsheets/open_table_bottomsheet.dart +++ b/lib/pages/bottomsheets/open_table_bottomsheet.dart @@ -85,14 +85,14 @@ class _OpenTableBottomSheetState extends State { DataCell( Row( children: [ - IconButton( - onPressed: () => _deleteItem(e), - icon: const Icon(Icons.delete), - ), IconButton( onPressed: () => _updateExistingItem(e), icon: const Icon(Icons.edit), ), + IconButton( + onPressed: () => _deleteItem(e), + icon: const Icon(Icons.delete), + ), ], ), ), diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 7496d4a..26e351c 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -31,6 +31,9 @@ class _HomePageState extends State with HomePageStateRef { final apiClient = Get.find(); TablesListModel tables = TablesListModel([]); + TableModel get usersTable => + tables.tables.firstWhere((element) => element.tableName == "users"); + bool get isNarrow => (MediaQuery.of(context).size.width - C.materialDrawerWidth) <= 600; @@ -142,7 +145,7 @@ class _HomePageState extends State with HomePageStateRef { case PageType.tables: return TablesListPanel(parent: this, tables: tables); case PageType.users: - return const UsersListPanel(); + return UsersListPanel(usersTable: usersTable); case PageType.settings: return const SettingsPanel(); case PageType.none: diff --git a/lib/pages/home_panels/users_list_panel.dart b/lib/pages/home_panels/users_list_panel.dart index 1d3ad2d..aa316f5 100644 --- a/lib/pages/home_panels/users_list_panel.dart +++ b/lib/pages/home_panels/users_list_panel.dart @@ -1,36 +1,225 @@ +import 'package:bottom_sheet/bottom_sheet.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:tuuli_app/api/api_client.dart'; +import 'package:tuuli_app/api/model/tables_list_model.dart'; +import 'package:tuuli_app/api/model/user_model.dart'; +import 'package:tuuli_app/pages/bottomsheets/create_user_bottomsheet.dart'; class UsersListPanel extends StatefulWidget { - const UsersListPanel({super.key}); + final TableModel usersTable; + + const UsersListPanel({super.key, required this.usersTable}); @override State createState() => _UsersListPanelState(); } class _UsersListPanelState extends State { + final apiClient = Get.find(); + + final users = []; + @override void initState() { super.initState(); + + _refreshUsers(); } @override Widget build(BuildContext context) { - return SingleChildScrollView( - child: IntrinsicHeight( - child: _buildBody(), + return _buildUserList(); + } + + Widget _buildUserCard(BuildContext ctx, UserModel user) { + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _openUser(user), + child: Container( + margin: const EdgeInsets.all(5), + padding: const EdgeInsets.all(5), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + user.username, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Row( + children: [ + Text( + "User id: ${user.id}", + style: const TextStyle( + fontSize: 12, + ), + ), + ], + ), + ], + ), + ], + ), + ), ), ); } - Widget _buildBody() { - return Column( - children: [ - Text( - 'Users', - style: Theme.of(context).textTheme.headlineSmall, + Widget get newUserCard => Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: _createNewUser, + child: Container( + margin: const EdgeInsets.all(5), + padding: const EdgeInsets.all(5), + child: const Icon(Icons.add), + ), ), - ], + ); + + Widget _buildUserList() { + if (users.isEmpty) { + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "No users found", + style: TextStyle( + color: Theme.of(context).disabledColor, + fontSize: 24, + ), + ), + const SizedBox(height: 4), + Text( + "Maybe create one? Or maybe you don't have permission to view them?", + style: TextStyle( + color: Theme.of(context).disabledColor, + fontSize: 14, + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _createNewUser, + icon: const Icon(Icons.add), + label: const Text("Create user"), + ) + ], + ), + ); + } + + return GridView.builder( + shrinkWrap: true, + itemCount: users.length + 1, + itemBuilder: (ctx, i) => + i == 0 ? newUserCard : _buildUserCard(ctx, users[i - 1]), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + childAspectRatio: 5 / 2, + ), ); } + + Future _createNewUser() async { + var result = await showFlexibleBottomSheet( + minHeight: 1, + initHeight: 1, + maxHeight: 1, + context: context, + builder: (_, __, ___) => const CreateUserBottomSheet(), + anchors: [0, 0.5, 1], + isSafeArea: true, + isDismissible: false, + ); + + if (result == null) { + return; + } + + final response = await apiClient.createUser( + widget.usersTable, result.username, result.password); + response.unfold((data) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("User created"), + ), + ); + _refreshUsers(); + }, (error) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Error: $error"), + ), + ); + }); + } + + Future _openUser(UserModel user) async { + final result = await showFlexibleBottomSheet( + minHeight: 1, + initHeight: 1, + maxHeight: 1, + context: context, + builder: (_, __, ___) => CreateUserBottomSheet(existingUser: user), + anchors: [0, 0.5, 1], + isSafeArea: true, + isDismissible: false, + ); + + if (result == null) { + return; + } + + final response = await apiClient.updateItem(widget.usersTable, { + "username": result.username, + "password": result.password, + "access_token": result.accessToken, + }, { + "id": user.id, + }); + response.unfold((data) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("User updated"), + ), + ); + _refreshUsers(); + }, (error) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Error: $error"), + ), + ); + }); + } + + Future _refreshUsers() async { + final result = await apiClient.getTableItems(widget.usersTable); + result.unfold((data) { + setState(() { + users.clear(); + users.addAll(data.map((e) => UserModel.fromJson(e))); + }); + }, (error) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Error: $error"), + ), + ); + }); + } } diff --git a/lib/utils.dart b/lib/utils.dart new file mode 100644 index 0000000..8aff8fb --- /dev/null +++ b/lib/utils.dart @@ -0,0 +1,11 @@ +import 'dart:math'; + +Random _random = Random(); + +String randomHexString(int length) { + StringBuffer sb = StringBuffer(); + for (var i = 0; i < length; i++) { + sb.write(_random.nextInt(16).toRadixString(16)); + } + return sb.toString(); +}