Sender Invitation

This commit is contained in:
Arseni
2026-01-12 21:28:18 +03:00
parent dedde76dd7
commit 5447433b5d
34 changed files with 1575 additions and 27 deletions

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/resources.dart';
import 'package:pshared/provider/account.dart';
import 'package:pshared/provider/invitations.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pweb/pages/invitations/widgets/header.dart';
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/generated/i18n/app_localizations.dart';
class InvitationsPage extends StatefulWidget {
const InvitationsPage({super.key});
@override
State<InvitationsPage> createState() => _InvitationsPageState();
}
class _InvitationsPageState extends State<InvitationsPage> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _nameController = TextEditingController();
final TextEditingController _messageController = TextEditingController();
String? _selectedRoleRef;
int _expiryDays = 7;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_bootstrapRoleSelection();
}
void _bootstrapRoleSelection() {
final roles = context.read<PermissionsProvider>().roleDescriptions;
if (_selectedRoleRef == null && roles.isNotEmpty) {
_selectedRoleRef = roles.first.storable.id;
}
}
@override
void dispose() {
_emailController.dispose();
_nameController.dispose();
_messageController.dispose();
super.dispose();
}
Future<void> _sendInvitation() async {
final form = _formKey.currentState;
if (form == null || !form.validate()) return;
final account = context.read<AccountProvider>().account;
if (account == null) return;
final permissions = context.read<PermissionsProvider>();
final roleRef = _selectedRoleRef ?? permissions.roleDescriptions.firstOrNull?.storable.id;
if (roleRef == null) return;
final invitations = context.read<InvitationsProvider>();
final loc = AppLocalizations.of(context)!;
await executeActionWithNotification(
context: context,
action: () => invitations.sendInvitation(
email: _emailController.text.trim(),
name: _nameController.text.trim(),
comment: _messageController.text.trim(),
roleRef: roleRef,
inviterRef: account.id,
expiresAt: DateTime.now().toUtc().add(Duration(days: _expiryDays)),
),
successMessage: loc.invitationCreatedSuccess,
errorMessage: loc.errorCreatingInvitation,
);
_emailController.clear();
_nameController.clear();
_messageController.clear();
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final permissions = context.watch<PermissionsProvider>();
if (!permissions.canRead(ResourceType.invitations)) {
return PageViewLoader(
child: Center(child: Text(loc.errorAccessDenied)),
);
}
return PageViewLoader(
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InvitationsHeader(loc: loc),
const SizedBox(height: 16),
InvitationsForm(
formKey: _formKey,
emailController: _emailController,
nameController: _nameController,
messageController: _messageController,
expiryDays: _expiryDays,
onExpiryChanged: (value) => setState(() => _expiryDays = value),
selectedRoleRef: _selectedRoleRef,
onRoleChanged: (role) => setState(() => _selectedRoleRef = role),
canCreate: permissions.canCreate(ResourceType.invitations),
onSubmit: _sendInvitation,
),
const SizedBox(height: 24),
const InvitationsList(),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/provider/invitations.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationCardActions extends StatelessWidget {
final Invitation invitation;
const InvitationCardActions({super.key, required this.invitation});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (invitation.isPending && !invitation.isExpired)
TextButton.icon(
onPressed: () => _revokeInvitation(context),
icon: const Icon(Icons.block),
label: Text(loc.invitationRevokeAction),
),
if (!invitation.isArchived)
TextButton.icon(
onPressed: () => _archiveInvitation(context),
icon: const Icon(Icons.archive_outlined),
label: Text(loc.invitationArchiveAction),
),
],
);
}
Future<void> _archiveInvitation(BuildContext context) async {
final loc = AppLocalizations.of(context)!;
final provider = context.read<InvitationsProvider>();
await executeActionWithNotification(
context: context,
action: () => provider.setInvitationArchived(invitation, true),
successMessage: loc.invitationArchived,
errorMessage: loc.invitationArchiveFailed,
);
}
Future<void> _revokeInvitation(BuildContext context) async {
final loc = AppLocalizations.of(context)!;
final provider = context.read<InvitationsProvider>();
await executeActionWithNotification(
context: context,
action: () => provider.revokeInvitation(invitation),
successMessage: loc.invitationRevoked,
errorMessage: loc.invitationRevokeFailed,
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pweb/pages/invitations/widgets/card/view.dart';
class InvitationsCard extends StatelessWidget {
final Invitation invitation;
const InvitationsCard({super.key, required this.invitation});
@override
Widget build(BuildContext context) => InvitationCardView(invitation: invitation);
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pweb/pages/invitations/widgets/card/helpers.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationCardDetails extends StatelessWidget {
final Invitation invitation;
final String roleLabel;
final String inviterName;
final DateFormat dateFormat;
final AppLocalizations loc;
const InvitationCardDetails({
super.key,
required this.invitation,
required this.roleLabel,
required this.inviterName,
required this.dateFormat,
required this.loc,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 20,
runSpacing: 10,
children: [
InvitationInfoRow(
icon: Icons.badge_outlined,
label: loc.invitationRoleLabel,
value: roleLabel,
),
InvitationInfoRow(
icon: Icons.schedule_outlined,
label: invitation.isExpired
? loc.invitationExpired(dateFormat.format(invitation.expiresAt.toLocal()))
: loc.invitationExpires(dateFormat.format(invitation.expiresAt.toLocal())),
value: '',
),
InvitationInfoRow(
icon: Icons.person_outline,
label: loc.invitationInvitedBy,
value: inviterName,
),
],
),
if (invitation.content.comment.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
invitation.content.comment,
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/invitation/invitation.dart';
class InvitationCardHeader extends StatelessWidget {
final Invitation invitation;
final String statusLabel;
final Color statusColor;
const InvitationCardHeader({
super.key,
required this.invitation,
required this.statusLabel,
required this.statusColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
invitation.inviteeDisplayName,
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
invitation.content.email,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
),
],
),
),
Chip(
backgroundColor: statusColor.withAlpha(40),
label: Text(
statusLabel,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w600),
),
),
],
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/models/invitation/status.dart';
import 'package:pshared/models/permissions/descriptions/role.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationInfoRow extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const InvitationInfoRow({
super.key,
required this.icon,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: theme.hintColor),
const SizedBox(width: 6),
if (value.isEmpty)
Text(label, style: theme.textTheme.bodyMedium)
else
RichText(
text: TextSpan(
style: theme.textTheme.bodyMedium,
children: [
TextSpan(text: '$label: ', style: const TextStyle(fontWeight: FontWeight.w600)),
TextSpan(text: value),
],
),
),
],
);
}
}
String invitationStatusLabel(Invitation invitation, AppLocalizations loc) {
if (invitation.isExpired && invitation.isPending) {
return loc.invitationStatusExpired;
}
switch (invitation.status) {
case InvitationStatus.created:
case InvitationStatus.sent:
return loc.invitationStatusPending;
case InvitationStatus.accepted:
return loc.invitationStatusAccepted;
case InvitationStatus.declined:
return loc.invitationStatusDeclined;
case InvitationStatus.revoked:
return loc.invitationStatusRevoked;
}
}
Color invitationStatusColor(ThemeData theme, Invitation invitation) {
if (invitation.isExpired && invitation.isPending) {
return theme.disabledColor;
}
switch (invitation.status) {
case InvitationStatus.created:
case InvitationStatus.sent:
return Colors.amber.shade800;
case InvitationStatus.accepted:
return Colors.green.shade700;
case InvitationStatus.declined:
case InvitationStatus.revoked:
return Colors.red.shade700;
}
}
String invitationRoleLabel(List<RoleDescription> roles, Invitation invitation, AppLocalizations loc) {
final role = roles.firstWhereOrNull((r) => r.storable.id == invitation.roleRef);
return role?.describable.name ?? loc.invitationUnknownRole;
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/models/resources.dart';
import 'package:pshared/provider/accounts/employees.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pweb/pages/invitations/widgets/card/actions.dart';
import 'package:pweb/pages/invitations/widgets/card/details.dart';
import 'package:pweb/pages/invitations/widgets/card/header.dart';
import 'package:pweb/pages/invitations/widgets/card/helpers.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationCardView extends StatelessWidget {
final Invitation invitation;
const InvitationCardView({super.key, required this.invitation});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final permissions = context.watch<PermissionsProvider>();
final employees = context.watch<EmployeesProvider>();
final dateFormat = DateFormat.yMMMd().add_Hm();
final statusLabel = invitationStatusLabel(invitation, loc);
final statusColor = invitationStatusColor(theme, invitation);
final roleLabel = invitationRoleLabel(permissions.roleDescriptions, invitation, loc);
final inviterName = employees.getEmployee(invitation.inviterRef)?.fullName ?? loc.unknown;
final canUpdate = permissions.canUpdate(ResourceType.invitations);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor.withAlpha(20)),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InvitationCardHeader(
invitation: invitation,
statusLabel: statusLabel,
statusColor: statusColor,
),
const SizedBox(height: 12),
InvitationCardDetails(
invitation: invitation,
roleLabel: roleLabel,
inviterName: inviterName,
dateFormat: dateFormat,
loc: loc,
),
if (canUpdate) ...[
const SizedBox(height: 10),
InvitationCardActions(invitation: invitation),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:pweb/models/invitation_filter.dart';
import 'package:pweb/pages/invitations/widgets/filter/invitation_filter.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationFilterChips extends StatelessWidget {
final InvitationFilter selectedFilter;
final ValueChanged<InvitationFilter> onSelected;
const InvitationFilterChips({
super.key,
required this.selectedFilter,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Wrap(
spacing: 8,
runSpacing: 8,
children: InvitationFilter.values.map(
(filter) => ChoiceChip(
label: Text(invitationFilterLabel(filter, loc)),
selected: selectedFilter == filter,
onSelected: (_) => onSelected(filter),
),
).toList(),
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/models/invitation/status.dart';
import 'package:pweb/models/invitation_filter.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
String invitationFilterLabel(InvitationFilter filter, AppLocalizations loc) {
switch (filter) {
case InvitationFilter.all:
return loc.invitationFilterAll;
case InvitationFilter.pending:
return loc.invitationFilterPending;
case InvitationFilter.accepted:
return loc.invitationFilterAccepted;
case InvitationFilter.declined:
return loc.invitationFilterDeclined;
case InvitationFilter.revoked:
return loc.invitationFilterRevoked;
case InvitationFilter.expired:
return loc.invitationFilterExpired;
case InvitationFilter.archived:
return loc.invitationFilterArchived;
}
}
bool invitationFilterMatches(InvitationFilter filter, Invitation inv) {
switch (filter) {
case InvitationFilter.pending:
return inv.isPending && !inv.isExpired;
case InvitationFilter.accepted:
return inv.status == InvitationStatus.accepted;
case InvitationFilter.declined:
return inv.status == InvitationStatus.declined;
case InvitationFilter.revoked:
return inv.status == InvitationStatus.revoked;
case InvitationFilter.expired:
return inv.isExpired && inv.isPending;
case InvitationFilter.archived:
return inv.isArchived;
case InvitationFilter.all:
return true;
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationFormActions extends StatelessWidget {
final int expiryDays;
final ValueChanged<int> onExpiryChanged;
final bool canCreate;
final bool hasRoles;
final VoidCallback onSubmit;
const InvitationFormActions({
super.key,
required this.expiryDays,
required this.onExpiryChanged,
required this.canCreate,
required this.hasRoles,
required this.onSubmit,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Row(
children: [
Text(loc.invitationExpiresIn(expiryDays)),
Expanded(
child: Slider(
label: '$expiryDays',
min: 1,
max: 30,
value: expiryDays.toDouble(),
onChanged: (value) => onExpiryChanged(value.round()),
),
),
FilledButton.icon(
onPressed: canCreate && hasRoles ? onSubmit : null,
icon: const Icon(Icons.send_outlined),
label: Text(loc.invitationSendButton),
),
],
);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/permissions/descriptions/role.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationFormFields extends StatelessWidget {
final List<RoleDescription> roles;
final TextEditingController emailController;
final TextEditingController nameController;
final TextEditingController messageController;
final String? selectedRoleRef;
final ValueChanged<String?> onRoleChanged;
const InvitationFormFields({
super.key,
required this.roles,
required this.emailController,
required this.nameController,
required this.messageController,
required this.selectedRoleRef,
required this.onRoleChanged,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Column(
children: [
Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: 320,
child: TextFormField(
controller: emailController,
decoration: InputDecoration(
labelText: loc.invitationEmailLabel,
prefixIcon: const Icon(Icons.alternate_email_outlined),
),
keyboardType: TextInputType.emailAddress,
validator: (value) => (value == null || value.trim().isEmpty)
? loc.errorEmailMissing
: null,
),
),
SizedBox(
width: 280,
child: TextFormField(
controller: nameController,
decoration: InputDecoration(
labelText: loc.invitationNameLabel,
prefixIcon: const Icon(Icons.person_outline),
),
),
),
SizedBox(
width: 260,
child: DropdownButtonFormField<String>(
initialValue: selectedRoleRef ?? (roles.isNotEmpty ? roles.first.storable.id : null),
items: roles.map((role) => DropdownMenuItem(
value: role.storable.id,
child: Text(role.describable.name),
)).toList(),
onChanged: roles.isEmpty ? null : onRoleChanged,
decoration: InputDecoration(
labelText: loc.invitationRoleLabel,
prefixIcon: const Icon(Icons.security_outlined),
),
),
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: messageController,
minLines: 2,
maxLines: 3,
decoration: InputDecoration(
labelText: loc.invitationMessageLabel,
prefixIcon: const Icon(Icons.chat_bubble_outline),
),
),
],
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/invitations/widgets/form/view.dart';
class InvitationsForm extends StatelessWidget {
final GlobalKey<FormState> formKey;
final TextEditingController emailController;
final TextEditingController nameController;
final TextEditingController messageController;
final int expiryDays;
final ValueChanged<int> onExpiryChanged;
final String? selectedRoleRef;
final ValueChanged<String?> onRoleChanged;
final bool canCreate;
final VoidCallback onSubmit;
const InvitationsForm({
super.key,
required this.formKey,
required this.emailController,
required this.nameController,
required this.messageController,
required this.expiryDays,
required this.onExpiryChanged,
required this.selectedRoleRef,
required this.onRoleChanged,
required this.canCreate,
required this.onSubmit,
});
@override
Widget build(BuildContext context) => InvitationFormView(
formKey: formKey,
emailController: emailController,
nameController: nameController,
messageController: messageController,
expiryDays: expiryDays,
onExpiryChanged: onExpiryChanged,
selectedRoleRef: selectedRoleRef,
onRoleChanged: onRoleChanged,
canCreate: canCreate,
onSubmit: onSubmit,
);
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pweb/pages/invitations/widgets/form/actions.dart';
import 'package:pweb/pages/invitations/widgets/form/fields.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationFormView extends StatelessWidget {
final GlobalKey<FormState> formKey;
final TextEditingController emailController;
final TextEditingController nameController;
final TextEditingController messageController;
final int expiryDays;
final ValueChanged<int> onExpiryChanged;
final String? selectedRoleRef;
final ValueChanged<String?> onRoleChanged;
final bool canCreate;
final VoidCallback onSubmit;
const InvitationFormView({
super.key,
required this.formKey,
required this.emailController,
required this.nameController,
required this.messageController,
required this.expiryDays,
required this.onExpiryChanged,
required this.selectedRoleRef,
required this.onRoleChanged,
required this.canCreate,
required this.onSubmit,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!;
final roles = context.watch<PermissionsProvider>().roleDescriptions;
return Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest.withAlpha(40),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.invitationCreateTitle,
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
InvitationFormFields(
roles: roles,
emailController: emailController,
nameController: nameController,
messageController: messageController,
selectedRoleRef: selectedRoleRef,
onRoleChanged: onRoleChanged,
),
const SizedBox(height: 12),
InvitationFormActions(
expiryDays: expiryDays,
onExpiryChanged: onExpiryChanged,
canCreate: canCreate,
hasRoles: roles.isNotEmpty,
onSubmit: onSubmit,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationsHeader extends StatelessWidget {
final AppLocalizations loc;
const InvitationsHeader({super.key, required this.loc});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.invitationsTitle,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
loc.invitationsSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).hintColor),
),
],
);
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pweb/pages/invitations/widgets/card/card.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationListBody extends StatelessWidget {
final List<Invitation> invitations;
const InvitationListBody({super.key, required this.invitations});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
if (invitations.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Text(loc.invitationListEmpty),
),
);
}
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: invitations.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, index) => InvitationsCard(invitation: invitations[index]),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/invitations/widgets/list/view.dart';
class InvitationsList extends StatefulWidget {
const InvitationsList({super.key});
@override
State<InvitationsList> createState() => _InvitationsListState();
}
class _InvitationsListState extends State<InvitationsList> {
@override
Widget build(BuildContext context) => const InvitationListView();
}

View File

@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
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/search_field.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationListView extends StatefulWidget {
const InvitationListView({super.key});
@override
State<InvitationListView> createState() => _InvitationListViewState();
}
class _InvitationListViewState extends State<InvitationListView> {
final TextEditingController _searchController = TextEditingController();
InvitationFilter _filter = InvitationFilter.all;
String _query = '';
Object? _lastError;
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _setQuery(String query) {
setState(() => _query = query.trim().toLowerCase());
}
void _setFilter(InvitationFilter filter) {
setState(() => _filter = filter);
}
void _notifyError(BuildContext context, Object error, AppLocalizations loc) {
if (identical(error, _lastError)) {
return;
}
_lastError = error;
postNotifyUserOfErrorX(
context: context,
errorSituation: loc.errorLoadingInvitations,
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
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final provider = context.watch<InvitationsProvider>();
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);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InvitationSearchField(
controller: _searchController,
hintText: loc.invitationSearchHint,
onChanged: _setQuery,
),
const SizedBox(height: 12),
InvitationFilterChips(
selectedFilter: _filter,
onSelected: _setFilter,
),
const SizedBox(height: 16),
InvitationListBody(invitations: invitations),
],
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
class InvitationSearchField extends StatelessWidget {
final TextEditingController controller;
final String hintText;
final ValueChanged<String> onChanged;
const InvitationSearchField({
super.key,
required this.controller,
required this.hintText,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: hintText,
),
onChanged: onChanged,
);
}
}