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