Added placeholder for lastName and role addition functionality

This commit is contained in:
Arseni
2026-01-14 17:06:33 +03:00
parent 62bc2644d4
commit a354a48213
17 changed files with 347 additions and 67 deletions

View File

@@ -9,11 +9,15 @@ part 'invitation.g.dart';
class InvitationContentDTO { class InvitationContentDTO {
final String email; final String email;
final String name; final String name;
@JsonKey(defaultValue: '')
//TODO remove when backend will accept lastName
final String lastName;
final String comment; final String comment;
const InvitationContentDTO({ const InvitationContentDTO({
required this.email, required this.email,
required this.name, required this.name,
required this.lastName,
required this.comment, required this.comment,
}); });

View File

@@ -20,6 +20,7 @@ extension InvitationModelMapper on Invitation {
content: InvitationContentDTO( content: InvitationContentDTO(
email: content.email, email: content.email,
name: content.name, name: content.name,
lastName: content.lastName,
comment: content.comment, comment: content.comment,
), ),
isArchived: isArchived, isArchived: isArchived,
@@ -40,6 +41,7 @@ extension InvitationDTOMapper on InvitationDTO {
content: InvitationContent( content: InvitationContent(
email: content.email, email: content.email,
name: content.name, name: content.name,
lastName: content.lastName,
comment: content.comment, comment: content.comment,
), ),
isArchived: isArchived, isArchived: isArchived,

View File

@@ -8,23 +8,36 @@ import 'package:pshared/models/invitation/status.dart';
class InvitationContent { class InvitationContent {
final String email; final String email;
final String name; final String name;
final String lastName;
final String comment; final String comment;
const InvitationContent({ const InvitationContent({
required this.email, required this.email,
required this.name, required this.name,
required this.lastName,
required this.comment, required this.comment,
}); });
InvitationContent copyWith({ InvitationContent copyWith({
String? email, String? email,
String? name, String? name,
String? lastName,
String? comment, String? comment,
}) => InvitationContent( }) => InvitationContent(
email: email ?? this.email, email: email ?? this.email,
name: name ?? this.name, name: name ?? this.name,
lastName: lastName ?? this.lastName,
comment: comment ?? this.comment, comment: comment ?? this.comment,
); );
String get fullName {
final trimmedName = name.trim();
final trimmedLastName = lastName.trim();
if (trimmedName.isEmpty && trimmedLastName.isEmpty) return '';
if (trimmedName.isEmpty) return trimmedLastName;
if (trimmedLastName.isEmpty) return trimmedName;
return '$trimmedName $trimmedLastName';
}
} }
class Invitation implements PermissionBoundStorable { class Invitation implements PermissionBoundStorable {
@@ -60,7 +73,7 @@ class Invitation implements PermissionBoundStorable {
@override @override
String get organizationRef => permissionBound.organizationRef; String get organizationRef => permissionBound.organizationRef;
String get inviteeDisplayName => content.name.isNotEmpty ? content.name : content.email; String get inviteeDisplayName => content.fullName.isNotEmpty ? content.fullName : content.email;
bool get isExpired => expiresAt.isBefore(DateTime.now().toUtc()); bool get isExpired => expiresAt.isBefore(DateTime.now().toUtc());
bool get isPending => status == InvitationStatus.created || status == InvitationStatus.sent; bool get isPending => status == InvitationStatus.created || status == InvitationStatus.sent;
@@ -91,6 +104,7 @@ Invitation newInvitation({
required String inviterRef, required String inviterRef,
required String email, required String email,
String name = '', String name = '',
String lastName = '',
String comment = '', String comment = '',
InvitationStatus status = InvitationStatus.created, InvitationStatus status = InvitationStatus.created,
DateTime? expiresAt, DateTime? expiresAt,
@@ -106,6 +120,6 @@ Invitation newInvitation({
inviterRef: inviterRef, inviterRef: inviterRef,
status: status, status: status,
expiresAt: expiresAt ?? DateTime.now().toUtc().add(const Duration(days: 7)), expiresAt: expiresAt ?? DateTime.now().toUtc().add(const Duration(days: 7)),
content: InvitationContent(email: email, name: name, comment: comment), content: InvitationContent(email: email, name: name, lastName: lastName, comment: comment),
isArchived: isArchived, isArchived: isArchived,
); );

View File

@@ -30,6 +30,7 @@ class InvitationsProvider extends GenericProvider<Invitation> {
required String roleRef, required String roleRef,
required String inviterRef, required String inviterRef,
String name = '', String name = '',
String lastName = '',
String comment = '', String comment = '',
DateTime? expiresAt, DateTime? expiresAt,
}) async { }) async {
@@ -39,6 +40,7 @@ class InvitationsProvider extends GenericProvider<Invitation> {
inviterRef: inviterRef, inviterRef: inviterRef,
email: email, email: email,
name: name, name: name,
lastName: lastName,
comment: comment, comment: comment,
expiresAt: expiresAt, expiresAt: expiresAt,
); );

View File

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:pshared/api/requests/change_role.dart'; import 'package:pshared/api/requests/change_role.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/permissions/access.dart'; import 'package:pshared/models/permissions/access.dart';
import 'package:pshared/models/permissions/action.dart' as perm; import 'package:pshared/models/permissions/action.dart' as perm;
import 'package:pshared/models/permissions/data/permission.dart'; import 'package:pshared/models/permissions/data/permission.dart';
@@ -101,6 +102,32 @@ class PermissionsProvider extends ChangeNotifier {
return _performServiceCall(() => PermissionsService.deleteRoleDescription(descRef)); return _performServiceCall(() => PermissionsService.deleteRoleDescription(descRef));
} }
Future<RoleDescription?> createRoleDescription({
required String name,
String? description,
}) async {
if (!_organizations.isOrganizationSet) {
throw StateError('Organization is not set');
}
final normalizedName = name.trim();
final normalizedDescription = description?.trim();
final roleDescription = RoleDescription.build(
organizationRef: _organizations.current.id,
roleDescription: newDescribable(name: normalizedName, description: normalizedDescription),
);
await _performServiceCall(() => PermissionsService.createRoleDescription(roleDescription));
final matches = roleDescriptions.where(
(role) =>
role.organizationRef == _organizations.current.id &&
role.name == normalizedName &&
(role.description ?? '') == (normalizedDescription ?? ''),
).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return matches.isEmpty ? null : matches.first;
}
Future<UserAccess?> createPermissions(List<Policy> policies) { Future<UserAccess?> createPermissions(List<Policy> policies) {
return _performServiceCall(() => PermissionsService.createPolicies(policies)); return _performServiceCall(() => PermissionsService.createPolicies(policies));
} }

View File

@@ -4,7 +4,9 @@ import 'package:pshared/api/requests/change_role.dart';
import 'package:pshared/api/requests/permissions/change_policies.dart'; import 'package:pshared/api/requests/permissions/change_policies.dart';
import 'package:pshared/api/responses/policies.dart'; import 'package:pshared/api/responses/policies.dart';
import 'package:pshared/data/mapper/permissions/data/permissions.dart'; import 'package:pshared/data/mapper/permissions/data/permissions.dart';
import 'package:pshared/data/mapper/permissions/descriptions/role.dart';
import 'package:pshared/data/mapper/permissions/descriptions/description.dart'; import 'package:pshared/data/mapper/permissions/descriptions/description.dart';
import 'package:pshared/models/permissions/descriptions/role.dart';
import 'package:pshared/models/permissions/access.dart'; import 'package:pshared/models/permissions/access.dart';
import 'package:pshared/models/permissions/data/policy.dart'; import 'package:pshared/models/permissions/data/policy.dart';
import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/authorization/service.dart';
@@ -35,6 +37,15 @@ class PermissionsService {
await AuthorizationService.getDELETEResponse(_objectType, '/role/$roleDescriptionRef', {}); await AuthorizationService.getDELETEResponse(_objectType, '/role/$roleDescriptionRef', {});
} }
static Future<void> createRoleDescription(RoleDescription roleDescription) async {
_logger.fine('Creating role ${roleDescription.name}...');
await AuthorizationService.getPOSTResponse(
_objectType,
'/role',
roleDescription.toDTO().toJson(),
);
}
static Future<void> createPolicies(List<Policy> policies) async { static Future<void> createPolicies(List<Policy> policies) async {
_logger.fine('Creating ${policies.length} policies...'); _logger.fine('Creating ${policies.length} policies...');
await AuthorizationService.getPOSTResponse( await AuthorizationService.getPOSTResponse(

View File

@@ -193,6 +193,11 @@
"invitationEmailLabel": "Work email", "invitationEmailLabel": "Work email",
"invitationNameLabel": "Full name", "invitationNameLabel": "Full name",
"invitationRoleLabel": "Role", "invitationRoleLabel": "Role",
"invitationAddRoleButton": "Add role",
"invitationAddRoleTitle": "New role",
"invitationRoleNameLabel": "Role name",
"invitationRoleNameRequired": "Role name is required",
"invitationRoleDescriptionLabel": "Role description (optional)",
"invitationMessageLabel": "Message (optional)", "invitationMessageLabel": "Message (optional)",
"invitationExpiresIn": "Expires in {days} days", "invitationExpiresIn": "Expires in {days} days",
"@invitationExpiresIn": { "@invitationExpiresIn": {
@@ -204,6 +209,7 @@
}, },
"invitationSendButton": "Send invitation", "invitationSendButton": "Send invitation",
"invitationCreatedSuccess": "Invitation sent", "invitationCreatedSuccess": "Invitation sent",
"invitationRoleCreated": "Role created",
"invitationSearchHint": "Search invitations", "invitationSearchHint": "Search invitations",
"invitationFilterAll": "All", "invitationFilterAll": "All",
"invitationFilterPending": "Pending", "invitationFilterPending": "Pending",
@@ -227,6 +233,7 @@
"invitationRevoked": "Invitation revoked", "invitationRevoked": "Invitation revoked",
"invitationArchiveFailed": "Could not archive the invitation", "invitationArchiveFailed": "Could not archive the invitation",
"invitationRevokeFailed": "Could not revoke the invitation", "invitationRevokeFailed": "Could not revoke the invitation",
"invitationRoleCreateFailed": "Could not create role",
"invitationUnknownRole": "Unknown role", "invitationUnknownRole": "Unknown role",
"operationfryTitle": "Operation history", "operationfryTitle": "Operation history",

View File

@@ -193,6 +193,11 @@
"invitationEmailLabel": "Рабочий email", "invitationEmailLabel": "Рабочий email",
"invitationNameLabel": "Полное имя", "invitationNameLabel": "Полное имя",
"invitationRoleLabel": "Роль", "invitationRoleLabel": "Роль",
"invitationAddRoleButton": "Добавить роль",
"invitationAddRoleTitle": "Новая роль",
"invitationRoleNameLabel": "Название роли",
"invitationRoleNameRequired": "Укажите название роли",
"invitationRoleDescriptionLabel": "Описание роли (необязательно)",
"invitationMessageLabel": "Сообщение (необязательно)", "invitationMessageLabel": "Сообщение (необязательно)",
"invitationExpiresIn": "Истекает через {days} дн.", "invitationExpiresIn": "Истекает через {days} дн.",
"@invitationExpiresIn": { "@invitationExpiresIn": {
@@ -204,6 +209,7 @@
}, },
"invitationSendButton": "Отправить приглашение", "invitationSendButton": "Отправить приглашение",
"invitationCreatedSuccess": "Приглашение отправлено", "invitationCreatedSuccess": "Приглашение отправлено",
"invitationRoleCreated": "Роль создана",
"invitationSearchHint": "Поиск приглашений", "invitationSearchHint": "Поиск приглашений",
"invitationFilterAll": "Все", "invitationFilterAll": "Все",
"invitationFilterPending": "В ожидании", "invitationFilterPending": "В ожидании",
@@ -227,6 +233,7 @@
"invitationRevoked": "Приглашение отозвано", "invitationRevoked": "Приглашение отозвано",
"invitationArchiveFailed": "Не удалось архивировать приглашение", "invitationArchiveFailed": "Не удалось архивировать приглашение",
"invitationRevokeFailed": "Не удалось отозвать приглашение", "invitationRevokeFailed": "Не удалось отозвать приглашение",
"invitationRoleCreateFailed": "Не удалось создать роль",
"invitationUnknownRole": "Неизвестная роль", "invitationUnknownRole": "Неизвестная роль",
"operationfryTitle": "История операций", "operationfryTitle": "История операций",

View File

@@ -25,6 +25,7 @@ import 'package:pshared/models/payment/type.dart';
import 'package:pshared/service/payment/wallets.dart'; import 'package:pshared/service/payment/wallets.dart';
import 'package:pweb/app/app.dart'; import 'package:pweb/app/app.dart';
import 'package:pweb/pages/invitations/widgets/list/view_model.dart';
import 'package:pweb/app/timeago.dart'; import 'package:pweb/app/timeago.dart';
import 'package:pweb/providers/carousel.dart'; import 'package:pweb/providers/carousel.dart';
import 'package:pweb/providers/operatioins.dart'; import 'package:pweb/providers/operatioins.dart';
@@ -91,9 +92,8 @@ void main() async {
create: (_) => InvitationsProvider(), create: (_) => InvitationsProvider(),
update: (context, organizations, provider) => provider!..updateProviders(organizations), update: (context, organizations, provider) => provider!..updateProviders(organizations),
), ),
ChangeNotifierProxyProvider2<OrganizationsProvider, RecipientsProvider, PaymentMethodsProvider>( ChangeNotifierProvider(
create: (_) => PaymentMethodsProvider(), create: (_) => InvitationListViewModel(),
update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients),
), ),
ChangeNotifierProxyProvider<OrganizationsProvider, WalletsProvider>( ChangeNotifierProxyProvider<OrganizationsProvider, WalletsProvider>(
create: (_) => WalletsProvider(ApiWalletsService()), create: (_) => WalletsProvider(ApiWalletsService()),

View File

@@ -0,0 +1,9 @@
class RoleDraft {
final String name;
final String description;
const RoleDraft({
required this.name,
required this.description,
});
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/resources.dart'; import 'package:pshared/models/resources.dart';
@@ -13,6 +14,7 @@ import 'package:pweb/pages/invitations/widgets/form/form.dart';
import 'package:pweb/pages/invitations/widgets/list/list.dart'; import 'package:pweb/pages/invitations/widgets/list/list.dart';
import 'package:pweb/pages/loader.dart'; import 'package:pweb/pages/loader.dart';
import 'package:pweb/widgets/error/snackbar.dart'; import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/widgets/roles/create_role_dialog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -27,12 +29,34 @@ class InvitationsPage extends StatefulWidget {
class _InvitationsPageState extends State<InvitationsPage> { class _InvitationsPageState extends State<InvitationsPage> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _emailController = TextEditingController(); final TextEditingController _emailController = TextEditingController();
final TextEditingController _nameController = TextEditingController(); final TextEditingController _firstNameController = TextEditingController();
final TextEditingController _lastNameController = TextEditingController();
final TextEditingController _messageController = TextEditingController(); final TextEditingController _messageController = TextEditingController();
String? _selectedRoleRef; String? _selectedRoleRef;
int _expiryDays = 7; int _expiryDays = 7;
Future<void> _createRole() async {
final loc = AppLocalizations.of(context)!;
final draft = await showCreateRoleDialog(context);
if (draft == null) return;
final permissions = context.read<PermissionsProvider>();
final createdRole = await executeActionWithNotification(
context: context,
action: () => permissions.createRoleDescription(
name: draft.name,
description: draft.description.isEmpty ? null : draft.description,
),
successMessage: loc.invitationRoleCreated,
errorMessage: loc.invitationRoleCreateFailed,
);
if (createdRole != null && mounted) {
setState(() => _selectedRoleRef = createdRole.id);
}
}
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
@@ -41,15 +65,20 @@ class _InvitationsPageState extends State<InvitationsPage> {
void _bootstrapRoleSelection() { void _bootstrapRoleSelection() {
final roles = context.read<PermissionsProvider>().roleDescriptions; final roles = context.read<PermissionsProvider>().roleDescriptions;
if (_selectedRoleRef == null && roles.isNotEmpty) { if (roles.isEmpty) return;
_selectedRoleRef = roles.first.storable.id; final firstRoleRef = roles.first.storable.id;
} final isSelectedAvailable = _selectedRoleRef != null
&& roles.any((role) => role.storable.id == _selectedRoleRef);
if (isSelectedAvailable) return;
if (!mounted) return;
setState(() => _selectedRoleRef = firstRoleRef);
} }
@override @override
void dispose() { void dispose() {
_emailController.dispose(); _emailController.dispose();
_nameController.dispose(); _firstNameController.dispose();
_lastNameController.dispose();
_messageController.dispose(); _messageController.dispose();
super.dispose(); super.dispose();
} }
@@ -71,7 +100,8 @@ class _InvitationsPageState extends State<InvitationsPage> {
context: context, context: context,
action: () => invitations.sendInvitation( action: () => invitations.sendInvitation(
email: _emailController.text.trim(), email: _emailController.text.trim(),
name: _nameController.text.trim(), name: _firstNameController.text.trim(),
lastName: _lastNameController.text.trim(),
comment: _messageController.text.trim(), comment: _messageController.text.trim(),
roleRef: roleRef, roleRef: roleRef,
inviterRef: account.id, inviterRef: account.id,
@@ -82,7 +112,8 @@ class _InvitationsPageState extends State<InvitationsPage> {
); );
_emailController.clear(); _emailController.clear();
_nameController.clear(); _firstNameController.clear();
_lastNameController.clear();
_messageController.clear(); _messageController.clear();
} }
@@ -90,6 +121,7 @@ class _InvitationsPageState extends State<InvitationsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
final permissions = context.watch<PermissionsProvider>(); final permissions = context.watch<PermissionsProvider>();
final canCreateRoles = permissions.canCreate(ResourceType.roles);
if (!permissions.canRead(ResourceType.invitations)) { if (!permissions.canRead(ResourceType.invitations)) {
return PageViewLoader( return PageViewLoader(
@@ -109,8 +141,11 @@ class _InvitationsPageState extends State<InvitationsPage> {
InvitationsForm( InvitationsForm(
formKey: _formKey, formKey: _formKey,
emailController: _emailController, emailController: _emailController,
nameController: _nameController, firstNameController: _firstNameController,
lastNameController: _lastNameController,
messageController: _messageController, messageController: _messageController,
canCreateRoles: canCreateRoles,
onCreateRole: _createRole,
expiryDays: _expiryDays, expiryDays: _expiryDays,
onExpiryChanged: (value) => setState(() => _expiryDays = value), onExpiryChanged: (value) => setState(() => _expiryDays = value),
selectedRoleRef: _selectedRoleRef, selectedRoleRef: _selectedRoleRef,

View File

@@ -8,8 +8,11 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationFormFields extends StatelessWidget { class InvitationFormFields extends StatelessWidget {
final List<RoleDescription> roles; final List<RoleDescription> roles;
final TextEditingController emailController; final TextEditingController emailController;
final TextEditingController nameController; final TextEditingController firstNameController;
final TextEditingController lastNameController;
final TextEditingController messageController; final TextEditingController messageController;
final bool canCreateRoles;
final VoidCallback onCreateRole;
final String? selectedRoleRef; final String? selectedRoleRef;
final ValueChanged<String?> onRoleChanged; final ValueChanged<String?> onRoleChanged;
@@ -17,8 +20,11 @@ class InvitationFormFields extends StatelessWidget {
super.key, super.key,
required this.roles, required this.roles,
required this.emailController, required this.emailController,
required this.nameController, required this.firstNameController,
required this.lastNameController,
required this.messageController, required this.messageController,
required this.canCreateRoles,
required this.onCreateRole,
required this.selectedRoleRef, required this.selectedRoleRef,
required this.onRoleChanged, required this.onRoleChanged,
}); });
@@ -47,11 +53,21 @@ class InvitationFormFields extends StatelessWidget {
), ),
), ),
SizedBox( SizedBox(
width: 280, width: 200,
child: TextFormField( child: TextFormField(
controller: nameController, controller: firstNameController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: loc.invitationNameLabel, labelText: loc.firstName,
prefixIcon: const Icon(Icons.person_outline),
),
),
),
SizedBox(
width: 200,
child: TextFormField(
controller: lastNameController,
decoration: InputDecoration(
labelText: loc.lastName,
prefixIcon: const Icon(Icons.person_outline), prefixIcon: const Icon(Icons.person_outline),
), ),
), ),
@@ -59,7 +75,7 @@ class InvitationFormFields extends StatelessWidget {
SizedBox( SizedBox(
width: 260, width: 260,
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
initialValue: selectedRoleRef ?? (roles.isNotEmpty ? roles.first.storable.id : null), value: selectedRoleRef,
items: roles.map((role) => DropdownMenuItem( items: roles.map((role) => DropdownMenuItem(
value: role.storable.id, value: role.storable.id,
child: Text(role.describable.name), child: Text(role.describable.name),
@@ -68,6 +84,11 @@ class InvitationFormFields extends StatelessWidget {
decoration: InputDecoration( decoration: InputDecoration(
labelText: loc.invitationRoleLabel, labelText: loc.invitationRoleLabel,
prefixIcon: const Icon(Icons.security_outlined), prefixIcon: const Icon(Icons.security_outlined),
suffixIcon: IconButton(
onPressed: canCreateRoles ? onCreateRole : null,
icon: const Icon(Icons.add_circle_outline),
tooltip: loc.invitationAddRoleButton,
),
), ),
), ),
), ),

View File

@@ -6,8 +6,11 @@ import 'package:pweb/pages/invitations/widgets/form/view.dart';
class InvitationsForm extends StatelessWidget { class InvitationsForm extends StatelessWidget {
final GlobalKey<FormState> formKey; final GlobalKey<FormState> formKey;
final TextEditingController emailController; final TextEditingController emailController;
final TextEditingController nameController; final TextEditingController firstNameController;
final TextEditingController lastNameController;
final TextEditingController messageController; final TextEditingController messageController;
final bool canCreateRoles;
final VoidCallback onCreateRole;
final int expiryDays; final int expiryDays;
final ValueChanged<int> onExpiryChanged; final ValueChanged<int> onExpiryChanged;
final String? selectedRoleRef; final String? selectedRoleRef;
@@ -19,8 +22,11 @@ class InvitationsForm extends StatelessWidget {
super.key, super.key,
required this.formKey, required this.formKey,
required this.emailController, required this.emailController,
required this.nameController, required this.firstNameController,
required this.lastNameController,
required this.messageController, required this.messageController,
required this.canCreateRoles,
required this.onCreateRole,
required this.expiryDays, required this.expiryDays,
required this.onExpiryChanged, required this.onExpiryChanged,
required this.selectedRoleRef, required this.selectedRoleRef,
@@ -33,8 +39,11 @@ class InvitationsForm extends StatelessWidget {
Widget build(BuildContext context) => InvitationFormView( Widget build(BuildContext context) => InvitationFormView(
formKey: formKey, formKey: formKey,
emailController: emailController, emailController: emailController,
nameController: nameController, firstNameController: firstNameController,
lastNameController: lastNameController,
messageController: messageController, messageController: messageController,
canCreateRoles: canCreateRoles,
onCreateRole: onCreateRole,
expiryDays: expiryDays, expiryDays: expiryDays,
onExpiryChanged: onExpiryChanged, onExpiryChanged: onExpiryChanged,
selectedRoleRef: selectedRoleRef, selectedRoleRef: selectedRoleRef,

View File

@@ -13,8 +13,11 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationFormView extends StatelessWidget { class InvitationFormView extends StatelessWidget {
final GlobalKey<FormState> formKey; final GlobalKey<FormState> formKey;
final TextEditingController emailController; final TextEditingController emailController;
final TextEditingController nameController; final TextEditingController firstNameController;
final TextEditingController lastNameController;
final TextEditingController messageController; final TextEditingController messageController;
final bool canCreateRoles;
final VoidCallback onCreateRole;
final int expiryDays; final int expiryDays;
final ValueChanged<int> onExpiryChanged; final ValueChanged<int> onExpiryChanged;
final String? selectedRoleRef; final String? selectedRoleRef;
@@ -26,8 +29,11 @@ class InvitationFormView extends StatelessWidget {
super.key, super.key,
required this.formKey, required this.formKey,
required this.emailController, required this.emailController,
required this.nameController, required this.firstNameController,
required this.lastNameController,
required this.messageController, required this.messageController,
required this.canCreateRoles,
required this.onCreateRole,
required this.expiryDays, required this.expiryDays,
required this.onExpiryChanged, required this.onExpiryChanged,
required this.selectedRoleRef, required this.selectedRoleRef,
@@ -61,8 +67,11 @@ class InvitationFormView extends StatelessWidget {
InvitationFormFields( InvitationFormFields(
roles: roles, roles: roles,
emailController: emailController, emailController: emailController,
nameController: nameController, firstNameController: firstNameController,
lastNameController: lastNameController,
messageController: messageController, messageController: messageController,
canCreateRoles: canCreateRoles,
onCreateRole: onCreateRole,
selectedRoleRef: selectedRoleRef, selectedRoleRef: selectedRoleRef,
onRoleChanged: onRoleChanged, onRoleChanged: onRoleChanged,
), ),

View File

@@ -6,9 +6,9 @@ import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/provider/invitations.dart'; import 'package:pshared/provider/invitations.dart';
import 'package:pweb/models/invitation_filter.dart'; import 'package:pweb/models/invitation_filter.dart';
import 'package:pweb/pages/invitations/widgets/filter/invitation_filter.dart';
import 'package:pweb/pages/invitations/widgets/filter/chips.dart'; import 'package:pweb/pages/invitations/widgets/filter/chips.dart';
import 'package:pweb/pages/invitations/widgets/list/body.dart'; import 'package:pweb/pages/invitations/widgets/list/body.dart';
import 'package:pweb/pages/invitations/widgets/list/view_model.dart';
import 'package:pweb/pages/invitations/widgets/search_field.dart'; import 'package:pweb/pages/invitations/widgets/search_field.dart';
import 'package:pweb/widgets/error/snackbar.dart'; import 'package:pweb/widgets/error/snackbar.dart';
@@ -25,70 +25,62 @@ class InvitationListView extends StatefulWidget {
class _InvitationListViewState extends State<InvitationListView> { class _InvitationListViewState extends State<InvitationListView> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
InvitationFilter _filter = InvitationFilter.all;
String _query = '';
Object? _lastError; Object? _lastError;
InvitationsProvider? _provider;
@override
void initState() {
super.initState();
_provider = context.read<InvitationsProvider>();
_provider?.addListener(_onProviderChanged);
}
@override @override
void dispose() { void dispose() {
_provider?.removeListener(_onProviderChanged);
_searchController.dispose(); _searchController.dispose();
super.dispose(); super.dispose();
} }
void _setQuery(String query) { void _setQuery(String query) => context.read<InvitationListViewModel>().setQuery(query);
setState(() => _query = query.trim().toLowerCase()); void _setFilter(InvitationFilter filter) => context.read<InvitationListViewModel>().setFilter(filter);
}
void _setFilter(InvitationFilter filter) { void _onProviderChanged() {
setState(() => _filter = filter); final provider = _provider;
if (provider == null) {
return;
}
final error = provider.error;
if (error == null) {
_lastError = null;
return;
} }
void _notifyError(BuildContext context, Object error, AppLocalizations loc) {
if (identical(error, _lastError)) { if (identical(error, _lastError)) {
return; return;
} }
_lastError = error; _lastError = error;
final loc = AppLocalizations.of(context)!;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
postNotifyUserOfErrorX( postNotifyUserOfErrorX(
context: context, context: context,
errorSituation: loc.errorLoadingInvitations, errorSituation: loc.errorLoadingInvitations,
exception: error, exception: error,
); );
}
List<Invitation> _filteredInvitations(List<Invitation> invitations) {
final showArchived = _filter == InvitationFilter.archived;
Iterable<Invitation> filtered = invitations
.where((inv) => showArchived ? inv.isArchived : !inv.isArchived)
.where((inv) => invitationFilterMatches(_filter, inv));
if (_query.isNotEmpty) {
filtered = filtered.where((inv) {
return inv.inviteeDisplayName.toLowerCase().contains(_query)
|| inv.content.email.toLowerCase().contains(_query);
}); });
} }
final sorted = filtered.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return sorted;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
final provider = context.watch<InvitationsProvider>(); final provider = context.watch<InvitationsProvider>();
final viewModel = context.watch<InvitationListViewModel>();
if (provider.isLoading) { if (provider.isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (provider.error != null) { final invitations = viewModel.filteredInvitations(provider.invitations);
_notifyError(context, provider.error!, loc);
} else {
_lastError = null;
}
final invitations = _filteredInvitations(provider.invitations);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -100,7 +92,7 @@ class _InvitationListViewState extends State<InvitationListView> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
InvitationFilterChips( InvitationFilterChips(
selectedFilter: _filter, selectedFilter: viewModel.filter,
onSelected: _setFilter, onSelected: _setFilter,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pweb/models/invitation_filter.dart';
import 'package:pweb/pages/invitations/widgets/filter/invitation_filter.dart';
class InvitationListViewModel extends ChangeNotifier {
InvitationFilter _filter = InvitationFilter.all;
String _query = '';
InvitationFilter get filter => _filter;
String get query => _query;
void setFilter(InvitationFilter filter) {
if (_filter == filter) return;
_filter = filter;
notifyListeners();
}
void setQuery(String query) {
final normalized = query.trim().toLowerCase();
if (_query == normalized) return;
_query = normalized;
notifyListeners();
}
List<Invitation> filteredInvitations(List<Invitation> invitations) {
final showArchived = _filter == InvitationFilter.archived;
Iterable<Invitation> filtered = invitations
.where((inv) => showArchived ? inv.isArchived : !inv.isArchived)
.where((inv) => invitationFilterMatches(_filter, inv));
if (_query.isNotEmpty) {
filtered = filtered.where((inv) {
return inv.inviteeDisplayName.toLowerCase().contains(_query)
|| inv.content.email.toLowerCase().contains(_query);
});
}
final sorted = filtered.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return sorted;
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:pweb/models/role_draft.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
Future<RoleDraft?> showCreateRoleDialog(BuildContext context) {
return showDialog<RoleDraft>(
context: context,
builder: (dialogContext) => const _CreateRoleDialog(),
);
}
class _CreateRoleDialog extends StatefulWidget {
const _CreateRoleDialog();
@override
State<_CreateRoleDialog> createState() => _CreateRoleDialogState();
}
class _CreateRoleDialogState extends State<_CreateRoleDialog> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _nameController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
void _submit() {
final form = _formKey.currentState;
if (form == null || !form.validate()) return;
Navigator.of(context).pop(RoleDraft(
name: _nameController.text.trim(),
description: _descriptionController.text.trim(),
));
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(loc.invitationAddRoleTitle),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: loc.invitationRoleNameLabel,
),
validator: (value) => (value == null || value.trim().isEmpty)
? loc.invitationRoleNameRequired
: null,
),
const SizedBox(height: 12),
TextFormField(
controller: _descriptionController,
minLines: 2,
maxLines: 3,
decoration: InputDecoration(
labelText: loc.invitationRoleDescriptionLabel,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(loc.cancel),
),
ElevatedButton(
onPressed: _submit,
child: Text(loc.add),
),
],
);
}
}