pin code screen, forgot pin code screen
This commit is contained in:
54
lib/features/securePin/presentation/bloc/pin_bloc.dart
Normal file
54
lib/features/securePin/presentation/bloc/pin_bloc.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:tanami_app/core/styles/app_text.dart';
|
||||
|
||||
import '../../../../core/utils/secure/secure_storage_service.dart';
|
||||
|
||||
part 'pin_event.dart';
|
||||
part 'pin_state.dart';
|
||||
|
||||
class PinBloc extends Bloc<PinEvent, PinState> {
|
||||
final SecureStorageService secureStorageService;
|
||||
|
||||
PinBloc({required this.secureStorageService})
|
||||
: super(const PinState(
|
||||
pin: '', pinComplete: false, isVerified: false, error: '')) {
|
||||
on<NumberPressed>((event, emit) {
|
||||
final newPin = state.pin + event.number;
|
||||
|
||||
if (newPin.length <= 6) {
|
||||
emit(state.copyWith(
|
||||
pin: newPin, pinComplete: newPin.length == 6, error: ''));
|
||||
|
||||
if (newPin.length == 6) {
|
||||
add(VerifyPinPressed(newPin));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
on<BackspacePressed>((event, emit) {
|
||||
if (state.pin.isNotEmpty) {
|
||||
final newPin = state.pin.substring(0, state.pin.length - 1);
|
||||
emit(state.copyWith(
|
||||
pin: newPin, pinComplete: newPin.length == 6, error: ''));
|
||||
}
|
||||
});
|
||||
|
||||
on<SavePinPressed>((event, emit) async {
|
||||
await secureStorageService.write('pin_code', state.pin);
|
||||
});
|
||||
|
||||
on<VerifyPinPressed>((event, emit) async {
|
||||
final storedPin = await secureStorageService.read('pin_code');
|
||||
|
||||
if (storedPin == event.pin) {
|
||||
emit(state.copyWith(isVerified: true, error: ''));
|
||||
} else {
|
||||
emit(state.copyWith(
|
||||
isVerified: false,
|
||||
error: AppText.incorrectPinCode,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
30
lib/features/securePin/presentation/bloc/pin_event.dart
Normal file
30
lib/features/securePin/presentation/bloc/pin_event.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
part of 'pin_bloc.dart';
|
||||
|
||||
abstract class PinEvent extends Equatable {
|
||||
const PinEvent();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class NumberPressed extends PinEvent {
|
||||
final String number;
|
||||
|
||||
const NumberPressed(this.number);
|
||||
|
||||
@override
|
||||
List<Object> get props => [number];
|
||||
}
|
||||
|
||||
class BackspacePressed extends PinEvent {}
|
||||
|
||||
class SavePinPressed extends PinEvent {}
|
||||
|
||||
class VerifyPinPressed extends PinEvent {
|
||||
final String pin;
|
||||
|
||||
const VerifyPinPressed(this.pin);
|
||||
|
||||
@override
|
||||
List<Object> get props => [pin];
|
||||
}
|
||||
32
lib/features/securePin/presentation/bloc/pin_state.dart
Normal file
32
lib/features/securePin/presentation/bloc/pin_state.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
part of 'pin_bloc.dart';
|
||||
|
||||
class PinState extends Equatable {
|
||||
final String pin;
|
||||
final bool pinComplete;
|
||||
final bool isVerified;
|
||||
final String error;
|
||||
|
||||
const PinState({
|
||||
required this.pin,
|
||||
required this.pinComplete,
|
||||
required this.isVerified,
|
||||
required this.error,
|
||||
});
|
||||
|
||||
PinState copyWith({
|
||||
String? pin,
|
||||
bool? pinComplete,
|
||||
bool? isVerified,
|
||||
String? error,
|
||||
}) {
|
||||
return PinState(
|
||||
pin: pin ?? this.pin,
|
||||
pinComplete: pinComplete ?? this.pinComplete,
|
||||
isVerified: isVerified ?? this.isVerified,
|
||||
error: error ?? this.error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object> get props => [pin, pinComplete, isVerified, error];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../widgets/confirm_pin_keypad_section.dart';
|
||||
import '../widgets/confirm_pin_top_section.dart';
|
||||
|
||||
class ConfirmPinLayout extends StatelessWidget {
|
||||
const ConfirmPinLayout({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: ListView(
|
||||
children: const [
|
||||
ConfirmPinTopSection(),
|
||||
ConfirmPinKey(),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../core/styles/app_text.dart';
|
||||
import '../../../../core/utils/secure/secure_storage_service.dart';
|
||||
import '../../../../shared/components/appbar_widget.dart';
|
||||
import '../bloc/pin_bloc.dart';
|
||||
import 'confirm_pin_layout.dart';
|
||||
|
||||
class ConfirmPinScreen extends StatelessWidget {
|
||||
const ConfirmPinScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final secureStorageService = SecureStorageService();
|
||||
return Scaffold(
|
||||
appBar: const AppBarWidget(
|
||||
height: 75,
|
||||
titleTxt: AppText.createPinCode,
|
||||
showLeading: true,
|
||||
),
|
||||
resizeToAvoidBottomInset: true,
|
||||
body: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
// Create an instance of the OnboardingBloc
|
||||
create: (context) =>
|
||||
PinBloc(secureStorageService: secureStorageService),
|
||||
),
|
||||
],
|
||||
child: const ConfirmPinLayout(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
21
lib/features/securePin/presentation/pages/pin_layout.dart
Normal file
21
lib/features/securePin/presentation/pages/pin_layout.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tanami_app/features/securePin/presentation/widgets/pin_keypad_section.dart';
|
||||
import 'package:tanami_app/features/securePin/presentation/widgets/pin_top_section.dart';
|
||||
|
||||
class PinLayout extends StatelessWidget {
|
||||
final String fromScreen;
|
||||
const PinLayout({super.key, required this.fromScreen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: ListView(
|
||||
children: [
|
||||
PinTopSection(fromScreen: fromScreen),
|
||||
PinKey(
|
||||
fromScreen: fromScreen,
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
40
lib/features/securePin/presentation/pages/pin_screen.dart
Normal file
40
lib/features/securePin/presentation/pages/pin_screen.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../core/styles/app_text.dart';
|
||||
import '../../../../core/utils/secure/secure_storage_service.dart';
|
||||
import '../../../../shared/components/appbar_widget.dart';
|
||||
import '../bloc/pin_bloc.dart';
|
||||
import 'pin_layout.dart';
|
||||
|
||||
class PinScreen extends StatelessWidget {
|
||||
final String fromScreen;
|
||||
const PinScreen({super.key, required this.fromScreen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final secureStorageService = SecureStorageService();
|
||||
return Scaffold(
|
||||
appBar: fromScreen == "register"
|
||||
? const AppBarWidget(
|
||||
height: 75,
|
||||
titleTxt: AppText.createPinCode,
|
||||
showLeading: false,
|
||||
)
|
||||
: null,
|
||||
resizeToAvoidBottomInset: true,
|
||||
body: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
// Create an instance of the OnboardingBloc
|
||||
create: (context) =>
|
||||
PinBloc(secureStorageService: secureStorageService),
|
||||
),
|
||||
],
|
||||
child: PinLayout(
|
||||
fromScreen: fromScreen,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:tanami_app/core/routes/route_name.dart';
|
||||
import 'package:tanami_app/core/routes/routes.dart';
|
||||
import 'package:tanami_app/core/styles/app_color.dart';
|
||||
import 'package:tanami_app/shared/components/text_widget.dart';
|
||||
|
||||
import '../bloc/pin_bloc.dart';
|
||||
|
||||
class ConfirmPinKey extends StatelessWidget {
|
||||
const ConfirmPinKey({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
const Gap(20),
|
||||
BlocConsumer<PinBloc, PinState>(
|
||||
listener: (context, state) {
|
||||
if (state.pinComplete && state.isVerified) {
|
||||
// successToastMessage(context, "Pin verified successfully");
|
||||
goRouter.goNamed(RouteName.mainScreen);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(6, (index) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: state.error.isNotEmpty
|
||||
? AppColor.txtErrorColor
|
||||
: index < state.pin.length
|
||||
? AppColor.pinFillBorderColor
|
||||
: AppColor.pinInActiveBorderColor),
|
||||
color: index < state.pin.length
|
||||
? AppColor.pinFillColor
|
||||
: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
BlocBuilder<PinBloc, PinState>(
|
||||
builder: (context, state) {
|
||||
if (state.error.isNotEmpty) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 5),
|
||||
child: TextWidget()
|
||||
.text14W500(state.error, clr: AppColor.txtErrorColor),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
const Gap(50),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 1.3,
|
||||
),
|
||||
itemCount: 12,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 9) {
|
||||
return const SizedBox.shrink();
|
||||
} else if (index == 11) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.read<PinBloc>().add(BackspacePressed());
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back_rounded,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final number = index == 10 ? '0' : '${index + 1}';
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.read<PinBloc>().add(NumberPressed(number));
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black12),
|
||||
),
|
||||
child: Center(
|
||||
child: TextWidget().text20W700(
|
||||
number,
|
||||
clr: AppColor.plainBlack,
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:tanami_app/core/styles/app_color.dart';
|
||||
import 'package:tanami_app/shared/components/text_widget.dart';
|
||||
|
||||
import '../../../../core/styles/app_images.dart';
|
||||
import '../../../../core/styles/app_text.dart';
|
||||
|
||||
class ConfirmPinTopSection extends StatelessWidget {
|
||||
const ConfirmPinTopSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
const Gap(85),
|
||||
Center(
|
||||
child: SvgPicture.asset(
|
||||
AppImages.weclomeLogo,
|
||||
),
|
||||
),
|
||||
const Gap(60),
|
||||
TextWidget().text14W500(
|
||||
AppText.confirmPinCode,
|
||||
clr: AppColor.textLabelColor,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:tanami_app/core/routes/routes.dart';
|
||||
import 'package:tanami_app/core/styles/app_color.dart';
|
||||
import 'package:tanami_app/core/styles/app_text.dart';
|
||||
import 'package:tanami_app/shared/components/button_widget.dart';
|
||||
import 'package:tanami_app/shared/components/text_widget.dart';
|
||||
|
||||
void forgotPinDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: AppColor.plainWhite,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
color: AppColor.plainWhite,
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextWidget().text17W700(AppText.notificationText,
|
||||
clr: AppColor.plainBlack),
|
||||
const Gap(25),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 25,
|
||||
),
|
||||
child: TextWidget().text15W500(
|
||||
AppText.toRestorePinYouWillBeLoggedOut,
|
||||
clr: AppColor.hintTextColor,
|
||||
),
|
||||
),
|
||||
const Gap(40),
|
||||
SizedBox(
|
||||
width: 0.9.sw,
|
||||
height: 55.h,
|
||||
child: ButtonWidget().elevatedBtn(
|
||||
txtClr: AppColor.plainWhite,
|
||||
text: AppText.allowText,
|
||||
clr: AppColor.primaryColor2,
|
||||
function: () {
|
||||
goRouter.pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
const Gap(10),
|
||||
ButtonWidget().textBtn(
|
||||
text: TextWidget().text14W700(
|
||||
AppText.declineText,
|
||||
textDecoration: TextDecoration.underline,
|
||||
clr: AppColor.textLabelColor,
|
||||
),
|
||||
function: () {
|
||||
goRouter.pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:tanami_app/core/routes/route_name.dart';
|
||||
import 'package:tanami_app/core/routes/routes.dart';
|
||||
import 'package:tanami_app/core/styles/app_color.dart';
|
||||
import 'package:tanami_app/core/styles/app_text.dart';
|
||||
import 'package:tanami_app/features/securePin/presentation/widgets/forgot_pin_dialog.dart';
|
||||
import 'package:tanami_app/shared/components/text_widget.dart';
|
||||
|
||||
import '../bloc/pin_bloc.dart';
|
||||
|
||||
class PinKey extends StatelessWidget {
|
||||
final String fromScreen;
|
||||
const PinKey({
|
||||
super.key,
|
||||
required this.fromScreen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const Gap(20),
|
||||
BlocConsumer<PinBloc, PinState>(
|
||||
listener: (context, state) {
|
||||
if (state.pinComplete) {
|
||||
if (fromScreen == "login") {
|
||||
goRouter.pushNamed(RouteName.mainScreen);
|
||||
} else {
|
||||
context.read<PinBloc>().add(SavePinPressed());
|
||||
goRouter.pushNamed(RouteName.confirmPinScreen);
|
||||
}
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(6, (index) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: index < state.pin.length
|
||||
? AppColor.pinFillBorderColor
|
||||
: AppColor.pinInActiveBorderColor,
|
||||
),
|
||||
color: index < state.pin.length
|
||||
? AppColor.pinFillColor
|
||||
: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
fromScreen == "login" ? const Gap(20) : const Gap(0),
|
||||
fromScreen == "login"
|
||||
? InkWell(
|
||||
onTap: () {
|
||||
forgotPinDialog(context);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 50,
|
||||
),
|
||||
child: TextWidget().text15W500(
|
||||
AppText.forgotPinCode,
|
||||
clr: AppColor.hintTextColor,
|
||||
textDecoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox(),
|
||||
const Gap(50),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 1.3,
|
||||
),
|
||||
itemCount: 12,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 9) {
|
||||
return const SizedBox.shrink();
|
||||
} else if (index == 11) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.read<PinBloc>().add(BackspacePressed());
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back_rounded,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final number = index == 10 ? '0' : '${index + 1}';
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.read<PinBloc>().add(NumberPressed(number));
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black12),
|
||||
),
|
||||
child: Center(
|
||||
child: TextWidget().text20W700(
|
||||
number,
|
||||
clr: AppColor.plainBlack,
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:tanami_app/core/styles/app_color.dart';
|
||||
import 'package:tanami_app/core/styles/app_text.dart';
|
||||
import 'package:tanami_app/shared/components/text_widget.dart';
|
||||
|
||||
import '../../../../core/styles/app_images.dart';
|
||||
|
||||
class PinTopSection extends StatelessWidget {
|
||||
final String fromScreen;
|
||||
const PinTopSection({
|
||||
super.key,
|
||||
required this.fromScreen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
const Gap(85),
|
||||
Center(
|
||||
child: SvgPicture.asset(
|
||||
AppImages.weclomeLogo,
|
||||
),
|
||||
),
|
||||
const Gap(60),
|
||||
fromScreen == "login"
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
TextWidget().text14W500(AppText.welcomeBackText,
|
||||
clr: AppColor.smokeGrayColor),
|
||||
TextWidget()
|
||||
.text14W700(", Jayesh", clr: AppColor.plainBlack),
|
||||
],
|
||||
),
|
||||
TextWidget().text14W500(
|
||||
AppText.userYourAppPinToLoginEnterTanami,
|
||||
clr: AppColor.smokeGrayColor),
|
||||
const Gap(25),
|
||||
TextWidget().text14W400(
|
||||
AppText.pinCode,
|
||||
clr: AppColor.textLabelColor,
|
||||
)
|
||||
],
|
||||
)
|
||||
: TextWidget().text14W500(
|
||||
AppText.createPinCode,
|
||||
clr: AppColor.textLabelColor,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user