diff --git a/frontend/pshared/lib/data/dto/invitation/invitation.dart b/frontend/pshared/lib/data/dto/invitation/invitation.dart index da63a80..087c3d6 100644 --- a/frontend/pshared/lib/data/dto/invitation/invitation.dart +++ b/frontend/pshared/lib/data/dto/invitation/invitation.dart @@ -9,11 +9,15 @@ part 'invitation.g.dart'; class InvitationContentDTO { final String email; final String name; + @JsonKey(defaultValue: '') + //TODO remove when backend will accept lastName + final String lastName; final String comment; const InvitationContentDTO({ required this.email, required this.name, + required this.lastName, required this.comment, }); diff --git a/frontend/pshared/lib/data/mapper/invitation/invitation.dart b/frontend/pshared/lib/data/mapper/invitation/invitation.dart index 85d2265..6c4ca03 100644 --- a/frontend/pshared/lib/data/mapper/invitation/invitation.dart +++ b/frontend/pshared/lib/data/mapper/invitation/invitation.dart @@ -20,6 +20,7 @@ extension InvitationModelMapper on Invitation { content: InvitationContentDTO( email: content.email, name: content.name, + lastName: content.lastName, comment: content.comment, ), isArchived: isArchived, @@ -40,6 +41,7 @@ extension InvitationDTOMapper on InvitationDTO { content: InvitationContent( email: content.email, name: content.name, + lastName: content.lastName, comment: content.comment, ), isArchived: isArchived, diff --git a/frontend/pshared/lib/models/invitation/invitation.dart b/frontend/pshared/lib/models/invitation/invitation.dart index f613f5f..e7f80b4 100644 --- a/frontend/pshared/lib/models/invitation/invitation.dart +++ b/frontend/pshared/lib/models/invitation/invitation.dart @@ -8,23 +8,36 @@ import 'package:pshared/models/invitation/status.dart'; class InvitationContent { final String email; final String name; + final String lastName; final String comment; const InvitationContent({ required this.email, required this.name, + required this.lastName, required this.comment, }); InvitationContent copyWith({ String? email, String? name, + String? lastName, String? comment, }) => InvitationContent( email: email ?? this.email, name: name ?? this.name, + lastName: lastName ?? this.lastName, 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 { @@ -60,7 +73,7 @@ class Invitation implements PermissionBoundStorable { @override 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 isPending => status == InvitationStatus.created || status == InvitationStatus.sent; @@ -91,6 +104,7 @@ Invitation newInvitation({ required String inviterRef, required String email, String name = '', + String lastName = '', String comment = '', InvitationStatus status = InvitationStatus.created, DateTime? expiresAt, @@ -106,6 +120,6 @@ Invitation newInvitation({ inviterRef: inviterRef, status: status, 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, ); diff --git a/frontend/pshared/lib/provider/invitations.dart b/frontend/pshared/lib/provider/invitations.dart index 74ffe4e..b476c38 100644 --- a/frontend/pshared/lib/provider/invitations.dart +++ b/frontend/pshared/lib/provider/invitations.dart @@ -30,6 +30,7 @@ class InvitationsProvider extends GenericProvider { required String roleRef, required String inviterRef, String name = '', + String lastName = '', String comment = '', DateTime? expiresAt, }) async { @@ -39,6 +40,7 @@ class InvitationsProvider extends GenericProvider { inviterRef: inviterRef, email: email, name: name, + lastName: lastName, comment: comment, expiresAt: expiresAt, ); diff --git a/frontend/pshared/lib/provider/permissions.dart b/frontend/pshared/lib/provider/permissions.dart index 3d2e29f..d8e9029 100644 --- a/frontend/pshared/lib/provider/permissions.dart +++ b/frontend/pshared/lib/provider/permissions.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.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/action.dart' as perm; import 'package:pshared/models/permissions/data/permission.dart'; @@ -101,6 +102,32 @@ class PermissionsProvider extends ChangeNotifier { return _performServiceCall(() => PermissionsService.deleteRoleDescription(descRef)); } + Future 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 createPermissions(List policies) { return _performServiceCall(() => PermissionsService.createPolicies(policies)); } diff --git a/frontend/pshared/lib/service/permissions.dart b/frontend/pshared/lib/service/permissions.dart index 7cbeefc..a6e083e 100644 --- a/frontend/pshared/lib/service/permissions.dart +++ b/frontend/pshared/lib/service/permissions.dart @@ -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/responses/policies.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/models/permissions/descriptions/role.dart'; import 'package:pshared/models/permissions/access.dart'; import 'package:pshared/models/permissions/data/policy.dart'; import 'package:pshared/service/authorization/service.dart'; @@ -35,6 +37,15 @@ class PermissionsService { await AuthorizationService.getDELETEResponse(_objectType, '/role/$roleDescriptionRef', {}); } + static Future createRoleDescription(RoleDescription roleDescription) async { + _logger.fine('Creating role ${roleDescription.name}...'); + await AuthorizationService.getPOSTResponse( + _objectType, + '/role', + roleDescription.toDTO().toJson(), + ); + } + static Future createPolicies(List policies) async { _logger.fine('Creating ${policies.length} policies...'); await AuthorizationService.getPOSTResponse( diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 34c182d..38c236f 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -193,6 +193,11 @@ "invitationEmailLabel": "Work email", "invitationNameLabel": "Full name", "invitationRoleLabel": "Role", + "invitationAddRoleButton": "Add role", + "invitationAddRoleTitle": "New role", + "invitationRoleNameLabel": "Role name", + "invitationRoleNameRequired": "Role name is required", + "invitationRoleDescriptionLabel": "Role description (optional)", "invitationMessageLabel": "Message (optional)", "invitationExpiresIn": "Expires in {days} days", "@invitationExpiresIn": { @@ -204,6 +209,7 @@ }, "invitationSendButton": "Send invitation", "invitationCreatedSuccess": "Invitation sent", + "invitationRoleCreated": "Role created", "invitationSearchHint": "Search invitations", "invitationFilterAll": "All", "invitationFilterPending": "Pending", @@ -227,6 +233,7 @@ "invitationRevoked": "Invitation revoked", "invitationArchiveFailed": "Could not archive the invitation", "invitationRevokeFailed": "Could not revoke the invitation", + "invitationRoleCreateFailed": "Could not create role", "invitationUnknownRole": "Unknown role", "operationfryTitle": "Operation history", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index 850d6e3..b6c764c 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -193,6 +193,11 @@ "invitationEmailLabel": "Рабочий email", "invitationNameLabel": "Полное имя", "invitationRoleLabel": "Роль", + "invitationAddRoleButton": "Добавить роль", + "invitationAddRoleTitle": "Новая роль", + "invitationRoleNameLabel": "Название роли", + "invitationRoleNameRequired": "Укажите название роли", + "invitationRoleDescriptionLabel": "Описание роли (необязательно)", "invitationMessageLabel": "Сообщение (необязательно)", "invitationExpiresIn": "Истекает через {days} дн.", "@invitationExpiresIn": { @@ -204,6 +209,7 @@ }, "invitationSendButton": "Отправить приглашение", "invitationCreatedSuccess": "Приглашение отправлено", + "invitationRoleCreated": "Роль создана", "invitationSearchHint": "Поиск приглашений", "invitationFilterAll": "Все", "invitationFilterPending": "В ожидании", @@ -227,6 +233,7 @@ "invitationRevoked": "Приглашение отозвано", "invitationArchiveFailed": "Не удалось архивировать приглашение", "invitationRevokeFailed": "Не удалось отозвать приглашение", + "invitationRoleCreateFailed": "Не удалось создать роль", "invitationUnknownRole": "Неизвестная роль", "operationfryTitle": "История операций", diff --git a/frontend/pweb/lib/main.dart b/frontend/pweb/lib/main.dart index b7c85e6..ac9d918 100644 --- a/frontend/pweb/lib/main.dart +++ b/frontend/pweb/lib/main.dart @@ -25,6 +25,7 @@ import 'package:pshared/models/payment/type.dart'; import 'package:pshared/service/payment/wallets.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/providers/carousel.dart'; import 'package:pweb/providers/operatioins.dart'; @@ -91,9 +92,8 @@ void main() async { create: (_) => InvitationsProvider(), update: (context, organizations, provider) => provider!..updateProviders(organizations), ), - ChangeNotifierProxyProvider2( - create: (_) => PaymentMethodsProvider(), - update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), + ChangeNotifierProvider( + create: (_) => InvitationListViewModel(), ), ChangeNotifierProxyProvider( create: (_) => WalletsProvider(ApiWalletsService()), diff --git a/frontend/pweb/lib/models/role_draft.dart b/frontend/pweb/lib/models/role_draft.dart new file mode 100644 index 0000000..8b3c843 --- /dev/null +++ b/frontend/pweb/lib/models/role_draft.dart @@ -0,0 +1,9 @@ +class RoleDraft { + final String name; + final String description; + + const RoleDraft({ + required this.name, + required this.description, + }); +} diff --git a/frontend/pweb/lib/pages/invitations/page.dart b/frontend/pweb/lib/pages/invitations/page.dart index 2e72468..57a7be2 100644 --- a/frontend/pweb/lib/pages/invitations/page.dart +++ b/frontend/pweb/lib/pages/invitations/page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; + import 'package:provider/provider.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/loader.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'; @@ -27,12 +29,34 @@ class InvitationsPage extends StatefulWidget { class _InvitationsPageState extends State { final GlobalKey _formKey = GlobalKey(); final TextEditingController _emailController = TextEditingController(); - final TextEditingController _nameController = TextEditingController(); + final TextEditingController _firstNameController = TextEditingController(); + final TextEditingController _lastNameController = TextEditingController(); final TextEditingController _messageController = TextEditingController(); String? _selectedRoleRef; int _expiryDays = 7; + Future _createRole() async { + final loc = AppLocalizations.of(context)!; + final draft = await showCreateRoleDialog(context); + if (draft == null) return; + + final permissions = context.read(); + 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 void didChangeDependencies() { super.didChangeDependencies(); @@ -41,15 +65,20 @@ class _InvitationsPageState extends State { void _bootstrapRoleSelection() { final roles = context.read().roleDescriptions; - if (_selectedRoleRef == null && roles.isNotEmpty) { - _selectedRoleRef = roles.first.storable.id; - } + if (roles.isEmpty) return; + 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 void dispose() { _emailController.dispose(); - _nameController.dispose(); + _firstNameController.dispose(); + _lastNameController.dispose(); _messageController.dispose(); super.dispose(); } @@ -71,7 +100,8 @@ class _InvitationsPageState extends State { context: context, action: () => invitations.sendInvitation( email: _emailController.text.trim(), - name: _nameController.text.trim(), + name: _firstNameController.text.trim(), + lastName: _lastNameController.text.trim(), comment: _messageController.text.trim(), roleRef: roleRef, inviterRef: account.id, @@ -82,7 +112,8 @@ class _InvitationsPageState extends State { ); _emailController.clear(); - _nameController.clear(); + _firstNameController.clear(); + _lastNameController.clear(); _messageController.clear(); } @@ -90,6 +121,7 @@ class _InvitationsPageState extends State { Widget build(BuildContext context) { final loc = AppLocalizations.of(context)!; final permissions = context.watch(); + final canCreateRoles = permissions.canCreate(ResourceType.roles); if (!permissions.canRead(ResourceType.invitations)) { return PageViewLoader( @@ -109,8 +141,11 @@ class _InvitationsPageState extends State { InvitationsForm( formKey: _formKey, emailController: _emailController, - nameController: _nameController, + firstNameController: _firstNameController, + lastNameController: _lastNameController, messageController: _messageController, + canCreateRoles: canCreateRoles, + onCreateRole: _createRole, expiryDays: _expiryDays, onExpiryChanged: (value) => setState(() => _expiryDays = value), selectedRoleRef: _selectedRoleRef, diff --git a/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart b/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart index 2864a17..e9116ae 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart @@ -8,8 +8,11 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class InvitationFormFields extends StatelessWidget { final List roles; final TextEditingController emailController; - final TextEditingController nameController; + final TextEditingController firstNameController; + final TextEditingController lastNameController; final TextEditingController messageController; + final bool canCreateRoles; + final VoidCallback onCreateRole; final String? selectedRoleRef; final ValueChanged onRoleChanged; @@ -17,8 +20,11 @@ class InvitationFormFields extends StatelessWidget { super.key, required this.roles, required this.emailController, - required this.nameController, + required this.firstNameController, + required this.lastNameController, required this.messageController, + required this.canCreateRoles, + required this.onCreateRole, required this.selectedRoleRef, required this.onRoleChanged, }); @@ -47,11 +53,21 @@ class InvitationFormFields extends StatelessWidget { ), ), SizedBox( - width: 280, + width: 200, child: TextFormField( - controller: nameController, + controller: firstNameController, 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), ), ), @@ -59,7 +75,7 @@ class InvitationFormFields extends StatelessWidget { SizedBox( width: 260, child: DropdownButtonFormField( - initialValue: selectedRoleRef ?? (roles.isNotEmpty ? roles.first.storable.id : null), + value: selectedRoleRef, items: roles.map((role) => DropdownMenuItem( value: role.storable.id, child: Text(role.describable.name), @@ -68,6 +84,11 @@ class InvitationFormFields extends StatelessWidget { decoration: InputDecoration( labelText: loc.invitationRoleLabel, prefixIcon: const Icon(Icons.security_outlined), + suffixIcon: IconButton( + onPressed: canCreateRoles ? onCreateRole : null, + icon: const Icon(Icons.add_circle_outline), + tooltip: loc.invitationAddRoleButton, + ), ), ), ), diff --git a/frontend/pweb/lib/pages/invitations/widgets/form/form.dart b/frontend/pweb/lib/pages/invitations/widgets/form/form.dart index f0e7bc2..49b9eb8 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/form/form.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/form/form.dart @@ -6,8 +6,11 @@ import 'package:pweb/pages/invitations/widgets/form/view.dart'; class InvitationsForm extends StatelessWidget { final GlobalKey formKey; final TextEditingController emailController; - final TextEditingController nameController; + final TextEditingController firstNameController; + final TextEditingController lastNameController; final TextEditingController messageController; + final bool canCreateRoles; + final VoidCallback onCreateRole; final int expiryDays; final ValueChanged onExpiryChanged; final String? selectedRoleRef; @@ -19,8 +22,11 @@ class InvitationsForm extends StatelessWidget { super.key, required this.formKey, required this.emailController, - required this.nameController, + required this.firstNameController, + required this.lastNameController, required this.messageController, + required this.canCreateRoles, + required this.onCreateRole, required this.expiryDays, required this.onExpiryChanged, required this.selectedRoleRef, @@ -33,8 +39,11 @@ class InvitationsForm extends StatelessWidget { Widget build(BuildContext context) => InvitationFormView( formKey: formKey, emailController: emailController, - nameController: nameController, + firstNameController: firstNameController, + lastNameController: lastNameController, messageController: messageController, + canCreateRoles: canCreateRoles, + onCreateRole: onCreateRole, expiryDays: expiryDays, onExpiryChanged: onExpiryChanged, selectedRoleRef: selectedRoleRef, diff --git a/frontend/pweb/lib/pages/invitations/widgets/form/view.dart b/frontend/pweb/lib/pages/invitations/widgets/form/view.dart index 555ecef..38ef225 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/form/view.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/form/view.dart @@ -13,8 +13,11 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class InvitationFormView extends StatelessWidget { final GlobalKey formKey; final TextEditingController emailController; - final TextEditingController nameController; + final TextEditingController firstNameController; + final TextEditingController lastNameController; final TextEditingController messageController; + final bool canCreateRoles; + final VoidCallback onCreateRole; final int expiryDays; final ValueChanged onExpiryChanged; final String? selectedRoleRef; @@ -26,8 +29,11 @@ class InvitationFormView extends StatelessWidget { super.key, required this.formKey, required this.emailController, - required this.nameController, + required this.firstNameController, + required this.lastNameController, required this.messageController, + required this.canCreateRoles, + required this.onCreateRole, required this.expiryDays, required this.onExpiryChanged, required this.selectedRoleRef, @@ -61,8 +67,11 @@ class InvitationFormView extends StatelessWidget { InvitationFormFields( roles: roles, emailController: emailController, - nameController: nameController, + firstNameController: firstNameController, + lastNameController: lastNameController, messageController: messageController, + canCreateRoles: canCreateRoles, + onCreateRole: onCreateRole, selectedRoleRef: selectedRoleRef, onRoleChanged: onRoleChanged, ), diff --git a/frontend/pweb/lib/pages/invitations/widgets/list/view.dart b/frontend/pweb/lib/pages/invitations/widgets/list/view.dart index 80970ec..f312257 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/list/view.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/list/view.dart @@ -6,9 +6,9 @@ import 'package:pshared/models/invitation/invitation.dart'; import 'package:pshared/provider/invitations.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/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/widgets/error/snackbar.dart'; @@ -25,70 +25,62 @@ class InvitationListView extends StatefulWidget { class _InvitationListViewState extends State { final TextEditingController _searchController = TextEditingController(); - InvitationFilter _filter = InvitationFilter.all; - String _query = ''; Object? _lastError; + InvitationsProvider? _provider; + + @override + void initState() { + super.initState(); + _provider = context.read(); + _provider?.addListener(_onProviderChanged); + } @override void dispose() { + _provider?.removeListener(_onProviderChanged); _searchController.dispose(); super.dispose(); } - void _setQuery(String query) { - setState(() => _query = query.trim().toLowerCase()); - } + void _setQuery(String query) => context.read().setQuery(query); + void _setFilter(InvitationFilter filter) => context.read().setFilter(filter); - void _setFilter(InvitationFilter filter) { - setState(() => _filter = filter); - } - - void _notifyError(BuildContext context, Object error, AppLocalizations loc) { + void _onProviderChanged() { + final provider = _provider; + if (provider == null) { + return; + } + final error = provider.error; + if (error == null) { + _lastError = null; + return; + } if (identical(error, _lastError)) { return; } _lastError = error; - postNotifyUserOfErrorX( - context: context, - errorSituation: loc.errorLoadingInvitations, - exception: error, - ); - } - - List _filteredInvitations(List invitations) { - final showArchived = _filter == InvitationFilter.archived; - Iterable 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; + final loc = AppLocalizations.of(context)!; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + postNotifyUserOfErrorX( + context: context, + errorSituation: loc.errorLoadingInvitations, + exception: error, + ); + }); } @override Widget build(BuildContext context) { final loc = AppLocalizations.of(context)!; final provider = context.watch(); + final viewModel = context.watch(); if (provider.isLoading) { return const Center(child: CircularProgressIndicator()); } - if (provider.error != null) { - _notifyError(context, provider.error!, loc); - } else { - _lastError = null; - } - - final invitations = _filteredInvitations(provider.invitations); + final invitations = viewModel.filteredInvitations(provider.invitations); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -100,7 +92,7 @@ class _InvitationListViewState extends State { ), const SizedBox(height: 12), InvitationFilterChips( - selectedFilter: _filter, + selectedFilter: viewModel.filter, onSelected: _setFilter, ), const SizedBox(height: 16), diff --git a/frontend/pweb/lib/pages/invitations/widgets/list/view_model.dart b/frontend/pweb/lib/pages/invitations/widgets/list/view_model.dart new file mode 100644 index 0000000..3e46049 --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/list/view_model.dart @@ -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 filteredInvitations(List invitations) { + final showArchived = _filter == InvitationFilter.archived; + Iterable 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; + } +} diff --git a/frontend/pweb/lib/widgets/roles/create_role_dialog.dart b/frontend/pweb/lib/widgets/roles/create_role_dialog.dart new file mode 100644 index 0000000..482ebba --- /dev/null +++ b/frontend/pweb/lib/widgets/roles/create_role_dialog.dart @@ -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 showCreateRoleDialog(BuildContext context) { + return showDialog( + context: context, + builder: (dialogContext) => const _CreateRoleDialog(), + ); +} + +class _CreateRoleDialog extends StatefulWidget { + const _CreateRoleDialog(); + + @override + State<_CreateRoleDialog> createState() => _CreateRoleDialogState(); +} + +class _CreateRoleDialogState extends State<_CreateRoleDialog> { + final GlobalKey _formKey = GlobalKey(); + 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), + ), + ], + ); + } +}