diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 542c840ee3..ce7ac59e98 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @tnotheis @jkoenig134 \ No newline at end of file +* @tnotheis @jkoenig134 diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/filters/date_filter.dart b/AdminUi/apps/admin_ui/lib/core/widgets/filters/date_filter.dart new file mode 100644 index 0000000000..3596326170 --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/core/widgets/filters/date_filter.dart @@ -0,0 +1,96 @@ +import 'package:admin_api_sdk/admin_api_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '/core/constants.dart'; +import 'to_filter_operator_dropdown_menu_item.dart'; + +class DateFilter extends StatefulWidget { + final void Function(FilterOperator operator, DateTime? selectedDate) onFilterSelected; + final String label; + + const DateFilter({required this.onFilterSelected, required this.label, super.key}); + + @override + State createState() => _DateFilterState(); +} + +class _DateFilterState extends State { + FilterOperator _operator = FilterOperator.equal; + DateTime? _selectedDate; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${widget.label}:', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Gaps.h8, + Row( + children: [ + DropdownButton( + value: _operator, + onChanged: (selectedOperator) { + if (selectedOperator == null) return; + setState(() => _operator = selectedOperator); + widget.onFilterSelected(selectedOperator, _selectedDate); + }, + items: FilterOperator.values.toDropdownMenuItems(), + ), + Gaps.w8, + InkWell( + onTap: _selectNewDate, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _selectedDate != null ? DateFormat('yyyy-MM-dd').format(_selectedDate!) : 'Select date', + style: const TextStyle(fontSize: 14), + ), + Gaps.w8, + const Icon(Icons.calendar_today), + if (_selectedDate != null) ...[ + Gaps.w8, + GestureDetector( + onTap: _clearDate, + child: const Icon(Icons.clear, size: 20), + ), + ], + ], + ), + ), + ), + ], + ), + ], + ); + } + + void _clearDate() { + setState(() => _selectedDate = null); + widget.onFilterSelected(_operator, null); + } + + Future _selectNewDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 1)), + ); + + if (picked == null) return; + + setState(() => _selectedDate = picked); + widget.onFilterSelected(_operator, picked); + } +} diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/filters/filters.dart b/AdminUi/apps/admin_ui/lib/core/widgets/filters/filters.dart new file mode 100644 index 0000000000..01502796be --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/core/widgets/filters/filters.dart @@ -0,0 +1,4 @@ +export 'date_filter.dart'; +export 'input_filter.dart'; +export 'multi_select.dart'; +export 'number_filter.dart'; diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/filters/input_filter.dart b/AdminUi/apps/admin_ui/lib/core/widgets/filters/input_filter.dart new file mode 100644 index 0000000000..6dbfd30720 --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/core/widgets/filters/input_filter.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import '/core/core.dart'; + +class InputField extends StatelessWidget { + final void Function(String enteredText) onEnteredText; + final String label; + + const InputField({required this.onEnteredText, required this.label, super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$label:', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Gaps.h8, + SizedBox( + width: 180, + child: TextField( + onChanged: onEnteredText, + decoration: const InputDecoration(border: OutlineInputBorder()), + ), + ), + ], + ); + } +} diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/filters/multi_select.dart b/AdminUi/apps/admin_ui/lib/core/widgets/filters/multi_select.dart new file mode 100644 index 0000000000..0f7e7ffee1 --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/core/widgets/filters/multi_select.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:multi_dropdown/multiselect_dropdown.dart'; + +import '/core/core.dart'; + +class MultiSelectFilter extends StatelessWidget { + final String label; + final String searchLabel; + final MultiSelectController controller; + final void Function(List> selectedOptions) onOptionSelected; + + const MultiSelectFilter({ + required this.label, + required this.searchLabel, + required this.controller, + required this.onOptionSelected, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$label:', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Gaps.h8, + SizedBox( + width: 250, + child: MultiSelectDropDown( + hint: '', + searchLabel: searchLabel, + searchEnabled: true, + controller: controller, + options: controller.options, + fieldBackgroundColor: Theme.of(context).colorScheme.background, + searchBackgroundColor: Theme.of(context).colorScheme.background, + dropdownBackgroundColor: Theme.of(context).colorScheme.background, + selectedOptionBackgroundColor: Theme.of(context).colorScheme.background, + selectedOptionTextColor: Theme.of(context).colorScheme.onBackground, + optionsBackgroundColor: Theme.of(context).colorScheme.background, + optionTextStyle: TextStyle(color: Theme.of(context).colorScheme.onBackground), + onOptionSelected: onOptionSelected, + ), + ), + ], + ); + } +} diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/filters/number_filter.dart b/AdminUi/apps/admin_ui/lib/core/widgets/filters/number_filter.dart new file mode 100644 index 0000000000..ba5ca18ede --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/core/widgets/filters/number_filter.dart @@ -0,0 +1,66 @@ +import 'package:admin_api_sdk/admin_api_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '/core/constants.dart'; +import 'to_filter_operator_dropdown_menu_item.dart'; + +class NumberFilter extends StatefulWidget { + final void Function(FilterOperator operator, String enteredValue) onNumberSelected; + final String label; + + const NumberFilter({ + required this.onNumberSelected, + required this.label, + super.key, + }); + + @override + State createState() => _NumberFilterState(); +} + +class _NumberFilterState extends State { + late FilterOperator _operator = FilterOperator.equal; + late String _value = ''; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${widget.label}:', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Gaps.h8, + Row( + children: [ + DropdownButton( + value: _operator, + onChanged: (selectedOperator) { + if (selectedOperator == null) return; + setState(() => _operator = selectedOperator); + widget.onNumberSelected(selectedOperator, _value); + }, + items: FilterOperator.values.toDropdownMenuItems(), + ), + Gaps.w16, + SizedBox( + width: 120, + child: TextField( + onChanged: (enteredValue) { + _value = enteredValue; + widget.onNumberSelected(_operator, enteredValue); + }, + decoration: const InputDecoration(border: OutlineInputBorder()), + style: const TextStyle(fontSize: 12), + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + keyboardType: TextInputType.number, + ), + ), + ], + ), + ], + ); + } +} diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/filters/to_filter_operator_dropdown_menu_item.dart b/AdminUi/apps/admin_ui/lib/core/widgets/filters/to_filter_operator_dropdown_menu_item.dart new file mode 100644 index 0000000000..ee08037f49 --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/core/widgets/filters/to_filter_operator_dropdown_menu_item.dart @@ -0,0 +1,11 @@ +import 'package:admin_api_sdk/admin_api_sdk.dart'; +import 'package:flutter/material.dart'; + +extension ToFilterOperatorDropdownMenuItem on List { + List> toDropdownMenuItems() => map( + (operator) => DropdownMenuItem( + value: operator, + child: Text(operator.userFriendlyOperator), + ), + ).toList(); +} diff --git a/AdminUi/apps/admin_ui/lib/core/widgets/widgets.dart b/AdminUi/apps/admin_ui/lib/core/widgets/widgets.dart index bdfa30afdc..30092c1139 100644 --- a/AdminUi/apps/admin_ui/lib/core/widgets/widgets.dart +++ b/AdminUi/apps/admin_ui/lib/core/widgets/widgets.dart @@ -1 +1,2 @@ export 'app_title.dart'; +export 'filters/filters.dart'; diff --git a/AdminUi/apps/admin_ui/lib/home/home.dart b/AdminUi/apps/admin_ui/lib/home/home.dart index d6a6d9556c..75e4f2e2c0 100644 --- a/AdminUi/apps/admin_ui/lib/home/home.dart +++ b/AdminUi/apps/admin_ui/lib/home/home.dart @@ -1,13 +1,6 @@ import 'package:flutter/material.dart'; -class Identities extends StatelessWidget { - const Identities({super.key}); - - @override - Widget build(BuildContext context) { - return const Placeholder(); - } -} +export 'identities_overview/identities_overview.dart'; class Tiers extends StatelessWidget { const Tiers({super.key}); diff --git a/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_data_table_source.dart b/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_data_table_source.dart new file mode 100644 index 0000000000..2910e3285f --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_data_table_source.dart @@ -0,0 +1,91 @@ +import 'package:admin_api_sdk/admin_api_sdk.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart'; +import 'package:logger/logger.dart'; + +class IdentityDataTableSource extends AsyncDataTableSource { + Pagination? _pagination; + var _sortingSettings = (sortColumnIndex: 0, sortAscending: true); + + IdentityOverviewFilter? _filter; + + set filter(IdentityOverviewFilter? newFilter) { + if (_filter != newFilter) { + _filter = newFilter; + notifyListeners(); + } + } + + void sort({required int sortColumnIndex, required bool sortColumnAscending}) { + _sortingSettings = (sortColumnIndex: sortColumnIndex, sortAscending: sortColumnAscending); + notifyListeners(); + } + + @override + bool get isRowCountApproximate => false; + + @override + int get rowCount => _pagination?.totalRecords ?? 0; + + @override + int get selectedRowCount => 0; + + @override + Future getRows(int startIndex, int count) async { + final pageNumber = startIndex ~/ count; + final orderBy = _getODataOrderBy(); + + try { + final response = await GetIt.I.get().identities.getIdentities( + pageNumber: pageNumber, + pageSize: count, + filter: _filter, + orderBy: orderBy, + ); + _pagination = response.pagination; + + final rows = response.data.indexed + .map( + (identity) => DataRow.byIndex( + index: pageNumber * count + identity.$1, + cells: [ + DataCell(Text(identity.$2.address)), + DataCell(Text(identity.$2.tier.name)), + DataCell(Text(identity.$2.createdWithClient)), + DataCell(Text(identity.$2.numberOfDevices.toString())), + DataCell(Text(DateFormat('yyyy-MM-dd').format(identity.$2.createdAt))), + DataCell(Text(identity.$2.lastLoginAt != null ? DateFormat('yyyy-MM-dd').format(identity.$2.lastLoginAt!) : '')), + DataCell(Text(identity.$2.datawalletVersion?.toString() ?? '')), + DataCell(Text(identity.$2.identityVersion.toString())), + ], + ), + ) + .toList(); + + return AsyncRowsResponse(response.pagination.totalRecords, rows); + } catch (e) { + GetIt.I.get().e('Failed to load data: $e'); + + throw Exception('Failed to load data: $e'); + } + } + + String _getODataOrderBy() { + final columnName = _getFieldNameByIndex(_sortingSettings.sortColumnIndex); + final sortingDirection = _sortingSettings.sortAscending ? 'asc' : 'desc'; + return '$columnName $sortingDirection'; + } + + String _getFieldNameByIndex(int index) => switch (index) { + 0 => 'address', + 2 => 'createdWithClient', + 3 => 'numberOfDevices', + 4 => 'createdAt', + 5 => 'lastLoginAt', + 6 => 'datawalletVersion', + 7 => 'identityVersion', + _ => throw Exception('Invalid column index') + }; +} diff --git a/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_filter.dart b/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_filter.dart new file mode 100644 index 0000000000..71603d641c --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_filter.dart @@ -0,0 +1,144 @@ +import 'package:admin_api_sdk/admin_api_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart'; +import 'package:multi_dropdown/multiselect_dropdown.dart'; + +import '/core/core.dart'; + +class IdentitiesFilter extends StatefulWidget { + final Future Function({IdentityOverviewFilter? filter}) onFilterChanged; + + const IdentitiesFilter({ + required this.onFilterChanged, + super.key, + }); + + @override + State createState() => _IdentitiesFilterState(); +} + +class _IdentitiesFilterState extends State { + IdentityOverviewFilter _filter = IdentityOverviewFilter(); + + final MultiSelectController _tierController = MultiSelectController(); + final MultiSelectController _clientController = MultiSelectController(); + + @override + void initState() { + super.initState(); + + _loadTiers(); + _loadClients(); + } + + @override + void dispose() { + _tierController.dispose(); + _clientController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + InputField( + label: 'Address', + onEnteredText: (String enteredText) { + _filter = _filter.copyWith(address: enteredText.isEmpty ? const Optional.absent() : Optional(enteredText)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + MultiSelectFilter( + label: 'Tiers', + searchLabel: 'Search Tiers', + controller: _tierController, + onOptionSelected: (List> selectedOptions) { + final selectedTiers = selectedOptions.map((item) => item.value!).toList(); + _filter = _filter.copyWith(tiers: selectedTiers.isEmpty ? const Optional.absent() : Optional(selectedTiers)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + MultiSelectFilter( + label: 'Clients', + searchLabel: 'Search Clients', + controller: _clientController, + onOptionSelected: (List> selectedOptions) { + final selectedClients = selectedOptions.map((item) => item.value!).toList(); + _filter = _filter.copyWith(clients: selectedClients.isEmpty ? const Optional.absent() : Optional(selectedClients)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + NumberFilter( + label: 'Number of Devices', + onNumberSelected: (FilterOperator operator, String enteredValue) { + final numberOfDevices = FilterOperatorValue(operator, enteredValue); + _filter = _filter.copyWith(numberOfDevices: numberOfDevices.value.isEmpty ? const Optional.absent() : Optional(numberOfDevices)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + DateFilter( + label: 'Created At', + onFilterSelected: (FilterOperator operator, DateTime? selectedDate) { + final createdAt = FilterOperatorValue(operator, selectedDate != null ? DateFormat('yyyy-MM-dd').format(selectedDate) : ''); + _filter = _filter.copyWith(createdAt: createdAt.value.isEmpty ? const Optional.absent() : Optional(createdAt)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + DateFilter( + label: 'Last Login At', + onFilterSelected: (FilterOperator operator, DateTime? selectedDate) { + final lastLoginAt = FilterOperatorValue(operator, selectedDate != null ? DateFormat('yyyy-MM-dd').format(selectedDate) : ''); + _filter = _filter.copyWith(lastLoginAt: lastLoginAt.value.isEmpty ? const Optional.absent() : Optional(lastLoginAt)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + NumberFilter( + label: 'Datawallet Version', + onNumberSelected: (FilterOperator operator, String enteredValue) { + final datawalletVersion = FilterOperatorValue(operator, enteredValue); + _filter = + _filter.copyWith(datawalletVersion: datawalletVersion.value.isEmpty ? const Optional.absent() : Optional(datawalletVersion)); + widget.onFilterChanged(filter: _filter); + }, + ), + Gaps.w16, + NumberFilter( + label: 'Identity Version', + onNumberSelected: (FilterOperator operator, String enteredValue) { + final identityVersion = FilterOperatorValue(operator, enteredValue); + _filter = _filter.copyWith(identityVersion: identityVersion.value.isEmpty ? const Optional.absent() : Optional(identityVersion)); + widget.onFilterChanged(filter: _filter); + }, + ), + ], + ), + ), + ); + } + + Future _loadTiers() async { + final response = await GetIt.I.get().tiers.getTiers(); + final tierItems = response.data.map((tier) => ValueItem(label: tier.name, value: tier.id)).toList(); + _tierController.setOptions(tierItems); + } + + Future _loadClients() async { + final response = await GetIt.I.get().clients.getClients(); + final clientItems = response.data.map((client) => ValueItem(label: client.displayName, value: client.clientId)).toList(); + _clientController.setOptions(clientItems); + } +} diff --git a/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_overview.dart b/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_overview.dart new file mode 100644 index 0000000000..d7d9bd5eb0 --- /dev/null +++ b/AdminUi/apps/admin_ui/lib/home/identities_overview/identities_overview.dart @@ -0,0 +1,116 @@ +import 'package:admin_api_sdk/admin_api_sdk.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:flutter/material.dart'; + +import '/core/core.dart'; +import 'identities_data_table_source.dart'; +import 'identities_filter.dart'; + +class IdentitiesOverview extends StatefulWidget { + const IdentitiesOverview({super.key}); + + @override + State createState() => _IdentitiesOverviewState(); +} + +class _IdentitiesOverviewState extends State { + late ScrollController _scrollController; + late IdentityDataTableSource _dataSource; + + int _sortColumnIndex = 0; + bool _sortColumnAscending = true; + + int _rowsPerPage = 5; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + _dataSource = IdentityDataTableSource(); + } + + @override + void dispose() { + _scrollController.dispose(); + _dataSource.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Padding( + padding: EdgeInsets.only(left: 32), + child: Text('A list of existing Identities'), + ), + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + IdentitiesFilter( + onFilterChanged: ({IdentityOverviewFilter? filter}) async { + _dataSource + ..filter = filter + ..refreshDatasource(); + }, + ), + Expanded( + child: AsyncPaginatedDataTable2( + rowsPerPage: _rowsPerPage, + onRowsPerPageChanged: _setRowsPerPage, + sortColumnIndex: _sortColumnIndex, + sortAscending: _sortColumnAscending, + showFirstLastButtons: true, + columnSpacing: 5, + source: _dataSource, + scrollController: _scrollController, + isVerticalScrollBarVisible: true, + renderEmptyRowsInTheEnd: false, + availableRowsPerPage: const [5, 10, 25, 50, 100], + errorBuilder: (error) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('An error occurred loading the data.'), + Gaps.h16, + FilledButton(onPressed: () => _dataSource.refreshDatasource(), child: const Text('Retry')), + ], + ), + ), + columns: [ + DataColumn2(label: const Text('Address'), size: ColumnSize.L, onSort: _sort), + const DataColumn2(label: Text('Tier'), size: ColumnSize.S), + DataColumn2(label: const Text('Created with Client'), onSort: _sort), + DataColumn2(label: const Text('Number of Devices'), onSort: _sort), + DataColumn2(label: const Text('Created at'), size: ColumnSize.S, onSort: _sort), + DataColumn2(label: const Text('Last Login at'), size: ColumnSize.S, onSort: _sort), + DataColumn2(label: const Text('Datawallet version'), onSort: _sort), + DataColumn2(label: const Text('Identity Version'), onSort: _sort), + ], + ), + ), + ], + ), + ), + ); + } + + void _setRowsPerPage(int? newValue) { + _rowsPerPage = newValue ?? _rowsPerPage; + _dataSource.refreshDatasource(); + } + + void _sort(int columnIndex, bool ascending) { + setState(() { + _sortColumnIndex = columnIndex; + _sortColumnAscending = ascending; + }); + _dataSource + ..sort(sortColumnIndex: _sortColumnIndex, sortColumnAscending: _sortColumnAscending) + ..refreshDatasource(); + } +} diff --git a/AdminUi/apps/admin_ui/lib/main.dart b/AdminUi/apps/admin_ui/lib/main.dart index 1be77a8845..bbc0fa6f11 100644 --- a/AdminUi/apps/admin_ui/lib/main.dart +++ b/AdminUi/apps/admin_ui/lib/main.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:logger/logger.dart'; import 'core/theme/theme.dart'; import 'home/home.dart'; @@ -9,6 +11,8 @@ import 'setup/setup_desktop.dart' if (dart.library.html) 'setup/setup_web.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + GetIt.I.registerSingleton(Logger()); + await setup(); runApp(const AdminUiApp()); @@ -38,7 +42,7 @@ final _router = GoRouter( GoRoute( parentNavigatorKey: _shellNavigatorKey, path: '/identities', - pageBuilder: (context, state) => const NoTransitionPage(child: Identities()), + pageBuilder: (context, state) => const NoTransitionPage(child: IdentitiesOverview()), ), GoRoute( parentNavigatorKey: _shellNavigatorKey, diff --git a/AdminUi/apps/admin_ui/pubspec.lock b/AdminUi/apps/admin_ui/pubspec.lock index ed3edec0a0..99d299d808 100644 --- a/AdminUi/apps/admin_ui/pubspec.lock +++ b/AdminUi/apps/admin_ui/pubspec.lock @@ -103,6 +103,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + data_table_2: + dependency: "direct main" + description: + name: data_table_2 + sha256: e403de6d9a58dddf27700114b614ea8ea5aa8442d7fbdfbe8b3d11b0512e7a49 + url: "https://pub.dev" + source: hosted + version: "2.5.12" dio: dependency: transitive description: @@ -206,6 +214,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.7" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" js: dependency: transitive description: @@ -246,6 +262,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + logger: + dependency: "direct main" + description: + name: logger + sha256: "8c94b8c219e7e50194efc8771cd0e9f10807d8d3e219af473d89b06cc2ee4e04" + url: "https://pub.dev" + source: hosted + version: "2.2.0" logging: dependency: transitive description: @@ -278,6 +302,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.0" + multi_dropdown: + dependency: "direct main" + description: + name: multi_dropdown + sha256: b63ff339fcc875d667f8688c8ef62853545b580dd2b6fe78b73339783268afd8 + url: "https://pub.dev" + source: hosted + version: "2.1.4" path: dependency: transitive description: diff --git a/AdminUi/apps/admin_ui/pubspec.yaml b/AdminUi/apps/admin_ui/pubspec.yaml index b288f4d9a4..0c70869c2e 100644 --- a/AdminUi/apps/admin_ui/pubspec.yaml +++ b/AdminUi/apps/admin_ui/pubspec.yaml @@ -9,11 +9,15 @@ environment: dependencies: admin_api_sdk: ^1.0.0 admin_api_types: ^1.0.0 + data_table_2: ^2.5.12 flutter: sdk: flutter flutter_svg: ^2.0.10+1 get_it: ^7.6.7 go_router: ^13.2.0 + intl: ^0.19.0 + logger: ^2.2.0 + multi_dropdown: ^2.1.4 shared_preferences: ^2.2.2 window_size: git: diff --git a/AdminUi/packages/admin_api_sdk/lib/admin_api_sdk.dart b/AdminUi/packages/admin_api_sdk/lib/admin_api_sdk.dart index 27933967b2..e4fbbfe4e3 100644 --- a/AdminUi/packages/admin_api_sdk/lib/admin_api_sdk.dart +++ b/AdminUi/packages/admin_api_sdk/lib/admin_api_sdk.dart @@ -1,2 +1,3 @@ export 'src/admin_api_sdk_base.dart'; +export 'src/builders/builders.dart'; export 'src/types/types.dart'; diff --git a/AdminUi/packages/admin_api_sdk/lib/src/builders/identity_overview_filter_builder.dart b/AdminUi/packages/admin_api_sdk/lib/src/builders/identity_overview_filter_builder.dart index 231801fc6d..6735dfa3cc 100644 --- a/AdminUi/packages/admin_api_sdk/lib/src/builders/identity_overview_filter_builder.dart +++ b/AdminUi/packages/admin_api_sdk/lib/src/builders/identity_overview_filter_builder.dart @@ -69,12 +69,16 @@ class IdentityOverviewFilterBuilder { } enum FilterOperator { - equal, - notEqual, - greaterThan, - greaterThanOrEqual, - lessThan, - lessThanOrEqual, + equal('='), + notEqual('!='), + greaterThan('>'), + greaterThanOrEqual('>='), + lessThan('<'), + lessThanOrEqual('<='); + + final String userFriendlyOperator; + + const FilterOperator(this.userFriendlyOperator); } class IdentityOverviewFilter { @@ -97,6 +101,35 @@ class IdentityOverviewFilter { this.datawalletVersion, this.identityVersion, }); + + IdentityOverviewFilter copyWith({ + Optional? address, + Optional?>? tiers, + Optional?>? clients, + Optional? createdAt, + Optional? lastLoginAt, + Optional? numberOfDevices, + Optional? datawalletVersion, + Optional? identityVersion, + }) { + return IdentityOverviewFilter( + address: (address != null) ? address.value : this.address, + tiers: (tiers != null) ? tiers.value : this.tiers, + clients: (clients != null) ? clients.value : this.clients, + createdAt: (createdAt != null) ? createdAt.value : this.createdAt, + lastLoginAt: (lastLoginAt != null) ? lastLoginAt.value : this.lastLoginAt, + numberOfDevices: (numberOfDevices != null) ? numberOfDevices.value : this.numberOfDevices, + datawalletVersion: (datawalletVersion != null) ? datawalletVersion.value : this.datawalletVersion, + identityVersion: (identityVersion != null) ? identityVersion.value : this.identityVersion, + ); + } +} + +class Optional { + final T? value; + + const Optional(this.value); + const Optional.absent() : value = null; } class FilterOperatorValue { diff --git a/AdminUi/packages/admin_api_sdk/lib/src/endpoints/endpoint.dart b/AdminUi/packages/admin_api_sdk/lib/src/endpoints/endpoint.dart index d8fb17a9ad..b589109b6a 100644 --- a/AdminUi/packages/admin_api_sdk/lib/src/endpoints/endpoint.dart +++ b/AdminUi/packages/admin_api_sdk/lib/src/endpoints/endpoint.dart @@ -98,6 +98,7 @@ abstract class Endpoint { Future> getOData( String path, { required T Function(dynamic) transformer, + required String orderBy, required int pageNumber, required int pageSize, Map? query, @@ -106,8 +107,9 @@ abstract class Endpoint { path, queryParameters: { r'$top': '$pageSize', - r'$skip': '${pageNumber * pageSize}', + r'$skip': '$pageNumber', r'$count': 'true', + r'$orderBy': orderBy, ...query ?? {}, }, options: Options(headers: {'Accept': 'application/json'}), diff --git a/AdminUi/packages/admin_api_sdk/lib/src/endpoints/identity_endpoint.dart b/AdminUi/packages/admin_api_sdk/lib/src/endpoints/identity_endpoint.dart index e20d6a86b5..ad926f9b1f 100644 --- a/AdminUi/packages/admin_api_sdk/lib/src/endpoints/identity_endpoint.dart +++ b/AdminUi/packages/admin_api_sdk/lib/src/endpoints/identity_endpoint.dart @@ -28,6 +28,7 @@ class IdentitiesEndpoint extends Endpoint { ); Future>> getIdentities({ + String orderBy = 'address asc', IdentityOverviewFilter? filter, int pageNumber = 0, int pageSize = 10, @@ -38,9 +39,14 @@ class IdentitiesEndpoint extends Endpoint { queryParameters[r'$filter'] = IdentityOverviewFilterBuilder(filter).build(); } + if (queryParameters[r'$filter'] == '') { + queryParameters.remove(r'$filter'); + } + return getOData( '/odata/Identities', query: queryParameters, + orderBy: orderBy, transformer: (e) => (e as List).map(IdentityOverview.fromJson).toList(), pageNumber: pageNumber, pageSize: pageSize, diff --git a/AdminUi/pubspec.lock b/AdminUi/pubspec.lock index 65616aa097..355c8e0cbe 100644 --- a/AdminUi/pubspec.lock +++ b/AdminUi/pubspec.lock @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.11.0" mustache_template: dependency: transitive description: