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 '../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(

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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