TF-2907 Hide widget when corresponding event attribute is null
This commit is contained in:
@@ -362,7 +362,10 @@ class EmailView extends GetWidget<SingleEmailController> {
|
|||||||
presentationEmail: controller.currentEmail,
|
presentationEmail: controller.currentEmail,
|
||||||
onMailtoAttendeesAction: controller.handleMailToAttendees,
|
onMailtoAttendeesAction: controller.handleMailToAttendees,
|
||||||
)),
|
)),
|
||||||
if (calendarEvent.getTitleEventAction(context, emailAddressSender ?? []).isNotEmpty)
|
if (_validateDisplayEventActionBanner(
|
||||||
|
context: context,
|
||||||
|
event: calendarEvent,
|
||||||
|
emailAddressSender: emailAddressSender ?? []))
|
||||||
CalendarEventActionBannerWidget(
|
CalendarEventActionBannerWidget(
|
||||||
calendarEvent: calendarEvent,
|
calendarEvent: calendarEvent,
|
||||||
listEmailAddressSender: emailAddressSender ?? []
|
listEmailAddressSender: emailAddressSender ?? []
|
||||||
@@ -472,6 +475,20 @@ class EmailView extends GetWidget<SingleEmailController> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _validateDisplayEventActionBanner({
|
||||||
|
required BuildContext context,
|
||||||
|
required CalendarEvent event,
|
||||||
|
required List<String> emailAddressSender
|
||||||
|
}) {
|
||||||
|
final usernameEvent = event.getUserNameEventAction(
|
||||||
|
context: context,
|
||||||
|
imagePaths: controller.imagePaths,
|
||||||
|
listEmailAddressSender: emailAddressSender);
|
||||||
|
final titleEvent = event.getTitleEventAction(context, emailAddressSender);
|
||||||
|
|
||||||
|
return usernameEvent.isNotEmpty && titleEvent.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
void _handleMoreEmailAction({
|
void _handleMoreEmailAction({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required PresentationEmail presentationEmail,
|
required PresentationEmail presentationEmail,
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ extension CalendarEventExtension on CalendarEvent {
|
|||||||
case EventMethod.refresh:
|
case EventMethod.refresh:
|
||||||
case EventMethod.cancel:
|
case EventMethod.cancel:
|
||||||
case EventMethod.declineCounter:
|
case EventMethod.declineCounter:
|
||||||
return getOrganizerNameEvent(context);
|
return organizerName;
|
||||||
case EventMethod.reply:
|
case EventMethod.reply:
|
||||||
case EventMethod.counter:
|
case EventMethod.counter:
|
||||||
return getAttendeeNameEvent(context, listEmailAddressSender);
|
return getAttendeeNameEvent(context, listEmailAddressSender);
|
||||||
@@ -119,8 +119,6 @@ extension CalendarEventExtension on CalendarEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String getOrganizerNameEvent(BuildContext context) => organizer?.name ?? AppLocalizations.of(context).you;
|
|
||||||
|
|
||||||
String get organizerName => organizer?.name ?? organizer?.mailto?.value ?? '';
|
String get organizerName => organizer?.name ?? organizer?.mailto?.value ?? '';
|
||||||
|
|
||||||
String getAttendeeNameEvent(BuildContext context, List<String> listEmailAddressSender) {
|
String getAttendeeNameEvent(BuildContext context, List<String> listEmailAddressSender) {
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ extension ListAttendeeExtension on List<CalendarAttendee> {
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
List<CalendarAttendee> withoutOrganizer(CalendarOrganizer organizer) {
|
List<CalendarAttendee> withoutOrganizer(CalendarOrganizer? organizer) {
|
||||||
return where((attendee) => attendee.mailto?.mailAddress != organizer.mailto)
|
if (organizer == null) {
|
||||||
.whereNotNull()
|
return this;
|
||||||
.toList();
|
}
|
||||||
|
return where((attendee) => attendee.mailto?.mailAddress != organizer.mailto)
|
||||||
|
.whereNotNull()
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+10
-9
@@ -72,12 +72,13 @@ class CalendarEventDetailWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (calendarEvent.title?.isNotEmpty == true)
|
if (calendarEvent.title?.isNotEmpty == true)
|
||||||
EventTitleWidget(title: calendarEvent.title!),
|
EventTitleWidget(title: calendarEvent.title!),
|
||||||
Padding(
|
if (eventDesc.isNotEmpty)
|
||||||
padding: const EdgeInsets.only(top: CalendarEventDetailWidgetStyles.fieldTopPadding),
|
Padding(
|
||||||
child: EventBodyContentWidget(
|
padding: const EdgeInsets.only(top: CalendarEventDetailWidgetStyles.fieldTopPadding),
|
||||||
content: eventDesc,
|
child: EventBodyContentWidget(
|
||||||
isDraggableAppActive: isDraggableAppActive,
|
content: eventDesc,
|
||||||
onMailtoDelegateAction: onMailtoDelegateAction)),
|
isDraggableAppActive: isDraggableAppActive,
|
||||||
|
onMailtoDelegateAction: onMailtoDelegateAction)),
|
||||||
_buildEventTimeWidget(),
|
_buildEventTimeWidget(),
|
||||||
if (calendarEvent.videoConferences.isNotEmpty)
|
if (calendarEvent.videoConferences.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
@@ -93,12 +94,12 @@ class CalendarEventDetailWidget extends StatelessWidget {
|
|||||||
onOpenNewTabAction: onOpenNewTabAction,
|
onOpenNewTabAction: onOpenNewTabAction,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (calendarEvent.participants?.isNotEmpty == true && calendarEvent.organizer != null)
|
if (calendarEvent.participants?.isNotEmpty == true || calendarEvent.organizer != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: CalendarEventDetailWidgetStyles.fieldTopPadding),
|
padding: const EdgeInsets.only(top: CalendarEventDetailWidgetStyles.fieldTopPadding),
|
||||||
child: EventAttendeeDetailWidget(
|
child: EventAttendeeDetailWidget(
|
||||||
attendees: calendarEvent.participants!,
|
attendees: calendarEvent.participants ?? [],
|
||||||
organizer: calendarEvent.organizer!,
|
organizer: calendarEvent.organizer,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (calendarEvent.isDisplayedEventReplyAction)
|
if (calendarEvent.isDisplayedEventReplyAction)
|
||||||
|
|||||||
+94
-90
@@ -79,27 +79,30 @@ class CalendarEventInformationWidget extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text.rich(
|
if (calendarEvent.organizerName.isNotEmpty)
|
||||||
TextSpan(
|
Padding(
|
||||||
style: const TextStyle(
|
padding: const EdgeInsets.only(bottom: CalendarEventInformationWidgetStyles.space),
|
||||||
fontSize: CalendarEventInformationWidgetStyles.invitationMessageTextSize,
|
child: Text.rich(
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: CalendarEventInformationWidgetStyles.invitationMessageColor
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: calendarEvent.organizerName,
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: CalendarEventInformationWidgetStyles.invitationMessageColor,
|
|
||||||
fontSize: CalendarEventInformationWidgetStyles.invitationMessageTextSize,
|
fontSize: CalendarEventInformationWidgetStyles.invitationMessageTextSize,
|
||||||
fontWeight: FontWeight.w700
|
fontWeight: FontWeight.w500,
|
||||||
|
color: CalendarEventInformationWidgetStyles.invitationMessageColor
|
||||||
),
|
),
|
||||||
),
|
children: [
|
||||||
TextSpan(text: AppLocalizations.of(context).invitationMessageCalendarInformation)
|
TextSpan(
|
||||||
]
|
text: calendarEvent.organizerName,
|
||||||
)
|
style: const TextStyle(
|
||||||
),
|
color: CalendarEventInformationWidgetStyles.invitationMessageColor,
|
||||||
const SizedBox(height: CalendarEventInformationWidgetStyles.space),
|
fontSize: CalendarEventInformationWidgetStyles.invitationMessageTextSize,
|
||||||
|
fontWeight: FontWeight.w700
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(text: AppLocalizations.of(context).invitationMessageCalendarInformation)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
if (calendarEvent.title?.isNotEmpty == true)
|
if (calendarEvent.title?.isNotEmpty == true)
|
||||||
EventTitleWidget(title: calendarEvent.title!),
|
EventTitleWidget(title: calendarEvent.title!),
|
||||||
_buildEventTimeInformationWidget(),
|
_buildEventTimeInformationWidget(),
|
||||||
@@ -112,12 +115,12 @@ class CalendarEventInformationWidget extends StatelessWidget {
|
|||||||
onOpenNewTabAction: onOpenNewTabAction,
|
onOpenNewTabAction: onOpenNewTabAction,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (calendarEvent.participants?.isNotEmpty == true && calendarEvent.organizer != null)
|
if (calendarEvent.participants?.isNotEmpty == true || calendarEvent.organizer != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: CalendarEventInformationWidgetStyles.fieldTopPadding),
|
padding: const EdgeInsets.only(top: CalendarEventInformationWidgetStyles.fieldTopPadding),
|
||||||
child: EventAttendeeInformationWidget(
|
child: EventAttendeeInformationWidget(
|
||||||
attendees: calendarEvent.participants!,
|
attendees: calendarEvent.participants ?? [],
|
||||||
organizer: calendarEvent.organizer!,
|
organizer: calendarEvent.organizer,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (calendarEvent.isDisplayedEventReplyAction)
|
if (calendarEvent.isDisplayedEventReplyAction)
|
||||||
@@ -136,81 +139,82 @@ class CalendarEventInformationWidget extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: Row(
|
: IntrinsicHeight(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Row(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
CalendarDateIconWidget(calendarEvent: calendarEvent),
|
children: [
|
||||||
Expanded(child: Container(
|
CalendarDateIconWidget(calendarEvent: calendarEvent),
|
||||||
clipBehavior: Clip.antiAlias,
|
Expanded(child: Container(
|
||||||
decoration: const ShapeDecoration(
|
clipBehavior: Clip.antiAlias,
|
||||||
shape: RoundedRectangleBorder(
|
decoration: const ShapeDecoration(
|
||||||
borderRadius: BorderRadius.only(
|
shape: RoundedRectangleBorder(
|
||||||
topRight: Radius.circular(CalendarEventInformationWidgetStyles.borderRadius),
|
borderRadius: BorderRadius.only(
|
||||||
bottomRight: Radius.circular(CalendarEventInformationWidgetStyles.borderRadius)
|
topRight: Radius.circular(CalendarEventInformationWidgetStyles.borderRadius),
|
||||||
)
|
bottomRight: Radius.circular(CalendarEventInformationWidgetStyles.borderRadius)
|
||||||
),
|
|
||||||
color: Colors.white
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(CalendarEventInformationWidgetStyles.calendarInformationMargin),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text.rich(
|
|
||||||
TextSpan(
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: CalendarEventInformationWidgetStyles.invitationMessageTextSize,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: CalendarEventInformationWidgetStyles.invitationMessageColor
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
TextSpan(
|
|
||||||
text: calendarEvent.organizerName,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: CalendarEventInformationWidgetStyles.invitationMessageColor,
|
|
||||||
fontSize: CalendarEventInformationWidgetStyles.invitationMessageTextSize,
|
|
||||||
fontWeight: FontWeight.w700
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(text: AppLocalizations.of(context).invitationMessageCalendarInformation)
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
const SizedBox(height: CalendarEventInformationWidgetStyles.space),
|
color: Colors.white
|
||||||
if (calendarEvent.title?.isNotEmpty == true)
|
),
|
||||||
EventTitleWidget(title: calendarEvent.title!),
|
padding: const EdgeInsets.all(CalendarEventInformationWidgetStyles.calendarInformationMargin),
|
||||||
_buildEventTimeInformationWidget(),
|
child: Column(
|
||||||
if (calendarEvent.location?.isNotEmpty == true)
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Padding(
|
children: [
|
||||||
padding: const EdgeInsets.only(top: CalendarEventInformationWidgetStyles.fieldTopPadding),
|
if (calendarEvent.organizerName.isNotEmpty)
|
||||||
child: EventLocationInformationWidget(
|
Padding(
|
||||||
locationEvent: calendarEvent.location!,
|
padding: const EdgeInsets.only(bottom: CalendarEventInformationWidgetStyles.space),
|
||||||
onOpenComposerAction: onOpenComposerAction,
|
child: Text.rich(
|
||||||
onOpenNewTabAction: onOpenNewTabAction,
|
TextSpan(
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: CalendarEventInformationWidgetStyles.invitationMessageTextSize,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: CalendarEventInformationWidgetStyles.invitationMessageColor
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: calendarEvent.organizerName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: CalendarEventInformationWidgetStyles.invitationMessageColor,
|
||||||
|
fontSize: CalendarEventInformationWidgetStyles.invitationMessageTextSize,
|
||||||
|
fontWeight: FontWeight.w700
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(text: AppLocalizations.of(context).invitationMessageCalendarInformation)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (calendarEvent.title?.isNotEmpty == true)
|
||||||
if (calendarEvent.participants?.isNotEmpty == true && calendarEvent.organizer != null)
|
EventTitleWidget(title: calendarEvent.title!),
|
||||||
Padding(
|
_buildEventTimeInformationWidget(),
|
||||||
padding: const EdgeInsets.only(top: CalendarEventInformationWidgetStyles.fieldTopPadding),
|
if (calendarEvent.location?.isNotEmpty == true)
|
||||||
child: EventAttendeeInformationWidget(
|
Padding(
|
||||||
attendees: calendarEvent.participants!,
|
padding: const EdgeInsets.only(top: CalendarEventInformationWidgetStyles.fieldTopPadding),
|
||||||
organizer: calendarEvent.organizer!,
|
child: EventLocationInformationWidget(
|
||||||
|
locationEvent: calendarEvent.location!,
|
||||||
|
onOpenComposerAction: onOpenComposerAction,
|
||||||
|
onOpenNewTabAction: onOpenNewTabAction,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (calendarEvent.participants?.isNotEmpty == true || calendarEvent.organizer != null)
|
||||||
if (calendarEvent.isDisplayedEventReplyAction)
|
Padding(
|
||||||
CalendarEventActionButtonWidget(
|
padding: const EdgeInsets.only(top: CalendarEventInformationWidgetStyles.fieldTopPadding),
|
||||||
margin: EdgeInsetsDirectional.zero,
|
child: EventAttendeeInformationWidget(
|
||||||
onCalendarEventReplyActionClick: onCalendarEventReplyActionClick,
|
attendees: calendarEvent.participants ?? [],
|
||||||
calendarEventReplying: calendarEventReplying,
|
organizer: calendarEvent.organizer,
|
||||||
presentationEmail: presentationEmail,
|
),
|
||||||
onMailToAttendeesAction: () => onMailtoAttendeesAction?.call(
|
|
||||||
calendarEvent.organizer,
|
|
||||||
calendarEvent.participants,
|
|
||||||
),
|
),
|
||||||
),
|
if (calendarEvent.isDisplayedEventReplyAction)
|
||||||
],
|
CalendarEventActionButtonWidget(
|
||||||
),
|
margin: EdgeInsetsDirectional.zero,
|
||||||
))
|
onCalendarEventReplyActionClick: onCalendarEventReplyActionClick,
|
||||||
],
|
calendarEventReplying: calendarEventReplying,
|
||||||
|
presentationEmail: presentationEmail,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
))
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -15,7 +15,7 @@ class EventAttendeeDetailWidget extends StatefulWidget {
|
|||||||
static const int maxAttendeeDisplayed = 6;
|
static const int maxAttendeeDisplayed = 6;
|
||||||
|
|
||||||
final List<CalendarAttendee> attendees;
|
final List<CalendarAttendee> attendees;
|
||||||
final CalendarOrganizer organizer;
|
final CalendarOrganizer? organizer;
|
||||||
|
|
||||||
const EventAttendeeDetailWidget({
|
const EventAttendeeDetailWidget({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -59,10 +59,12 @@ class _EventAttendeeDetailWidgetState extends State<EventAttendeeDetailWidget> {
|
|||||||
Expanded(child: Column(
|
Expanded(child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
OrganizerWidget(organizer: widget.organizer),
|
if (widget.organizer != null)
|
||||||
..._attendeesDisplayed
|
OrganizerWidget(organizer: widget.organizer!),
|
||||||
.map((attendee) => AttendeeWidget(attendee: attendee, listAttendees: _attendeesDisplayed))
|
if (_attendeesDisplayed.isNotEmpty)
|
||||||
.toList(),
|
..._attendeesDisplayed
|
||||||
|
.map((attendee) => AttendeeWidget(attendee: attendee, listAttendees: _attendeesDisplayed))
|
||||||
|
.toList(),
|
||||||
if (!_isShowAllAttendee)
|
if (!_isShowAllAttendee)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: EventAttendeeDetailWidgetStyles.fieldTopPadding),
|
padding: const EdgeInsets.only(top: EventAttendeeDetailWidgetStyles.fieldTopPadding),
|
||||||
|
|||||||
+20
-15
@@ -11,9 +11,11 @@ import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
|
|||||||
class EventAttendeeInformationWidget extends StatelessWidget {
|
class EventAttendeeInformationWidget extends StatelessWidget {
|
||||||
|
|
||||||
final List<CalendarAttendee> attendees;
|
final List<CalendarAttendee> attendees;
|
||||||
final CalendarOrganizer organizer;
|
final CalendarOrganizer? organizer;
|
||||||
|
|
||||||
const EventAttendeeInformationWidget({
|
final _responsiveUtils = Get.find<ResponsiveUtils>();
|
||||||
|
|
||||||
|
EventAttendeeInformationWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.attendees,
|
required this.attendees,
|
||||||
required this.organizer
|
required this.organizer
|
||||||
@@ -21,7 +23,6 @@ class EventAttendeeInformationWidget extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final responsiveUtils = Get.find<ResponsiveUtils>();
|
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -44,22 +45,26 @@ class EventAttendeeInformationWidget extends StatelessWidget {
|
|||||||
color: EventAttendeeInformationWidgetStyles.valueColor
|
color: EventAttendeeInformationWidgetStyles.valueColor
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
TextSpan(
|
if (organizer != null)
|
||||||
text: '${organizer.mailto?.value} (${AppLocalizations.of(context).organizer})',
|
...[
|
||||||
style: const TextStyle(
|
TextSpan(
|
||||||
color: EventAttendeeInformationWidgetStyles.valueOrganizerColor,
|
text: '${organizer!.mailto?.value} (${AppLocalizations.of(context).organizer})',
|
||||||
fontSize: EventAttendeeInformationWidgetStyles.textSize,
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w500
|
color: EventAttendeeInformationWidgetStyles.valueOrganizerColor,
|
||||||
),
|
fontSize: EventAttendeeInformationWidgetStyles.textSize,
|
||||||
),
|
fontWeight: FontWeight.w500
|
||||||
const TextSpan(text: ', '),
|
),
|
||||||
TextSpan(text: attendees.withoutOrganizer(organizer).mailtoAsString)
|
),
|
||||||
|
const TextSpan(text: ', '),
|
||||||
|
],
|
||||||
|
if (attendees.isNotEmpty)
|
||||||
|
TextSpan(text: attendees.withoutOrganizer(organizer).mailtoAsString)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
overflow: responsiveUtils.isPortraitMobile(context)
|
overflow: _responsiveUtils.isPortraitMobile(context)
|
||||||
? TextOverflow.clip
|
? TextOverflow.clip
|
||||||
: TextOverflow.ellipsis,
|
: TextOverflow.ellipsis,
|
||||||
maxLines: responsiveUtils.isPortraitMobile(context) ? null : 2,
|
maxLines: _responsiveUtils.isPortraitMobile(context) ? null : 2,
|
||||||
))
|
))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user