screens functionality done
This commit is contained in:
parent
1fc8abaee7
commit
bea7e37090
@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
import '../../app.dart';
|
import '../../app.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../utils/favicon_store.dart';
|
||||||
import 'environment.dart';
|
import 'environment.dart';
|
||||||
|
|
||||||
Future<void> startApp() async {
|
Future<void> startApp() async {
|
||||||
@ -16,6 +17,7 @@ Future<void> startApp() async {
|
|||||||
}
|
}
|
||||||
await dotenv.load(fileName: Environment.envFileName);
|
await dotenv.load(fileName: Environment.envFileName);
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
FaviconStore(prefs).apply();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
|
|||||||
@ -9,6 +9,7 @@ class StorageKeys {
|
|||||||
static const String brandingPrimaryColor = 'branding_primary_color';
|
static const String brandingPrimaryColor = 'branding_primary_color';
|
||||||
static const String brandingSecondaryColor = 'branding_secondary_color';
|
static const String brandingSecondaryColor = 'branding_secondary_color';
|
||||||
static const String brandingLogoUrl = 'branding_logo_url';
|
static const String brandingLogoUrl = 'branding_logo_url';
|
||||||
|
static const String faviconUrl = 'favicon_url';
|
||||||
static const String appSettings = 'app_settings';
|
static const String appSettings = 'app_settings';
|
||||||
static const String rememberMe = 'remember_me';
|
static const String rememberMe = 'remember_me';
|
||||||
static const String rememberedEmail = 'remembered_email';
|
static const String rememberedEmail = 'remembered_email';
|
||||||
|
|||||||
29
lib/core/utils/favicon_store.dart
Normal file
29
lib/core/utils/favicon_store.dart
Normal file
@ -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<void> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
lib/core/utils/favicon_updater.dart
Normal file
4
lib/core/utils/favicon_updater.dart
Normal file
@ -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);
|
||||||
1
lib/core/utils/favicon_updater_stub.dart
Normal file
1
lib/core/utils/favicon_updater_stub.dart
Normal file
@ -0,0 +1 @@
|
|||||||
|
void updateFavicon(String? url) {}
|
||||||
36
lib/core/utils/favicon_updater_web.dart
Normal file
36
lib/core/utils/favicon_updater_web.dart
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -206,6 +206,24 @@ class Validators {
|
|||||||
return null;
|
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<TextInputFormatter> get roleNameInput => [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9 ]')),
|
||||||
|
];
|
||||||
|
|
||||||
/// Resolves validators for master-data and dynamic form fields by key.
|
/// Resolves validators for master-data and dynamic form fields by key.
|
||||||
static String? forFieldKey(
|
static String? forFieldKey(
|
||||||
String key,
|
String key,
|
||||||
|
|||||||
@ -56,6 +56,7 @@ class AssetAlertsScreen extends ConsumerWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
_ExpiryAlertsTab(state: state),
|
_ExpiryAlertsTab(state: state),
|
||||||
_ServiceAlertsTab(state: state),
|
_ServiceAlertsTab(state: state),
|
||||||
@ -86,12 +87,13 @@ class _ExpiryAlertsTab extends ConsumerWidget {
|
|||||||
Wrap(
|
Wrap(
|
||||||
spacing: 12,
|
spacing: 12,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 160,
|
width: 160,
|
||||||
child: AppDropdown<int>(
|
child: AppDropdown<int>(
|
||||||
label: 'Days ahead',
|
label: 'Days ahead',
|
||||||
|
isDense: true,
|
||||||
value: state.expiryDays,
|
value: state.expiryDays,
|
||||||
options: const [7, 15, 30, 60, 90]
|
options: const [7, 15, 30, 60, 90]
|
||||||
.map((d) => AppDropdownOption(value: d, label: '$d days'))
|
.map((d) => AppDropdownOption(value: d, label: '$d days'))
|
||||||
@ -105,6 +107,7 @@ class _ExpiryAlertsTab extends ConsumerWidget {
|
|||||||
width: 180,
|
width: 180,
|
||||||
child: AppDropdown<String?>(
|
child: AppDropdown<String?>(
|
||||||
label: 'Type',
|
label: 'Type',
|
||||||
|
isDense: true,
|
||||||
value: state.expiryType,
|
value: state.expiryType,
|
||||||
options: const [
|
options: const [
|
||||||
AppDropdownOption(value: null, label: 'All'),
|
AppDropdownOption(value: null, label: 'All'),
|
||||||
@ -181,6 +184,7 @@ class _ServiceAlertsTab extends ConsumerWidget {
|
|||||||
width: 200,
|
width: 200,
|
||||||
child: AppDropdown<String?>(
|
child: AppDropdown<String?>(
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
|
isDense: true,
|
||||||
value: state.serviceStatus,
|
value: state.serviceStatus,
|
||||||
options: const [
|
options: const [
|
||||||
AppDropdownOption(value: null, label: 'All'),
|
AppDropdownOption(value: null, label: 'All'),
|
||||||
|
|||||||
@ -89,7 +89,7 @@ class GrnRemoteDataSource {
|
|||||||
limit: limit,
|
limit: limit,
|
||||||
total: total,
|
total: total,
|
||||||
totalPages:
|
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,
|
limit: limit,
|
||||||
total: total,
|
total: total,
|
||||||
totalPages:
|
totalPages:
|
||||||
limit > 0 ? ((total + limit - 1) / limit).ceil().clamp(1, 999999) : 1,
|
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,6 +92,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
|||||||
totalPages: state.totalPages,
|
totalPages: state.totalPages,
|
||||||
totalItems: state.total,
|
totalItems: state.total,
|
||||||
pageSize: state.query.limit,
|
pageSize: state.query.limit,
|
||||||
|
itemLabel: 'GRNs',
|
||||||
onPageChanged: ref.read(grnListProvider.notifier).setPage,
|
onPageChanged: ref.read(grnListProvider.notifier).setPage,
|
||||||
onPageSizeChanged: ref.read(grnListProvider.notifier).setPageSize,
|
onPageSizeChanged: ref.read(grnListProvider.notifier).setPageSize,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -161,7 +161,7 @@ class PurchaseOrderRemoteDataSource {
|
|||||||
limit: limit,
|
limit: limit,
|
||||||
total: total,
|
total: total,
|
||||||
totalPages:
|
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,
|
limit: limit,
|
||||||
total: total,
|
total: total,
|
||||||
totalPages:
|
totalPages:
|
||||||
limit > 0 ? ((total + limit - 1) / limit).ceil().clamp(1, 999999) : 1,
|
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,6 +108,7 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
|||||||
totalPages: state.totalPages,
|
totalPages: state.totalPages,
|
||||||
totalItems: state.total,
|
totalItems: state.total,
|
||||||
pageSize: state.query.limit,
|
pageSize: state.query.limit,
|
||||||
|
itemLabel: 'purchase orders',
|
||||||
onPageChanged:
|
onPageChanged:
|
||||||
ref.read(purchaseOrdersListProvider.notifier).setPage,
|
ref.read(purchaseOrdersListProvider.notifier).setPage,
|
||||||
onPageSizeChanged:
|
onPageSizeChanged:
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import '../../../../shared/utils/file_download_helper.dart';
|
|||||||
import '../../../../shared/providers/permissions_provider.dart';
|
import '../../../../shared/providers/permissions_provider.dart';
|
||||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||||
import '../../../users/presentation/widgets/user_rich_data_table.dart';
|
import '../../../users/presentation/widgets/user_rich_data_table.dart';
|
||||||
|
import '../../../../shared/widgets/app_pagination.dart';
|
||||||
import '../../../../shared/widgets/app_dropdown.dart';
|
import '../../../../shared/widgets/app_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_card.dart';
|
import '../../../../shared/widgets/app_card.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
@ -466,50 +467,86 @@ class _UsersTab extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _UsersTabState extends ConsumerState<_UsersTab> {
|
class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||||
String? _selectedRoleName;
|
final _searchController = TextEditingController();
|
||||||
String? _selectedDepartmentName;
|
|
||||||
String? _selectedStatusLabel;
|
|
||||||
|
|
||||||
int? _roleIdForFilter(UserFiltersModel? filters) {
|
@override
|
||||||
if (_selectedRoleName == null || filters == null) return null;
|
void initState() {
|
||||||
for (final role in filters.roles) {
|
super.initState();
|
||||||
if (role.name == _selectedRoleName) {
|
WidgetsBinding.instance.addPostFrameCallback((_) => _resetStaleFilters());
|
||||||
return int.tryParse(role.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int? _departmentIdForFilter(UserFiltersModel? filters) {
|
@override
|
||||||
if (_selectedDepartmentName == null || filters == null) return null;
|
void dispose() {
|
||||||
for (final department in filters.departments) {
|
_searchController.dispose();
|
||||||
if (department.name == _selectedDepartmentName) {
|
super.dispose();
|
||||||
return int.tryParse(department.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _statusValueForFilter(UserFiltersModel? filters) {
|
Future<void> _resetStaleFilters() async {
|
||||||
if (_selectedStatusLabel == null || filters == null) return null;
|
if (!mounted) return;
|
||||||
for (final status in filters.statuses) {
|
|
||||||
if (status.name == _selectedStatusLabel) {
|
|
||||||
return status.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _applyFilters(UserFiltersModel? filters) {
|
|
||||||
final current = ref.read(usersListProvider).valueOrNull;
|
final current = ref.read(usersListProvider).valueOrNull;
|
||||||
ref.read(usersListProvider.notifier).applyQuery(
|
if (current == null) return;
|
||||||
(current?.query ?? const UserListQuery(limit: 10)).copyWith(
|
|
||||||
page: 1,
|
final query = current.query;
|
||||||
roleId: _roleIdForFilter(filters),
|
final hasActiveFilters = (query.search?.isNotEmpty ?? false) ||
|
||||||
departmentId: _departmentIdForFilter(filters),
|
query.roleId != null ||
|
||||||
status: _statusValueForFilter(filters),
|
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) {
|
void _editUser(ManagedUserModel user) {
|
||||||
@ -646,14 +683,21 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
|||||||
'All statuses',
|
'All statuses',
|
||||||
...?filters?.statuses.map((s) => s.name),
|
...?filters?.statuses.map((s) => s.name),
|
||||||
];
|
];
|
||||||
final roleFilter = _selectedRoleName ?? 'All roles';
|
final roleFilter =
|
||||||
final departmentFilter = _selectedDepartmentName ?? 'All departments';
|
_roleNameForId(usersState.query.roleId, filters) ?? 'All roles';
|
||||||
final statusFilter = _selectedStatusLabel ?? 'All statuses';
|
final departmentFilter = _departmentNameForId(
|
||||||
|
usersState.query.departmentId,
|
||||||
|
filters,
|
||||||
|
) ??
|
||||||
|
'All departments';
|
||||||
|
final statusFilter = _statusLabelForValue(
|
||||||
|
usersState.query.status,
|
||||||
|
filters,
|
||||||
|
) ??
|
||||||
|
'All statuses';
|
||||||
final page = usersState.query.page;
|
final page = usersState.query.page;
|
||||||
final pageSize = usersState.query.limit;
|
final pageSize = usersState.query.limit;
|
||||||
final total = usersState.total;
|
final total = usersState.total;
|
||||||
final start = total == 0 ? 0 : ((page - 1) * pageSize) + 1;
|
|
||||||
final end = (page * pageSize).clamp(0, total);
|
|
||||||
|
|
||||||
return AppCard(
|
return AppCard(
|
||||||
enableHover: false,
|
enableHover: false,
|
||||||
@ -682,27 +726,29 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
|||||||
statuses: statuses,
|
statuses: statuses,
|
||||||
isExporting: usersState.isExporting,
|
isExporting: usersState.isExporting,
|
||||||
showExport: canExport,
|
showExport: canExport,
|
||||||
|
searchController: _searchController,
|
||||||
onExport: _exportUsers,
|
onExport: _exportUsers,
|
||||||
onSearch: ref.read(usersListProvider.notifier).setSearch,
|
onSearch: ref.read(usersListProvider.notifier).setSearch,
|
||||||
onRoleChanged: (value) {
|
onRoleChanged: (value) {
|
||||||
setState(() {
|
ref.read(usersListProvider.notifier).setRoleFilter(
|
||||||
_selectedRoleName = value == 'All roles' ? null : value;
|
value == 'All roles'
|
||||||
});
|
? null
|
||||||
_applyFilters(filters);
|
: _roleIdForName(value, filters),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onDepartmentChanged: (value) {
|
onDepartmentChanged: (value) {
|
||||||
setState(() {
|
ref.read(usersListProvider.notifier).setDepartmentFilter(
|
||||||
_selectedDepartmentName =
|
value == 'All departments'
|
||||||
value == 'All departments' ? null : value;
|
? null
|
||||||
});
|
: _departmentIdForName(value, filters),
|
||||||
_applyFilters(filters);
|
);
|
||||||
},
|
},
|
||||||
onStatusChanged: (value) {
|
onStatusChanged: (value) {
|
||||||
setState(() {
|
ref.read(usersListProvider.notifier).setStatusFilter(
|
||||||
_selectedStatusLabel =
|
value == 'All statuses'
|
||||||
value == 'All statuses' ? null : value;
|
? null
|
||||||
});
|
: _statusValueForLabel(value, filters),
|
||||||
_applyFilters(filters);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -737,63 +783,15 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Padding(
|
AppPagination(
|
||||||
padding: const EdgeInsets.all(16),
|
currentPage: page,
|
||||||
child: Row(
|
totalPages: usersState.totalPages,
|
||||||
children: [
|
totalItems: total,
|
||||||
Text(
|
pageSize: pageSize,
|
||||||
'Showing $start–$end of $total users',
|
itemLabel: 'users',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
),
|
onPageChanged:
|
||||||
const Spacer(),
|
ref.read(usersListProvider.notifier).setPage,
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -812,6 +810,7 @@ class _UsersFilterBar extends StatelessWidget {
|
|||||||
required this.roles,
|
required this.roles,
|
||||||
required this.departments,
|
required this.departments,
|
||||||
required this.statuses,
|
required this.statuses,
|
||||||
|
required this.searchController,
|
||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
required this.onExport,
|
required this.onExport,
|
||||||
this.isExporting = false,
|
this.isExporting = false,
|
||||||
@ -828,6 +827,7 @@ class _UsersFilterBar extends StatelessWidget {
|
|||||||
final List<String> roles;
|
final List<String> roles;
|
||||||
final List<String> departments;
|
final List<String> departments;
|
||||||
final List<String> statuses;
|
final List<String> statuses;
|
||||||
|
final TextEditingController searchController;
|
||||||
final ValueChanged<String> onSearch;
|
final ValueChanged<String> onSearch;
|
||||||
final VoidCallback onExport;
|
final VoidCallback onExport;
|
||||||
final bool isExporting;
|
final bool isExporting;
|
||||||
@ -839,6 +839,7 @@ class _UsersFilterBar extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final searchField = TextField(
|
final searchField = TextField(
|
||||||
|
controller: searchController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'Search by name, email, employee code...',
|
hintText: 'Search by name, email, employee code...',
|
||||||
prefixIcon: Icon(Icons.search, size: 20),
|
prefixIcon: Icon(Icons.search, size: 20),
|
||||||
@ -1208,7 +1209,7 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> {
|
|||||||
|
|
||||||
return AppCard(
|
return AppCard(
|
||||||
enableHover: false,
|
enableHover: false,
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.antiAlias,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@ -1268,21 +1269,18 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 16),
|
||||||
child: SingleChildScrollView(
|
child: ArrowScrollRow(
|
||||||
scrollDirection: Axis.horizontal,
|
height: 40,
|
||||||
child: Row(
|
itemCount: roles.length,
|
||||||
children: roles.map((role) {
|
itemBuilder: (context, index) {
|
||||||
return Padding(
|
final role = roles[index];
|
||||||
padding: const EdgeInsets.only(right: 8),
|
return RolePill(
|
||||||
child: RolePill(
|
label: role.name,
|
||||||
label: role.name,
|
selected: role.id == selectedRoleId,
|
||||||
selected: role.id == selectedRoleId,
|
onTap: () => setState(() => _selectedRoleId = role.id),
|
||||||
onTap: () => setState(() => _selectedRoleId = role.id),
|
);
|
||||||
),
|
},
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
|
|||||||
@ -157,9 +157,10 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
|
|||||||
children: [
|
children: [
|
||||||
AppTextField(
|
AppTextField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
label: 'Role name',
|
label: 'Role name *',
|
||||||
hint: 'e.g. QC Manager',
|
hint: 'e.g. QC Manager',
|
||||||
validator: (v) => Validators.required(v, fieldName: 'Role name'),
|
validator: Validators.roleName,
|
||||||
|
inputFormatters: Validators.roleNameInput,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
@ -138,12 +139,16 @@ class RoleBadge extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
|
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Flexible(
|
||||||
label,
|
child: Text(
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
label,
|
||||||
color: primary,
|
maxLines: 1,
|
||||||
fontWeight: FontWeight.w600,
|
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<String> 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 {
|
class EmployeeCodeBadge extends StatelessWidget {
|
||||||
const EmployeeCodeBadge({super.key, required this.code});
|
const EmployeeCodeBadge({super.key, required this.code});
|
||||||
|
|
||||||
@ -393,6 +417,7 @@ class RolePill extends StatelessWidget {
|
|||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
child: Container(
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 200),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
@ -404,6 +429,8 @@ class RolePill extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
color: selected
|
color: selected
|
||||||
? Theme.of(context).colorScheme.onPrimary
|
? 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<ArrowScrollRow> createState() => _ArrowScrollRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArrowScrollRowState extends State<ArrowScrollRow> {
|
||||||
|
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 {
|
class ModulePermissionRow extends StatelessWidget {
|
||||||
const ModulePermissionRow({
|
const ModulePermissionRow({
|
||||||
super.key,
|
super.key,
|
||||||
|
|||||||
@ -16,15 +16,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
|||||||
@override
|
@override
|
||||||
Future<Result<AppSettings>> getSettings() async {
|
Future<Result<AppSettings>> getSettings() async {
|
||||||
return safeApiCall(() async {
|
return safeApiCall(() async {
|
||||||
try {
|
// Local-first until settings API is available.
|
||||||
final remoteSettings = await remote.fetch();
|
|
||||||
if (remoteSettings != null) {
|
|
||||||
await local.write(remoteSettings);
|
|
||||||
return remoteSettings;
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Fall back to local cache when API is unavailable.
|
|
||||||
}
|
|
||||||
return await local.read() ?? const AppSettings();
|
return await local.read() ?? const AppSettings();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -32,14 +24,9 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
|||||||
@override
|
@override
|
||||||
Future<Result<AppSettings>> saveSettings(AppSettings settings) async {
|
Future<Result<AppSettings>> saveSettings(AppSettings settings) async {
|
||||||
return safeApiCall(() async {
|
return safeApiCall(() async {
|
||||||
try {
|
// Always persist locally; remote sync can be wired when API is ready.
|
||||||
final saved = await remote.save(settings);
|
await local.write(settings);
|
||||||
await local.write(saved);
|
return settings;
|
||||||
return saved;
|
|
||||||
} catch (_) {
|
|
||||||
await local.write(settings);
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,6 +77,7 @@ class CompanyProfileSettings {
|
|||||||
this.phone = '',
|
this.phone = '',
|
||||||
this.website = '',
|
this.website = '',
|
||||||
this.logoUrl = '',
|
this.logoUrl = '',
|
||||||
|
this.faviconUrl = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
final String companyName;
|
final String companyName;
|
||||||
@ -88,6 +89,7 @@ class CompanyProfileSettings {
|
|||||||
final String phone;
|
final String phone;
|
||||||
final String website;
|
final String website;
|
||||||
final String logoUrl;
|
final String logoUrl;
|
||||||
|
final String faviconUrl;
|
||||||
|
|
||||||
CompanyProfileSettings copyWith({
|
CompanyProfileSettings copyWith({
|
||||||
String? companyName,
|
String? companyName,
|
||||||
@ -99,6 +101,7 @@ class CompanyProfileSettings {
|
|||||||
String? phone,
|
String? phone,
|
||||||
String? website,
|
String? website,
|
||||||
String? logoUrl,
|
String? logoUrl,
|
||||||
|
String? faviconUrl,
|
||||||
}) {
|
}) {
|
||||||
return CompanyProfileSettings(
|
return CompanyProfileSettings(
|
||||||
companyName: companyName ?? this.companyName,
|
companyName: companyName ?? this.companyName,
|
||||||
@ -110,6 +113,7 @@ class CompanyProfileSettings {
|
|||||||
phone: phone ?? this.phone,
|
phone: phone ?? this.phone,
|
||||||
website: website ?? this.website,
|
website: website ?? this.website,
|
||||||
logoUrl: logoUrl ?? this.logoUrl,
|
logoUrl: logoUrl ?? this.logoUrl,
|
||||||
|
faviconUrl: faviconUrl ?? this.faviconUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,6 +127,7 @@ class CompanyProfileSettings {
|
|||||||
'phone': phone,
|
'phone': phone,
|
||||||
'website': website,
|
'website': website,
|
||||||
'logoUrl': logoUrl,
|
'logoUrl': logoUrl,
|
||||||
|
'faviconUrl': faviconUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory CompanyProfileSettings.fromJson(Map<String, dynamic> json) =>
|
factory CompanyProfileSettings.fromJson(Map<String, dynamic> json) =>
|
||||||
@ -136,6 +141,7 @@ class CompanyProfileSettings {
|
|||||||
phone: json['phone'] as String? ?? '',
|
phone: json['phone'] as String? ?? '',
|
||||||
website: json['website'] as String? ?? '',
|
website: json['website'] as String? ?? '',
|
||||||
logoUrl: json['logoUrl'] as String? ?? '',
|
logoUrl: json['logoUrl'] as String? ?? '',
|
||||||
|
faviconUrl: json['faviconUrl'] as String? ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -615,7 +621,7 @@ const phase1SettingsSections = [
|
|||||||
SettingsSection(
|
SettingsSection(
|
||||||
id: 'company-profile',
|
id: 'company-profile',
|
||||||
title: 'Company Profile',
|
title: 'Company Profile',
|
||||||
subtitle: 'Company information and logo',
|
subtitle: 'Company information and branding assets',
|
||||||
icon: Icons.business_outlined,
|
icon: Icons.business_outlined,
|
||||||
route: '/settings/company-profile',
|
route: '/settings/company-profile',
|
||||||
),
|
),
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../../core/network/dio_client.dart';
|
import '../../../../core/network/dio_client.dart';
|
||||||
import '../../../../core/theme/theme_provider.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_local_data_source.dart';
|
||||||
import '../../data/datasources/settings_remote_data_source.dart';
|
import '../../data/datasources/settings_remote_data_source.dart';
|
||||||
import '../../data/repositories/settings_repository_impl.dart';
|
import '../../data/repositories/settings_repository_impl.dart';
|
||||||
@ -39,6 +40,7 @@ final appSettingsProvider =
|
|||||||
return AppSettingsNotifier(
|
return AppSettingsNotifier(
|
||||||
getSettings: ref.watch(getSettingsUseCaseProvider),
|
getSettings: ref.watch(getSettingsUseCaseProvider),
|
||||||
saveSettings: ref.watch(saveSettingsUseCaseProvider),
|
saveSettings: ref.watch(saveSettingsUseCaseProvider),
|
||||||
|
faviconStore: FaviconStore(ref.watch(sharedPreferencesProvider)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -46,19 +48,23 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
|||||||
AppSettingsNotifier({
|
AppSettingsNotifier({
|
||||||
required GetSettingsUseCase getSettings,
|
required GetSettingsUseCase getSettings,
|
||||||
required SaveSettingsUseCase saveSettings,
|
required SaveSettingsUseCase saveSettings,
|
||||||
|
required FaviconStore faviconStore,
|
||||||
}) : _getSettings = getSettings,
|
}) : _getSettings = getSettings,
|
||||||
_saveSettings = saveSettings,
|
_saveSettings = saveSettings,
|
||||||
|
_faviconStore = faviconStore,
|
||||||
super(const AppSettings()) {
|
super(const AppSettings()) {
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
final GetSettingsUseCase _getSettings;
|
final GetSettingsUseCase _getSettings;
|
||||||
final SaveSettingsUseCase _saveSettings;
|
final SaveSettingsUseCase _saveSettings;
|
||||||
|
final FaviconStore _faviconStore;
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final result = await _getSettings();
|
final result = await _getSettings();
|
||||||
state = result.data ?? const AppSettings();
|
state = result.data ?? const AppSettings();
|
||||||
}
|
_faviconStore.apply();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _persist(AppSettings settings) async {
|
Future<void> _persist(AppSettings settings) async {
|
||||||
state = settings;
|
state = settings;
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/theme_provider.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 '../../../../core/utils/validators.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
@ -33,11 +35,14 @@ class _CompanyProfileSettingsScreenState
|
|||||||
late final TextEditingController _phoneController;
|
late final TextEditingController _phoneController;
|
||||||
late final TextEditingController _websiteController;
|
late final TextEditingController _websiteController;
|
||||||
late final TextEditingController _logoUrlController;
|
late final TextEditingController _logoUrlController;
|
||||||
|
late final TextEditingController _faviconUrlController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final profile = ref.read(appSettingsProvider).companyProfile;
|
final profile = ref.read(appSettingsProvider).companyProfile;
|
||||||
|
final faviconFromPrefs =
|
||||||
|
FaviconStore(ref.read(sharedPreferencesProvider)).read();
|
||||||
_nameController = TextEditingController(text: profile.companyName);
|
_nameController = TextEditingController(text: profile.companyName);
|
||||||
_codeController = TextEditingController(text: profile.companyCode);
|
_codeController = TextEditingController(text: profile.companyCode);
|
||||||
_registrationController =
|
_registrationController =
|
||||||
@ -48,6 +53,11 @@ class _CompanyProfileSettingsScreenState
|
|||||||
_phoneController = TextEditingController(text: profile.phone);
|
_phoneController = TextEditingController(text: profile.phone);
|
||||||
_websiteController = TextEditingController(text: profile.website);
|
_websiteController = TextEditingController(text: profile.website);
|
||||||
_logoUrlController = TextEditingController(text: profile.logoUrl);
|
_logoUrlController = TextEditingController(text: profile.logoUrl);
|
||||||
|
_faviconUrlController = TextEditingController(
|
||||||
|
text: profile.faviconUrl.isNotEmpty
|
||||||
|
? profile.faviconUrl
|
||||||
|
: faviconFromPrefs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -61,6 +71,7 @@ class _CompanyProfileSettingsScreenState
|
|||||||
_phoneController.dispose();
|
_phoneController.dispose();
|
||||||
_websiteController.dispose();
|
_websiteController.dispose();
|
||||||
_logoUrlController.dispose();
|
_logoUrlController.dispose();
|
||||||
|
_faviconUrlController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +79,7 @@ class _CompanyProfileSettingsScreenState
|
|||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
final logoUrl = _logoUrlController.text.trim();
|
final logoUrl = _logoUrlController.text.trim();
|
||||||
|
final faviconUrl = _faviconUrlController.text.trim();
|
||||||
final companyName = _nameController.text.trim();
|
final companyName = _nameController.text.trim();
|
||||||
|
|
||||||
await ref.read(appSettingsProvider.notifier).updateCompanyProfile(
|
await ref.read(appSettingsProvider.notifier).updateCompanyProfile(
|
||||||
@ -81,6 +93,7 @@ class _CompanyProfileSettingsScreenState
|
|||||||
phone: _phoneController.text.trim(),
|
phone: _phoneController.text.trim(),
|
||||||
website: _websiteController.text.trim(),
|
website: _websiteController.text.trim(),
|
||||||
logoUrl: logoUrl,
|
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) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Company profile saved')),
|
const SnackBar(content: Text('Company profile saved')),
|
||||||
@ -98,7 +116,7 @@ class _CompanyProfileSettingsScreenState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickLogo() async {
|
Future<void> _pickImage(void Function(String dataUri) onPicked) async {
|
||||||
final result = await FilePicker.pickFiles(
|
final result = await FilePicker.pickFiles(
|
||||||
type: FileType.image,
|
type: FileType.image,
|
||||||
withData: true,
|
withData: true,
|
||||||
@ -115,10 +133,18 @@ class _CompanyProfileSettingsScreenState
|
|||||||
final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}';
|
final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}';
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_logoUrlController.text = dataUri;
|
onPicked(dataUri);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pickLogo() => _pickImage((dataUri) {
|
||||||
|
_logoUrlController.text = dataUri;
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> _pickFavicon() => _pickImage((dataUri) {
|
||||||
|
_faviconUrlController.text = dataUri;
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SettingsPageLayout(
|
return SettingsPageLayout(
|
||||||
@ -193,7 +219,9 @@ class _CompanyProfileSettingsScreenState
|
|||||||
logoUrl: _logoUrlController.text.trim().isEmpty
|
logoUrl: _logoUrlController.text.trim().isEmpty
|
||||||
? null
|
? null
|
||||||
: _logoUrlController.text.trim(),
|
: _logoUrlController.text.trim(),
|
||||||
size: 72,
|
width: 240,
|
||||||
|
height: 80,
|
||||||
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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),
|
const SizedBox(height: 24),
|
||||||
AppButton(label: 'Save Changes', onPressed: _save),
|
AppButton(label: 'Save Changes', onPressed: _save),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -81,7 +81,8 @@ class UserRemoteDataSource {
|
|||||||
final page = (meta['page'] as num?)?.toInt() ?? query.page;
|
final page = (meta['page'] as num?)?.toInt() ?? query.page;
|
||||||
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit;
|
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit;
|
||||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
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<ManagedUserModel>(
|
return PaginatedResponse<ManagedUserModel>(
|
||||||
items: items,
|
items: items,
|
||||||
|
|||||||
@ -186,6 +186,26 @@ class UsersListNotifier extends AsyncNotifier<UsersListState> {
|
|||||||
applyQuery(current.query.copyWith(departmentId: departmentId, page: 1));
|
applyQuery(current.query.copyWith(departmentId: departmentId, page: 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> 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<ExportFileResult?> exportUsers() async {
|
Future<ExportFileResult?> exportUsers() async {
|
||||||
final current = state.valueOrNull;
|
final current = state.valueOrNull;
|
||||||
if (current == null) return null;
|
if (current == null) return null;
|
||||||
|
|||||||
@ -89,6 +89,7 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
|
|||||||
totalPages: state.totalPages,
|
totalPages: state.totalPages,
|
||||||
totalItems: state.total,
|
totalItems: state.total,
|
||||||
pageSize: state.query.limit,
|
pageSize: state.query.limit,
|
||||||
|
itemLabel: 'users',
|
||||||
onPageChanged: ref.read(usersListProvider.notifier).setPage,
|
onPageChanged: ref.read(usersListProvider.notifier).setPage,
|
||||||
onPageSizeChanged:
|
onPageSizeChanged:
|
||||||
ref.read(usersListProvider.notifier).setPageSize,
|
ref.read(usersListProvider.notifier).setPageSize,
|
||||||
|
|||||||
@ -54,7 +54,7 @@ class UserRichDataTable extends StatelessWidget {
|
|||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Role',
|
label: 'Role',
|
||||||
flex: 2,
|
flex: 2,
|
||||||
cellBuilder: (_, user) => RoleBadge(label: user.roleLabel),
|
cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames),
|
||||||
),
|
),
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Department',
|
label: 'Department',
|
||||||
|
|||||||
@ -207,7 +207,7 @@ class VendorRemoteDataSource {
|
|||||||
page: (meta['page'] as num?)?.toInt() ?? 1,
|
page: (meta['page'] as num?)?.toInt() ?? 1,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
total: total,
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -107,6 +107,7 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
|
|||||||
totalPages: state.totalPages,
|
totalPages: state.totalPages,
|
||||||
totalItems: state.total,
|
totalItems: state.total,
|
||||||
pageSize: state.query.limit,
|
pageSize: state.query.limit,
|
||||||
|
itemLabel: 'vendors',
|
||||||
onPageChanged: ref.read(vendorsListProvider.notifier).setPage,
|
onPageChanged: ref.read(vendorsListProvider.notifier).setPage,
|
||||||
onPageSizeChanged:
|
onPageSizeChanged:
|
||||||
ref.read(vendorsListProvider.notifier).setPageSize,
|
ref.read(vendorsListProvider.notifier).setPageSize,
|
||||||
|
|||||||
@ -71,6 +71,23 @@ Object? _readRoleIds(Map<dynamic, dynamic> json, String key) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Object? _readAssignedRoles(Map<dynamic, dynamic> json, String key) =>
|
||||||
|
json['roles'];
|
||||||
|
|
||||||
|
List<FilterOptionModel> _assignedRolesFromJson(dynamic value) {
|
||||||
|
if (value is! List) return const [];
|
||||||
|
return value
|
||||||
|
.whereType<Map>()
|
||||||
|
.map(
|
||||||
|
(item) => FilterOptionModel(
|
||||||
|
id: item['id']?.toString() ?? '',
|
||||||
|
name: item['name'] as String? ?? '',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where((role) => role.id.isNotEmpty && role.name.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
List<String> _roleIdsFromJson(dynamic value) {
|
List<String> _roleIdsFromJson(dynamic value) {
|
||||||
if (value is List) {
|
if (value is List) {
|
||||||
return value.map((item) => item.toString()).toList();
|
return value.map((item) => item.toString()).toList();
|
||||||
@ -136,6 +153,13 @@ class ManagedUserModel with _$ManagedUserModel {
|
|||||||
)
|
)
|
||||||
@Default([])
|
@Default([])
|
||||||
List<String> roleIds,
|
List<String> roleIds,
|
||||||
|
@JsonKey(
|
||||||
|
name: 'roles',
|
||||||
|
readValue: _readAssignedRoles,
|
||||||
|
fromJson: _assignedRolesFromJson,
|
||||||
|
)
|
||||||
|
@Default([])
|
||||||
|
List<FilterOptionModel> assignedRoles,
|
||||||
@JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName,
|
@JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName,
|
||||||
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
||||||
String? departmentId,
|
String? departmentId,
|
||||||
@ -164,12 +188,27 @@ class ManagedUserModel with _$ManagedUserModel {
|
|||||||
|
|
||||||
extension ManagedUserModelX on ManagedUserModel {
|
extension ManagedUserModelX on ManagedUserModel {
|
||||||
String get displayName => fullName;
|
String get displayName => fullName;
|
||||||
String get roleLabel => roleName ?? '—';
|
|
||||||
String get departmentLabel => departmentName ?? '—';
|
String get departmentLabel => departmentName ?? '—';
|
||||||
String get plantLabel => plantName ?? '—';
|
String get plantLabel => plantName ?? '—';
|
||||||
|
|
||||||
|
List<String> 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<String> get effectiveRoleIds {
|
List<String> get effectiveRoleIds {
|
||||||
if (roleIds.isNotEmpty) return roleIds;
|
if (roleIds.isNotEmpty) return roleIds;
|
||||||
|
if (assignedRoles.isNotEmpty) {
|
||||||
|
return assignedRoles.map((role) => role.id).toList();
|
||||||
|
}
|
||||||
if (roleId != null && roleId!.isNotEmpty) return [roleId!];
|
if (roleId != null && roleId!.isNotEmpty) return [roleId!];
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -755,6 +755,13 @@ mixin _$ManagedUserModel {
|
|||||||
fromJson: _roleIdsFromJson,
|
fromJson: _roleIdsFromJson,
|
||||||
)
|
)
|
||||||
List<String> get roleIds => throw _privateConstructorUsedError;
|
List<String> get roleIds => throw _privateConstructorUsedError;
|
||||||
|
@JsonKey(
|
||||||
|
name: 'roles',
|
||||||
|
readValue: _readAssignedRoles,
|
||||||
|
fromJson: _assignedRolesFromJson,
|
||||||
|
)
|
||||||
|
List<FilterOptionModel> get assignedRoles =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'role_name', readValue: _readRoleName)
|
@JsonKey(name: 'role_name', readValue: _readRoleName)
|
||||||
String? get roleName => throw _privateConstructorUsedError;
|
String? get roleName => throw _privateConstructorUsedError;
|
||||||
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
||||||
@ -824,6 +831,12 @@ abstract class $ManagedUserModelCopyWith<$Res> {
|
|||||||
fromJson: _roleIdsFromJson,
|
fromJson: _roleIdsFromJson,
|
||||||
)
|
)
|
||||||
List<String> roleIds,
|
List<String> roleIds,
|
||||||
|
@JsonKey(
|
||||||
|
name: 'roles',
|
||||||
|
readValue: _readAssignedRoles,
|
||||||
|
fromJson: _assignedRolesFromJson,
|
||||||
|
)
|
||||||
|
List<FilterOptionModel> assignedRoles,
|
||||||
@JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName,
|
@JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName,
|
||||||
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
||||||
String? departmentId,
|
String? departmentId,
|
||||||
@ -871,6 +884,7 @@ class _$ManagedUserModelCopyWithImpl<$Res, $Val extends ManagedUserModel>
|
|||||||
Object? mobile = null,
|
Object? mobile = null,
|
||||||
Object? roleId = freezed,
|
Object? roleId = freezed,
|
||||||
Object? roleIds = null,
|
Object? roleIds = null,
|
||||||
|
Object? assignedRoles = null,
|
||||||
Object? roleName = freezed,
|
Object? roleName = freezed,
|
||||||
Object? departmentId = freezed,
|
Object? departmentId = freezed,
|
||||||
Object? departmentName = freezed,
|
Object? departmentName = freezed,
|
||||||
@ -926,6 +940,10 @@ class _$ManagedUserModelCopyWithImpl<$Res, $Val extends ManagedUserModel>
|
|||||||
? _value.roleIds
|
? _value.roleIds
|
||||||
: roleIds // ignore: cast_nullable_to_non_nullable
|
: roleIds // ignore: cast_nullable_to_non_nullable
|
||||||
as List<String>,
|
as List<String>,
|
||||||
|
assignedRoles: null == assignedRoles
|
||||||
|
? _value.assignedRoles
|
||||||
|
: assignedRoles // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<FilterOptionModel>,
|
||||||
roleName: freezed == roleName
|
roleName: freezed == roleName
|
||||||
? _value.roleName
|
? _value.roleName
|
||||||
: roleName // ignore: cast_nullable_to_non_nullable
|
: roleName // ignore: cast_nullable_to_non_nullable
|
||||||
@ -1026,6 +1044,12 @@ abstract class _$$ManagedUserModelImplCopyWith<$Res>
|
|||||||
fromJson: _roleIdsFromJson,
|
fromJson: _roleIdsFromJson,
|
||||||
)
|
)
|
||||||
List<String> roleIds,
|
List<String> roleIds,
|
||||||
|
@JsonKey(
|
||||||
|
name: 'roles',
|
||||||
|
readValue: _readAssignedRoles,
|
||||||
|
fromJson: _assignedRolesFromJson,
|
||||||
|
)
|
||||||
|
List<FilterOptionModel> assignedRoles,
|
||||||
@JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName,
|
@JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName,
|
||||||
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
||||||
String? departmentId,
|
String? departmentId,
|
||||||
@ -1072,6 +1096,7 @@ class __$$ManagedUserModelImplCopyWithImpl<$Res>
|
|||||||
Object? mobile = null,
|
Object? mobile = null,
|
||||||
Object? roleId = freezed,
|
Object? roleId = freezed,
|
||||||
Object? roleIds = null,
|
Object? roleIds = null,
|
||||||
|
Object? assignedRoles = null,
|
||||||
Object? roleName = freezed,
|
Object? roleName = freezed,
|
||||||
Object? departmentId = freezed,
|
Object? departmentId = freezed,
|
||||||
Object? departmentName = freezed,
|
Object? departmentName = freezed,
|
||||||
@ -1127,6 +1152,10 @@ class __$$ManagedUserModelImplCopyWithImpl<$Res>
|
|||||||
? _value._roleIds
|
? _value._roleIds
|
||||||
: roleIds // ignore: cast_nullable_to_non_nullable
|
: roleIds // ignore: cast_nullable_to_non_nullable
|
||||||
as List<String>,
|
as List<String>,
|
||||||
|
assignedRoles: null == assignedRoles
|
||||||
|
? _value._assignedRoles
|
||||||
|
: assignedRoles // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<FilterOptionModel>,
|
||||||
roleName: freezed == roleName
|
roleName: freezed == roleName
|
||||||
? _value.roleName
|
? _value.roleName
|
||||||
: roleName // ignore: cast_nullable_to_non_nullable
|
: roleName // ignore: cast_nullable_to_non_nullable
|
||||||
@ -1221,6 +1250,12 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
|
|||||||
fromJson: _roleIdsFromJson,
|
fromJson: _roleIdsFromJson,
|
||||||
)
|
)
|
||||||
final List<String> roleIds = const [],
|
final List<String> roleIds = const [],
|
||||||
|
@JsonKey(
|
||||||
|
name: 'roles',
|
||||||
|
readValue: _readAssignedRoles,
|
||||||
|
fromJson: _assignedRolesFromJson,
|
||||||
|
)
|
||||||
|
final List<FilterOptionModel> assignedRoles = const [],
|
||||||
@JsonKey(name: 'role_name', readValue: _readRoleName) this.roleName,
|
@JsonKey(name: 'role_name', readValue: _readRoleName) this.roleName,
|
||||||
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
||||||
this.departmentId,
|
this.departmentId,
|
||||||
@ -1241,7 +1276,8 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
|
|||||||
@JsonKey(name: 'avatar_url') this.avatarUrl,
|
@JsonKey(name: 'avatar_url') this.avatarUrl,
|
||||||
@JsonKey(name: 'created_at') this.createdAt,
|
@JsonKey(name: 'created_at') this.createdAt,
|
||||||
@JsonKey(name: 'updated_at') this.updatedAt,
|
@JsonKey(name: 'updated_at') this.updatedAt,
|
||||||
}) : _roleIds = roleIds;
|
}) : _roleIds = roleIds,
|
||||||
|
_assignedRoles = assignedRoles;
|
||||||
|
|
||||||
factory _$ManagedUserModelImpl.fromJson(Map<String, dynamic> json) =>
|
factory _$ManagedUserModelImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
_$$ManagedUserModelImplFromJson(json);
|
_$$ManagedUserModelImplFromJson(json);
|
||||||
@ -1286,6 +1322,19 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
|
|||||||
return EqualUnmodifiableListView(_roleIds);
|
return EqualUnmodifiableListView(_roleIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final List<FilterOptionModel> _assignedRoles;
|
||||||
|
@override
|
||||||
|
@JsonKey(
|
||||||
|
name: 'roles',
|
||||||
|
readValue: _readAssignedRoles,
|
||||||
|
fromJson: _assignedRolesFromJson,
|
||||||
|
)
|
||||||
|
List<FilterOptionModel> get assignedRoles {
|
||||||
|
if (_assignedRoles is EqualUnmodifiableListView) return _assignedRoles;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(_assignedRoles);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@JsonKey(name: 'role_name', readValue: _readRoleName)
|
@JsonKey(name: 'role_name', readValue: _readRoleName)
|
||||||
final String? roleName;
|
final String? roleName;
|
||||||
@ -1336,7 +1385,7 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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
|
@override
|
||||||
@ -1357,6 +1406,10 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
|
|||||||
(identical(other.mobile, mobile) || other.mobile == mobile) &&
|
(identical(other.mobile, mobile) || other.mobile == mobile) &&
|
||||||
(identical(other.roleId, roleId) || other.roleId == roleId) &&
|
(identical(other.roleId, roleId) || other.roleId == roleId) &&
|
||||||
const DeepCollectionEquality().equals(other._roleIds, _roleIds) &&
|
const DeepCollectionEquality().equals(other._roleIds, _roleIds) &&
|
||||||
|
const DeepCollectionEquality().equals(
|
||||||
|
other._assignedRoles,
|
||||||
|
_assignedRoles,
|
||||||
|
) &&
|
||||||
(identical(other.roleName, roleName) ||
|
(identical(other.roleName, roleName) ||
|
||||||
other.roleName == roleName) &&
|
other.roleName == roleName) &&
|
||||||
(identical(other.departmentId, departmentId) ||
|
(identical(other.departmentId, departmentId) ||
|
||||||
@ -1402,6 +1455,7 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
|
|||||||
mobile,
|
mobile,
|
||||||
roleId,
|
roleId,
|
||||||
const DeepCollectionEquality().hash(_roleIds),
|
const DeepCollectionEquality().hash(_roleIds),
|
||||||
|
const DeepCollectionEquality().hash(_assignedRoles),
|
||||||
roleName,
|
roleName,
|
||||||
departmentId,
|
departmentId,
|
||||||
departmentName,
|
departmentName,
|
||||||
@ -1461,6 +1515,12 @@ abstract class _ManagedUserModel implements ManagedUserModel {
|
|||||||
fromJson: _roleIdsFromJson,
|
fromJson: _roleIdsFromJson,
|
||||||
)
|
)
|
||||||
final List<String> roleIds,
|
final List<String> roleIds,
|
||||||
|
@JsonKey(
|
||||||
|
name: 'roles',
|
||||||
|
readValue: _readAssignedRoles,
|
||||||
|
fromJson: _assignedRolesFromJson,
|
||||||
|
)
|
||||||
|
final List<FilterOptionModel> assignedRoles,
|
||||||
@JsonKey(name: 'role_name', readValue: _readRoleName)
|
@JsonKey(name: 'role_name', readValue: _readRoleName)
|
||||||
final String? roleName,
|
final String? roleName,
|
||||||
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
||||||
@ -1523,6 +1583,13 @@ abstract class _ManagedUserModel implements ManagedUserModel {
|
|||||||
)
|
)
|
||||||
List<String> get roleIds;
|
List<String> get roleIds;
|
||||||
@override
|
@override
|
||||||
|
@JsonKey(
|
||||||
|
name: 'roles',
|
||||||
|
readValue: _readAssignedRoles,
|
||||||
|
fromJson: _assignedRolesFromJson,
|
||||||
|
)
|
||||||
|
List<FilterOptionModel> get assignedRoles;
|
||||||
|
@override
|
||||||
@JsonKey(name: 'role_name', readValue: _readRoleName)
|
@JsonKey(name: 'role_name', readValue: _readRoleName)
|
||||||
String? get roleName;
|
String? get roleName;
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -84,6 +84,9 @@ _$ManagedUserModelImpl _$$ManagedUserModelImplFromJson(
|
|||||||
roleIds: _readRoleIds(json, 'role_ids') == null
|
roleIds: _readRoleIds(json, 'role_ids') == null
|
||||||
? const []
|
? const []
|
||||||
: _roleIdsFromJson(_readRoleIds(json, 'role_ids')),
|
: _roleIdsFromJson(_readRoleIds(json, 'role_ids')),
|
||||||
|
assignedRoles: _readAssignedRoles(json, 'roles') == null
|
||||||
|
? const []
|
||||||
|
: _assignedRolesFromJson(_readAssignedRoles(json, 'roles')),
|
||||||
roleName: _readRoleName(json, 'role_name') as String?,
|
roleName: _readRoleName(json, 'role_name') as String?,
|
||||||
departmentId: _idFromJsonNullable(json['department_id']),
|
departmentId: _idFromJsonNullable(json['department_id']),
|
||||||
departmentName: _readDepartmentName(json, 'department_name') as String?,
|
departmentName: _readDepartmentName(json, 'department_name') as String?,
|
||||||
@ -120,6 +123,7 @@ Map<String, dynamic> _$$ManagedUserModelImplToJson(
|
|||||||
'mobile': instance.mobile,
|
'mobile': instance.mobile,
|
||||||
'role_id': instance.roleId,
|
'role_id': instance.roleId,
|
||||||
'role_ids': instance.roleIds,
|
'role_ids': instance.roleIds,
|
||||||
|
'roles': instance.assignedRoles,
|
||||||
'role_name': instance.roleName,
|
'role_name': instance.roleName,
|
||||||
'department_id': instance.departmentId,
|
'department_id': instance.departmentId,
|
||||||
'department_name': instance.departmentName,
|
'department_name': instance.departmentName,
|
||||||
|
|||||||
@ -29,19 +29,19 @@ const List<MenuItem> appMenuItems = [
|
|||||||
route: RouteConstants.dashboard,
|
route: RouteConstants.dashboard,
|
||||||
module: 'dashboard',
|
module: 'dashboard',
|
||||||
),
|
),
|
||||||
MenuItem(
|
// MenuItem(
|
||||||
label: 'Companies',
|
// label: 'Companies',
|
||||||
icon: Icons.business_outlined,
|
// icon: Icons.business_outlined,
|
||||||
route: RouteConstants.companies,
|
// route: RouteConstants.companies,
|
||||||
module: 'companies',
|
// module: 'companies',
|
||||||
requiredRole: UserRole.superAdmin,
|
// requiredRole: UserRole.superAdmin,
|
||||||
),
|
// ),
|
||||||
MenuItem(
|
// MenuItem(
|
||||||
label: 'Branches',
|
// label: 'Branches',
|
||||||
icon: Icons.account_tree_outlined,
|
// icon: Icons.account_tree_outlined,
|
||||||
route: RouteConstants.branches,
|
// route: RouteConstants.branches,
|
||||||
module: 'branches',
|
// module: 'branches',
|
||||||
),
|
// ),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'Users & Roles',
|
label: 'Users & Roles',
|
||||||
icon: Icons.admin_panel_settings_outlined,
|
icon: Icons.admin_panel_settings_outlined,
|
||||||
|
|||||||
@ -10,6 +10,9 @@ class AppPagination extends StatelessWidget {
|
|||||||
required this.onPageChanged,
|
required this.onPageChanged,
|
||||||
this.onPageSizeChanged,
|
this.onPageSizeChanged,
|
||||||
this.pageSizeOptions = const [10, 20, 50],
|
this.pageSizeOptions = const [10, 20, 50],
|
||||||
|
this.itemLabel = 'items',
|
||||||
|
this.maxVisiblePages = 10,
|
||||||
|
this.padding = EdgeInsets.zero,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int currentPage;
|
final int currentPage;
|
||||||
@ -19,43 +22,122 @@ class AppPagination extends StatelessWidget {
|
|||||||
final ValueChanged<int> onPageChanged;
|
final ValueChanged<int> onPageChanged;
|
||||||
final ValueChanged<int>? onPageSizeChanged;
|
final ValueChanged<int>? onPageSizeChanged;
|
||||||
final List<int> pageSizeOptions;
|
final List<int> pageSizeOptions;
|
||||||
|
final String itemLabel;
|
||||||
|
final int maxVisiblePages;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
final start = totalItems == 0 ? 0 : ((currentPage - 1) * pageSize) + 1;
|
final start = totalItems == 0 ? 0 : ((currentPage - 1) * pageSize) + 1;
|
||||||
final end = (currentPage * pageSize).clamp(0, totalItems);
|
final end = (currentPage * pageSize).clamp(0, totalItems);
|
||||||
|
final visiblePages = _visiblePageNumbers(currentPage, totalPages);
|
||||||
|
|
||||||
return Wrap(
|
return Padding(
|
||||||
spacing: 12,
|
padding: padding,
|
||||||
runSpacing: 8,
|
child: SizedBox(
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
height: 40,
|
||||||
children: [
|
child: Row(
|
||||||
Text(
|
children: [
|
||||||
'Showing $start–$end of $totalItems',
|
Text(
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
'Showing $start–$end of $totalItems $itemLabel',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (onPageSizeChanged != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
DropdownButton<int>(
|
||||||
|
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<int>(
|
|
||||||
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<int> _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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -153,41 +153,47 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
|||||||
final canOpen = widget.enabled && widget.options.isNotEmpty;
|
final canOpen = widget.enabled && widget.options.isNotEmpty;
|
||||||
final colors = theme.colorScheme;
|
final colors = theme.colorScheme;
|
||||||
|
|
||||||
return CompositedTransformTarget(
|
// Top padding keeps the always-floating label from being clipped by
|
||||||
link: _layerLink,
|
// tight parents (e.g. TabBarView toolbars).
|
||||||
child: KeyedSubtree(
|
return Padding(
|
||||||
key: _fieldKey,
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: InkWell(
|
child: CompositedTransformTarget(
|
||||||
onTap: canOpen ? () => _openPicker(field) : null,
|
link: _layerLink,
|
||||||
borderRadius: BorderRadius.circular(8),
|
child: KeyedSubtree(
|
||||||
child: InputDecorator(
|
key: _fieldKey,
|
||||||
isFocused: _overlayEntry != null,
|
child: InkWell(
|
||||||
isEmpty: displayLabel == null,
|
onTap: canOpen ? () => _openPicker(field) : null,
|
||||||
decoration: InputDecoration(
|
borderRadius: BorderRadius.circular(8),
|
||||||
labelText: widget.label,
|
child: InputDecorator(
|
||||||
hintText: displayLabel == null ? effectiveHint : null,
|
isFocused: _overlayEntry != null,
|
||||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
isEmpty: displayLabel == null,
|
||||||
isDense: widget.isDense,
|
decoration: InputDecoration(
|
||||||
errorText: field.errorText,
|
labelText: widget.label,
|
||||||
suffixIcon: Icon(
|
hintText: displayLabel == null ? effectiveHint : null,
|
||||||
_overlayEntry != null
|
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||||
? Icons.arrow_drop_up
|
isDense: widget.isDense,
|
||||||
: Icons.arrow_drop_down,
|
errorText: field.errorText,
|
||||||
color:
|
suffixIcon: Icon(
|
||||||
canOpen ? colors.onSurfaceVariant : theme.disabledColor,
|
_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()
|
||||||
child: displayLabel == null
|
: Text(
|
||||||
? const SizedBox.shrink()
|
displayLabel,
|
||||||
: Text(
|
maxLines: 1,
|
||||||
displayLabel,
|
overflow: TextOverflow.ellipsis,
|
||||||
maxLines: 1,
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
overflow: TextOverflow.ellipsis,
|
color: colors.onSurface,
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
),
|
||||||
color: colors.onSurface,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -172,41 +172,45 @@ class _AppSearchableMultiSelectDropdownState<T>
|
|||||||
final canOpen = widget.enabled && widget.options.isNotEmpty;
|
final canOpen = widget.enabled && widget.options.isNotEmpty;
|
||||||
final colors = theme.colorScheme;
|
final colors = theme.colorScheme;
|
||||||
|
|
||||||
return CompositedTransformTarget(
|
return Padding(
|
||||||
link: _layerLink,
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: KeyedSubtree(
|
child: CompositedTransformTarget(
|
||||||
key: _fieldKey,
|
link: _layerLink,
|
||||||
child: InkWell(
|
child: KeyedSubtree(
|
||||||
onTap: canOpen ? () => _openPicker(field) : null,
|
key: _fieldKey,
|
||||||
borderRadius: BorderRadius.circular(8),
|
child: InkWell(
|
||||||
child: InputDecorator(
|
onTap: canOpen ? () => _openPicker(field) : null,
|
||||||
isFocused: _overlayEntry != null,
|
borderRadius: BorderRadius.circular(8),
|
||||||
isEmpty: displayText.isEmpty,
|
child: InputDecorator(
|
||||||
decoration: InputDecoration(
|
isFocused: _overlayEntry != null,
|
||||||
labelText: widget.label,
|
isEmpty: displayText.isEmpty,
|
||||||
hintText: displayText.isEmpty ? effectiveHint : null,
|
decoration: InputDecoration(
|
||||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
labelText: widget.label,
|
||||||
isDense: widget.isDense,
|
hintText: displayText.isEmpty ? effectiveHint : null,
|
||||||
errorText: field.errorText,
|
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||||
suffixIcon: Icon(
|
isDense: widget.isDense,
|
||||||
_overlayEntry != null
|
errorText: field.errorText,
|
||||||
? Icons.arrow_drop_up
|
suffixIcon: Icon(
|
||||||
: Icons.arrow_drop_down,
|
_overlayEntry != null
|
||||||
color:
|
? Icons.arrow_drop_up
|
||||||
canOpen ? colors.onSurfaceVariant : theme.disabledColor,
|
: Icons.arrow_drop_down,
|
||||||
|
color: canOpen
|
||||||
|
? colors.onSurfaceVariant
|
||||||
|
: theme.disabledColor,
|
||||||
|
),
|
||||||
|
enabled: canOpen,
|
||||||
),
|
),
|
||||||
enabled: canOpen,
|
child: displayText.isEmpty
|
||||||
),
|
? const SizedBox.shrink()
|
||||||
child: displayText.isEmpty
|
: Text(
|
||||||
? const SizedBox.shrink()
|
displayText,
|
||||||
: Text(
|
maxLines: 2,
|
||||||
displayText,
|
overflow: TextOverflow.ellipsis,
|
||||||
maxLines: 2,
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
overflow: TextOverflow.ellipsis,
|
color: colors.onSurface,
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
),
|
||||||
color: colors.onSurface,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -205,8 +205,6 @@ class _UserMenu extends ConsumerWidget {
|
|||||||
switch (value) {
|
switch (value) {
|
||||||
case 'password':
|
case 'password':
|
||||||
context.push(RouteConstants.changePassword);
|
context.push(RouteConstants.changePassword);
|
||||||
case 'settings':
|
|
||||||
goAndDismissOverlays(context, RouteConstants.settings);
|
|
||||||
case 'logout':
|
case 'logout':
|
||||||
await ref.read(authStateProvider.notifier).logout();
|
await ref.read(authStateProvider.notifier).logout();
|
||||||
if (context.mounted) context.go(RouteConstants.login);
|
if (context.mounted) context.go(RouteConstants.login);
|
||||||
@ -214,7 +212,6 @@ class _UserMenu extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
const PopupMenuItem(value: 'password', child: Text('Change Password')),
|
const PopupMenuItem(value: 'password', child: Text('Change Password')),
|
||||||
const PopupMenuItem(value: 'settings', child: Text('Settings')),
|
|
||||||
const PopupMenuDivider(),
|
const PopupMenuDivider(),
|
||||||
const PopupMenuItem(value: 'logout', child: Text('Logout')),
|
const PopupMenuItem(value: 'logout', child: Text('Logout')),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -292,30 +292,56 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 16, 8, 0),
|
padding: const EdgeInsets.fromLTRB(14, 16, 8, 0),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
SidebarLogo(logoUrl: logoUrl, size: 36),
|
Row(
|
||||||
const SizedBox(width: 8),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Expanded(
|
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(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
maxLines: 1,
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.2,
|
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);
|
context.push(RouteConstants.profile);
|
||||||
case 'password':
|
case 'password':
|
||||||
context.push(RouteConstants.changePassword);
|
context.push(RouteConstants.changePassword);
|
||||||
case 'settings':
|
|
||||||
context.go(RouteConstants.settings);
|
|
||||||
case 'logout':
|
case 'logout':
|
||||||
await ref.read(authStateProvider.notifier).logout();
|
await ref.read(authStateProvider.notifier).logout();
|
||||||
if (context.mounted) context.go(RouteConstants.login);
|
if (context.mounted) context.go(RouteConstants.login);
|
||||||
@ -951,7 +975,6 @@ class _UserProfileMenu extends ConsumerWidget {
|
|||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
const PopupMenuItem(value: 'profile', child: Text('Profile')),
|
const PopupMenuItem(value: 'profile', child: Text('Profile')),
|
||||||
const PopupMenuItem(value: 'password', child: Text('Change Password')),
|
const PopupMenuItem(value: 'password', child: Text('Change Password')),
|
||||||
const PopupMenuItem(value: 'settings', child: Text('Settings')),
|
|
||||||
const PopupMenuDivider(),
|
const PopupMenuDivider(),
|
||||||
const PopupMenuItem(value: 'logout', child: Text('Logout')),
|
const PopupMenuItem(value: 'logout', child: Text('Logout')),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -41,7 +41,7 @@ class AppTableShell extends StatelessWidget {
|
|||||||
if (footer != null) ...[
|
if (footer != null) ...[
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: footer!,
|
child: footer!,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -153,8 +153,6 @@ class _TopNavUserMenu extends ConsumerWidget {
|
|||||||
context.push(RouteConstants.profile);
|
context.push(RouteConstants.profile);
|
||||||
case 'password':
|
case 'password':
|
||||||
context.push(RouteConstants.changePassword);
|
context.push(RouteConstants.changePassword);
|
||||||
case 'settings':
|
|
||||||
context.go(RouteConstants.settings);
|
|
||||||
case 'logout':
|
case 'logout':
|
||||||
await ref.read(authStateProvider.notifier).logout();
|
await ref.read(authStateProvider.notifier).logout();
|
||||||
if (context.mounted) context.go(RouteConstants.login);
|
if (context.mounted) context.go(RouteConstants.login);
|
||||||
@ -163,7 +161,6 @@ class _TopNavUserMenu extends ConsumerWidget {
|
|||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
const PopupMenuItem(value: 'profile', child: Text('Profile')),
|
const PopupMenuItem(value: 'profile', child: Text('Profile')),
|
||||||
const PopupMenuItem(value: 'password', child: Text('Change Password')),
|
const PopupMenuItem(value: 'password', child: Text('Change Password')),
|
||||||
const PopupMenuItem(value: 'settings', child: Text('Settings')),
|
|
||||||
const PopupMenuDivider(),
|
const PopupMenuDivider(),
|
||||||
const PopupMenuItem(value: 'logout', child: Text('Logout')),
|
const PopupMenuItem(value: 'logout', child: Text('Logout')),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -9,29 +9,46 @@ class SidebarLogo extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
this.logoUrl,
|
this.logoUrl,
|
||||||
this.size = 36,
|
this.size = 36,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.fit = BoxFit.cover,
|
||||||
|
this.showBackground = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? logoUrl;
|
final String? logoUrl;
|
||||||
final double size;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final fallback = Icon(
|
final fallback = Icon(
|
||||||
Icons.inventory_2_outlined,
|
Icons.inventory_2_outlined,
|
||||||
size: size * 0.55,
|
size: (_width < _height ? _width : _height) * 0.55,
|
||||||
color: theme.colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final content = _buildLogoContent(fallback);
|
||||||
|
|
||||||
|
if (!showBackground) {
|
||||||
|
return SizedBox(width: _width, height: _height, child: content);
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: size,
|
width: _width,
|
||||||
height: size,
|
height: _height,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.12),
|
color: theme.colorScheme.primary.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: _buildLogoContent(fallback),
|
child: content,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,9 +63,9 @@ class SidebarLogo extends StatelessWidget {
|
|||||||
final base64Str = url.contains(',') ? url.split(',').last : url;
|
final base64Str = url.contains(',') ? url.split(',').last : url;
|
||||||
return Image.memory(
|
return Image.memory(
|
||||||
base64Decode(base64Str),
|
base64Decode(base64Str),
|
||||||
width: size,
|
width: _width,
|
||||||
height: size,
|
height: _height,
|
||||||
fit: BoxFit.cover,
|
fit: fit,
|
||||||
errorBuilder: (_, __, ___) => Center(child: fallback),
|
errorBuilder: (_, __, ___) => Center(child: fallback),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@ -59,13 +76,13 @@ class SidebarLogo extends StatelessWidget {
|
|||||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
imageUrl: url,
|
imageUrl: url,
|
||||||
width: size,
|
width: _width,
|
||||||
height: size,
|
height: _height,
|
||||||
fit: BoxFit.cover,
|
fit: fit,
|
||||||
placeholder: (_, __) => Center(
|
placeholder: (_, __) => Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: size * 0.4,
|
width: _width * 0.4,
|
||||||
height: size * 0.4,
|
height: _height * 0.4,
|
||||||
child: const CircularProgressIndicator(strokeWidth: 2),
|
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user