Browse Source

Update the profile page to show the QR code on dropdown

Add change password screen and endpoint
pull/2/head
Tovi Jaeschke-Rogers 2 years ago
parent
commit
fce45d4cad
11 changed files with 585 additions and 232 deletions
  1. +76
    -0
      Backend/Api/Auth/ChangePassword.go
  2. +1
    -0
      Backend/Api/Auth/Check.go
  3. +5
    -4
      Backend/Api/Auth/Signup.go
  4. +2
    -0
      Backend/Api/Routes.go
  5. +1
    -0
      Backend/Database/Seeder/Seed.go
  6. +1
    -0
      mobile/lib/main.dart
  7. +136
    -134
      mobile/lib/views/authentication/signup.dart
  8. +180
    -0
      mobile/lib/views/main/profile/change_password.dart
  9. +175
    -94
      mobile/lib/views/main/profile/profile.dart
  10. +7
    -0
      mobile/pubspec.lock
  11. +1
    -0
      mobile/pubspec.yaml

+ 76
- 0
Backend/Api/Auth/ChangePassword.go View File

@ -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)
}

+ 1
- 0
Backend/Api/Auth/Check.go View File

@ -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)
}

+ 5
- 4
Backend/Api/Auth/Signup.go View File

@ -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


+ 2
- 0
Backend/Api/Routes.go View File

@ -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")


+ 1
- 0
Backend/Database/Seeder/Seed.go View File

@ -58,6 +58,7 @@ var (
decodedPrivateKey *rsa.PrivateKey
)
// Seed seeds semi random data for use in testing & development
func Seed() {
var (
block *pem.Block


+ 1
- 0
mobile/lib/main.dart View File

@ -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,


+ 136
- 134
mobile/lib/views/authentication/signup.dart View File

@ -18,17 +18,17 @@ Future<SignupResponse> 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: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{
'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: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{
'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<String, dynamic> 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<SignupWidget> {
final _formKey = GlobalKey<FormState>();
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<SignupWidget> {
);
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'),
),
],
)
)
)
)
);
}


+ 180
- 0
mobile/lib/views/main/profile/change_password.dart View File

@ -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<FormState>();
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<void> _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: <String, String>{
'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,
);
}
}
}

+ 175
- 94
mobile/lib/views/main/profile/profile.dart View File

@ -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<Profile> {
Widget usernameHeading() {
return Row(
children: <Widget> [
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: <Widget>[
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<Color>(
(Set<MaterialState> states) {
return Theme.of(context).colorScheme.onBackground;
},
)
),
onPressed: () {
print('Disappearing Messages');
}
Widget usernameHeading() {
return Row(
children: <Widget> [
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<Profile> {
);
}
@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<Color>(
(Set<MaterialState> 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<Color>(
(Set<MaterialState> 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<Color>(
(Set<MaterialState> 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: <Widget>[
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),
),
),
),
]
);
}
}

+ 7
- 0
mobile/pubspec.lock View File

@ -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:


+ 1
- 0
mobile/pubspec.yaml View File

@ -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:


Loading…
Cancel
Save