screens functionality done

This commit is contained in:
Surendiran 2026-07-02 16:44:45 +05:30
parent 1fc8abaee7
commit bea7e37090
37 changed files with 900 additions and 315 deletions

View File

@ -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(

View File

@ -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';

View 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);
}
}
}

View 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);

View File

@ -0,0 +1 @@
void updateFavicon(String? url) {}

View 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;
}

View File

@ -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,

View File

@ -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'),

View File

@ -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,
); );
} }
} }

View File

@ -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,
), ),

View File

@ -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,
); );
} }
} }

View File

@ -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:

View File

@ -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),

View File

@ -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(

View File

@ -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,

View File

@ -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;
}
}); });
} }
} }

View File

@ -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',
), ),

View File

@ -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;

View File

@ -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),
], ],

View File

@ -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,

View File

@ -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;

View File

@ -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,

View File

@ -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',

View File

@ -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,
); );
} }

View File

@ -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,

View File

@ -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 [];
} }

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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);
}
} }

View File

@ -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,
), ),
), ),
), ),
), ),
), ),

View File

@ -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,
), ),
), ),
), ),
), ),
), ),

View File

@ -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')),
], ],

View File

@ -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')),
], ],

View File

@ -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!,
), ),
], ],

View File

@ -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')),
], ],

View File

@ -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),
), ),
), ),