diff --git a/lib/core/config/app_bootstrap.dart b/lib/core/config/app_bootstrap.dart index 2532960..5de3623 100644 --- a/lib/core/config/app_bootstrap.dart +++ b/lib/core/config/app_bootstrap.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../app.dart'; import '../theme/theme_provider.dart'; +import '../utils/favicon_store.dart'; import 'environment.dart'; Future startApp() async { @@ -16,6 +17,7 @@ Future startApp() async { } await dotenv.load(fileName: Environment.envFileName); final prefs = await SharedPreferences.getInstance(); + FaviconStore(prefs).apply(); runApp( ProviderScope( diff --git a/lib/core/constants/storage_keys.dart b/lib/core/constants/storage_keys.dart index 5ec0263..ff29ac1 100644 --- a/lib/core/constants/storage_keys.dart +++ b/lib/core/constants/storage_keys.dart @@ -9,6 +9,7 @@ class StorageKeys { static const String brandingPrimaryColor = 'branding_primary_color'; static const String brandingSecondaryColor = 'branding_secondary_color'; static const String brandingLogoUrl = 'branding_logo_url'; + static const String faviconUrl = 'favicon_url'; static const String appSettings = 'app_settings'; static const String rememberMe = 'remember_me'; static const String rememberedEmail = 'remembered_email'; diff --git a/lib/core/utils/favicon_store.dart b/lib/core/utils/favicon_store.dart new file mode 100644 index 0000000..996048d --- /dev/null +++ b/lib/core/utils/favicon_store.dart @@ -0,0 +1,29 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../constants/storage_keys.dart'; +import 'favicon_updater.dart'; + +class FaviconStore { + FaviconStore(this._prefs); + + final SharedPreferences _prefs; + + String read() => _prefs.getString(StorageKeys.faviconUrl) ?? ''; + + Future write(String? url) async { + final value = url?.trim() ?? ''; + if (value.isEmpty) { + await _prefs.remove(StorageKeys.faviconUrl); + return; + } + + await _prefs.setString(StorageKeys.faviconUrl, value); + } + + void apply() { + final url = read(); + if (url.isNotEmpty) { + updateFavicon(url); + } + } +} diff --git a/lib/core/utils/favicon_updater.dart b/lib/core/utils/favicon_updater.dart new file mode 100644 index 0000000..d4a3ccb --- /dev/null +++ b/lib/core/utils/favicon_updater.dart @@ -0,0 +1,4 @@ +import 'favicon_updater_stub.dart' + if (dart.library.html) 'favicon_updater_web.dart' as impl; + +void updateFavicon(String? url) => impl.updateFavicon(url); diff --git a/lib/core/utils/favicon_updater_stub.dart b/lib/core/utils/favicon_updater_stub.dart new file mode 100644 index 0000000..c6e3b99 --- /dev/null +++ b/lib/core/utils/favicon_updater_stub.dart @@ -0,0 +1 @@ +void updateFavicon(String? url) {} diff --git a/lib/core/utils/favicon_updater_web.dart b/lib/core/utils/favicon_updater_web.dart new file mode 100644 index 0000000..2b2b600 --- /dev/null +++ b/lib/core/utils/favicon_updater_web.dart @@ -0,0 +1,36 @@ +import 'dart:html' as html; + +void updateFavicon(String? url) { + for (final link in html.document.querySelectorAll('link[rel="icon"]')) { + link.remove(); + } + + final faviconUrl = url?.trim(); + if (faviconUrl == null || faviconUrl.isEmpty) return; + + final link = html.LinkElement() + ..rel = 'icon' + ..href = faviconUrl; + + final type = _resolveMimeType(faviconUrl); + if (type != null) { + link.type = type; + } + + html.document.head?.append(link); +} + +String? _resolveMimeType(String url) { + if (url.startsWith('data:image/')) { + final end = url.indexOf(';'); + if (end > 5) return url.substring(5, end); + } + + final lower = url.toLowerCase(); + if (lower.endsWith('.ico')) return 'image/x-icon'; + if (lower.endsWith('.png')) return 'image/png'; + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; + if (lower.endsWith('.svg')) return 'image/svg+xml'; + if (lower.endsWith('.webp')) return 'image/webp'; + return null; +} diff --git a/lib/core/utils/validators.dart b/lib/core/utils/validators.dart index 0306579..f6bca06 100644 --- a/lib/core/utils/validators.dart +++ b/lib/core/utils/validators.dart @@ -206,6 +206,24 @@ class Validators { return null; } + static final RegExp _roleNamePattern = RegExp(r'^[a-zA-Z0-9 ]+$'); + + /// Required role name — letters, numbers, and spaces only. + static String? roleName(String? value) { + final requiredError = required(value, fieldName: 'Role name'); + if (requiredError != null) return requiredError; + + final trimmed = value!.trim(); + if (!_roleNamePattern.hasMatch(trimmed)) { + return 'Role name must not contain special characters'; + } + return null; + } + + static List get roleNameInput => [ + FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9 ]')), + ]; + /// Resolves validators for master-data and dynamic form fields by key. static String? forFieldKey( String key, diff --git a/lib/modules/assets/presentation/screens/asset_alerts_screen.dart b/lib/modules/assets/presentation/screens/asset_alerts_screen.dart index d885fff..39023cf 100644 --- a/lib/modules/assets/presentation/screens/asset_alerts_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_alerts_screen.dart @@ -56,6 +56,7 @@ class AssetAlertsScreen extends ConsumerWidget { const SizedBox(height: 16), Expanded( child: TabBarView( + clipBehavior: Clip.none, children: [ _ExpiryAlertsTab(state: state), _ServiceAlertsTab(state: state), @@ -86,12 +87,13 @@ class _ExpiryAlertsTab extends ConsumerWidget { Wrap( spacing: 12, runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, + crossAxisAlignment: WrapCrossAlignment.start, children: [ SizedBox( width: 160, child: AppDropdown( label: 'Days ahead', + isDense: true, value: state.expiryDays, options: const [7, 15, 30, 60, 90] .map((d) => AppDropdownOption(value: d, label: '$d days')) @@ -105,6 +107,7 @@ class _ExpiryAlertsTab extends ConsumerWidget { width: 180, child: AppDropdown( label: 'Type', + isDense: true, value: state.expiryType, options: const [ AppDropdownOption(value: null, label: 'All'), @@ -181,6 +184,7 @@ class _ServiceAlertsTab extends ConsumerWidget { width: 200, child: AppDropdown( label: 'Status', + isDense: true, value: state.serviceStatus, options: const [ AppDropdownOption(value: null, label: 'All'), diff --git a/lib/modules/grn/data/datasources/grn_remote_data_source.dart b/lib/modules/grn/data/datasources/grn_remote_data_source.dart index a7c6f5b..efb6126 100644 --- a/lib/modules/grn/data/datasources/grn_remote_data_source.dart +++ b/lib/modules/grn/data/datasources/grn_remote_data_source.dart @@ -89,7 +89,7 @@ class GrnRemoteDataSource { limit: limit, total: total, totalPages: - limit > 0 ? ((total + limit - 1) / limit).ceil().clamp(1, 999999) : 1, + limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1, ); } @@ -105,7 +105,7 @@ class GrnRemoteDataSource { limit: limit, total: total, totalPages: - limit > 0 ? ((total + limit - 1) / limit).ceil().clamp(1, 999999) : 1, + limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1, ); } } diff --git a/lib/modules/grn/presentation/screens/grn_list_screen.dart b/lib/modules/grn/presentation/screens/grn_list_screen.dart index be9ed09..553aa2f 100644 --- a/lib/modules/grn/presentation/screens/grn_list_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_list_screen.dart @@ -92,6 +92,7 @@ class _GrnListScreenState extends ConsumerState { totalPages: state.totalPages, totalItems: state.total, pageSize: state.query.limit, + itemLabel: 'GRNs', onPageChanged: ref.read(grnListProvider.notifier).setPage, onPageSizeChanged: ref.read(grnListProvider.notifier).setPageSize, ), diff --git a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart index 412f7ca..599b8a6 100644 --- a/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart +++ b/lib/modules/purchase_orders/data/datasources/purchase_order_remote_data_source.dart @@ -161,7 +161,7 @@ class PurchaseOrderRemoteDataSource { limit: limit, total: total, totalPages: - limit > 0 ? ((total + limit - 1) / limit).ceil().clamp(1, 999999) : 1, + limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1, ); } @@ -177,7 +177,7 @@ class PurchaseOrderRemoteDataSource { limit: limit, total: total, totalPages: - limit > 0 ? ((total + limit - 1) / limit).ceil().clamp(1, 999999) : 1, + limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1, ); } } diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart index 57f0616..693a4bc 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart @@ -108,6 +108,7 @@ class _PurchaseOrderListScreenState extends ConsumerState { - String? _selectedRoleName; - String? _selectedDepartmentName; - String? _selectedStatusLabel; + final _searchController = TextEditingController(); - int? _roleIdForFilter(UserFiltersModel? filters) { - if (_selectedRoleName == null || filters == null) return null; - for (final role in filters.roles) { - if (role.name == _selectedRoleName) { - return int.tryParse(role.id); - } - } - return null; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _resetStaleFilters()); } - int? _departmentIdForFilter(UserFiltersModel? filters) { - if (_selectedDepartmentName == null || filters == null) return null; - for (final department in filters.departments) { - if (department.name == _selectedDepartmentName) { - return int.tryParse(department.id); - } - } - return null; + @override + void dispose() { + _searchController.dispose(); + super.dispose(); } - String? _statusValueForFilter(UserFiltersModel? filters) { - if (_selectedStatusLabel == null || filters == null) return null; - for (final status in filters.statuses) { - if (status.name == _selectedStatusLabel) { - return status.id; - } - } - return null; - } + Future _resetStaleFilters() async { + if (!mounted) return; - void _applyFilters(UserFiltersModel? filters) { final current = ref.read(usersListProvider).valueOrNull; - ref.read(usersListProvider.notifier).applyQuery( - (current?.query ?? const UserListQuery(limit: 10)).copyWith( - page: 1, - roleId: _roleIdForFilter(filters), - departmentId: _departmentIdForFilter(filters), - status: _statusValueForFilter(filters), - ), - ); + if (current == null) return; + + final query = current.query; + final hasActiveFilters = (query.search?.isNotEmpty ?? false) || + query.roleId != null || + query.departmentId != null || + query.status != null; + + if (!hasActiveFilters) return; + + _searchController.clear(); + await ref.read(usersListProvider.notifier).resetFilters(); + } + + int? _roleIdForName(String name, UserFiltersModel? filters) { + if (filters == null) return null; + for (final role in filters.roles) { + if (role.name == name) return int.tryParse(role.id); + } + return null; + } + + String? _roleNameForId(int? roleId, UserFiltersModel? filters) { + if (roleId == null || filters == null) return null; + for (final role in filters.roles) { + if (int.tryParse(role.id) == roleId) return role.name; + } + return null; + } + + int? _departmentIdForName(String name, UserFiltersModel? filters) { + if (filters == null) return null; + for (final department in filters.departments) { + if (department.name == name) return int.tryParse(department.id); + } + return null; + } + + String? _departmentNameForId(int? departmentId, UserFiltersModel? filters) { + if (departmentId == null || filters == null) return null; + for (final department in filters.departments) { + if (int.tryParse(department.id) == departmentId) { + return department.name; + } + } + return null; + } + + String? _statusValueForLabel(String label, UserFiltersModel? filters) { + if (filters == null) return null; + for (final status in filters.statuses) { + if (status.name == label) return status.id; + } + return null; + } + + String? _statusLabelForValue(String? value, UserFiltersModel? filters) { + if (value == null || filters == null) return null; + for (final status in filters.statuses) { + if (status.id == value) return status.name; + } + return null; } void _editUser(ManagedUserModel user) { @@ -646,14 +683,21 @@ class _UsersTabState extends ConsumerState<_UsersTab> { 'All statuses', ...?filters?.statuses.map((s) => s.name), ]; - final roleFilter = _selectedRoleName ?? 'All roles'; - final departmentFilter = _selectedDepartmentName ?? 'All departments'; - final statusFilter = _selectedStatusLabel ?? 'All statuses'; + final roleFilter = + _roleNameForId(usersState.query.roleId, filters) ?? 'All roles'; + final departmentFilter = _departmentNameForId( + usersState.query.departmentId, + filters, + ) ?? + 'All departments'; + final statusFilter = _statusLabelForValue( + usersState.query.status, + filters, + ) ?? + 'All statuses'; final page = usersState.query.page; final pageSize = usersState.query.limit; final total = usersState.total; - final start = total == 0 ? 0 : ((page - 1) * pageSize) + 1; - final end = (page * pageSize).clamp(0, total); return AppCard( enableHover: false, @@ -682,27 +726,29 @@ class _UsersTabState extends ConsumerState<_UsersTab> { statuses: statuses, isExporting: usersState.isExporting, showExport: canExport, + searchController: _searchController, onExport: _exportUsers, onSearch: ref.read(usersListProvider.notifier).setSearch, onRoleChanged: (value) { - setState(() { - _selectedRoleName = value == 'All roles' ? null : value; - }); - _applyFilters(filters); + ref.read(usersListProvider.notifier).setRoleFilter( + value == 'All roles' + ? null + : _roleIdForName(value, filters), + ); }, onDepartmentChanged: (value) { - setState(() { - _selectedDepartmentName = - value == 'All departments' ? null : value; - }); - _applyFilters(filters); + ref.read(usersListProvider.notifier).setDepartmentFilter( + value == 'All departments' + ? null + : _departmentIdForName(value, filters), + ); }, onStatusChanged: (value) { - setState(() { - _selectedStatusLabel = - value == 'All statuses' ? null : value; - }); - _applyFilters(filters); + ref.read(usersListProvider.notifier).setStatusFilter( + value == 'All statuses' + ? null + : _statusValueForLabel(value, filters), + ); }, ); }, @@ -737,63 +783,15 @@ class _UsersTabState extends ConsumerState<_UsersTab> { ), ), const Divider(height: 1), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Text( - 'Showing $start–$end of $total users', - style: Theme.of(context).textTheme.bodySmall, - ), - const Spacer(), - TextButton( - onPressed: page > 1 - ? () => ref.read(usersListProvider.notifier).setPage(page - 1) - : null, - child: const Text('Previous'), - ), - ...List.generate(usersState.totalPages.clamp(0, 4), (i) { - final pageIndex = i + 1; - final selected = page == pageIndex; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Material( - color: selected - ? Theme.of(context).colorScheme.primary - : Colors.transparent, - shape: const CircleBorder(), - child: InkWell( - customBorder: const CircleBorder(), - onTap: () => ref - .read(usersListProvider.notifier) - .setPage(pageIndex), - child: SizedBox( - width: 36, - height: 36, - child: Center( - child: Text( - '$pageIndex', - style: TextStyle( - color: selected - ? Theme.of(context).colorScheme.onPrimary - : null, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ), - ); - }), - TextButton( - onPressed: page < usersState.totalPages - ? () => ref.read(usersListProvider.notifier).setPage(page + 1) - : null, - child: const Text('Next'), - ), - ], - ), + AppPagination( + currentPage: page, + totalPages: usersState.totalPages, + totalItems: total, + pageSize: pageSize, + itemLabel: 'users', + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + onPageChanged: + ref.read(usersListProvider.notifier).setPage, ), ], ), @@ -812,6 +810,7 @@ class _UsersFilterBar extends StatelessWidget { required this.roles, required this.departments, required this.statuses, + required this.searchController, required this.onSearch, required this.onExport, this.isExporting = false, @@ -828,6 +827,7 @@ class _UsersFilterBar extends StatelessWidget { final List roles; final List departments; final List statuses; + final TextEditingController searchController; final ValueChanged onSearch; final VoidCallback onExport; final bool isExporting; @@ -839,6 +839,7 @@ class _UsersFilterBar extends StatelessWidget { @override Widget build(BuildContext context) { final searchField = TextField( + controller: searchController, decoration: const InputDecoration( hintText: 'Search by name, email, employee code...', prefixIcon: Icon(Icons.search, size: 20), @@ -1208,7 +1209,7 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> { return AppCard( enableHover: false, - clipBehavior: Clip.none, + clipBehavior: Clip.antiAlias, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -1268,21 +1269,18 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> { ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: roles.map((role) { - return Padding( - padding: const EdgeInsets.only(right: 8), - child: RolePill( - label: role.name, - selected: role.id == selectedRoleId, - onTap: () => setState(() => _selectedRoleId = role.id), - ), - ); - }).toList(), - ), + padding: const EdgeInsets.fromLTRB(12, 0, 12, 16), + child: ArrowScrollRow( + height: 40, + itemCount: roles.length, + itemBuilder: (context, index) { + final role = roles[index]; + return RolePill( + label: role.name, + selected: role.id == selectedRoleId, + onTap: () => setState(() => _selectedRoleId = role.id), + ); + }, ), ), const Divider(height: 1), diff --git a/lib/modules/rbac/presentation/widgets/create_role_panel.dart b/lib/modules/rbac/presentation/widgets/create_role_panel.dart index 6d3cf42..3b15396 100644 --- a/lib/modules/rbac/presentation/widgets/create_role_panel.dart +++ b/lib/modules/rbac/presentation/widgets/create_role_panel.dart @@ -157,9 +157,10 @@ class _RoleFormPanelState extends ConsumerState { children: [ AppTextField( controller: _nameController, - label: 'Role name', + label: 'Role name *', hint: 'e.g. QC Manager', - validator: (v) => Validators.required(v, fieldName: 'Role name'), + validator: Validators.roleName, + inputFormatters: Validators.roleNameInput, ), const SizedBox(height: 16), AppTextField( diff --git a/lib/modules/rbac/presentation/widgets/rbac_widgets.dart b/lib/modules/rbac/presentation/widgets/rbac_widgets.dart index 5f17ca6..4d68e47 100644 --- a/lib/modules/rbac/presentation/widgets/rbac_widgets.dart +++ b/lib/modules/rbac/presentation/widgets/rbac_widgets.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../../../../shared/models/user_management_models.dart'; @@ -138,12 +139,16 @@ class RoleBadge extends StatelessWidget { decoration: BoxDecoration(color: primary, shape: BoxShape.circle), ), const SizedBox(width: 6), - Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: primary, - fontWeight: FontWeight.w600, - ), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: primary, + fontWeight: FontWeight.w600, + ), + ), ), ], ), @@ -151,6 +156,25 @@ class RoleBadge extends StatelessWidget { } } +class UserRolesCell extends StatelessWidget { + const UserRolesCell({super.key, required this.roles}); + + final List roles; + + @override + Widget build(BuildContext context) { + if (roles.isEmpty) { + return const Text('—'); + } + + return Wrap( + spacing: 6, + runSpacing: 6, + children: roles.map((role) => RoleBadge(label: role)).toList(), + ); + } +} + class EmployeeCodeBadge extends StatelessWidget { const EmployeeCodeBadge({super.key, required this.code}); @@ -393,6 +417,7 @@ class RolePill extends StatelessWidget { onTap: onTap, borderRadius: BorderRadius.circular(24), child: Container( + constraints: const BoxConstraints(maxWidth: 200), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), @@ -404,6 +429,8 @@ class RolePill extends StatelessWidget { ), child: Text( label, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelLarge?.copyWith( color: selected ? Theme.of(context).colorScheme.onPrimary @@ -417,6 +444,152 @@ class RolePill extends StatelessWidget { } } +class ArrowScrollRow extends StatefulWidget { + const ArrowScrollRow({ + super.key, + required this.height, + required this.itemCount, + required this.itemBuilder, + this.separatorWidth = 8, + this.scrollStep = 200, + }); + + final double height; + final int itemCount; + final IndexedWidgetBuilder itemBuilder; + final double separatorWidth; + final double scrollStep; + + @override + State createState() => _ArrowScrollRowState(); +} + +class _ArrowScrollRowState extends State { + final _controller = ScrollController(); + bool _canScrollLeft = false; + bool _canScrollRight = false; + + @override + void initState() { + super.initState(); + _controller.addListener(_updateScrollButtons); + WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollButtons()); + } + + @override + void didUpdateWidget(covariant ArrowScrollRow oldWidget) { + super.didUpdateWidget(oldWidget); + WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollButtons()); + } + + @override + void dispose() { + _controller.removeListener(_updateScrollButtons); + _controller.dispose(); + super.dispose(); + } + + void _updateScrollButtons() { + if (!_controller.hasClients) return; + final position = _controller.position; + final canLeft = position.pixels > position.minScrollExtent + 0.5; + final canRight = position.pixels < position.maxScrollExtent - 0.5; + if (canLeft == _canScrollLeft && canRight == _canScrollRight) return; + setState(() { + _canScrollLeft = canLeft; + _canScrollRight = canRight; + }); + } + + void _scrollBy(double delta) { + if (!_controller.hasClients) return; + final target = (_controller.offset + delta).clamp( + _controller.position.minScrollExtent, + _controller.position.maxScrollExtent, + ); + _controller.animateTo( + target, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: widget.height, + child: Row( + children: [ + _ScrollArrowButton( + icon: Icons.chevron_left, + enabled: _canScrollLeft, + onPressed: () => _scrollBy(-widget.scrollStep), + ), + Expanded( + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.trackpad, + PointerDeviceKind.stylus, + }, + ), + child: ListView.separated( + controller: _controller, + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + itemCount: widget.itemCount, + separatorBuilder: (_, __) => + SizedBox(width: widget.separatorWidth), + itemBuilder: widget.itemBuilder, + ), + ), + ), + _ScrollArrowButton( + icon: Icons.chevron_right, + enabled: _canScrollRight, + onPressed: () => _scrollBy(widget.scrollStep), + ), + ], + ), + ); + } +} + +class _ScrollArrowButton extends StatelessWidget { + const _ScrollArrowButton({ + required this.icon, + required this.enabled, + required this.onPressed, + }); + + final IconData icon; + final bool enabled; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: 32, + height: 32, + child: IconButton( + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + onPressed: enabled ? onPressed : null, + icon: Icon( + icon, + size: 22, + color: enabled + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withValues(alpha: 0.3), + ), + ), + ); + } +} + class ModulePermissionRow extends StatelessWidget { const ModulePermissionRow({ super.key, diff --git a/lib/modules/settings/data/repositories/settings_repository_impl.dart b/lib/modules/settings/data/repositories/settings_repository_impl.dart index 586dee2..c59c1b8 100644 --- a/lib/modules/settings/data/repositories/settings_repository_impl.dart +++ b/lib/modules/settings/data/repositories/settings_repository_impl.dart @@ -16,15 +16,7 @@ class SettingsRepositoryImpl implements SettingsRepository { @override Future> getSettings() async { return safeApiCall(() async { - try { - final remoteSettings = await remote.fetch(); - if (remoteSettings != null) { - await local.write(remoteSettings); - return remoteSettings; - } - } catch (_) { - // Fall back to local cache when API is unavailable. - } + // Local-first until settings API is available. return await local.read() ?? const AppSettings(); }); } @@ -32,14 +24,9 @@ class SettingsRepositoryImpl implements SettingsRepository { @override Future> saveSettings(AppSettings settings) async { return safeApiCall(() async { - try { - final saved = await remote.save(settings); - await local.write(saved); - return saved; - } catch (_) { - await local.write(settings); - return settings; - } + // Always persist locally; remote sync can be wired when API is ready. + await local.write(settings); + return settings; }); } } diff --git a/lib/modules/settings/domain/entities/app_settings.dart b/lib/modules/settings/domain/entities/app_settings.dart index 8c6fc8b..fb0861e 100644 --- a/lib/modules/settings/domain/entities/app_settings.dart +++ b/lib/modules/settings/domain/entities/app_settings.dart @@ -77,6 +77,7 @@ class CompanyProfileSettings { this.phone = '', this.website = '', this.logoUrl = '', + this.faviconUrl = '', }); final String companyName; @@ -88,6 +89,7 @@ class CompanyProfileSettings { final String phone; final String website; final String logoUrl; + final String faviconUrl; CompanyProfileSettings copyWith({ String? companyName, @@ -99,6 +101,7 @@ class CompanyProfileSettings { String? phone, String? website, String? logoUrl, + String? faviconUrl, }) { return CompanyProfileSettings( companyName: companyName ?? this.companyName, @@ -110,6 +113,7 @@ class CompanyProfileSettings { phone: phone ?? this.phone, website: website ?? this.website, logoUrl: logoUrl ?? this.logoUrl, + faviconUrl: faviconUrl ?? this.faviconUrl, ); } @@ -123,6 +127,7 @@ class CompanyProfileSettings { 'phone': phone, 'website': website, 'logoUrl': logoUrl, + 'faviconUrl': faviconUrl, }; factory CompanyProfileSettings.fromJson(Map json) => @@ -136,6 +141,7 @@ class CompanyProfileSettings { phone: json['phone'] as String? ?? '', website: json['website'] as String? ?? '', logoUrl: json['logoUrl'] as String? ?? '', + faviconUrl: json['faviconUrl'] as String? ?? '', ); } @@ -615,7 +621,7 @@ const phase1SettingsSections = [ SettingsSection( id: 'company-profile', title: 'Company Profile', - subtitle: 'Company information and logo', + subtitle: 'Company information and branding assets', icon: Icons.business_outlined, route: '/settings/company-profile', ), diff --git a/lib/modules/settings/presentation/providers/settings_provider.dart b/lib/modules/settings/presentation/providers/settings_provider.dart index 88e3f35..d0a937e 100644 --- a/lib/modules/settings/presentation/providers/settings_provider.dart +++ b/lib/modules/settings/presentation/providers/settings_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../core/theme/theme_provider.dart'; +import '../../../../core/utils/favicon_store.dart'; import '../../data/datasources/settings_local_data_source.dart'; import '../../data/datasources/settings_remote_data_source.dart'; import '../../data/repositories/settings_repository_impl.dart'; @@ -39,6 +40,7 @@ final appSettingsProvider = return AppSettingsNotifier( getSettings: ref.watch(getSettingsUseCaseProvider), saveSettings: ref.watch(saveSettingsUseCaseProvider), + faviconStore: FaviconStore(ref.watch(sharedPreferencesProvider)), ); }); @@ -46,19 +48,23 @@ class AppSettingsNotifier extends StateNotifier { AppSettingsNotifier({ required GetSettingsUseCase getSettings, required SaveSettingsUseCase saveSettings, + required FaviconStore faviconStore, }) : _getSettings = getSettings, _saveSettings = saveSettings, + _faviconStore = faviconStore, super(const AppSettings()) { _load(); } final GetSettingsUseCase _getSettings; final SaveSettingsUseCase _saveSettings; + final FaviconStore _faviconStore; Future _load() async { final result = await _getSettings(); state = result.data ?? const AppSettings(); - } + _faviconStore.apply(); + } Future _persist(AppSettings settings) async { state = settings; diff --git a/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart b/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart index 96c7c29..c189959 100644 --- a/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart +++ b/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/theme/theme_provider.dart'; +import '../../../../core/utils/favicon_store.dart'; +import '../../../../core/utils/favicon_updater.dart'; import '../../../../core/utils/validators.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_text_field.dart'; @@ -33,11 +35,14 @@ class _CompanyProfileSettingsScreenState late final TextEditingController _phoneController; late final TextEditingController _websiteController; late final TextEditingController _logoUrlController; + late final TextEditingController _faviconUrlController; @override void initState() { super.initState(); final profile = ref.read(appSettingsProvider).companyProfile; + final faviconFromPrefs = + FaviconStore(ref.read(sharedPreferencesProvider)).read(); _nameController = TextEditingController(text: profile.companyName); _codeController = TextEditingController(text: profile.companyCode); _registrationController = @@ -48,6 +53,11 @@ class _CompanyProfileSettingsScreenState _phoneController = TextEditingController(text: profile.phone); _websiteController = TextEditingController(text: profile.website); _logoUrlController = TextEditingController(text: profile.logoUrl); + _faviconUrlController = TextEditingController( + text: profile.faviconUrl.isNotEmpty + ? profile.faviconUrl + : faviconFromPrefs, + ); } @override @@ -61,6 +71,7 @@ class _CompanyProfileSettingsScreenState _phoneController.dispose(); _websiteController.dispose(); _logoUrlController.dispose(); + _faviconUrlController.dispose(); super.dispose(); } @@ -68,6 +79,7 @@ class _CompanyProfileSettingsScreenState if (!_formKey.currentState!.validate()) return; final logoUrl = _logoUrlController.text.trim(); + final faviconUrl = _faviconUrlController.text.trim(); final companyName = _nameController.text.trim(); await ref.read(appSettingsProvider.notifier).updateCompanyProfile( @@ -81,6 +93,7 @@ class _CompanyProfileSettingsScreenState phone: _phoneController.text.trim(), website: _websiteController.text.trim(), logoUrl: logoUrl, + faviconUrl: faviconUrl, ), ); @@ -91,6 +104,11 @@ class _CompanyProfileSettingsScreenState ), ); + await FaviconStore(ref.read(sharedPreferencesProvider)).write( + faviconUrl.isEmpty ? null : faviconUrl, + ); + updateFavicon(faviconUrl.isEmpty ? null : faviconUrl); + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Company profile saved')), @@ -98,7 +116,7 @@ class _CompanyProfileSettingsScreenState } } - Future _pickLogo() async { + Future _pickImage(void Function(String dataUri) onPicked) async { final result = await FilePicker.pickFiles( type: FileType.image, withData: true, @@ -115,10 +133,18 @@ class _CompanyProfileSettingsScreenState final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}'; setState(() { - _logoUrlController.text = dataUri; + onPicked(dataUri); }); } + Future _pickLogo() => _pickImage((dataUri) { + _logoUrlController.text = dataUri; + }); + + Future _pickFavicon() => _pickImage((dataUri) { + _faviconUrlController.text = dataUri; + }); + @override Widget build(BuildContext context) { return SettingsPageLayout( @@ -193,7 +219,9 @@ class _CompanyProfileSettingsScreenState logoUrl: _logoUrlController.text.trim().isEmpty ? null : _logoUrlController.text.trim(), - size: 72, + width: 240, + height: 80, + fit: BoxFit.contain, ), ), const SizedBox(height: 16), @@ -211,6 +239,36 @@ class _CompanyProfileSettingsScreenState ), ], ), + const SizedBox(height: 16), + SettingsFormCard( + title: 'Favicon Upload', + subtitle: 'Upload an image or provide a favicon URL for the browser tab', + children: [ + Center( + child: SidebarLogo( + logoUrl: _faviconUrlController.text.trim().isEmpty + ? null + : _faviconUrlController.text.trim(), + width: 64, + height: 64, + fit: BoxFit.contain, + ), + ), + const SizedBox(height: 16), + AppTextField( + controller: _faviconUrlController, + label: 'Favicon URL', + hint: 'https://example.com/favicon.ico', + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _pickFavicon, + icon: const Icon(Icons.upload_file), + label: const Text('Upload Favicon'), + ), + ], + ), const SizedBox(height: 24), AppButton(label: 'Save Changes', onPressed: _save), ], diff --git a/lib/modules/users/data/datasources/user_remote_data_source.dart b/lib/modules/users/data/datasources/user_remote_data_source.dart index 5876083..ce22e42 100644 --- a/lib/modules/users/data/datasources/user_remote_data_source.dart +++ b/lib/modules/users/data/datasources/user_remote_data_source.dart @@ -81,7 +81,8 @@ class UserRemoteDataSource { final page = (meta['page'] as num?)?.toInt() ?? query.page; final limit = (meta['limit'] as num?)?.toInt() ?? query.limit; final total = (meta['total'] as num?)?.toInt() ?? items.length; - final totalPages = limit > 0 ? ((total + limit - 1) / limit).ceil().clamp(1, 999999) : 1; + final totalPages = + limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1; return PaginatedResponse( items: items, diff --git a/lib/modules/users/presentation/providers/users_provider.dart b/lib/modules/users/presentation/providers/users_provider.dart index cfad2ac..ce4fee1 100644 --- a/lib/modules/users/presentation/providers/users_provider.dart +++ b/lib/modules/users/presentation/providers/users_provider.dart @@ -186,6 +186,26 @@ class UsersListNotifier extends AsyncNotifier { applyQuery(current.query.copyWith(departmentId: departmentId, page: 1)); } + Future resetFilters() async { + final current = state.valueOrNull; + if (current == null) return; + + final clearedQuery = UserListQuery(limit: current.query.limit); + final query = current.query; + final hasActiveFilters = (query.search?.isNotEmpty ?? false) || + query.roleId != null || + query.departmentId != null || + query.status != null; + + if (!hasActiveFilters && + query.page == clearedQuery.page && + query.limit == clearedQuery.limit) { + return; + } + + await applyQuery(clearedQuery); + } + Future exportUsers() async { final current = state.valueOrNull; if (current == null) return null; diff --git a/lib/modules/users/presentation/screens/user_list_screen.dart b/lib/modules/users/presentation/screens/user_list_screen.dart index 342c574..9c1e625 100644 --- a/lib/modules/users/presentation/screens/user_list_screen.dart +++ b/lib/modules/users/presentation/screens/user_list_screen.dart @@ -89,6 +89,7 @@ class _UserListScreenState extends ConsumerState { totalPages: state.totalPages, totalItems: state.total, pageSize: state.query.limit, + itemLabel: 'users', onPageChanged: ref.read(usersListProvider.notifier).setPage, onPageSizeChanged: ref.read(usersListProvider.notifier).setPageSize, diff --git a/lib/modules/users/presentation/widgets/user_rich_data_table.dart b/lib/modules/users/presentation/widgets/user_rich_data_table.dart index 7e253c8..36726e3 100644 --- a/lib/modules/users/presentation/widgets/user_rich_data_table.dart +++ b/lib/modules/users/presentation/widgets/user_rich_data_table.dart @@ -54,7 +54,7 @@ class UserRichDataTable extends StatelessWidget { AppDataColumn( label: 'Role', flex: 2, - cellBuilder: (_, user) => RoleBadge(label: user.roleLabel), + cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames), ), AppDataColumn( label: 'Department', diff --git a/lib/modules/vendors/data/datasources/vendor_remote_data_source.dart b/lib/modules/vendors/data/datasources/vendor_remote_data_source.dart index 218dc69..5910dd4 100644 --- a/lib/modules/vendors/data/datasources/vendor_remote_data_source.dart +++ b/lib/modules/vendors/data/datasources/vendor_remote_data_source.dart @@ -207,7 +207,7 @@ class VendorRemoteDataSource { page: (meta['page'] as num?)?.toInt() ?? 1, limit: limit, total: total, - totalPages: limit > 0 ? ((total + limit - 1) / limit).ceil().clamp(1, 999999) : 1, + totalPages: limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1, ); } diff --git a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart index 4b33250..9dcfcc2 100644 --- a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart @@ -107,6 +107,7 @@ class _VendorListScreenState extends ConsumerState { totalPages: state.totalPages, totalItems: state.total, pageSize: state.query.limit, + itemLabel: 'vendors', onPageChanged: ref.read(vendorsListProvider.notifier).setPage, onPageSizeChanged: ref.read(vendorsListProvider.notifier).setPageSize, diff --git a/lib/shared/models/user_management_models.dart b/lib/shared/models/user_management_models.dart index 4190b01..f4698b9 100644 --- a/lib/shared/models/user_management_models.dart +++ b/lib/shared/models/user_management_models.dart @@ -71,6 +71,23 @@ Object? _readRoleIds(Map json, String key) { return null; } +Object? _readAssignedRoles(Map json, String key) => + json['roles']; + +List _assignedRolesFromJson(dynamic value) { + if (value is! List) return const []; + return value + .whereType() + .map( + (item) => FilterOptionModel( + id: item['id']?.toString() ?? '', + name: item['name'] as String? ?? '', + ), + ) + .where((role) => role.id.isNotEmpty && role.name.isNotEmpty) + .toList(); +} + List _roleIdsFromJson(dynamic value) { if (value is List) { return value.map((item) => item.toString()).toList(); @@ -136,6 +153,13 @@ class ManagedUserModel with _$ManagedUserModel { ) @Default([]) List roleIds, + @JsonKey( + name: 'roles', + readValue: _readAssignedRoles, + fromJson: _assignedRolesFromJson, + ) + @Default([]) + List assignedRoles, @JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName, @JsonKey(fromJson: _idFromJsonNullable, name: 'department_id') String? departmentId, @@ -164,12 +188,27 @@ class ManagedUserModel with _$ManagedUserModel { extension ManagedUserModelX on ManagedUserModel { String get displayName => fullName; - String get roleLabel => roleName ?? '—'; String get departmentLabel => departmentName ?? '—'; String get plantLabel => plantName ?? '—'; + List get roleNames { + if (assignedRoles.isNotEmpty) { + return assignedRoles.map((role) => role.name).toList(); + } + if (roleName != null && roleName!.trim().isNotEmpty) { + return [roleName!.trim()]; + } + return const []; + } + + String get roleLabel => + roleNames.isEmpty ? '—' : roleNames.join(', '); + List get effectiveRoleIds { if (roleIds.isNotEmpty) return roleIds; + if (assignedRoles.isNotEmpty) { + return assignedRoles.map((role) => role.id).toList(); + } if (roleId != null && roleId!.isNotEmpty) return [roleId!]; return const []; } diff --git a/lib/shared/models/user_management_models.freezed.dart b/lib/shared/models/user_management_models.freezed.dart index 8dba4f8..dd20cdf 100644 --- a/lib/shared/models/user_management_models.freezed.dart +++ b/lib/shared/models/user_management_models.freezed.dart @@ -755,6 +755,13 @@ mixin _$ManagedUserModel { fromJson: _roleIdsFromJson, ) List get roleIds => throw _privateConstructorUsedError; + @JsonKey( + name: 'roles', + readValue: _readAssignedRoles, + fromJson: _assignedRolesFromJson, + ) + List get assignedRoles => + throw _privateConstructorUsedError; @JsonKey(name: 'role_name', readValue: _readRoleName) String? get roleName => throw _privateConstructorUsedError; @JsonKey(fromJson: _idFromJsonNullable, name: 'department_id') @@ -824,6 +831,12 @@ abstract class $ManagedUserModelCopyWith<$Res> { fromJson: _roleIdsFromJson, ) List roleIds, + @JsonKey( + name: 'roles', + readValue: _readAssignedRoles, + fromJson: _assignedRolesFromJson, + ) + List assignedRoles, @JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName, @JsonKey(fromJson: _idFromJsonNullable, name: 'department_id') String? departmentId, @@ -871,6 +884,7 @@ class _$ManagedUserModelCopyWithImpl<$Res, $Val extends ManagedUserModel> Object? mobile = null, Object? roleId = freezed, Object? roleIds = null, + Object? assignedRoles = null, Object? roleName = freezed, Object? departmentId = freezed, Object? departmentName = freezed, @@ -926,6 +940,10 @@ class _$ManagedUserModelCopyWithImpl<$Res, $Val extends ManagedUserModel> ? _value.roleIds : roleIds // ignore: cast_nullable_to_non_nullable as List, + assignedRoles: null == assignedRoles + ? _value.assignedRoles + : assignedRoles // ignore: cast_nullable_to_non_nullable + as List, roleName: freezed == roleName ? _value.roleName : roleName // ignore: cast_nullable_to_non_nullable @@ -1026,6 +1044,12 @@ abstract class _$$ManagedUserModelImplCopyWith<$Res> fromJson: _roleIdsFromJson, ) List roleIds, + @JsonKey( + name: 'roles', + readValue: _readAssignedRoles, + fromJson: _assignedRolesFromJson, + ) + List assignedRoles, @JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName, @JsonKey(fromJson: _idFromJsonNullable, name: 'department_id') String? departmentId, @@ -1072,6 +1096,7 @@ class __$$ManagedUserModelImplCopyWithImpl<$Res> Object? mobile = null, Object? roleId = freezed, Object? roleIds = null, + Object? assignedRoles = null, Object? roleName = freezed, Object? departmentId = freezed, Object? departmentName = freezed, @@ -1127,6 +1152,10 @@ class __$$ManagedUserModelImplCopyWithImpl<$Res> ? _value._roleIds : roleIds // ignore: cast_nullable_to_non_nullable as List, + assignedRoles: null == assignedRoles + ? _value._assignedRoles + : assignedRoles // ignore: cast_nullable_to_non_nullable + as List, roleName: freezed == roleName ? _value.roleName : roleName // ignore: cast_nullable_to_non_nullable @@ -1221,6 +1250,12 @@ class _$ManagedUserModelImpl implements _ManagedUserModel { fromJson: _roleIdsFromJson, ) final List roleIds = const [], + @JsonKey( + name: 'roles', + readValue: _readAssignedRoles, + fromJson: _assignedRolesFromJson, + ) + final List assignedRoles = const [], @JsonKey(name: 'role_name', readValue: _readRoleName) this.roleName, @JsonKey(fromJson: _idFromJsonNullable, name: 'department_id') this.departmentId, @@ -1241,7 +1276,8 @@ class _$ManagedUserModelImpl implements _ManagedUserModel { @JsonKey(name: 'avatar_url') this.avatarUrl, @JsonKey(name: 'created_at') this.createdAt, @JsonKey(name: 'updated_at') this.updatedAt, - }) : _roleIds = roleIds; + }) : _roleIds = roleIds, + _assignedRoles = assignedRoles; factory _$ManagedUserModelImpl.fromJson(Map json) => _$$ManagedUserModelImplFromJson(json); @@ -1286,6 +1322,19 @@ class _$ManagedUserModelImpl implements _ManagedUserModel { return EqualUnmodifiableListView(_roleIds); } + final List _assignedRoles; + @override + @JsonKey( + name: 'roles', + readValue: _readAssignedRoles, + fromJson: _assignedRolesFromJson, + ) + List get assignedRoles { + if (_assignedRoles is EqualUnmodifiableListView) return _assignedRoles; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_assignedRoles); + } + @override @JsonKey(name: 'role_name', readValue: _readRoleName) final String? roleName; @@ -1336,7 +1385,7 @@ class _$ManagedUserModelImpl implements _ManagedUserModel { @override String toString() { - return 'ManagedUserModel(id: $id, employeeCode: $employeeCode, fullName: $fullName, firstName: $firstName, lastName: $lastName, email: $email, mobile: $mobile, roleId: $roleId, roleIds: $roleIds, roleName: $roleName, departmentId: $departmentId, departmentName: $departmentName, designationId: $designationId, designationName: $designationName, plantId: $plantId, plantName: $plantName, reportingTo: $reportingTo, reportingToName: $reportingToName, lastLoginAt: $lastLoginAt, initials: $initials, status: $status, isActive: $isActive, avatarUrl: $avatarUrl, createdAt: $createdAt, updatedAt: $updatedAt)'; + return 'ManagedUserModel(id: $id, employeeCode: $employeeCode, fullName: $fullName, firstName: $firstName, lastName: $lastName, email: $email, mobile: $mobile, roleId: $roleId, roleIds: $roleIds, assignedRoles: $assignedRoles, roleName: $roleName, departmentId: $departmentId, departmentName: $departmentName, designationId: $designationId, designationName: $designationName, plantId: $plantId, plantName: $plantName, reportingTo: $reportingTo, reportingToName: $reportingToName, lastLoginAt: $lastLoginAt, initials: $initials, status: $status, isActive: $isActive, avatarUrl: $avatarUrl, createdAt: $createdAt, updatedAt: $updatedAt)'; } @override @@ -1357,6 +1406,10 @@ class _$ManagedUserModelImpl implements _ManagedUserModel { (identical(other.mobile, mobile) || other.mobile == mobile) && (identical(other.roleId, roleId) || other.roleId == roleId) && const DeepCollectionEquality().equals(other._roleIds, _roleIds) && + const DeepCollectionEquality().equals( + other._assignedRoles, + _assignedRoles, + ) && (identical(other.roleName, roleName) || other.roleName == roleName) && (identical(other.departmentId, departmentId) || @@ -1402,6 +1455,7 @@ class _$ManagedUserModelImpl implements _ManagedUserModel { mobile, roleId, const DeepCollectionEquality().hash(_roleIds), + const DeepCollectionEquality().hash(_assignedRoles), roleName, departmentId, departmentName, @@ -1461,6 +1515,12 @@ abstract class _ManagedUserModel implements ManagedUserModel { fromJson: _roleIdsFromJson, ) final List roleIds, + @JsonKey( + name: 'roles', + readValue: _readAssignedRoles, + fromJson: _assignedRolesFromJson, + ) + final List assignedRoles, @JsonKey(name: 'role_name', readValue: _readRoleName) final String? roleName, @JsonKey(fromJson: _idFromJsonNullable, name: 'department_id') @@ -1523,6 +1583,13 @@ abstract class _ManagedUserModel implements ManagedUserModel { ) List get roleIds; @override + @JsonKey( + name: 'roles', + readValue: _readAssignedRoles, + fromJson: _assignedRolesFromJson, + ) + List get assignedRoles; + @override @JsonKey(name: 'role_name', readValue: _readRoleName) String? get roleName; @override diff --git a/lib/shared/models/user_management_models.g.dart b/lib/shared/models/user_management_models.g.dart index 85390da..04bcf84 100644 --- a/lib/shared/models/user_management_models.g.dart +++ b/lib/shared/models/user_management_models.g.dart @@ -84,6 +84,9 @@ _$ManagedUserModelImpl _$$ManagedUserModelImplFromJson( roleIds: _readRoleIds(json, 'role_ids') == null ? const [] : _roleIdsFromJson(_readRoleIds(json, 'role_ids')), + assignedRoles: _readAssignedRoles(json, 'roles') == null + ? const [] + : _assignedRolesFromJson(_readAssignedRoles(json, 'roles')), roleName: _readRoleName(json, 'role_name') as String?, departmentId: _idFromJsonNullable(json['department_id']), departmentName: _readDepartmentName(json, 'department_name') as String?, @@ -120,6 +123,7 @@ Map _$$ManagedUserModelImplToJson( 'mobile': instance.mobile, 'role_id': instance.roleId, 'role_ids': instance.roleIds, + 'roles': instance.assignedRoles, 'role_name': instance.roleName, 'department_id': instance.departmentId, 'department_name': instance.departmentName, diff --git a/lib/shared/routes/menu_config.dart b/lib/shared/routes/menu_config.dart index fff9c1d..e42e197 100644 --- a/lib/shared/routes/menu_config.dart +++ b/lib/shared/routes/menu_config.dart @@ -29,19 +29,19 @@ const List appMenuItems = [ route: RouteConstants.dashboard, module: 'dashboard', ), - MenuItem( - label: 'Companies', - icon: Icons.business_outlined, - route: RouteConstants.companies, - module: 'companies', - requiredRole: UserRole.superAdmin, - ), - MenuItem( - label: 'Branches', - icon: Icons.account_tree_outlined, - route: RouteConstants.branches, - module: 'branches', - ), + // MenuItem( + // label: 'Companies', + // icon: Icons.business_outlined, + // route: RouteConstants.companies, + // module: 'companies', + // requiredRole: UserRole.superAdmin, + // ), + // MenuItem( + // label: 'Branches', + // icon: Icons.account_tree_outlined, + // route: RouteConstants.branches, + // module: 'branches', + // ), MenuItem( label: 'Users & Roles', icon: Icons.admin_panel_settings_outlined, diff --git a/lib/shared/widgets/app_pagination.dart b/lib/shared/widgets/app_pagination.dart index 88fb974..dd2c399 100644 --- a/lib/shared/widgets/app_pagination.dart +++ b/lib/shared/widgets/app_pagination.dart @@ -10,6 +10,9 @@ class AppPagination extends StatelessWidget { required this.onPageChanged, this.onPageSizeChanged, this.pageSizeOptions = const [10, 20, 50], + this.itemLabel = 'items', + this.maxVisiblePages = 10, + this.padding = EdgeInsets.zero, }); final int currentPage; @@ -19,43 +22,122 @@ class AppPagination extends StatelessWidget { final ValueChanged onPageChanged; final ValueChanged? onPageSizeChanged; final List pageSizeOptions; + final String itemLabel; + final int maxVisiblePages; + final EdgeInsetsGeometry padding; @override Widget build(BuildContext context) { + final theme = Theme.of(context); final start = totalItems == 0 ? 0 : ((currentPage - 1) * pageSize) + 1; final end = (currentPage * pageSize).clamp(0, totalItems); + final visiblePages = _visiblePageNumbers(currentPage, totalPages); - return Wrap( - spacing: 12, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - 'Showing $start–$end of $totalItems', - style: Theme.of(context).textTheme.bodySmall, + return Padding( + padding: padding, + child: SizedBox( + height: 40, + child: Row( + children: [ + Text( + 'Showing $start–$end of $totalItems $itemLabel', + style: theme.textTheme.bodySmall, + ), + if (onPageSizeChanged != null) ...[ + const SizedBox(width: 12), + DropdownButton( + value: pageSize, + isDense: true, + underline: const SizedBox.shrink(), + style: theme.textTheme.bodySmall, + items: pageSizeOptions + .map( + (size) => DropdownMenuItem( + value: size, + child: Text('$size / page'), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) onPageSizeChanged!(value); + }, + ), + ], + const Spacer(), + TextButton( + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 36), + ), + onPressed: currentPage > 1 + ? () => onPageChanged(currentPage - 1) + : null, + child: const Text('Previous'), + ), + ...visiblePages.map((pageIndex) { + final selected = currentPage == pageIndex; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Material( + color: selected + ? theme.colorScheme.primary + : Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => onPageChanged(pageIndex), + child: SizedBox( + width: 32, + height: 32, + child: Center( + child: Text( + '$pageIndex', + style: theme.textTheme.bodySmall?.copyWith( + color: selected + ? theme.colorScheme.onPrimary + : null, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ); + }), + TextButton( + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 36), + ), + onPressed: currentPage < totalPages + ? () => onPageChanged(currentPage + 1) + : null, + child: const Text('Next'), + ), + ], ), - if (onPageSizeChanged != null) - DropdownButton( - value: pageSize, - items: pageSizeOptions - .map((size) => DropdownMenuItem(value: size, child: Text('$size / page'))) - .toList(), - onChanged: (value) { - if (value != null) onPageSizeChanged!(value); - }, - ), - IconButton( - tooltip: 'Previous page', - onPressed: currentPage > 1 ? () => onPageChanged(currentPage - 1) : null, - icon: const Icon(Icons.chevron_left), - ), - Text('Page $currentPage of ${totalPages.clamp(1, totalPages)}'), - IconButton( - tooltip: 'Next page', - onPressed: currentPage < totalPages ? () => onPageChanged(currentPage + 1) : null, - icon: const Icon(Icons.chevron_right), - ), - ], + ), ); } + + List _visiblePageNumbers(int current, int total) { + if (total <= 0) return const []; + if (total <= maxVisiblePages) { + return List.generate(total, (index) => index + 1); + } + + final half = maxVisiblePages ~/ 2; + var start = current - half; + if (start < 1) start = 1; + var end = start + maxVisiblePages - 1; + if (end > total) { + end = total; + start = end - maxVisiblePages + 1; + } + + return List.generate(end - start + 1, (index) => start + index); + } } diff --git a/lib/shared/widgets/app_searchable_dropdown.dart b/lib/shared/widgets/app_searchable_dropdown.dart index 383640f..256fa69 100644 --- a/lib/shared/widgets/app_searchable_dropdown.dart +++ b/lib/shared/widgets/app_searchable_dropdown.dart @@ -153,41 +153,47 @@ class _AppSearchableDropdownState extends State> { final canOpen = widget.enabled && widget.options.isNotEmpty; final colors = theme.colorScheme; - return CompositedTransformTarget( - link: _layerLink, - child: KeyedSubtree( - key: _fieldKey, - child: InkWell( - onTap: canOpen ? () => _openPicker(field) : null, - borderRadius: BorderRadius.circular(8), - child: InputDecorator( - isFocused: _overlayEntry != null, - isEmpty: displayLabel == null, - decoration: InputDecoration( - labelText: widget.label, - hintText: displayLabel == null ? effectiveHint : null, - floatingLabelBehavior: FloatingLabelBehavior.always, - isDense: widget.isDense, - errorText: field.errorText, - suffixIcon: Icon( - _overlayEntry != null - ? Icons.arrow_drop_up - : Icons.arrow_drop_down, - color: - canOpen ? colors.onSurfaceVariant : theme.disabledColor, + // Top padding keeps the always-floating label from being clipped by + // tight parents (e.g. TabBarView toolbars). + return Padding( + padding: const EdgeInsets.only(top: 8), + child: CompositedTransformTarget( + link: _layerLink, + child: KeyedSubtree( + key: _fieldKey, + child: InkWell( + onTap: canOpen ? () => _openPicker(field) : null, + borderRadius: BorderRadius.circular(8), + child: InputDecorator( + isFocused: _overlayEntry != null, + isEmpty: displayLabel == null, + decoration: InputDecoration( + labelText: widget.label, + hintText: displayLabel == null ? effectiveHint : null, + floatingLabelBehavior: FloatingLabelBehavior.always, + isDense: widget.isDense, + errorText: field.errorText, + suffixIcon: Icon( + _overlayEntry != null + ? Icons.arrow_drop_up + : Icons.arrow_drop_down, + color: canOpen + ? colors.onSurfaceVariant + : theme.disabledColor, + ), + enabled: canOpen, ), - enabled: canOpen, - ), - child: displayLabel == null - ? const SizedBox.shrink() - : Text( - displayLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyLarge?.copyWith( - color: colors.onSurface, + child: displayLabel == null + ? const SizedBox.shrink() + : Text( + displayLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + color: colors.onSurface, + ), ), - ), + ), ), ), ), diff --git a/lib/shared/widgets/app_searchable_multi_select_dropdown.dart b/lib/shared/widgets/app_searchable_multi_select_dropdown.dart index 6e1efa4..825f0b9 100644 --- a/lib/shared/widgets/app_searchable_multi_select_dropdown.dart +++ b/lib/shared/widgets/app_searchable_multi_select_dropdown.dart @@ -172,41 +172,45 @@ class _AppSearchableMultiSelectDropdownState final canOpen = widget.enabled && widget.options.isNotEmpty; final colors = theme.colorScheme; - return CompositedTransformTarget( - link: _layerLink, - child: KeyedSubtree( - key: _fieldKey, - child: InkWell( - onTap: canOpen ? () => _openPicker(field) : null, - borderRadius: BorderRadius.circular(8), - child: InputDecorator( - isFocused: _overlayEntry != null, - isEmpty: displayText.isEmpty, - decoration: InputDecoration( - labelText: widget.label, - hintText: displayText.isEmpty ? effectiveHint : null, - floatingLabelBehavior: FloatingLabelBehavior.always, - isDense: widget.isDense, - errorText: field.errorText, - suffixIcon: Icon( - _overlayEntry != null - ? Icons.arrow_drop_up - : Icons.arrow_drop_down, - color: - canOpen ? colors.onSurfaceVariant : theme.disabledColor, + return Padding( + padding: const EdgeInsets.only(top: 8), + child: CompositedTransformTarget( + link: _layerLink, + child: KeyedSubtree( + key: _fieldKey, + child: InkWell( + onTap: canOpen ? () => _openPicker(field) : null, + borderRadius: BorderRadius.circular(8), + child: InputDecorator( + isFocused: _overlayEntry != null, + isEmpty: displayText.isEmpty, + decoration: InputDecoration( + labelText: widget.label, + hintText: displayText.isEmpty ? effectiveHint : null, + floatingLabelBehavior: FloatingLabelBehavior.always, + isDense: widget.isDense, + errorText: field.errorText, + suffixIcon: Icon( + _overlayEntry != null + ? Icons.arrow_drop_up + : Icons.arrow_drop_down, + color: canOpen + ? colors.onSurfaceVariant + : theme.disabledColor, + ), + enabled: canOpen, ), - enabled: canOpen, - ), - child: displayText.isEmpty - ? const SizedBox.shrink() - : Text( - displayText, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyLarge?.copyWith( - color: colors.onSurface, + child: displayText.isEmpty + ? const SizedBox.shrink() + : Text( + displayText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + color: colors.onSurface, + ), ), - ), + ), ), ), ), diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index 75d525d..69067eb 100644 --- a/lib/shared/widgets/app_shell.dart +++ b/lib/shared/widgets/app_shell.dart @@ -205,8 +205,6 @@ class _UserMenu extends ConsumerWidget { switch (value) { case 'password': context.push(RouteConstants.changePassword); - case 'settings': - goAndDismissOverlays(context, RouteConstants.settings); case 'logout': await ref.read(authStateProvider.notifier).logout(); if (context.mounted) context.go(RouteConstants.login); @@ -214,7 +212,6 @@ class _UserMenu extends ConsumerWidget { }, itemBuilder: (context) => [ const PopupMenuItem(value: 'password', child: Text('Change Password')), - const PopupMenuItem(value: 'settings', child: Text('Settings')), const PopupMenuDivider(), const PopupMenuItem(value: 'logout', child: Text('Logout')), ], diff --git a/lib/shared/widgets/app_sidebar.dart b/lib/shared/widgets/app_sidebar.dart index 05d71a0..2c0037d 100644 --- a/lib/shared/widgets/app_sidebar.dart +++ b/lib/shared/widgets/app_sidebar.dart @@ -292,30 +292,56 @@ class _AppSidebarState extends ConsumerState { return Padding( padding: const EdgeInsets.fromLTRB(14, 16, 8, 0), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SidebarLogo(logoUrl: logoUrl, size: 36), - const SizedBox(width: 8), - Expanded( + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: SidebarLogo( + logoUrl: logoUrl, + height: 44, + width: double.infinity, + fit: BoxFit.contain, + showBackground: false, + ), + ), + if (widget.onToggleCollapse != null) + IconButton( + icon: const Icon(Icons.chevron_left, size: 20), + tooltip: 'Collapse sidebar', + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(8), + constraints: + const BoxConstraints.tightFor(width: 36, height: 36), + onPressed: widget.onToggleCollapse, + ), + ], + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.24), + ), + ), child: Text( title, - maxLines: 1, + textAlign: TextAlign.center, + maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, letterSpacing: -0.2, + color: theme.colorScheme.primary, ), ), ), - if (widget.onToggleCollapse != null) - IconButton( - icon: const Icon(Icons.chevron_left, size: 20), - tooltip: 'Collapse sidebar', - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.all(8), - constraints: const BoxConstraints.tightFor(width: 36, height: 36), - onPressed: widget.onToggleCollapse, - ), ], ), ); @@ -941,8 +967,6 @@ class _UserProfileMenu extends ConsumerWidget { context.push(RouteConstants.profile); case 'password': context.push(RouteConstants.changePassword); - case 'settings': - context.go(RouteConstants.settings); case 'logout': await ref.read(authStateProvider.notifier).logout(); if (context.mounted) context.go(RouteConstants.login); @@ -951,7 +975,6 @@ class _UserProfileMenu extends ConsumerWidget { itemBuilder: (context) => [ const PopupMenuItem(value: 'profile', child: Text('Profile')), const PopupMenuItem(value: 'password', child: Text('Change Password')), - const PopupMenuItem(value: 'settings', child: Text('Settings')), const PopupMenuDivider(), const PopupMenuItem(value: 'logout', child: Text('Logout')), ], diff --git a/lib/shared/widgets/app_table_shell.dart b/lib/shared/widgets/app_table_shell.dart index 04dc345..f09d5a1 100644 --- a/lib/shared/widgets/app_table_shell.dart +++ b/lib/shared/widgets/app_table_shell.dart @@ -41,7 +41,7 @@ class AppTableShell extends StatelessWidget { if (footer != null) ...[ const Divider(height: 1), Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: footer!, ), ], diff --git a/lib/shared/widgets/app_top_nav.dart b/lib/shared/widgets/app_top_nav.dart index 58f7b3e..63fdb35 100644 --- a/lib/shared/widgets/app_top_nav.dart +++ b/lib/shared/widgets/app_top_nav.dart @@ -153,8 +153,6 @@ class _TopNavUserMenu extends ConsumerWidget { context.push(RouteConstants.profile); case 'password': context.push(RouteConstants.changePassword); - case 'settings': - context.go(RouteConstants.settings); case 'logout': await ref.read(authStateProvider.notifier).logout(); if (context.mounted) context.go(RouteConstants.login); @@ -163,7 +161,6 @@ class _TopNavUserMenu extends ConsumerWidget { itemBuilder: (context) => [ const PopupMenuItem(value: 'profile', child: Text('Profile')), const PopupMenuItem(value: 'password', child: Text('Change Password')), - const PopupMenuItem(value: 'settings', child: Text('Settings')), const PopupMenuDivider(), const PopupMenuItem(value: 'logout', child: Text('Logout')), ], diff --git a/lib/shared/widgets/sidebar_logo.dart b/lib/shared/widgets/sidebar_logo.dart index d3a7147..0ec8cc3 100644 --- a/lib/shared/widgets/sidebar_logo.dart +++ b/lib/shared/widgets/sidebar_logo.dart @@ -9,29 +9,46 @@ class SidebarLogo extends StatelessWidget { super.key, this.logoUrl, this.size = 36, + this.width, + this.height, + this.fit = BoxFit.cover, + this.showBackground = true, }); final String? logoUrl; final double size; + final double? width; + final double? height; + final BoxFit fit; + final bool showBackground; + + double get _width => width ?? size; + double get _height => height ?? size; @override Widget build(BuildContext context) { final theme = Theme.of(context); final fallback = Icon( Icons.inventory_2_outlined, - size: size * 0.55, + size: (_width < _height ? _width : _height) * 0.55, color: theme.colorScheme.primary, ); + final content = _buildLogoContent(fallback); + + if (!showBackground) { + return SizedBox(width: _width, height: _height, child: content); + } + return Container( - width: size, - height: size, + width: _width, + height: _height, decoration: BoxDecoration( color: theme.colorScheme.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), ), clipBehavior: Clip.antiAlias, - child: _buildLogoContent(fallback), + child: content, ); } @@ -46,9 +63,9 @@ class SidebarLogo extends StatelessWidget { final base64Str = url.contains(',') ? url.split(',').last : url; return Image.memory( base64Decode(base64Str), - width: size, - height: size, - fit: BoxFit.cover, + width: _width, + height: _height, + fit: fit, errorBuilder: (_, __, ___) => Center(child: fallback), ); } catch (_) { @@ -59,13 +76,13 @@ class SidebarLogo extends StatelessWidget { if (url.startsWith('http://') || url.startsWith('https://')) { return CachedNetworkImage( imageUrl: url, - width: size, - height: size, - fit: BoxFit.cover, + width: _width, + height: _height, + fit: fit, placeholder: (_, __) => Center( child: SizedBox( - width: size * 0.4, - height: size * 0.4, + width: _width * 0.4, + height: _height * 0.4, child: const CircularProgressIndicator(strokeWidth: 2), ), ),