review changes

This commit is contained in:
Surendiran 2026-07-20 09:47:39 +05:30
parent 03486ea3f4
commit 09ffc5fb6a
52 changed files with 1999 additions and 663 deletions

View File

@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
class AppColors {
AppColors._();
static const Color primary = Color(0xFF1565C0);
static const Color secondary = Color(0xFF00897B);
static const Color primary = Color(0xFF2563EB);
static const Color secondary = Color(0xFF0891B2);
static const Color error = Color(0xFFD32F2F);
static const Color warning = Color(0xFFF57C00);
static const Color success = Color(0xFF388E3C);

View File

@ -12,8 +12,9 @@ class AppTheme {
final primary = branding?.primaryColor ?? AppColors.primary;
final secondary = branding?.secondaryColor ?? AppColors.secondary;
final colorScheme = ColorScheme.fromSeed(
seedColor: primary,
final colorScheme = _brandedColorScheme(
seed: primary,
primary: primary,
secondary: secondary,
brightness: Brightness.light,
surface: AppColors.lightSurface,
@ -26,8 +27,9 @@ class AppTheme {
final primary = branding?.primaryColor ?? AppColors.primary;
final secondary = branding?.secondaryColor ?? AppColors.secondary;
final colorScheme = ColorScheme.fromSeed(
seedColor: primary,
final colorScheme = _brandedColorScheme(
seed: primary,
primary: primary,
secondary: secondary,
brightness: Brightness.dark,
surface: AppColors.darkSurface,
@ -36,12 +38,56 @@ class AppTheme {
return _buildTheme(colorScheme, Brightness.dark);
}
/// Keeps Material tonal containers from [seed], but locks brand [primary] /
/// [secondary] to the exact chosen hex values (fromSeed remaps primary).
static ColorScheme _brandedColorScheme({
required Color seed,
required Color primary,
required Color secondary,
required Brightness brightness,
required Color surface,
}) {
final base = ColorScheme.fromSeed(
seedColor: seed,
secondary: secondary,
brightness: brightness,
surface: surface,
);
final onPrimary = _onColor(primary);
final onSecondary = _onColor(secondary);
return base.copyWith(
primary: primary,
onPrimary: onPrimary,
primaryContainer: Color.alphaBlend(
primary.withValues(alpha: 0.18),
surface,
),
onPrimaryContainer: primary,
secondary: secondary,
onSecondary: onSecondary,
secondaryContainer: Color.alphaBlend(
secondary.withValues(alpha: 0.18),
surface,
),
onSecondaryContainer: secondary,
);
}
static Color _onColor(Color color) {
return ThemeData.estimateBrightnessForColor(color) == Brightness.dark
? Colors.white
: const Color(0xFF212121);
}
/// Theme for white cards, form fields, and picker sheets (unchanged in dark mode).
static ThemeData cardContentTheme(ThemeData theme) {
final scheme = theme.brightness == Brightness.light
? theme.colorScheme
: ColorScheme.fromSeed(
seedColor: theme.colorScheme.primary,
: _brandedColorScheme(
seed: theme.colorScheme.primary,
primary: theme.colorScheme.primary,
secondary: theme.colorScheme.secondary,
brightness: Brightness.light,
surface: AppColors.card,

View File

@ -8,8 +8,8 @@ part 'branding_config.g.dart';
class BrandingConfig with _$BrandingConfig {
const factory BrandingConfig({
String? logoUrl,
@Default(0xFF1565C0) int primaryColorValue,
@Default(0xFF00897B) int secondaryColorValue,
@Default(0xFF2563EB) int primaryColorValue,
@Default(0xFF0891B2) int secondaryColorValue,
String? companyName,
}) = _BrandingConfig;

View File

@ -159,8 +159,8 @@ class __$$BrandingConfigImplCopyWithImpl<$Res>
class _$BrandingConfigImpl implements _BrandingConfig {
const _$BrandingConfigImpl({
this.logoUrl,
this.primaryColorValue = 0xFF1565C0,
this.secondaryColorValue = 0xFF00897B,
this.primaryColorValue = 0xFF2563EB,
this.secondaryColorValue = 0xFF0891B2,
this.companyName,
});

View File

@ -10,9 +10,9 @@ _$BrandingConfigImpl _$$BrandingConfigImplFromJson(Map<String, dynamic> json) =>
_$BrandingConfigImpl(
logoUrl: json['logoUrl'] as String?,
primaryColorValue:
(json['primaryColorValue'] as num?)?.toInt() ?? 0xFF1565C0,
(json['primaryColorValue'] as num?)?.toInt() ?? 0xFF2563EB,
secondaryColorValue:
(json['secondaryColorValue'] as num?)?.toInt() ?? 0xFF00897B,
(json['secondaryColorValue'] as num?)?.toInt() ?? 0xFF0891B2,
companyName: json['companyName'] as String?,
);

View File

@ -58,3 +58,47 @@ class CurrencyFormatter {
return _formatter.format(amount);
}
}
/// Converts `snake_case` / `kebab-case` keys into readable Title Case labels.
String humanizeLabel(String key) {
final cleaned = key.trim().replaceAll(RegExp(r'[_\-.]+'), ' ');
if (cleaned.isEmpty) return key;
const acronyms = {
'id': 'ID',
'amc': 'AMC',
'gst': 'GST',
'hsn': 'HSN',
'uom': 'UOM',
'po': 'PO',
'grn': 'GRN',
'url': 'URL',
'api': 'API',
};
return cleaned
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.map((part) {
final lower = part.toLowerCase();
if (acronyms.containsKey(lower)) return acronyms[lower]!;
return '${lower[0].toUpperCase()}${lower.substring(1)}';
})
.join(' ');
}
/// Subject used in dropdown hints (`Select ` / `Search `).
/// Strips required asterisks and title-cases each word.
String dropdownHintLabel(String label) {
final cleaned =
label.replaceAll('*', '').trim().replaceAll(RegExp(r'\s+'), ' ');
if (cleaned.isEmpty) return label.trim();
return cleaned.split(' ').map((word) {
if (word.isEmpty) return word;
// Keep short all-caps tokens (GST, UOM, PO).
if (word.length <= 4 && word == word.toUpperCase()) return word;
final lower = word.toLowerCase();
return '${lower[0].toUpperCase()}${lower.substring(1)}';
}).join(' ');
}

View File

@ -0,0 +1,102 @@
/// Shared helpers for list API pagination meta.
library;
int? paginationInt(dynamic value) {
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value.trim());
return null;
}
/// Prefer nested `meta`, then fields on `data` when it is a map, then top-level.
Map<String, dynamic> extractPaginationMeta(dynamic body) {
if (body is! Map) return const {};
final root = Map<String, dynamic>.from(body);
final meta = <String, dynamic>{};
final topMeta = root['meta'];
if (topMeta is Map) {
meta.addAll(Map<String, dynamic>.from(topMeta));
}
final data = root['data'];
if (data is Map) {
final nested = Map<String, dynamic>.from(data);
for (final key in const [
'page',
'limit',
'per_page',
'page_size',
'total',
'totalPages',
'total_pages',
]) {
if (nested[key] != null) meta[key] = nested[key];
}
}
for (final key in const [
'page',
'limit',
'per_page',
'page_size',
'total',
'totalPages',
'total_pages',
]) {
if (meta[key] == null && root[key] != null) {
meta[key] = root[key];
}
}
return meta;
}
/// Page count from total rows and page size. Never trust a bad API `total_pages`.
int resolveTotalPages({
required int total,
required int limit,
}) {
if (total <= 0 || limit <= 0) return 1;
final pages = (total + limit - 1) ~/ limit;
return pages < 1 ? 1 : pages;
}
class ParsedPagination {
const ParsedPagination({
required this.page,
required this.limit,
required this.total,
required this.totalPages,
});
final int page;
final int limit;
final int total;
final int totalPages;
}
/// Parse pagination using request fallbacks. [limit] never falls back to item count.
ParsedPagination parsePagination({
required dynamic body,
required int fallbackPage,
required int fallbackLimit,
required int itemCount,
}) {
final meta = extractPaginationMeta(body);
final page = paginationInt(meta['page']) ?? fallbackPage;
final limit = paginationInt(meta['limit']) ??
paginationInt(meta['per_page']) ??
paginationInt(meta['page_size']) ??
fallbackLimit;
final total = paginationInt(meta['total']) ?? itemCount;
final safeLimit = limit > 0 ? limit : fallbackLimit;
final totalPages = resolveTotalPages(total: total, limit: safeLimit);
return ParsedPagination(
page: page < 1 ? 1 : page,
limit: safeLimit,
total: total < 0 ? 0 : total,
totalPages: totalPages,
);
}

View File

@ -36,6 +36,26 @@ class TableSearch {
}
return items.where((item) => matches(q, valuesOf(item))).toList();
}
/// Merges two lists by [idOf], preferring items from [primary] on conflict.
static List<T> mergeById<T>(
Iterable<T> primary,
Iterable<T> secondary,
String Function(T item) idOf,
) {
final map = <String, T>{};
for (final item in secondary) {
final id = idOf(item);
if (id.isEmpty) continue;
map[id] = item;
}
for (final item in primary) {
final id = idOf(item);
if (id.isEmpty) continue;
map[id] = item;
}
return map.values.toList();
}
}
/// Debounces search input so API-backed lists are not hit on every keystroke.

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/export_file_name.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/asset_model.dart';
@ -17,7 +18,12 @@ class AssetRemoteDataSource {
ApiEndpoints.assets,
queryParameters: _queryToMap(query),
);
return _parsePaginated(response.data, AssetModel.fromJson);
return _parsePaginated(
response.data,
AssetModel.fromJson,
fallbackPage: query.page,
fallbackLimit: query.limit,
);
}
Future<AssetModel> getAssetById(String id) async {
@ -215,7 +221,12 @@ class AssetRemoteDataSource {
if (type != null) 'type': type,
},
);
return _parsePaginated(response.data, AssetAlertModel.fromJson);
return _parsePaginated(
response.data,
AssetAlertModel.fromJson,
fallbackPage: page,
fallbackLimit: limit,
);
}
Future<List<AssetAlertModel>> getServiceAlerts({String? status}) async {
@ -370,7 +381,12 @@ class AssetRemoteDataSource {
if (dueOnly) 'due_only': true,
},
);
return _parsePaginated(response.data, AssetModel.fromJson);
return _parsePaginated(
response.data,
AssetModel.fromJson,
fallbackPage: page,
fallbackLimit: limit,
);
}
Future<List<AssetMaintenanceLogModel>> getMaintenanceLogs(String assetId) async {
@ -464,86 +480,45 @@ class AssetRemoteDataSource {
PaginatedResponse<T> _parsePaginated<T>(
dynamic body,
T Function(Map<String, dynamic>) fromJson,
) {
if (body is! Map) {
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
);
}
final map = Map<String, dynamic>.from(body);
final raw = map['data'];
final meta = map['meta'] is Map
? Map<String, dynamic>.from(map['meta'] as Map)
: <String, dynamic>{};
if (raw is List) {
final items = raw
.whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList();
final limit = (meta['limit'] as num?)?.toInt() ?? items.length;
final total = (meta['total'] as num?)?.toInt() ?? items.length;
final explicitTotalPages =
(meta['totalPages'] as num?)?.toInt() ??
(meta['total_pages'] as num?)?.toInt();
return PaginatedResponse(
items: items,
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: limit,
total: total,
totalPages: explicitTotalPages ??
(limit > 0
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
: 1),
);
}
if (raw is Map) {
final nested = Map<String, dynamic>.from(raw);
final list = nested['items'];
if (list is List) {
final items = list
T Function(Map<String, dynamic>) fromJson, {
int fallbackPage = 1,
int fallbackLimit = 20,
}) {
var items = <T>[];
if (body is Map) {
final raw = body['data'];
if (raw is List) {
items = raw
.whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList();
final limit = (meta['limit'] as num?)?.toInt() ??
(nested['limit'] as num?)?.toInt() ??
20;
final total = (meta['total'] as num?)?.toInt() ??
(nested['total'] as num?)?.toInt() ??
items.length;
final explicitTotalPages =
(meta['totalPages'] as num?)?.toInt() ??
(meta['total_pages'] as num?)?.toInt() ??
(nested['totalPages'] as num?)?.toInt() ??
(nested['total_pages'] as num?)?.toInt();
return PaginatedResponse(
items: items,
page: (meta['page'] as num?)?.toInt() ??
(nested['page'] as num?)?.toInt() ??
1,
limit: limit,
total: total,
totalPages: explicitTotalPages ??
(limit > 0
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
: 1),
);
} else if (raw is Map) {
final list = raw['items'];
if (list is List) {
items = list
.whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList();
}
}
}
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
final pagination = parsePagination(
body: body,
fallbackPage: fallbackPage,
fallbackLimit: fallbackLimit,
itemCount: items.length,
);
return PaginatedResponse(
items: items,
page: pagination.page,
limit: fallbackLimit,
total: pagination.total,
totalPages: resolveTotalPages(
total: pagination.total,
limit: fallbackLimit,
),
);
}

View File

@ -6,6 +6,7 @@ import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/entity_attachment_model.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../data/repositories/asset_repository_impl.dart';
import 'asset_categories_provider.dart';
class AssetsListState {
const AssetsListState({
@ -68,11 +69,49 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
final result = await repository.getAssets(query);
if (result.failure != null) throw result.failure!;
final page = result.data!;
var items = List<AssetModel>.from(page.items);
final search = TableSearch.normalize(query.search);
// API search often ignores category name pull matching categories too.
if (search.isNotEmpty && query.itemCategoryId == null) {
final categories = await ref.read(itemCategoriesProvider.future);
var categoryMatches = 0;
for (final category in categories) {
if (!TableSearch.matches(search, [category.name])) continue;
if (++categoryMatches > 5) break;
final categoryId = int.tryParse(category.id);
if (categoryId == null) continue;
final byCategory = await repository.getAssets(
query.copyWith(search: null, itemCategoryId: categoryId),
);
if (byCategory.failure == null && byCategory.data != null) {
items = TableSearch.mergeById(
items,
byCategory.data!.items,
(asset) => asset.id,
);
}
}
items = TableSearch.filter(
items,
search,
(asset) => [
asset.assetCode,
asset.assetName,
asset.assetCategoryName,
asset.locationName,
asset.status,
asset.condition,
],
);
}
return AssetsListState(
assets: page.items,
assets: items,
query: query,
total: page.total,
totalPages: page.totalPages,
total: search.isEmpty ? page.total : items.length,
totalPages: search.isEmpty ? page.totalPages : 1,
);
}

View File

@ -193,6 +193,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
itemsOnPage: state.assets.length,
itemLabel: 'assets',
onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize,

View File

@ -142,6 +142,7 @@ class _MyMaintenanceBody extends ConsumerWidget {
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.limit,
itemsOnPage: state.assets.length,
onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize,
itemLabel: 'assets',

View File

@ -16,7 +16,6 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../master_data/presentation/widgets/master_quick_add.dart';
import '../providers/asset_form_lookups_provider.dart';
import '../providers/assets_provider.dart';
@ -1223,7 +1222,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
final _reasonController = TextEditingController();
DateTime _transferDate = DateTime.now();
int? _toLocationId;
int? _toDepartmentId;
int? _toUserId;
bool _isSubmitting = false;
@ -1249,10 +1247,7 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final hasDestination =
_toLocationId != null ||
_toDepartmentId != null ||
_toUserId != null;
final hasDestination = _toLocationId != null || _toUserId != null;
if (!hasDestination) {
showSidePanelSnackBar(
context,
@ -1266,7 +1261,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
await ref.read(assetDetailProvider(widget.assetId).notifier).transferAsset({
'transfer_date': DateFormatter.toApiDate(_transferDate),
if (_toLocationId != null) 'to_location_id': _toLocationId,
if (_toDepartmentId != null) 'to_department_id': _toDepartmentId,
if (_toUserId != null) 'to_user_id': _toUserId,
'reason': _reasonController.text.trim(),
});
@ -1323,25 +1317,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
onChanged: (v) => setState(() => _toLocationId = v),
),
const SizedBox(height: 12),
MasterQuickAddDropdown<int>(
masterId: 'departments',
label: 'To Department',
value: _toDepartmentId,
searchHint: 'Search department...',
options: lookups.departments
.map((option) {
final id = int.tryParse(option.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: option.name);
})
.whereType<AppDropdownOption<int>>()
.toList(),
refreshLookups: () =>
ref.invalidate(assetFormLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _toDepartmentId = v),
),
const SizedBox(height: 12),
AppSearchableDropdown<int>(
label: 'To User',
value: _toUserId,
@ -1423,36 +1398,11 @@ class _TransferHistoryEntry extends StatelessWidget {
],
),
const SizedBox(height: 16),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _TransferLocationBlock(
label: 'From',
location: item.fromLocationName,
department: item.fromDepartmentName,
user: item.fromUserName,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon(
Icons.arrow_forward_rounded,
size: 18,
color: theme.colorScheme.outline,
),
),
Expanded(
child: _TransferLocationBlock(
label: 'To',
location: item.toLocationName,
department: item.toDepartmentName,
user: item.toUserName,
),
),
],
),
_TransferFromToGrid(
fromLocation: item.fromLocationName,
fromUser: item.fromUserName,
toLocation: item.toLocationName,
toUser: item.toUserName,
),
if (showReason) ...[
const SizedBox(height: 16),
@ -1484,46 +1434,86 @@ class _TransferHistoryEntry extends StatelessWidget {
}
}
class _TransferLocationBlock extends StatelessWidget {
const _TransferLocationBlock({
required this.label,
this.location,
this.department,
this.user,
class _TransferFromToGrid extends StatelessWidget {
const _TransferFromToGrid({
this.fromLocation,
this.fromUser,
this.toLocation,
this.toUser,
});
final String label;
final String? location;
final String? department;
final String? user;
final String? fromLocation;
final String? fromUser;
final String? toLocation;
final String? toUser;
static const _arrowWidth = 34.0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final labelStyle = theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant,
),
Row(
children: [
Expanded(child: Text('From', style: labelStyle)),
const SizedBox(width: _arrowWidth),
Expanded(child: Text('To', style: labelStyle)),
],
),
const SizedBox(height: 8),
_TransferLocationRow(
icon: Icons.place_outlined,
value: location,
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _TransferLocationRow(
icon: Icons.place_outlined,
value: fromLocation,
),
),
SizedBox(
width: _arrowWidth,
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(
Icons.arrow_forward_rounded,
size: 18,
color: theme.colorScheme.outline,
),
),
),
Expanded(
child: _TransferLocationRow(
icon: Icons.place_outlined,
value: toLocation,
),
),
],
),
_TransferLocationRow(
icon: Icons.apartment_outlined,
value: department,
),
_TransferLocationRow(
icon: Icons.person_outline,
value: user,
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _TransferLocationRow(
icon: Icons.person_outline,
value: fromUser,
),
),
const SizedBox(width: _arrowWidth),
Expanded(
child: _TransferLocationRow(
icon: Icons.person_outline,
value: toUser,
),
),
],
),
],
);

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../shared/models/audit_log_model.dart';
import '../../../../shared/models/export_file_result.dart';
@ -32,20 +33,23 @@ class AuditRemoteDataSource {
: <AuditLogEntryModel>[];
final meta = body['meta'] as Map<String, dynamic>? ?? {};
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).clamp(1, 999999)
: 1;
final pagination = parsePagination(
body: body,
fallbackPage: query.page,
fallbackLimit: query.limit,
itemCount: items.length,
);
final filtersRequired = meta['filters_required'] == true;
return AuditLogListResult(
items: items,
page: page,
limit: limit,
total: total,
totalPages: totalPages,
page: pagination.page,
limit: query.limit,
total: pagination.total,
totalPages: resolveTotalPages(
total: pagination.total,
limit: query.limit,
),
filtersRequired: filtersRequired,
);
}

View File

@ -184,6 +184,7 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
itemsOnPage: state.items.length,
itemLabel: 'audit logs',
onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize,
@ -291,7 +292,10 @@ class _FiltersBar extends StatelessWidget {
options: [
const AppDropdownOption(value: null, label: 'All Tables'),
...filters.tableNames.map(
(name) => AppDropdownOption(value: name, label: name),
(name) => AppDropdownOption(
value: name,
label: humanizeLabel(name),
),
),
],
onChanged: onTableChanged,
@ -400,8 +404,9 @@ class _AuditDataTable extends StatelessWidget {
AppDataColumn(
label: 'Table',
flex: 2,
searchText: (row) => row.tableName,
cellBuilder: (_, row) => AppTableCell.text(row.tableName),
searchText: (row) => humanizeLabel(row.tableName),
cellBuilder: (_, row) =>
AppTableCell.text(humanizeLabel(row.tableName)),
),
AppDataColumn(
label: 'Record',
@ -485,7 +490,7 @@ class _AuditCardList extends StatelessWidget {
children: [
Expanded(
child: Text(
log.tableName,
humanizeLabel(log.tableName),
style: Theme.of(context).textTheme.titleMedium,
),
),

View File

@ -57,7 +57,7 @@ class _DetailBody extends StatelessWidget {
label: 'Action',
child: AppStatusChip(status: detail.action, compact: true),
),
_DetailRow(label: 'Table', value: _humanizeKey(detail.tableName)),
_DetailRow(label: 'Table', value: humanizeLabel(detail.tableName)),
_DetailRow(label: 'Record ID', value: detail.recordId ?? ''),
_DetailRow(
label: 'Performed At',
@ -269,15 +269,15 @@ List<_FieldRow> _flattenFields(
for (final entry in entries) {
final key = entry.key.toString();
final label = prefix == null
? _humanizeKey(key)
: '${_humanizeKey(prefix)} ${_humanizeKey(key)}';
? humanizeLabel(key)
: '${humanizeLabel(prefix)} ${humanizeLabel(key)}';
final value = entry.value;
if (value is Map) {
final nested = Map<String, dynamic>.from(value);
final summary = _nestedSummary(nested);
if (summary != null) {
rows.add(_FieldRow(label: _humanizeKey(key), value: summary));
rows.add(_FieldRow(label: humanizeLabel(key), value: summary));
} else {
rows.addAll(
_flattenFields(
@ -330,7 +330,7 @@ String? _nestedSummary(Map<String, dynamic> nested) {
return nested.entries
.map(
(e) =>
'${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}',
'${humanizeLabel(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}',
)
.join(', ');
}
@ -393,30 +393,3 @@ String _formatValue(String key, Object? value) {
return text;
}
String _humanizeKey(String key) {
final cleaned = key.trim().replaceAll(RegExp(r'[_\-.]+'), ' ');
if (cleaned.isEmpty) return key;
const acronyms = {
'id': 'ID',
'amc': 'AMC',
'gst': 'GST',
'hsn': 'HSN',
'uom': 'UOM',
'po': 'PO',
'grn': 'GRN',
'url': 'URL',
'api': 'API',
};
return cleaned
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.map((part) {
final lower = part.toLowerCase();
if (acronyms.containsKey(lower)) return acronyms[lower]!;
return '${lower[0].toUpperCase()}${lower.substring(1)}';
})
.join(' ');
}

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/export_file_name.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/export_file_result.dart';
@ -16,7 +17,12 @@ class GrnRemoteDataSource {
ApiEndpoints.grn,
queryParameters: _queryToMap(query),
);
return _parsePaginated(response.data, GrnModel.fromJson);
return _parsePaginated(
response.data,
GrnModel.fromJson,
fallbackPage: query.page,
fallbackLimit: query.limit,
);
}
/// Form-dropdown loader: all active GRNs (`dropdown_call=true`).
@ -158,58 +164,45 @@ class GrnRemoteDataSource {
PaginatedResponse<T> _parsePaginated<T>(
dynamic body,
T Function(Map<String, dynamic>) fromJson,
) {
if (body is! Map<String, dynamic>) {
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
);
}
final raw = body['data'];
final meta = body['meta'] as Map<String, dynamic>? ?? {};
if (raw is List) {
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList();
final limit = (meta['limit'] as num?)?.toInt() ?? items.length;
final total = (meta['total'] as num?)?.toInt() ?? items.length;
return PaginatedResponse(
items: items,
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: limit,
total: total,
totalPages:
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
);
}
if (raw is Map<String, dynamic>) {
final list = raw['items'];
if (list is List) {
final items = list.whereType<Map<String, dynamic>>().map(fromJson).toList();
final limit = (meta['limit'] as num?)?.toInt() ?? 20;
final total = (meta['total'] as num?)?.toInt() ?? items.length;
return PaginatedResponse(
items: items,
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: limit,
total: total,
totalPages:
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
);
T Function(Map<String, dynamic>) fromJson, {
int fallbackPage = 1,
int fallbackLimit = 20,
}) {
var items = <T>[];
if (body is Map) {
final raw = body['data'];
if (raw is List) {
items = raw
.whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList();
} else if (raw is Map) {
final list = raw['items'];
if (list is List) {
items = list
.whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList();
}
}
}
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
final pagination = parsePagination(
body: body,
fallbackPage: fallbackPage,
fallbackLimit: fallbackLimit,
itemCount: items.length,
);
return PaginatedResponse(
items: items,
page: pagination.page,
limit: fallbackLimit,
total: pagination.total,
totalPages: resolveTotalPages(
total: pagination.total,
limit: fallbackLimit,
),
);
}
}

View File

@ -4,7 +4,11 @@ import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../purchase_orders/data/repositories/purchase_order_repository_impl.dart';
import '../../../purchase_orders/presentation/providers/purchase_order_lookups_provider.dart';
import '../../data/repositories/grn_repository_impl.dart';
import 'grn_lookups_provider.dart';
class GrnListState {
const GrnListState({
@ -67,11 +71,88 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
final result = await repository.getGrns(query);
if (result.failure != null) throw result.failure!;
final page = result.data!;
var items = List<GrnModel>.from(page.items);
final search = TableSearch.normalize(query.search);
// API search often ignores PO number / vendor name enrich via filters.
if (search.isNotEmpty &&
query.poId == null &&
query.vendorId == null) {
try {
final grnLookups = await ref.read(grnLookupsProvider.future);
final poCandidates = <PurchaseOrderModel>[
...grnLookups.receivablePurchaseOrders,
];
// Include broader PO dropdown options so search works beyond receivable.
final allPoOptions = await ref
.read(purchaseOrderRepositoryProvider)
.listPurchaseOrderOptions();
if (allPoOptions.failure == null && allPoOptions.data != null) {
poCandidates.addAll(allPoOptions.data!);
}
final seenPoIds = <String>{};
var poMatches = 0;
for (final po in poCandidates) {
if (!seenPoIds.add(po.id)) continue;
if (!TableSearch.matches(search, [po.poNo, po.vendorName])) {
continue;
}
if (++poMatches > 5) break;
final poId = int.tryParse(po.id);
if (poId == null) continue;
final byPo = await repository.getGrns(
query.copyWith(search: null, poId: poId),
);
if (byPo.failure == null && byPo.data != null) {
items = TableSearch.mergeById(
items,
byPo.data!.items,
(grn) => grn.id,
);
}
}
final poLookups = await ref.read(purchaseOrderLookupsProvider.future);
var vendorMatches = 0;
for (final vendor in poLookups.vendors) {
if (!TableSearch.matches(search, [vendor.name])) continue;
if (++vendorMatches > 5) break;
final vendorId = int.tryParse(vendor.id);
if (vendorId == null) continue;
final byVendor = await repository.getGrns(
query.copyWith(search: null, vendorId: vendorId),
);
if (byVendor.failure == null && byVendor.data != null) {
items = TableSearch.mergeById(
items,
byVendor.data!.items,
(grn) => grn.id,
);
}
}
} catch (_) {
// Lookups enrichment is best-effort.
}
items = TableSearch.filter(
items,
search,
(grn) => [
grn.grnNumber,
grn.poNumber,
grn.vendorName,
grn.locationName,
grn.status,
],
);
}
return GrnListState(
grns: page.items,
grns: items,
query: query,
total: page.total,
totalPages: page.totalPages,
total: search.isEmpty ? page.total : items.length,
totalPages: search.isEmpty ? page.totalPages : 1,
);
}

View File

@ -126,6 +126,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
itemsOnPage: state.grns.length,
itemLabel: 'GRNs',
onPageChanged: ref.read(grnListProvider.notifier).setPage,
onPageSizeChanged: ref.read(grnListProvider.notifier).setPageSize,

View File

@ -332,7 +332,7 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
const SizedBox(height: 8),
FormRow(
columnCount: 12,
spans: const [2, 2, 2, 2, 2, 2],
spans: const [2, 2, 2, 3, 3],
spacing: 8,
stackBelowWidth: 1100,
children: [
@ -398,14 +398,6 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
readOnly: true,
fillColor: currentBg,
),
AppTextField(
key: ValueKey('$lineKey-rate'),
controller: item.rateController,
label: 'Rate',
hint: '0.00',
isDense: true,
readOnly: true,
),
AppTextField(
key: ValueKey('$lineKey-batch'),
controller: item.batchNoController,

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../domain/entities/master_definition.dart';
@ -17,18 +18,14 @@ class MasterListResult {
required this.total,
required this.page,
required this.limit,
required this.totalPages,
});
final List<Map<String, dynamic>> items;
final int total;
final int page;
final int limit;
int get totalPages {
if (limit <= 0) return 1;
final pages = (total / limit).ceil();
return pages < 1 ? 1 : pages;
}
final int totalPages;
}
class MasterCrudRemoteDataSource {
@ -68,16 +65,20 @@ class MasterCrudRemoteDataSource {
.map((item) => Map<String, dynamic>.from(item))
.toList();
final meta = raw is Map<String, dynamic> ? raw : body;
final total = _asInt(meta['total']) ?? items.length;
final currentPage = _asInt(meta['page']) ?? page;
final currentLimit = _asInt(meta['limit']) ?? limit;
final pagination = parsePagination(
body: body,
fallbackPage: page,
fallbackLimit: limit,
itemCount: items.length,
);
return MasterListResult(
items: items,
total: total,
page: currentPage,
limit: currentLimit,
total: pagination.total,
page: pagination.page,
// Keep the requested page size so the UI dropdown matches the next request.
limit: limit,
totalPages: resolveTotalPages(total: pagination.total, limit: limit),
);
}
@ -196,11 +197,4 @@ class MasterCrudRemoteDataSource {
if (data is Map<String, dynamic>) return Map<String, dynamic>.from(data);
return Map<String, dynamic>.from(body);
}
int? _asInt(Object? value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
}

View File

@ -21,6 +21,7 @@ class MasterRepositoryImpl implements MasterRepository {
int page = 1,
int limit = 20,
String? search,
Map<String, dynamic>? extraQueryParameters,
}) =>
safeApiCall(
() => remote.list(
@ -28,6 +29,7 @@ class MasterRepositoryImpl implements MasterRepository {
page: page,
limit: limit,
search: search,
extraQueryParameters: extraQueryParameters,
),
);

View File

@ -22,6 +22,7 @@ class MasterFieldDef {
this.filterByOptionKey,
this.visibleWhenFieldKey,
this.visibleWhenValue,
this.listNestedKey,
});
final String key;
@ -47,6 +48,8 @@ class MasterFieldDef {
/// Show this field only when [visibleWhenFieldKey] equals [visibleWhenValue].
final String? visibleWhenFieldKey;
final String? visibleWhenValue;
/// Nested object key on list rows for display (e.g. `plant` for `parent_id`).
final String? listNestedKey;
/// Cache key for dropdown option rows (includes query params when set).
String get dropdownLookupKey {
@ -272,12 +275,17 @@ const masterDefinitions = <MasterDefinition>[
label: 'Min Order Qty',
type: MasterFieldType.number,
required: true,
// Stock items only hidden when Asset Item is checked.
visibleWhenFieldKey: 'is_asset_item',
visibleWhenValue: 'false',
),
MasterFieldDef(
key: 'reorder_level',
label: 'Reorder Level',
type: MasterFieldType.number,
required: true,
visibleWhenFieldKey: 'is_asset_item',
visibleWhenValue: 'false',
),
MasterFieldDef(
key: 'tags',
@ -374,6 +382,7 @@ const masterDefinitions = <MasterDefinition>[
showInList: true,
optionsMasterKey: 'locations',
optionsQueryParams: const {'type': 'plant'},
listNestedKey: 'plant',
visibleWhenFieldKey: 'type',
visibleWhenValue: 'warehouse',
required: true,
@ -606,18 +615,25 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
if (explicitName != null && explicitName.toString().trim().isNotEmpty) {
return explicitName.toString().trim();
}
final nested = row[baseKey];
if (nested is Map) {
for (final nestedKey in ['name', 'code', 'description']) {
final nestedValue = nested[nestedKey];
if (nestedValue != null &&
nestedValue.toString().trim().isNotEmpty) {
return nestedValue.toString().trim();
final nestedKeys = <String>[
if (field.listNestedKey != null) field.listNestedKey!,
baseKey,
];
for (final nestedKey in nestedKeys) {
final nested = row[nestedKey];
if (nested is Map) {
for (final nameKey in ['name', 'code', 'description']) {
final nestedValue = nested[nameKey];
if (nestedValue != null &&
nestedValue.toString().trim().isNotEmpty) {
return nestedValue.toString().trim();
}
}
} else if (nested != null && nested.toString().trim().isNotEmpty) {
// Flat denormalized value (e.g. items.hsn_code string)
return nested.toString().trim();
}
} else if (nested != null && nested.toString().trim().isNotEmpty) {
// Flat denormalized value (e.g. items.hsn_code string)
return nested.toString().trim();
}
}
@ -670,6 +686,28 @@ String masterFieldDropdownLookupKey({
return '$masterKey?$query';
}
/// Placeholder / hint casing for master field labels (keeps UOM, HSN, GST, etc.).
String masterFieldHintLabel(String label) {
const acronyms = {
'uom': 'UOM',
'hsn': 'HSN',
'gst': 'GST',
'sac': 'SAC',
'po': 'PO',
'grn': 'GRN',
'amc': 'AMC',
};
return label
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.map((part) {
final lower = part.toLowerCase();
return acronyms[lower] ?? lower;
})
.join(' ');
}
/// Display label for GST filled from HSN nested `gst_rate.description`.
String? gstRateDisplayFromValues(Map<String, dynamic> values) {
final nested = values['gst_rate'];

View File

@ -9,6 +9,7 @@ abstract class MasterRepository {
int page,
int limit,
String? search,
Map<String, dynamic>? extraQueryParameters,
});
Future<Result<List<Map<String, dynamic>>>> listOptions(

View File

@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../../assets/data/repositories/asset_repository_impl.dart';
@ -114,45 +115,68 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
final current = state.valueOrNull;
final nextPage = page ?? current?.page ?? 1;
final nextLimit = limit ?? current?.limit ?? AppConstants.defaultPageSize;
final nextSearch = search ?? current?.search;
final nextSearch = TableSearch.normalize(search ?? current?.search);
final repository = ref.read(masterRepositoryProvider);
final result = await ref.read(masterRepositoryProvider).list(
_definition,
page: nextPage,
limit: nextLimit,
search: nextSearch,
);
final result = await repository.list(
_definition,
page: nextPage,
limit: nextLimit,
search: nextSearch.isEmpty ? null : nextSearch,
);
if (result.failure != null) throw result.failure!;
final data = result.data!;
var items = List<Map<String, dynamic>>.from(result.data!.items);
// API `search` often ignores related/enum fields (e.g. category_type).
// Supplement with dedicated filters and client-side multi-field matching.
if (nextSearch.isNotEmpty) {
if (_definition.id == 'item_categories') {
var typeMatches = 0;
for (final type in categoryTypeOptions) {
if (!TableSearch.matches(nextSearch, [type])) continue;
if (++typeMatches > 5) break;
final typed = await repository.list(
_definition,
page: nextPage,
limit: nextLimit,
extraQueryParameters: {'category_type': type},
);
if (typed.failure == null && typed.data != null) {
items = TableSearch.mergeById(
items,
typed.data!.items,
(row) => row['id']?.toString() ?? '',
);
}
}
}
items = TableSearch.filter(
items,
nextSearch,
(row) => [
..._definition.listFields.map((field) => masterCellValue(row, field)),
masterStatusValue(row),
],
);
}
final data = result.data!;
return MasterListState(
items: data.items,
search: nextSearch ?? '',
items: items,
search: nextSearch,
page: data.page,
// Keep the requested page size so the /page dropdown stays valid
// even if API meta omits or mismatches `limit`.
limit: nextLimit,
total: data.total,
totalPages: _resolveTotalPages(
apiTotalPages: data.totalPages,
total: data.total,
limit: nextLimit,
),
total: nextSearch.isEmpty ? data.total : items.length,
totalPages: nextSearch.isEmpty
? resolveTotalPages(total: data.total, limit: nextLimit)
: resolveTotalPages(total: items.length, limit: nextLimit),
);
}
int _resolveTotalPages({
required int apiTotalPages,
required int total,
required int limit,
}) {
if (apiTotalPages > 0) return apiTotalPages;
if (total <= 0 || limit <= 0) return 1;
final pages = (total / limit).ceil();
return pages < 1 ? 1 : pages;
}
Future<void> refresh() async {
final previous = state.valueOrNull;
if (previous == null) {
@ -169,6 +193,13 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
await _reload(page: 1, search: search);
}
/// Clears generic search and reloads the full list.
Future<void> clearSearch() async {
final current = state.valueOrNull;
if (current != null && current.search.isEmpty) return;
await _reload(page: 1, search: '');
}
Future<void> setPage(int page) async {
await _reload(page: page);
}
@ -447,10 +478,12 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
_clearHiddenFieldValues(values);
}
// Asset Item toggles Items category list between STOCK / ASSET.
// Asset Item toggles Items category list between STOCK / ASSET
// and hides stock-only fields (min order qty / reorder level).
if (key == 'is_asset_item' && _definition.id == 'items') {
values['item_category_id'] = null;
values['item_subcategory_id'] = null;
_clearHiddenFieldValues(values);
state = AsyncData(current.copyWith(values: values));
_reloadItemCategoryOptions(values);
return;
@ -496,7 +529,11 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
final current = state.valueOrNull;
if (current == null) return;
final options = await _loadDropdownOptions(values: current.values);
state = AsyncData(current.copyWith(dropdownOptions: options));
// Re-read latest state so a Quick Add selection applied while we were
// loading is not overwritten by the snapshot from the start of this call.
final latest = state.valueOrNull;
if (latest == null) return;
state = AsyncData(latest.copyWith(dropdownOptions: options));
}
Map<String, dynamic> _buildPayload(MasterFormState current) {
@ -569,8 +606,27 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
}
state = AsyncData(current.copyWith(isSubmitting: false, clearError: true));
final createdId = result.data?['id']?.toString();
final data = result.data;
final createdId = _readCreatedId(data);
if (createdId != null && createdId.isNotEmpty) return createdId;
return arg.recordId ?? 'created';
}
String? _readCreatedId(Map<String, dynamic>? data) {
if (data == null) return null;
for (final key in const ['id', 'ID', 'gst_rate_id']) {
final value = data[key];
if (value != null && value.toString().trim().isNotEmpty) {
return value.toString().trim();
}
}
final nested = data['data'];
if (nested is Map) {
final value = nested['id'];
if (value != null && value.toString().trim().isNotEmpty) {
return value.toString().trim();
}
}
return null;
}
}

View File

@ -37,6 +37,7 @@ class MasterListScreen extends ConsumerStatefulWidget {
class _MasterListScreenState extends ConsumerState<MasterListScreen> {
final _searchController = TextEditingController();
bool _filtersExpanded = false;
bool _didResetSearchOnOpen = false;
MasterDefinition get _definition {
final def = masterDefinitionById(widget.masterId);
@ -44,6 +45,24 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
return def;
}
@override
void initState() {
super.initState();
// keepAlive can restore a previous search after All Masters another master.
// Always open list screens with a clean generic search.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _didResetSearchOnOpen) return;
_didResetSearchOnOpen = true;
_searchController.clear();
final notifier = ref.read(masterListProvider(widget.masterId).notifier);
final existing =
ref.read(masterListProvider(widget.masterId)).valueOrNull;
if (existing != null && existing.search.isNotEmpty) {
notifier.clearSearch();
}
});
}
@override
void dispose() {
_searchController.dispose();
@ -197,7 +216,13 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
],
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => context.push(RouteConstants.masterData),
onPressed: () {
_searchController.clear();
ref
.read(masterListProvider(widget.masterId).notifier)
.clearSearch();
context.go(RouteConstants.masterData);
},
icon: const Icon(Icons.grid_view_outlined),
label: const Text('All Masters'),
),
@ -225,6 +250,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.limit,
itemsOnPage: state.items.length,
itemLabel: def.title.toLowerCase(),
onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize,

View File

@ -226,7 +226,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
if (filterField != null) {
for (final f in _definition.formFields) {
if (f.key == filterField) {
parentLabel = f.label.toLowerCase();
parentLabel = masterFieldHintLabel(f.label);
break;
}
}
@ -247,8 +247,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
? 'Select $parentLabel first'
: dropdownOptions.isEmpty
? 'No options available'
: 'Select ${field.label.toLowerCase()}',
searchHint: 'Search ${field.label.toLowerCase()}...',
: 'Select ${masterFieldHintLabel(field.label)}',
searchHint: 'Search ${masterFieldHintLabel(field.label)}...',
enabled: parentSelected,
initialValues: () {
final values = <String, dynamic>{};
@ -263,11 +263,9 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
}
return values.isEmpty ? null : values;
}(),
refreshLookups: () {
ref
.read(masterFormProvider(_args).notifier)
.reloadDropdownOptions();
},
refreshLookups: () => ref
.read(masterFormProvider(_args).notifier)
.reloadDropdownOptions(),
parseCreatedId: (id) => id,
onChanged: (selected) => notifier.updateValue(field.key, selected),
validator: field.required
@ -287,8 +285,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
? 'Select $parentLabel first'
: dropdownOptions.isEmpty
? 'No options available'
: 'Select ${field.label.toLowerCase()}',
searchHint: 'Search ${field.label.toLowerCase()}...',
: 'Select ${masterFieldHintLabel(field.label)}',
searchHint: 'Search ${masterFieldHintLabel(field.label)}...',
enabled: field.staticOptions != null
? true
: parentSelected && dropdownOptions.isNotEmpty,

View File

@ -162,7 +162,7 @@ class _MasterInlineCreateFormState
if (filterField != null) {
for (final f in _definition.formFields) {
if (f.key == filterField) {
parentLabel = f.label.toLowerCase();
parentLabel = masterFieldHintLabel(f.label);
break;
}
}
@ -181,8 +181,8 @@ class _MasterInlineCreateFormState
? 'Select $parentLabel first'
: dropdownOptions.isEmpty
? 'No options available'
: 'Select ${field.label.toLowerCase()}',
searchHint: 'Search ${field.label.toLowerCase()}...',
: 'Select ${masterFieldHintLabel(field.label)}',
searchHint: 'Search ${masterFieldHintLabel(field.label)}...',
enabled: field.staticOptions != null
? true
: parentSelected && dropdownOptions.isNotEmpty,

View File

@ -9,15 +9,16 @@ import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../providers/master_provider.dart';
import '../../domain/entities/master_definition.dart';
import 'master_form_panel.dart';
import 'master_inline_create_form.dart';
import '../../../../shared/widgets/app_toast.dart';
/// Dropdown with inline Quick Add that expands below the field (no dialog).
/// Dropdown with Quick Add.
///
/// When placed inside a [QuickAddInlineHost] (FormRow / SidePanelFormRow), the
/// create form is rendered at full row width under the row so every field and
/// button stays clickable. Without a host, the form expands directly below the
/// dropdown at the available column width.
/// By default expands an inline create form below the field (or under a
/// [QuickAddInlineHost]). Set [openInSidePanel] to open [MasterFormPanel]
/// in a popup side panel instead.
class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
const MasterQuickAddDropdown({
super.key,
@ -35,6 +36,7 @@ class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
this.initialValues,
this.refreshLookups,
this.addNewLabel,
this.openInSidePanel = false,
});
final String masterId;
@ -54,6 +56,9 @@ class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
final FutureOr<void> Function()? refreshLookups;
final String? addNewLabel;
/// When true, Quick Add opens [MasterFormPanel] in a side panel popup.
final bool openInSidePanel;
@override
ConsumerState<MasterQuickAddDropdown<T>> createState() =>
_MasterQuickAddDropdownState<T>();
@ -75,9 +80,10 @@ class _MasterQuickAddDropdownState<T>
bool get _canQuickAdd => ref.can('masters', PermissionAction.create);
void _openInlineForm() {
Future<void> _onAddNew() async {
if (!_canQuickAdd) {
showAppToastFromSnackBar(context,
showAppToastFromSnackBar(
context,
const SnackBar(
content: Text('You do not have permission to add master data'),
),
@ -85,6 +91,41 @@ class _MasterQuickAddDropdownState<T>
return;
}
if (widget.openInSidePanel) {
await _openSidePanelForm();
return;
}
_openInlineForm();
}
Future<void> _openSidePanelForm() async {
final sessionId =
'panel-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}';
ref.invalidate(
masterFormProvider((
masterId: widget.masterId,
recordId: null,
initialValues: widget.initialValues,
formSessionId: sessionId,
)),
);
final createdId = await showSidePanel<String>(
context,
MasterFormPanel(
masterId: widget.masterId,
initialValues: widget.initialValues,
formSessionId: sessionId,
),
width: 560,
);
if (!mounted || createdId == null) return;
await _applyCreated(createdId);
}
void _openInlineForm() {
final sessionId =
'inline-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}';
ref.invalidate(
@ -119,6 +160,11 @@ class _MasterQuickAddDropdownState<T>
}
Future<void> _onSaved(String createdId) async {
await _applyCreated(createdId);
_collapse();
}
Future<void> _applyCreated(String createdId) async {
try {
final refresh = widget.refreshLookups;
if (refresh != null) await refresh();
@ -127,15 +173,14 @@ class _MasterQuickAddDropdownState<T>
}
if (!mounted) return;
if (createdId != 'created') {
// Prefer a real id from create; fall back only when API omits it.
if (createdId.isNotEmpty && createdId != 'created') {
final parsed = widget.parseCreatedId(createdId);
if (parsed != null) {
widget.onChanged(parsed);
}
}
_collapse();
if (!mounted) return;
showAppToastFromSnackBar(
context,
@ -168,7 +213,9 @@ class _MasterQuickAddDropdownState<T>
@override
Widget build(BuildContext context) {
final addLabel = widget.addNewLabel ?? masterQuickAddLabel(widget.masterId);
_host ??= QuickAddInlineScope.maybeOf(context);
if (!widget.openInSidePanel) {
_host ??= QuickAddInlineScope.maybeOf(context);
}
final dropdown = Focus(
focusNode: _dropdownFocus,
@ -179,19 +226,19 @@ class _MasterQuickAddDropdownState<T>
onChanged: widget.onChanged,
validator: widget.validator,
hint: widget.hint,
searchHint:
widget.searchHint ?? 'Search ${widget.label.toLowerCase()}...',
searchHint: widget.searchHint ??
'Search ${masterFieldHintLabel(widget.label.replaceAll('*', '').trim())}...',
enabled: widget.enabled,
isDense: widget.isDense,
addNewLabel: _canQuickAdd ? addLabel : null,
onAddNew: !_canQuickAdd || !widget.enabled
? null
: () async => _openInlineForm(),
onAddNew: !_canQuickAdd || !widget.enabled ? null : _onAddNew,
),
);
// Host renders the form at full row width.
if (_host != null || !_expanded || _sessionId == null) {
if (widget.openInSidePanel ||
_host != null ||
!_expanded ||
_sessionId == null) {
return dropdown;
}

View File

@ -5,6 +5,7 @@ import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../shared/models/user_management_models.dart';
final masterRemoteDataSourceProvider = Provider<MasterRemoteDataSource>((ref) {
@ -331,20 +332,12 @@ class MasterRemoteDataSource {
}
final raw = body['data'];
final meta = body['meta'] is Map
? Map<String, dynamic>.from(body['meta'] as Map)
: <String, dynamic>{};
List list;
Map<String, dynamic> pageMeta = meta;
if (raw is List) {
list = raw;
} else if (raw is Map) {
final map = Map<String, dynamic>.from(raw);
final items = map['items'];
final items = raw['items'];
list = items is List ? items : const [];
pageMeta = {...meta, ...map};
} else {
list = const [];
}
@ -354,14 +347,20 @@ class MasterRemoteDataSource {
.map((item) => Map<String, dynamic>.from(item))
.toList();
final total = _asInt(pageMeta['total']) ?? items.length;
final limit = _asInt(pageMeta['limit']) ?? fallbackLimit;
final explicitTotalPages = _asInt(pageMeta['totalPages']) ??
_asInt(pageMeta['total_pages']);
final totalPages = explicitTotalPages ??
(limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1);
final pagination = parsePagination(
body: body,
fallbackPage: 1,
fallbackLimit: fallbackLimit,
itemCount: items.length,
);
return (items: items, totalPages: totalPages < 1 ? 1 : totalPages);
return (
items: items,
totalPages: resolveTotalPages(
total: pagination.total,
limit: fallbackLimit,
),
);
}
int? _asInt(dynamic value) {

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/export_file_name.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/entity_attachment_model.dart';
@ -19,7 +20,12 @@ class PurchaseOrderRemoteDataSource {
ApiEndpoints.purchaseOrders,
queryParameters: _queryToMap(query),
);
return _parsePaginated(response.data, PurchaseOrderModel.fromJson);
return _parsePaginated(
response.data,
PurchaseOrderModel.fromJson,
fallbackPage: query.page,
fallbackLimit: query.limit,
);
}
/// Approver-only list of POs with status PENDING_APPROVAL.
@ -30,7 +36,12 @@ class PurchaseOrderRemoteDataSource {
ApiEndpoints.purchaseOrdersPendingApproval,
queryParameters: _queryToMap(query),
);
return _parsePaginated(response.data, PurchaseOrderModel.fromJson);
return _parsePaginated(
response.data,
PurchaseOrderModel.fromJson,
fallbackPage: query.page,
fallbackLimit: query.limit,
);
}
/// Form-dropdown loader (`dropdown_call=true`). Optional status filter.
@ -252,58 +263,45 @@ class PurchaseOrderRemoteDataSource {
PaginatedResponse<T> _parsePaginated<T>(
dynamic body,
T Function(Map<String, dynamic>) fromJson,
) {
if (body is! Map<String, dynamic>) {
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
);
}
final raw = body['data'];
final meta = body['meta'] as Map<String, dynamic>? ?? {};
if (raw is List) {
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList();
final limit = (meta['limit'] as num?)?.toInt() ?? items.length;
final total = (meta['total'] as num?)?.toInt() ?? items.length;
return PaginatedResponse(
items: items,
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: limit,
total: total,
totalPages:
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
);
}
if (raw is Map<String, dynamic>) {
final list = raw['items'];
if (list is List) {
final items = list.whereType<Map<String, dynamic>>().map(fromJson).toList();
final limit = (meta['limit'] as num?)?.toInt() ?? 20;
final total = (meta['total'] as num?)?.toInt() ?? items.length;
return PaginatedResponse(
items: items,
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: limit,
total: total,
totalPages:
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
);
T Function(Map<String, dynamic>) fromJson, {
int fallbackPage = 1,
int fallbackLimit = 20,
}) {
var items = <T>[];
if (body is Map) {
final raw = body['data'];
if (raw is List) {
items = raw
.whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList();
} else if (raw is Map) {
final list = raw['items'];
if (list is List) {
items = list
.whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList();
}
}
}
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
final pagination = parsePagination(
body: body,
fallbackPage: fallbackPage,
fallbackLimit: fallbackLimit,
itemCount: items.length,
);
return PaginatedResponse(
items: items,
page: pagination.page,
limit: fallbackLimit,
total: pagination.total,
totalPages: resolveTotalPages(
total: pagination.total,
limit: fallbackLimit,
),
);
}
}

View File

@ -1,12 +1,14 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/entity_attachment_model.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
import '../../data/repositories/purchase_order_repository_impl.dart';
import 'purchase_order_lookups_provider.dart';
class PurchaseOrdersListState {
const PurchaseOrdersListState({
@ -76,11 +78,18 @@ class PurchaseOrdersListNotifier
final result = await repository.getPurchaseOrders(query);
if (result.failure != null) throw result.failure!;
final page = result.data!;
return PurchaseOrdersListState(
orders: page.items,
final orders = await _enrichPurchaseOrderSearch(
ref: ref,
query: query,
total: page.total,
totalPages: page.totalPages,
items: page.items,
load: repository.getPurchaseOrders,
);
final search = TableSearch.normalize(query.search);
return PurchaseOrdersListState(
orders: orders,
query: query,
total: search.isEmpty ? page.total : orders.length,
totalPages: search.isEmpty ? page.totalPages : 1,
);
}
@ -187,11 +196,18 @@ class PendingApprovalPurchaseOrdersListNotifier
final result = await repository.getPendingApprovalPurchaseOrders(query);
if (result.failure != null) throw result.failure!;
final page = result.data!;
return PurchaseOrdersListState(
orders: page.items,
final orders = await _enrichPurchaseOrderSearch(
ref: ref,
query: query,
total: page.total,
totalPages: page.totalPages,
items: page.items,
load: repository.getPendingApprovalPurchaseOrders,
);
final search = TableSearch.normalize(query.search);
return PurchaseOrdersListState(
orders: orders,
query: query,
total: search.isEmpty ? page.total : orders.length,
totalPages: search.isEmpty ? page.totalPages : 1,
);
}
@ -425,3 +441,53 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier<PurchaseOrderModel?,
return result.data!;
}
}
Future<List<PurchaseOrderModel>> _enrichPurchaseOrderSearch({
required Ref ref,
required PurchaseOrderListQuery query,
required List<PurchaseOrderModel> items,
required Future<Result<PaginatedResponse<PurchaseOrderModel>>> Function(
PurchaseOrderListQuery query,
) load,
}) async {
final search = TableSearch.normalize(query.search);
if (search.isEmpty || query.vendorId != null) {
return items;
}
var merged = List<PurchaseOrderModel>.from(items);
try {
final lookups = await ref.read(purchaseOrderLookupsProvider.future);
var vendorMatches = 0;
for (final vendor in lookups.vendors) {
if (!TableSearch.matches(search, [vendor.name])) continue;
if (++vendorMatches > 5) break;
final vendorId = int.tryParse(vendor.id);
if (vendorId == null) continue;
final byVendor = await load(
query.copyWith(search: null, vendorId: vendorId),
);
if (byVendor.failure == null && byVendor.data != null) {
merged = TableSearch.mergeById(
merged,
byVendor.data!.items,
(order) => order.id,
);
}
}
} catch (_) {
// Lookups/vendor enrichment is best-effort.
}
return TableSearch.filter(
merged,
search,
(order) => [
order.poNo,
order.vendorName,
order.status,
order.billingName,
order.shippingName,
],
);
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/network/api_handler.dart';
@ -9,6 +10,7 @@ import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/navigation_utils.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_date_popup.dart';
@ -21,6 +23,7 @@ import '../../../../shared/widgets/app_side_panel.dart';
import '../../data/repositories/purchase_order_repository_impl.dart';
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
import '../../../master_data/presentation/widgets/master_quick_add.dart';
import '../../../vendors/presentation/widgets/vendor_form_panel.dart';
import '../providers/purchase_order_lookups_provider.dart';
import '../providers/purchase_orders_provider.dart';
import '../widgets/po_status_chip.dart';
@ -417,24 +420,74 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
onChanged: (v) => setState(() => _vendorId = v),
validator: (v) =>
v == null ? 'Vendor is required' : null,
addNewLabel: ref.can(
'vendors',
PermissionAction.create,
)
? 'Add vendor'
: null,
onAddNew: !ref.can(
'vendors',
PermissionAction.create,
)
? null
: () async {
final createdId =
await openVendorFormPanel(
context,
ref,
);
if (!mounted || createdId == null) {
return;
}
ref.invalidate(
purchaseOrderLookupsProvider,
);
await ref.read(
purchaseOrderLookupsProvider.future,
);
if (!mounted) return;
setState(
() => _vendorId =
int.tryParse(createdId),
);
},
),
AppSearchableDropdown<int>(
MasterQuickAddDropdown<int>(
masterId: 'locations',
label: 'Billing *',
value: _dropdownValue(_billingId, locationIds),
hint: 'Select billing location',
searchHint: 'Search plant or warehouse...',
options: _intOptions(lookups.locations),
openInSidePanel: true,
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(
purchaseOrderLookupsProvider.future,
);
},
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _billingId = v),
validator: (v) =>
v == null ? 'Billing is required' : null,
),
AppSearchableDropdown<int>(
MasterQuickAddDropdown<int>(
masterId: 'locations',
label: 'Shipping *',
value: _dropdownValue(_shippingId, locationIds),
hint: 'Select shipping location',
searchHint: 'Search plant or warehouse...',
options: _intOptions(lookups.locations),
openInSidePanel: true,
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(
purchaseOrderLookupsProvider.future,
);
},
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _shippingId = v),
validator: (v) =>
@ -448,6 +501,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
searchHint: 'Search payment term...',
options:
_nullableIntOptions(lookups.paymentTerms),
openInSidePanel: true,
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
@ -462,6 +516,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
searchHint: 'Search delivery term...',
options:
_nullableIntOptions(lookups.deliveryTerms),
openInSidePanel: true,
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,

View File

@ -185,6 +185,7 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
itemsOnPage: state.orders.length,
itemLabel: pendingOnly
? 'pending approvals'
: 'purchase orders',

View File

@ -541,6 +541,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select item',
searchHint: 'Search item name or code...',
options: itemOptions,
openInSidePanel: true,
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future);
@ -570,6 +571,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select UOM',
searchHint: 'Search UOM...',
options: uomOptions,
openInSidePanel: true,
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future);
@ -606,6 +608,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select',
searchHint: 'Search GST %...',
options: gstOptions,
openInSidePanel: true,
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future);

View File

@ -866,6 +866,7 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
totalPages: usersState.totalPages,
totalItems: total,
pageSize: pageSize,
itemsOnPage: usersState.users.length,
itemLabel: 'users',
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
onPageChanged:
@ -1126,29 +1127,39 @@ class _RolesTab extends ConsumerWidget {
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: appearance.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
appearance.icon,
color: appearance.color,
size: 20,
Tooltip(
message: role.name,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color:
appearance.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
appearance.icon,
color: appearance.color,
size: 20,
),
),
),
const Spacer(),
if (canEditRole)
IconButton(
tooltip: 'Edit role',
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () => onEditRole(role),
visualDensity: VisualDensity.compact,
),
if (canDeleteRole && !isProtectedRole(role))
IconButton(
icon: const Icon(Icons.delete_outline, size: 18),
tooltip: 'Delete role',
icon: Icon(
Icons.delete_outline,
size: 18,
color: Theme.of(context).colorScheme.error,
),
onPressed: () => onDeleteRole(role),
visualDensity: VisualDensity.compact,
),
@ -1183,38 +1194,60 @@ class _RolesTab extends ConsumerWidget {
const SizedBox(height: 10),
Row(
children: [
Icon(
Icons.people_outline,
size: 14,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${role.userCount} users',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
Tooltip(
message: 'Assigned users',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.people_outline,
size: 14,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${role.userCount} users',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 16),
Icon(
Icons.vpn_key_outlined,
size: 14,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${role.permissionCount} permissions',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
Tooltip(
message: 'Granted permissions',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.vpn_key_outlined,
size: 14,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${role.permissionCount} permissions',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
),
),
],
),
@ -1231,6 +1264,7 @@ class _RolesTab extends ConsumerWidget {
totalPages: rolesState.totalPages,
totalItems: rolesState.total,
pageSize: rolesState.limit,
itemsOnPage: rolesState.pagedRoles.length,
itemLabel: 'roles',
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
onPageChanged: notifier.setPage,

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../domain/entities/depreciation_report.dart';
@ -47,12 +48,16 @@ class ReportsRemoteDataSource {
)
.toList();
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).clamp(1, 999999)
: 1;
final pagination = parsePagination(
body: body,
fallbackPage: query.page,
fallbackLimit: query.limit,
itemCount: items.length,
);
final page = pagination.page;
final limit = query.limit;
final total = pagination.total;
final totalPages = resolveTotalPages(total: total, limit: limit);
final summaryRaw = meta['summary'];
final summary = summaryRaw is Map

View File

@ -208,6 +208,7 @@ class _DepreciationReportScreenState
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
itemsOnPage: state.items.length,
itemLabel: 'assets',
onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize,

View File

@ -162,6 +162,7 @@ class PermissionMatrixNotifier
final current = state.valueOrNull;
if (current == null) return;
final normalizedAction = action.toLowerCase();
final updated = current.modules.map((row) {
if (row.moduleId != moduleId) return row;
if (!isPermissionActionApplicable(
@ -172,7 +173,19 @@ class PermissionMatrixNotifier
return row;
}
final granted = Map<String, bool>.from(row.granted);
granted[action] = value;
granted[normalizedAction] = value;
// CREATE / EDIT imply VIEW.
if (value &&
(normalizedAction == 'create' || normalizedAction == 'edit') &&
isPermissionActionApplicable(
row.code,
'view',
columnActions: current.actionColumns,
)) {
granted['view'] = true;
}
return row.copyWith(granted: granted);
}).toList();

View File

@ -81,6 +81,7 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.limit,
itemsOnPage: state.roles.length,
itemLabel: 'roles',
onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize,

View File

@ -9,6 +9,75 @@ import '../providers/settings_provider.dart';
import '../widgets/settings_widgets.dart';
import '../../../../shared/widgets/app_toast.dart';
class _BrandThemePreset {
const _BrandThemePreset({
required this.name,
required this.primary,
required this.secondary,
});
final String name;
final int primary;
final int secondary;
bool matches(BrandingConfig branding) =>
branding.primaryColorValue == primary &&
branding.secondaryColorValue == secondary;
}
const _brandThemePresets = <_BrandThemePreset>[
_BrandThemePreset(
name: 'Ocean Blue',
primary: 0xFF2563EB,
secondary: 0xFF0891B2,
),
_BrandThemePreset(
name: 'Royal Purple',
primary: 0xFF6D28D9,
secondary: 0xFF0F766E,
),
_BrandThemePreset(
name: 'Emerald Green',
primary: 0xFF15803D,
secondary: 0xFF0F766E,
),
_BrandThemePreset(
name: 'Sunset Orange',
primary: 0xFFEA580C,
secondary: 0xFFD97706,
),
_BrandThemePreset(
name: 'Crimson Red',
primary: 0xFFDC2626,
secondary: 0xFFB45309,
),
_BrandThemePreset(
name: 'Indigo Sky',
primary: 0xFF4F46E5,
secondary: 0xFF0284C7,
),
_BrandThemePreset(
name: 'Slate Blue',
primary: 0xFF334155,
secondary: 0xFF2563EB,
),
_BrandThemePreset(
name: 'Teal Mint',
primary: 0xFF0F766E,
secondary: 0xFF059669,
),
_BrandThemePreset(
name: 'Rose Pink',
primary: 0xFFDB2777,
secondary: 0xFF7C3AED,
),
_BrandThemePreset(
name: 'Charcoal Gold',
primary: 0xFF374151,
secondary: 0xFFD97706,
),
];
class AppearanceSettingsScreen extends ConsumerWidget {
const AppearanceSettingsScreen({super.key});
@ -17,6 +86,9 @@ class AppearanceSettingsScreen extends ConsumerWidget {
final themeMode = ref.watch(themeModeProvider);
final branding = ref.watch(brandingProvider);
final uiPrefs = ref.watch(appSettingsProvider).uiPreferences;
final selectedPreset = _brandThemePresets
.where((preset) => preset.matches(branding))
.firstOrNull;
return SettingsPageLayout(
title: 'Appearance',
@ -43,58 +115,66 @@ class AppearanceSettingsScreen extends ConsumerWidget {
const SizedBox(height: 16),
SettingsFormCard(
title: 'Branding',
subtitle: 'Primary and secondary colors for the application',
subtitle:
'Professionally curated primary and secondary color pairs',
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(backgroundColor: branding.primaryColor),
title: const Text('Primary Color'),
subtitle: Text(
'#${branding.primaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}',
),
_BrandingPreviewCard(
primary: branding.primaryColor,
secondary: branding.secondaryColor,
themeName: selectedPreset?.name ?? 'Custom',
primaryHex: _hex(branding.primaryColorValue),
secondaryHex: _hex(branding.secondaryColorValue),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(backgroundColor: branding.secondaryColor),
title: const Text('Secondary Color'),
subtitle: Text(
'#${branding.secondaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}',
),
const SizedBox(height: 20),
Text(
'Color themes',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_ColorPreset(
label: 'Blue',
primary: 0xFF1565C0,
secondary: 0xFF00897B,
branding: branding,
ref: ref,
),
_ColorPreset(
label: 'Purple',
primary: 0xFF6A1B9A,
secondary: 0xFF00838F,
branding: branding,
ref: ref,
),
_ColorPreset(
label: 'Green',
primary: 0xFF2E7D32,
secondary: 0xFF558B2F,
branding: branding,
ref: ref,
),
_ColorPreset(
label: 'Orange',
primary: 0xFFE65100,
secondary: 0xFFF57C00,
branding: branding,
ref: ref,
),
],
const SizedBox(height: 4),
Text(
'Choose a cohesive pair optimized for light and dark modes.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final crossAxisCount = width >= 720
? 3
: width >= 480
? 2
: 1;
final spacing = 12.0;
final itemWidth =
(width - spacing * (crossAxisCount - 1)) / crossAxisCount;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final preset in _brandThemePresets)
SizedBox(
width: itemWidth,
child: _ThemePresetCard(
preset: preset,
selected: preset.matches(branding),
onTap: () {
ref.read(brandingProvider.notifier).updateBranding(
branding.copyWith(
primaryColorValue: preset.primary,
secondaryColorValue: preset.secondary,
),
);
},
),
),
],
);
},
),
],
),
@ -112,10 +192,13 @@ class AppearanceSettingsScreen extends ConsumerWidget {
: 'Horizontal menu bar at the top',
),
value: layout,
groupValue: NavigationLayout.fromValue(uiPrefs.navigationLayout),
groupValue:
NavigationLayout.fromValue(uiPrefs.navigationLayout),
onChanged: (v) {
if (v != null) {
ref.read(appSettingsProvider.notifier).updateUiPreferences(
ref
.read(appSettingsProvider.notifier)
.updateUiPreferences(
uiPrefs.copyWith(navigationLayout: v.value),
);
}
@ -183,8 +266,11 @@ class AppearanceSettingsScreen extends ConsumerWidget {
AppButton(
label: 'Settings Auto-Saved',
onPressed: () {
showAppToastFromSnackBar(context,
const SnackBar(content: Text('Appearance settings are saved automatically')),
showAppToastFromSnackBar(
context,
const SnackBar(
content: Text('Appearance settings are saved automatically'),
),
);
},
),
@ -198,42 +284,348 @@ class AppearanceSettingsScreen extends ConsumerWidget {
ThemeModeOption.dark => 'Dark Theme',
ThemeModeOption.system => 'System Theme',
};
static String _hex(int value) =>
'#${value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}';
}
class _ColorPreset extends StatelessWidget {
const _ColorPreset({
required this.label,
class _BrandingPreviewCard extends StatelessWidget {
const _BrandingPreviewCard({
required this.primary,
required this.secondary,
required this.branding,
required this.ref,
required this.themeName,
required this.primaryHex,
required this.secondaryHex,
});
final String label;
final int primary;
final int secondary;
final BrandingConfig branding;
final WidgetRef ref;
final Color primary;
final Color secondary;
final String themeName;
final String primaryHex;
final String secondaryHex;
@override
Widget build(BuildContext context) {
return OutlinedButton(
onPressed: () {
ref.read(brandingProvider.notifier).updateBranding(
branding.copyWith(
primaryColorValue: primary,
secondaryColorValue: secondary,
),
);
},
child: Row(
mainAxisSize: MainAxisSize.min,
final theme = Theme.of(context);
final onPrimary =
ThemeData.estimateBrightnessForColor(primary) == Brightness.dark
? Colors.white
: Colors.black87;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
CircleAvatar(radius: 8, backgroundColor: Color(primary)),
const SizedBox(width: 6),
CircleAvatar(radius: 8, backgroundColor: Color(secondary)),
const SizedBox(width: 8),
Text(label),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: primary,
child: Row(
children: [
Icon(Icons.dashboard_outlined, color: onPrimary, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
'Bharat ERP · $themeName',
style: theme.textTheme.labelLarge?.copyWith(
color: onPrimary,
fontWeight: FontWeight.w700,
),
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: secondary,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'Accent',
style: theme.textTheme.labelSmall?.copyWith(
color: ThemeData.estimateBrightnessForColor(secondary) ==
Brightness.dark
? Colors.white
: Colors.black87,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Live preview',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'How headers, primary actions, and secondary accents will look.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () {},
style: FilledButton.styleFrom(
backgroundColor: primary,
foregroundColor: onPrimary,
),
icon: const Icon(Icons.add, size: 18),
label: const Text('Add'),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton(
onPressed: () {},
style: OutlinedButton.styleFrom(
foregroundColor: secondary,
side: BorderSide(
color: secondary.withValues(alpha: 0.55),
),
),
child: const Text('Export'),
),
),
],
),
const SizedBox(height: 14),
Row(
children: [
_ColorSwatchLabel(
color: primary,
label: 'Primary',
hex: primaryHex,
),
const SizedBox(width: 16),
_ColorSwatchLabel(
color: secondary,
label: 'Secondary',
hex: secondaryHex,
),
],
),
],
),
),
],
),
);
}
}
class _ColorSwatchLabel extends StatelessWidget {
const _ColorSwatchLabel({
required this.color,
required this.label,
required this.hex,
});
final Color color;
final String label;
final String hex;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.25),
),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.28),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
hex,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
);
}
}
class _ThemePresetCard extends StatelessWidget {
const _ThemePresetCard({
required this.preset,
required this.selected,
required this.onTap,
});
final _BrandThemePreset preset;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primary = Color(preset.primary);
final secondary = Color(preset.secondary);
final borderColor = selected
? primary
: theme.colorScheme.outline.withValues(alpha: 0.28);
return Material(
color: selected
? primary.withValues(alpha: 0.06)
: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: borderColor,
width: selected ? 2 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_StackedColorCircles(
primary: primary,
secondary: secondary,
),
const Spacer(),
if (selected)
Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
size: 14,
color: ThemeData.estimateBrightnessForColor(primary) ==
Brightness.dark
? Colors.white
: Colors.black87,
),
),
],
),
const SizedBox(height: 12),
Text(
preset.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'${AppearanceSettingsScreen._hex(preset.primary)} · '
'${AppearanceSettingsScreen._hex(preset.secondary)}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
}
class _StackedColorCircles extends StatelessWidget {
const _StackedColorCircles({
required this.primary,
required this.secondary,
});
final Color primary;
final Color secondary;
@override
Widget build(BuildContext context) {
final outline = Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.2);
return SizedBox(
width: 56,
height: 32,
child: Stack(
children: [
Positioned(
left: 0,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: primary,
shape: BoxShape.circle,
border: Border.all(color: outline, width: 2),
),
),
),
Positioned(
left: 22,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: secondary,
shape: BoxShape.circle,
border: Border.all(color: outline, width: 2),
),
),
),
],
),
);

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/export_file_result.dart';
@ -80,19 +81,22 @@ class UserRemoteDataSource {
(json) => ManagedUserModel.fromJson(json! as Map<String, dynamic>),
).items;
final meta = body['meta'] as Map<String, dynamic>? ?? {};
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).clamp(1, 999999) : 1;
final pagination = parsePagination(
body: body,
fallbackPage: query.page,
fallbackLimit: query.limit,
itemCount: items.length,
);
return PaginatedResponse<ManagedUserModel>(
items: items,
page: page,
limit: limit,
total: total,
totalPages: totalPages,
page: pagination.page,
limit: query.limit,
total: pagination.total,
totalPages: resolveTotalPages(
total: pagination.total,
limit: query.limit,
),
);
}

View File

@ -101,6 +101,7 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
itemsOnPage: state.users.length,
itemLabel: 'users',
onPageChanged: ref.read(usersListProvider.notifier).setPage,
onPageSizeChanged:

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/export_file_name.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/export_file_result.dart';
@ -17,7 +18,12 @@ class VendorRemoteDataSource {
ApiEndpoints.vendors,
queryParameters: _queryToMap(query),
);
return _parsePaginated(response.data, VendorModel.fromJson);
return _parsePaginated(
response.data,
VendorModel.fromJson,
fallbackPage: query.page,
fallbackLimit: query.limit,
);
}
/// Form-dropdown loader: all active vendors (`dropdown_call=true`).
@ -246,47 +252,45 @@ class VendorRemoteDataSource {
PaginatedResponse<T> _parsePaginated<T>(
dynamic body,
T Function(Map<String, dynamic>) fromJson,
) {
if (body is! Map<String, dynamic>) {
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
);
T Function(Map<String, dynamic>) fromJson, {
int fallbackPage = 1,
int fallbackLimit = 20,
}) {
var items = <T>[];
if (body is Map) {
final raw = body['data'];
if (raw is List) {
items = raw
.whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList();
} else if (raw is Map) {
final list = raw['items'];
if (list is List) {
items = list
.whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList();
}
}
}
final raw = body['data'];
final meta = body['meta'] as Map<String, dynamic>? ?? {};
final pagination = parsePagination(
body: body,
fallbackPage: fallbackPage,
fallbackLimit: fallbackLimit,
itemCount: items.length,
);
if (raw is List) {
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList();
final limit = (meta['limit'] as num?)?.toInt() ?? items.length;
final total = (meta['total'] as num?)?.toInt() ?? items.length;
return PaginatedResponse(
items: items,
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: limit,
total: total,
totalPages: limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
);
}
if (raw is Map<String, dynamic>) {
return PaginatedResponse.fromJson(
raw,
(json) => fromJson(json! as Map<String, dynamic>),
);
}
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
return PaginatedResponse(
items: items,
page: pagination.page,
limit: fallbackLimit,
total: pagination.total,
totalPages: resolveTotalPages(
total: pagination.total,
limit: fallbackLimit,
),
);
}
}

View File

@ -129,6 +129,7 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
itemsOnPage: state.vendors.length,
itemLabel: 'vendors',
onPageChanged: ref.read(vendorsListProvider.notifier).setPage,
onPageSizeChanged:

View File

@ -19,19 +19,20 @@ import '../../data/repositories/vendor_repository_impl.dart';
import '../providers/vendors_provider.dart';
import '../../../../shared/widgets/app_toast.dart';
Future<void> openVendorFormPanel(
Future<String?> openVendorFormPanel(
BuildContext context,
WidgetRef ref, {
String? vendorId,
}) async {
ref.invalidate(vendorFormProvider(vendorId));
final saved = await showSidePanel<bool>(
final savedId = await showSidePanel<String>(
context,
VendorFormPanel(vendorId: vendorId),
width: 560,
);
if (saved == true && context.mounted) {
showAppToastFromSnackBar(context,
if (savedId != null && context.mounted) {
showAppToastFromSnackBar(
context,
SnackBar(
content: Text(
vendorId == null
@ -41,6 +42,7 @@ Future<void> openVendorFormPanel(
),
);
}
return savedId;
}
class VendorFormPanel extends ConsumerStatefulWidget {
@ -148,12 +150,15 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
try {
final notifier = ref.read(vendorFormProvider(widget.vendorId).notifier);
final payload = _buildPayload();
final String savedId;
if (widget.isEditing) {
await notifier.submitUpdate(widget.vendorId!, payload);
savedId = widget.vendorId!;
} else {
await notifier.submitCreate(payload);
final created = await notifier.submitCreate(payload);
savedId = created.id;
}
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
if (mounted) Navigator.of(context, rootNavigator: true).pop(savedId);
} catch (e) {
if (mounted) {
showAppToastFromSnackBar(context,

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../core/utils/formatters.dart';
import 'app_searchable_dropdown.dart';
class AppDropdownOption<T> {
@ -61,14 +62,15 @@ class AppDropdown<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
final subject = dropdownHintLabel(label);
return AppSearchableDropdown<T>(
label: label,
value: value,
options: options,
onChanged: onChanged,
validator: validator,
hint: hint,
searchHint: searchHint ?? 'Search ${label.toLowerCase()}...',
hint: hint ?? 'Select $subject',
searchHint: searchHint ?? 'Search $subject...',
enabled: enabled,
isDense: isDense,
addNewLabel: addNewLabel,

View File

@ -9,6 +9,7 @@ class AppPagination extends StatelessWidget {
required this.pageSize,
required this.onPageChanged,
this.onPageSizeChanged,
this.itemsOnPage,
this.pageSizeOptions = const [10, 20, 50],
this.itemLabel = 'items',
this.maxVisiblePages = 10,
@ -21,6 +22,8 @@ class AppPagination extends StatelessWidget {
final int pageSize;
final ValueChanged<int> onPageChanged;
final ValueChanged<int>? onPageSizeChanged;
/// When set, "Showing" end index uses loaded row count instead of pageSize.
final int? itemsOnPage;
final List<int> pageSizeOptions;
final String itemLabel;
final int maxVisiblePages;
@ -30,7 +33,11 @@ class AppPagination extends StatelessWidget {
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 end = totalItems == 0
? 0
: itemsOnPage != null
? (start + itemsOnPage! - 1).clamp(0, totalItems)
: (currentPage * pageSize).clamp(0, totalItems);
final visiblePages = _visiblePageNumbers(currentPage, totalPages);
final sizes = List<int>.from(pageSizeOptions);
if (!sizes.contains(pageSize)) {

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../core/utils/formatters.dart';
import 'app_dropdown.dart';
/// Dropdown that opens a searchable popup anchored to the field.
@ -206,7 +207,8 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
initialValue: widget.value,
validator: widget.validator,
builder: (field) {
final effectiveHint = widget.hint ?? 'Select ${widget.label.toLowerCase()}';
final effectiveHint =
widget.hint ?? 'Select ${dropdownHintLabel(widget.label)}';
final canOpen = widget.enabled &&
(widget.options.isNotEmpty || widget.onAddNew != null);
final colors = theme.colorScheme;
@ -595,7 +597,7 @@ class _AppSearchableLookupFieldState<T>
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveHint =
widget.hint ?? 'Select ${widget.label.toLowerCase()}';
widget.hint ?? 'Select ${dropdownHintLabel(widget.label)}';
final canOpen = widget.enabled &&
(widget.options.isNotEmpty || widget.onAddNew != null);
final colors = theme.colorScheme;

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../core/utils/formatters.dart';
import 'app_dropdown.dart';
/// Dropdown that opens a searchable popup with multi-select checkboxes.
@ -167,7 +168,7 @@ class _AppSearchableMultiSelectDropdownState<T>
validator: widget.validator,
builder: (field) {
final effectiveHint =
widget.hint ?? 'Select ${widget.label.toLowerCase()}';
widget.hint ?? 'Select ${dropdownHintLabel(widget.label)}';
final canOpen = widget.enabled && widget.options.isNotEmpty;
final colors = theme.colorScheme;
final labels = _selectedLabels();

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@ -28,9 +30,33 @@ class AppTopNav extends ConsumerWidget {
final void Function(String route) onItemTap;
final UserModel? user;
bool _isSelected(String route) {
bool _routeMatches(String route) {
if (route == '/') return currentRoute == route;
return currentRoute.startsWith(route);
if (currentRoute == route) return true;
return currentRoute.startsWith('$route/');
}
bool _isSelectedAmongSiblings(
String route,
List<menu.MenuItem> siblings,
) {
if (!_routeMatches(route)) return false;
for (final sibling in siblings) {
if (sibling.route == route) continue;
if (sibling.route.length > route.length && _routeMatches(sibling.route)) {
return false;
}
}
return true;
}
bool _isGroupActive(menu.MenuItem item) => item.children.any(
(child) => _isSelectedAmongSiblings(child.route, item.children),
);
bool _isItemSelected(menu.MenuItem item) {
if (item.children.isNotEmpty) return _isGroupActive(item);
return _routeMatches(item.route);
}
@override
@ -72,30 +98,25 @@ class AppTopNav extends ConsumerWidget {
scrollDirection: Axis.horizontal,
child: Row(
children: menuItems.map((item) {
final selected = _isSelected(item.route);
final selected = _isItemSelected(item);
return Padding(
padding: const EdgeInsets.only(right: 4),
child: TextButton(
onPressed: () => onItemTap(item.route),
style: TextButton.styleFrom(
foregroundColor: selected
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
backgroundColor: selected
? theme.colorScheme.primary.withValues(alpha: 0.08)
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
item.label,
style: TextStyle(
fontWeight:
selected ? FontWeight.w600 : FontWeight.w500,
),
),
),
child: item.children.isNotEmpty
? _TopNavMenuGroup(
item: item,
selected: selected,
isChildSelected: (route) =>
_isSelectedAmongSiblings(
route,
item.children,
),
onChildTap: onItemTap,
)
: _TopNavMenuButton(
label: item.label,
selected: selected,
onPressed: () => onItemTap(item.route),
),
);
}).toList(),
),
@ -123,6 +144,294 @@ class AppTopNav extends ConsumerWidget {
}
}
class _TopNavMenuLabel extends StatelessWidget {
const _TopNavMenuLabel({
required this.label,
required this.selected,
this.showChevron = false,
});
final String label;
final bool selected;
final bool showChevron;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = selected
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: selected
? theme.colorScheme.primary.withValues(alpha: 0.08)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
color: color,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
),
if (showChevron) ...[
const SizedBox(width: 2),
Icon(Icons.arrow_drop_down, size: 18, color: color),
],
],
),
);
}
}
class _TopNavMenuButton extends StatelessWidget {
const _TopNavMenuButton({
required this.label,
required this.selected,
required this.onPressed,
});
final String label;
final bool selected;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: _TopNavMenuLabel(label: label, selected: selected),
);
}
}
class _TopNavMenuGroup extends StatefulWidget {
const _TopNavMenuGroup({
required this.item,
required this.selected,
required this.isChildSelected,
required this.onChildTap,
});
final menu.MenuItem item;
final bool selected;
final bool Function(String route) isChildSelected;
final void Function(String route) onChildTap;
@override
State<_TopNavMenuGroup> createState() => _TopNavMenuGroupState();
}
class _TopNavMenuGroupState extends State<_TopNavMenuGroup> {
final _anchorKey = GlobalKey();
OverlayEntry? _overlayEntry;
Timer? _hideTimer;
bool _open = false;
@override
void dispose() {
_hideTimer?.cancel();
_removeOverlay();
super.dispose();
}
void _cancelHide() {
_hideTimer?.cancel();
_hideTimer = null;
}
void _scheduleHide() {
_cancelHide();
_hideTimer = Timer(const Duration(milliseconds: 120), () {
if (!mounted) return;
_removeOverlay();
});
}
void _removeOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
if (mounted && _open) setState(() => _open = false);
}
void _showOverlay() {
_cancelHide();
if (_overlayEntry != null) return;
_insertOverlay();
if (_overlayEntry == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _overlayEntry != null) return;
_insertOverlay();
});
}
}
void _insertOverlay() {
if (_overlayEntry != null) return;
final renderBox =
_anchorKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null || !renderBox.hasSize) return;
final anchorTopLeft = renderBox.localToGlobal(Offset.zero);
final anchorSize = renderBox.size;
const panelWidth = 220.0;
_overlayEntry = OverlayEntry(
builder: (overlayContext) {
final theme = Theme.of(overlayContext);
final primary = theme.colorScheme.primary;
final isDark = theme.brightness == Brightness.dark;
final panelColor = isDark ? theme.colorScheme.surface : Colors.white;
return Positioned(
left: anchorTopLeft.dx,
top: anchorTopLeft.dy,
child: TapRegion(
onTapOutside: (_) => _removeOverlay(),
child: MouseRegion(
onEnter: (_) => _cancelHide(),
onExit: (_) => _scheduleHide(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: anchorSize.width,
height: anchorSize.height,
),
Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: panelColor,
shadowColor: Colors.black26,
child: SizedBox(
width: panelWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final child in widget.item.children)
_TopNavSubmenuItem(
icon: child.icon,
label: child.label,
selected: widget.isChildSelected(child.route),
primary: primary,
onTap: () {
_removeOverlay();
widget.onChildTap(child.route);
},
),
],
),
),
),
],
),
),
),
);
},
);
Overlay.of(context).insert(_overlayEntry!);
setState(() => _open = true);
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => _showOverlay(),
onExit: (_) => _scheduleHide(),
child: GestureDetector(
onTap: _showOverlay,
child: KeyedSubtree(
key: _anchorKey,
child: _TopNavMenuLabel(
label: widget.item.label,
selected: widget.selected || _open,
showChevron: true,
),
),
),
);
}
}
class _TopNavSubmenuItem extends StatefulWidget {
const _TopNavSubmenuItem({
required this.icon,
required this.label,
required this.selected,
required this.primary,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final Color primary;
final VoidCallback onTap;
@override
State<_TopNavSubmenuItem> createState() => _TopNavSubmenuItemState();
}
class _TopNavSubmenuItemState extends State<_TopNavSubmenuItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final highlight = widget.selected || _hovered;
final color = highlight ? widget.primary : theme.colorScheme.onSurfaceVariant;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
height: 40,
margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: highlight
? widget.primary.withValues(alpha: 0.08)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(widget.icon, size: 18, color: color),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: color,
fontWeight:
highlight ? FontWeight.w600 : FontWeight.w500,
),
),
),
],
),
),
),
);
}
}
class _TopNavUserMenu extends ConsumerWidget {
const _TopNavUserMenu({this.userName, this.avatarUrl});

View File

@ -17,10 +17,13 @@ class ThemeKeyedSubtree extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeProvider);
final branding = ref.watch(brandingProvider);
final brightness = Theme.of(context).brightness;
final brandKey =
'${branding.primaryColorValue}-${branding.secondaryColorValue}';
final key = pageKey == null
? '$themeMode-$brightness'
: '$pageKey-$themeMode-$brightness';
? '$themeMode-$brightness-$brandKey'
: '$pageKey-$themeMode-$brightness-$brandKey';
return KeyedSubtree(
key: ValueKey(key),