diff --git a/Backend/Api/Auth/ChangePassword.go b/Backend/Api/Auth/ChangePassword.go new file mode 100644 index 0000000..f4335cc --- /dev/null +++ b/Backend/Api/Auth/ChangePassword.go @@ -0,0 +1,76 @@ +package Auth + +import ( + "encoding/json" + "io/ioutil" + "net/http" + + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" +) + +type rawChangePassword struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + NewPasswordConfirm string `json:"new_password_confirm"` + PrivateKey string `json:"private_key"` +} + +// ChangePassword handle change password action +func ChangePassword(w http.ResponseWriter, r *http.Request) { + var ( + user Models.User + changePassword rawChangePassword + requestBody []byte + err error + ) + + user, err = CheckCookieCurrentUser(w, r) + if err != nil { + // Don't bother showing an error here, as the middleware handles auth + return + } + + requestBody, err = ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + err = json.Unmarshal(requestBody, &changePassword) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + if !CheckPasswordHash(changePassword.OldPassword, user.Password) { + http.Error(w, "Invalid Current Password", http.StatusForbidden) + return + } + + // This should never occur, due to frontend validation + if changePassword.NewPassword != changePassword.NewPasswordConfirm { + http.Error(w, "Invalid New Password", http.StatusUnprocessableEntity) + return + } + + user.Password, err = HashPassword(changePassword.NewPassword) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + // Private key doesn't change at this point, is just re-encrypted with the new password + user.AsymmetricPrivateKey = changePassword.PrivateKey + + err = Database.UpdateUser( + user.ID.String(), + &user, + ) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/Backend/Api/Auth/Check.go b/Backend/Api/Auth/Check.go index e503183..a5f49ba 100644 --- a/Backend/Api/Auth/Check.go +++ b/Backend/Api/Auth/Check.go @@ -4,6 +4,7 @@ import ( "net/http" ) +// Check is used to check session viability func Check(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } diff --git a/Backend/Api/Auth/Signup.go b/Backend/Api/Auth/Signup.go index 57509ab..b60f880 100644 --- a/Backend/Api/Auth/Signup.go +++ b/Backend/Api/Auth/Signup.go @@ -18,15 +18,15 @@ type signupResponse struct { func makeSignupResponse(w http.ResponseWriter, code int, message string) { var ( - status string = "error" - returnJson []byte + status = "error" + returnJSON []byte err error ) if code > 200 && code < 300 { status = "success" } - returnJson, err = json.MarshalIndent(signupResponse{ + returnJSON, err = json.MarshalIndent(signupResponse{ Status: status, Message: message, }, "", " ") @@ -37,10 +37,11 @@ func makeSignupResponse(w http.ResponseWriter, code int, message string) { // Return updated json w.WriteHeader(code) - w.Write(returnJson) + w.Write(returnJSON) } +// Signup to the platform func Signup(w http.ResponseWriter, r *http.Request) { var ( userData Models.User diff --git a/Backend/Api/Routes.go b/Backend/Api/Routes.go index 50f4f01..f7b8151 100644 --- a/Backend/Api/Routes.go +++ b/Backend/Api/Routes.go @@ -61,6 +61,8 @@ func InitAPIEndpoints(router *mux.Router) { authAPI.HandleFunc("/check", Auth.Check).Methods("GET") + authAPI.HandleFunc("/change_password", Auth.ChangePassword).Methods("POST") + authAPI.HandleFunc("/users", Users.SearchUsers).Methods("GET") authAPI.HandleFunc("/friend_requests", Friends.EncryptedFriendRequestList).Methods("GET") diff --git a/Backend/Database/Seeder/Seed.go b/Backend/Database/Seeder/Seed.go index 7e9a373..7bd5c40 100644 --- a/Backend/Database/Seeder/Seed.go +++ b/Backend/Database/Seeder/Seed.go @@ -58,6 +58,7 @@ var ( decodedPrivateKey *rsa.PrivateKey ) +// Seed seeds semi random data for use in testing & development func Seed() { var ( block *pem.Block diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 656c188..01f32c8 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -49,6 +49,7 @@ class MyApp extends StatelessWidget { brightness: Brightness.dark, primaryColor: Colors.orange.shade900, backgroundColor: Colors.grey.shade800, + scaffoldBackgroundColor: Colors.grey[850], colorScheme: ColorScheme( brightness: Brightness.dark, primary: Colors.orange.shade900, diff --git a/mobile/lib/views/authentication/signup.dart b/mobile/lib/views/authentication/signup.dart index c9d3447..9522444 100644 --- a/mobile/lib/views/authentication/signup.dart +++ b/mobile/lib/views/authentication/signup.dart @@ -18,17 +18,17 @@ Future signUp(context, String username, String password, String // TODO: Check for timeout here final resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/signup'), - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: jsonEncode({ - 'username': username, - 'password': password, - 'confirm_password': confirmPassword, - 'asymmetric_public_key': rsaPubPem, - 'asymmetric_private_key': encRsaPriv, - }), + Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/signup'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: jsonEncode({ + 'username': username, + 'password': password, + 'confirm_password': confirmPassword, + 'asymmetric_public_key': rsaPubPem, + 'asymmetric_private_key': encRsaPriv, + }), ); SignupResponse response = SignupResponse.fromJson(jsonDecode(resp.body)); @@ -47,17 +47,17 @@ class Signup extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: null, - automaticallyImplyLeading: true, - //`true` if you want Flutter to automatically add Back Button when needed, - //or `false` if you want to force your own back button every where - leading: IconButton(icon: const Icon(Icons.arrow_back), - onPressed:() => { - Navigator.pop(context) - } - ), - backgroundColor: Colors.transparent, - shadowColor: Colors.transparent, + title: null, + automaticallyImplyLeading: true, + //`true` if you want Flutter to automatically add Back Button when needed, + //or `false` if you want to force your own back button every where + leading: IconButton(icon: const Icon(Icons.arrow_back), + onPressed:() => { + Navigator.pop(context) + } + ), + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, ), body: const SafeArea( child: SignupWidget(), @@ -77,8 +77,8 @@ class SignupResponse { factory SignupResponse.fromJson(Map json) { return SignupResponse( - status: json['status'], - message: json['message'], + status: json['status'], + message: json['message'], ); } } @@ -93,9 +93,9 @@ class SignupWidget extends StatefulWidget { class _SignupWidgetState extends State { final _formKey = GlobalKey(); - TextEditingController usernameController = TextEditingController(); - TextEditingController passwordController = TextEditingController(); - TextEditingController passwordConfirmController = TextEditingController(); + TextEditingController _usernameController = TextEditingController(); + TextEditingController _passwordController = TextEditingController(); + TextEditingController _passwordConfirmController = TextEditingController(); @override Widget build(BuildContext context) { @@ -123,114 +123,116 @@ class _SignupWidgetState extends State { ); return Center( - child: Form( - key: _formKey, - child: Center( - child: Padding( - padding: const EdgeInsets.only( - left: 20, - right: 20, - top: 0, - bottom: 100, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'Sign Up', - style: TextStyle( - fontSize: 35, - color: Theme.of(context).colorScheme.onBackground, - ), - ), - const SizedBox(height: 30), - TextFormField( - controller: usernameController, - decoration: InputDecoration( - hintText: 'Username', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Create a username'; - } - return null; - }, - ), - const SizedBox(height: 10), - TextFormField( - controller: passwordController, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - hintText: 'Password', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Enter a password'; - } - return null; - }, - ), - const SizedBox(height: 10), - TextFormField( - controller: passwordConfirmController, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - hintText: 'Password', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Confirm your password'; - } - if (value != passwordController.text) { - return 'Passwords do not match'; - } - return null; - }, - ), - const SizedBox(height: 15), - ElevatedButton( - style: buttonStyle, - onPressed: () { - if (_formKey.currentState!.validate()) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Processing Data')), - ); - - signUp( - context, - usernameController.text, - passwordController.text, - passwordConfirmController.text - ).then((value) { - Navigator.of(context).popUntil((route) => route.isFirst); - }).catchError((error) { - print(error); // TODO: Show error on interface - }); - } - }, - child: const Text('Submit'), - ), - ], - ) - ) + child: Form( + key: _formKey, + child: Center( + child: Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + top: 0, + bottom: 100, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Sign Up', + style: TextStyle( + fontSize: 35, + color: Theme.of(context).colorScheme.onBackground, + ), + ), + const SizedBox(height: 30), + TextFormField( + controller: _usernameController, + decoration: InputDecoration( + hintText: 'Username', + enabledBorder: inputBorderStyle, + focusedBorder: inputBorderStyle, + ), + style: inputTextStyle, + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Create a username'; + } + return null; + }, + ), + const SizedBox(height: 10), + TextFormField( + controller: _passwordController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + hintText: 'Password', + enabledBorder: inputBorderStyle, + focusedBorder: inputBorderStyle, + ), + style: inputTextStyle, + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter a password'; + } + return null; + }, + ), + const SizedBox(height: 10), + TextFormField( + controller: _passwordConfirmController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + hintText: 'Confirm Password', + enabledBorder: inputBorderStyle, + focusedBorder: inputBorderStyle, + ), + style: inputTextStyle, + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Confirm your password'; + } + if (value != _passwordController.text) { + return 'Passwords do not match'; + } + return null; + }, + ), + const SizedBox(height: 15), + ElevatedButton( + style: buttonStyle, + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Processing Data')), + ); + + signUp( + context, + _usernameController.text, + _passwordController.text, + _passwordConfirmController.text + ).then((value) { + Navigator.of(context).popUntil((route) => route.isFirst); + }).catchError((error) { + print(error); // TODO: Show error on interface + }); + }, + child: const Text('Submit'), + ), + ], + ) ) + ) ) ); } diff --git a/mobile/lib/views/main/profile/change_password.dart b/mobile/lib/views/main/profile/change_password.dart new file mode 100644 index 0000000..3b2e6f8 --- /dev/null +++ b/mobile/lib/views/main/profile/change_password.dart @@ -0,0 +1,180 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter/material.dart'; +import 'package:pointycastle/impl.dart'; + +import '/components/flash_message.dart'; +import '/components/custom_title_bar.dart'; +import '/utils/encryption/aes_helper.dart'; +import '/utils/encryption/crypto_utils.dart'; +import '/utils/storage/session_cookie.dart'; + +@immutable +class ChangePassword extends StatelessWidget { + ChangePassword({ + Key? key, + required this.privateKey + }) : super(key: key); + + final RSAPrivateKey privateKey; + + final _formKey = GlobalKey(); + + final TextEditingController _currentPasswordController = TextEditingController(); + final TextEditingController _newPasswordController = TextEditingController(); + final TextEditingController _newPasswordConfirmController = TextEditingController(); + + bool invalidCurrentPassword = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const CustomTitleBar( + title: Text( + 'Profile', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold + ) + ), + showBack: true, + backgroundColor: Colors.transparent, + ), + body: Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + top: 30, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + 'Change Password', + style: TextStyle( + fontSize: 25, + ), + ), + const SizedBox(height: 30), + TextFormField( + controller: _currentPasswordController, + decoration: const InputDecoration( + hintText: 'Current Password', + ), + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter your current password'; + } + if (invalidCurrentPassword) { + return 'Invalid password'; + } + return null; + }, + ), + const SizedBox(height: 10), + TextFormField( + controller: _newPasswordController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: const InputDecoration( + hintText: 'New Password', + ), + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter a new password'; + } + return null; + }, + ), + const SizedBox(height: 10), + TextFormField( + controller: _newPasswordConfirmController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: const InputDecoration( + hintText: 'Confirm Password', + ), + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Confirm your password'; + } + if (value != _newPasswordController.text) { + return 'Passwords do not match'; + } + return null; + }, + ), + const SizedBox(height: 15), + ElevatedButton( + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Processing Data')), + ); + + _changePassword(context) + .then((dynamic) { + Navigator.of(context).pop(); + }); + }, + child: const Text('Submit'), + ), + ], + ) + ) + ) + ); + } + + Future _changePassword(BuildContext context) async { + String privateKeyPem = CryptoUtils.encodeRSAPrivateKeyToPem(privateKey); + + String privateKeyEncrypted = AesHelper.aesEncrypt( + _newPasswordController.text, + Uint8List.fromList(privateKeyPem.codeUnits), + ); + + String payload = jsonEncode({ + 'old_password': _currentPasswordController.text, + 'new_password': _newPasswordController.text, + 'new_password_confirm': _newPasswordConfirmController.text, + 'private_key': privateKeyEncrypted, + }); + + var resp = await http.post( + Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/change_password'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'cookie': await getSessionCookie(), + }, + body: payload, + ); + + if (resp.statusCode == 403) { + invalidCurrentPassword = true; + _formKey.currentState!.validate(); + return; + } + + if (resp.statusCode != 200) { + showMessage( + 'An unexpected error occured, please try again later.', + context, + ); + } + } +} + diff --git a/mobile/lib/views/main/profile/profile.dart b/mobile/lib/views/main/profile/profile.dart index 5127da0..b25770d 100644 --- a/mobile/lib/views/main/profile/profile.dart +++ b/mobile/lib/views/main/profile/profile.dart @@ -1,10 +1,16 @@ -import 'package:Envelope/components/custom_title_bar.dart'; +import 'dart:convert'; + +import 'package:Envelope/utils/encryption/crypto_utils.dart'; +import 'package:Envelope/views/main/profile/change_password.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:qr_flutter/qr_flutter.dart'; -import '/utils/storage/database.dart'; -import '/models/my_profile.dart'; +import 'package:sliding_up_panel/sliding_up_panel.dart'; + import '/components/custom_circle_avatar.dart'; +import '/components/custom_title_bar.dart'; +import '/models/my_profile.dart'; +import '/utils/storage/database.dart'; class Profile extends StatefulWidget { final MyProfile profile; @@ -18,81 +24,81 @@ class Profile extends StatefulWidget { } class _ProfileState extends State { - Widget usernameHeading() { - return Row( - children: [ - const CustomCircleAvatar( - icon: Icon(Icons.person, size: 40), - imagePath: null, // TODO: Add image here - radius: 30, - ), - const SizedBox(width: 20), - Text( - widget.profile.username, - style: const TextStyle( - fontSize: 25, - fontWeight: FontWeight.w500, - ), - ), - // widget.conversation.admin ? IconButton( - // iconSize: 20, - // icon: const Icon(Icons.edit), - // padding: const EdgeInsets.all(5.0), - // splashRadius: 25, - // onPressed: () { - // // TODO: Redirect to edit screen - // }, - // ) : const SizedBox.shrink(), - ], - ); - } + final PanelController _panelController = PanelController(); - Widget _profileQrCode() { - return Container( - child: QrImage( - data: 'This is a simple QR code', - version: QrVersions.auto, - size: 130, - gapless: true, + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const CustomTitleBar( + title: Text( + 'Profile', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold + ) + ), + showBack: false, + backgroundColor: Colors.transparent, + ), + body: SlidingUpPanel( + controller: _panelController, + slideDirection: SlideDirection.DOWN, + defaultPanelState: PanelState.CLOSED, + color: Theme.of(context).scaffoldBackgroundColor, + backdropTapClosesPanel: true, + backdropEnabled: true, + backdropOpacity: 0.2, + minHeight: 0, + maxHeight: 450, + panel: Center( + child: _profileQrCode(), ), - width: 130, - height: 130, - color: Theme.of(context).colorScheme.onPrimary, + body: Padding( + padding: const EdgeInsets.only(top: 16,left: 16,right: 16), + child: Column( + children: [ + usernameHeading(), + const SizedBox(height: 30), + settings(), + const SizedBox(height: 30), + logout(), + ], + ) + ), + ), ); } - Widget settings() { - return Align( - alignment: Alignment.centerLeft, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 5), - TextButton.icon( - label: const Text( - 'Disappearing Messages', - style: TextStyle(fontSize: 16) - ), - icon: const Icon(Icons.timer), - style: ButtonStyle( - alignment: Alignment.centerLeft, - foregroundColor: MaterialStateProperty.resolveWith( - (Set states) { - return Theme.of(context).colorScheme.onBackground; - }, - ) - ), - onPressed: () { - print('Disappearing Messages'); - } + Widget usernameHeading() { + return Row( + children: [ + const CustomCircleAvatar( + icon: Icon(Icons.person, size: 40), + imagePath: null, // TODO: Add image here + radius: 30, + ), + const SizedBox(width: 20), + Expanded( + flex: 1, + child: Text( + widget.profile.username, + style: const TextStyle( + fontSize: 25, + fontWeight: FontWeight.w500, ), - ], - ), + ), + ), + IconButton( + onPressed: () => _panelController.open(), + icon: const Icon(Icons.qr_code_2), + ), + ], ); } Widget logout() { - bool isTesting = dotenv.env["ENVIRONMENT"] == 'development'; + bool isTesting = dotenv.env['ENVIRONMENT'] == 'development'; + return Align( alignment: Alignment.centerLeft, child: Column( @@ -131,34 +137,109 @@ class _ProfileState extends State { ); } - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: const CustomTitleBar( - title: Text( - 'Profile', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold - ) - ), - showBack: false, - backgroundColor: Colors.transparent, + Widget settings() { + return Align( + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 5), + TextButton.icon( + label: const Text( + 'Disappearing Messages', + style: TextStyle(fontSize: 16) + ), + icon: const Icon(Icons.timer), + style: ButtonStyle( + alignment: Alignment.centerLeft, + foregroundColor: MaterialStateProperty.resolveWith( + (Set states) { + return Theme.of(context).colorScheme.onBackground; + }, + ) + ), + onPressed: () { + print('Disappearing Messages'); + } + ), + const SizedBox(height: 5), + TextButton.icon( + label: const Text( + 'Server URL', + style: TextStyle(fontSize: 16) + ), + icon: const Icon(Icons.dataset_linked_outlined), + style: ButtonStyle( + alignment: Alignment.centerLeft, + foregroundColor: MaterialStateProperty.resolveWith( + (Set states) { + return Theme.of(context).colorScheme.onBackground; + }, + ) + ), + onPressed: () { + print('Server URL'); + } + ), + const SizedBox(height: 5), + TextButton.icon( + label: const Text( + 'Change Password', + style: TextStyle(fontSize: 16) + ), + icon: const Icon(Icons.password), + style: ButtonStyle( + alignment: Alignment.centerLeft, + foregroundColor: MaterialStateProperty.resolveWith( + (Set states) { + return Theme.of(context).colorScheme.onBackground; + }, + ) + ), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => ChangePassword( + privateKey: widget.profile.privateKey!, + )) + ); + } + ), + ], ), - body: Padding( - padding: const EdgeInsets.only(top: 16,left: 16,right: 16), - child: Column( - children: [ - usernameHeading(), - const SizedBox(height: 30), - _profileQrCode(), - const SizedBox(height: 30), - settings(), - const SizedBox(height: 30), - logout(), - ], - ) + ); + } + + Widget _profileQrCode() { + String payload = jsonEncode({ + 'i': widget.profile.id, + 'u': widget.profile.username, + 'k': base64.encode( + CryptoUtils.encodeRSAPublicKeyToPem(widget.profile.publicKey!).codeUnits ), + }); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: QrImage( + backgroundColor: Theme.of(context).colorScheme.primary, + data: payload, + version: QrVersions.auto, + gapless: true, + ), + ), + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: 20), + child: IconButton( + onPressed: () => _panelController.close(), + icon: const Icon(Icons.arrow_upward), + ), + ), + ), + ] ); } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 917ab66..128580d 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -322,6 +322,13 @@ packages: description: flutter source: sdk version: "0.0.99" + sliding_up_panel: + dependency: "direct main" + description: + name: sliding_up_panel + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0+1" source_span: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6007c51..9a4d284 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: uuid: ^3.0.6 qr_flutter: ^4.0.0 qr_code_scanner: ^1.0.1 + sliding_up_panel: ^2.0.0+1 dev_dependencies: flutter_test: