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 { class AppColors {
AppColors._(); AppColors._();
static const Color primary = Color(0xFF1565C0); static const Color primary = Color(0xFF2563EB);
static const Color secondary = Color(0xFF00897B); static const Color secondary = Color(0xFF0891B2);
static const Color error = Color(0xFFD32F2F); static const Color error = Color(0xFFD32F2F);
static const Color warning = Color(0xFFF57C00); static const Color warning = Color(0xFFF57C00);
static const Color success = Color(0xFF388E3C); static const Color success = Color(0xFF388E3C);

View File

@ -12,8 +12,9 @@ class AppTheme {
final primary = branding?.primaryColor ?? AppColors.primary; final primary = branding?.primaryColor ?? AppColors.primary;
final secondary = branding?.secondaryColor ?? AppColors.secondary; final secondary = branding?.secondaryColor ?? AppColors.secondary;
final colorScheme = ColorScheme.fromSeed( final colorScheme = _brandedColorScheme(
seedColor: primary, seed: primary,
primary: primary,
secondary: secondary, secondary: secondary,
brightness: Brightness.light, brightness: Brightness.light,
surface: AppColors.lightSurface, surface: AppColors.lightSurface,
@ -26,8 +27,9 @@ class AppTheme {
final primary = branding?.primaryColor ?? AppColors.primary; final primary = branding?.primaryColor ?? AppColors.primary;
final secondary = branding?.secondaryColor ?? AppColors.secondary; final secondary = branding?.secondaryColor ?? AppColors.secondary;
final colorScheme = ColorScheme.fromSeed( final colorScheme = _brandedColorScheme(
seedColor: primary, seed: primary,
primary: primary,
secondary: secondary, secondary: secondary,
brightness: Brightness.dark, brightness: Brightness.dark,
surface: AppColors.darkSurface, surface: AppColors.darkSurface,
@ -36,12 +38,56 @@ class AppTheme {
return _buildTheme(colorScheme, Brightness.dark); 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). /// Theme for white cards, form fields, and picker sheets (unchanged in dark mode).
static ThemeData cardContentTheme(ThemeData theme) { static ThemeData cardContentTheme(ThemeData theme) {
final scheme = theme.brightness == Brightness.light final scheme = theme.brightness == Brightness.light
? theme.colorScheme ? theme.colorScheme
: ColorScheme.fromSeed( : _brandedColorScheme(
seedColor: theme.colorScheme.primary, seed: theme.colorScheme.primary,
primary: theme.colorScheme.primary,
secondary: theme.colorScheme.secondary, secondary: theme.colorScheme.secondary,
brightness: Brightness.light, brightness: Brightness.light,
surface: AppColors.card, surface: AppColors.card,

View File

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

View File

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

View File

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

View File

@ -58,3 +58,47 @@ class CurrencyFormatter {
return _formatter.format(amount); 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(); 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. /// 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 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/export_file_name.dart'; import '../../../../core/utils/export_file_name.dart';
import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/asset_model.dart';
@ -17,7 +18,12 @@ class AssetRemoteDataSource {
ApiEndpoints.assets, ApiEndpoints.assets,
queryParameters: _queryToMap(query), 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 { Future<AssetModel> getAssetById(String id) async {
@ -215,7 +221,12 @@ class AssetRemoteDataSource {
if (type != null) 'type': type, 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 { Future<List<AssetAlertModel>> getServiceAlerts({String? status}) async {
@ -370,7 +381,12 @@ class AssetRemoteDataSource {
if (dueOnly) 'due_only': true, 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 { Future<List<AssetMaintenanceLogModel>> getMaintenanceLogs(String assetId) async {
@ -464,86 +480,45 @@ class AssetRemoteDataSource {
PaginatedResponse<T> _parsePaginated<T>( PaginatedResponse<T> _parsePaginated<T>(
dynamic body, dynamic body,
T Function(Map<String, dynamic>) fromJson, T Function(Map<String, dynamic>) fromJson, {
) { int fallbackPage = 1,
if (body is! Map) { int fallbackLimit = 20,
return const PaginatedResponse( }) {
items: [], var items = <T>[];
page: 1, if (body is Map) {
limit: 20, final raw = body['data'];
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) { if (raw is List) {
final items = raw items = raw
.whereType<Map>() .whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e))) .map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList(); .toList();
final limit = (meta['limit'] as num?)?.toInt() ?? items.length; } else if (raw is Map) {
final total = (meta['total'] as num?)?.toInt() ?? items.length; final list = raw['items'];
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) { if (list is List) {
final items = list items = list
.whereType<Map>() .whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e))) .map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList(); .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),
);
} }
} }
return const PaginatedResponse( final pagination = parsePagination(
items: [], body: body,
page: 1, fallbackPage: fallbackPage,
limit: 20, fallbackLimit: fallbackLimit,
total: 0, itemCount: items.length,
totalPages: 1, );
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/entity_attachment_model.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
import '../../data/repositories/asset_repository_impl.dart'; import '../../data/repositories/asset_repository_impl.dart';
import 'asset_categories_provider.dart';
class AssetsListState { class AssetsListState {
const AssetsListState({ const AssetsListState({
@ -68,11 +69,49 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
final result = await repository.getAssets(query); final result = await repository.getAssets(query);
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
final page = result.data!; 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( return AssetsListState(
assets: page.items, assets: items,
query: query, query: query,
total: page.total, total: search.isEmpty ? page.total : items.length,
totalPages: page.totalPages, totalPages: search.isEmpty ? page.totalPages : 1,
); );
} }

View File

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

View File

@ -142,6 +142,7 @@ class _MyMaintenanceBody extends ConsumerWidget {
totalPages: state.totalPages, totalPages: state.totalPages,
totalItems: state.total, totalItems: state.total,
pageSize: state.limit, pageSize: state.limit,
itemsOnPage: state.assets.length,
onPageChanged: notifier.setPage, onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize, onPageSizeChanged: notifier.setPageSize,
itemLabel: 'assets', 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_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.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/asset_form_lookups_provider.dart';
import '../providers/assets_provider.dart'; import '../providers/assets_provider.dart';
@ -1223,7 +1222,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
final _reasonController = TextEditingController(); final _reasonController = TextEditingController();
DateTime _transferDate = DateTime.now(); DateTime _transferDate = DateTime.now();
int? _toLocationId; int? _toLocationId;
int? _toDepartmentId;
int? _toUserId; int? _toUserId;
bool _isSubmitting = false; bool _isSubmitting = false;
@ -1249,10 +1247,7 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
Future<void> _save() async { Future<void> _save() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
final hasDestination = final hasDestination = _toLocationId != null || _toUserId != null;
_toLocationId != null ||
_toDepartmentId != null ||
_toUserId != null;
if (!hasDestination) { if (!hasDestination) {
showSidePanelSnackBar( showSidePanelSnackBar(
context, context,
@ -1266,7 +1261,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
await ref.read(assetDetailProvider(widget.assetId).notifier).transferAsset({ await ref.read(assetDetailProvider(widget.assetId).notifier).transferAsset({
'transfer_date': DateFormatter.toApiDate(_transferDate), 'transfer_date': DateFormatter.toApiDate(_transferDate),
if (_toLocationId != null) 'to_location_id': _toLocationId, if (_toLocationId != null) 'to_location_id': _toLocationId,
if (_toDepartmentId != null) 'to_department_id': _toDepartmentId,
if (_toUserId != null) 'to_user_id': _toUserId, if (_toUserId != null) 'to_user_id': _toUserId,
'reason': _reasonController.text.trim(), 'reason': _reasonController.text.trim(),
}); });
@ -1323,25 +1317,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
onChanged: (v) => setState(() => _toLocationId = v), onChanged: (v) => setState(() => _toLocationId = v),
), ),
const SizedBox(height: 12), 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>( AppSearchableDropdown<int>(
label: 'To User', label: 'To User',
value: _toUserId, value: _toUserId,
@ -1423,36 +1398,11 @@ class _TransferHistoryEntry extends StatelessWidget {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
IntrinsicHeight( _TransferFromToGrid(
child: Row( fromLocation: item.fromLocationName,
crossAxisAlignment: CrossAxisAlignment.center, fromUser: item.fromUserName,
children: [ toLocation: item.toLocationName,
Expanded( toUser: item.toUserName,
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,
),
),
],
),
), ),
if (showReason) ...[ if (showReason) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
@ -1484,46 +1434,86 @@ class _TransferHistoryEntry extends StatelessWidget {
} }
} }
class _TransferLocationBlock extends StatelessWidget { class _TransferFromToGrid extends StatelessWidget {
const _TransferLocationBlock({ const _TransferFromToGrid({
required this.label, this.fromLocation,
this.location, this.fromUser,
this.department, this.toLocation,
this.user, this.toUser,
}); });
final String label; final String? fromLocation;
final String? location; final String? fromUser;
final String? department; final String? toLocation;
final String? user; final String? toUser;
static const _arrowWidth = 34.0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final labelStyle = theme.textTheme.labelSmall?.copyWith(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.6, letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), );
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(child: Text('From', style: labelStyle)),
const SizedBox(width: _arrowWidth),
Expanded(child: Text('To', style: labelStyle)),
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_TransferLocationRow( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _TransferLocationRow(
icon: Icons.place_outlined, icon: Icons.place_outlined,
value: location, value: fromLocation,
), ),
_TransferLocationRow(
icon: Icons.apartment_outlined,
value: department,
), ),
_TransferLocationRow( 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,
),
),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _TransferLocationRow(
icon: Icons.person_outline, icon: Icons.person_outline,
value: user, 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 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../shared/models/audit_log_model.dart'; import '../../../../shared/models/audit_log_model.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
@ -32,20 +33,23 @@ class AuditRemoteDataSource {
: <AuditLogEntryModel>[]; : <AuditLogEntryModel>[];
final meta = body['meta'] as Map<String, dynamic>? ?? {}; final meta = body['meta'] as Map<String, dynamic>? ?? {};
final page = (meta['page'] as num?)?.toInt() ?? query.page; final pagination = parsePagination(
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit; body: body,
final total = (meta['total'] as num?)?.toInt() ?? items.length; fallbackPage: query.page,
final totalPages = limit > 0 fallbackLimit: query.limit,
? ((total + limit - 1) ~/ limit).clamp(1, 999999) itemCount: items.length,
: 1; );
final filtersRequired = meta['filters_required'] == true; final filtersRequired = meta['filters_required'] == true;
return AuditLogListResult( return AuditLogListResult(
items: items, items: items,
page: page, page: pagination.page,
limit: limit, limit: query.limit,
total: total, total: pagination.total,
totalPages: totalPages, totalPages: resolveTotalPages(
total: pagination.total,
limit: query.limit,
),
filtersRequired: filtersRequired, filtersRequired: filtersRequired,
); );
} }

View File

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

View File

@ -57,7 +57,7 @@ class _DetailBody extends StatelessWidget {
label: 'Action', label: 'Action',
child: AppStatusChip(status: detail.action, compact: true), 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: 'Record ID', value: detail.recordId ?? ''),
_DetailRow( _DetailRow(
label: 'Performed At', label: 'Performed At',
@ -269,15 +269,15 @@ List<_FieldRow> _flattenFields(
for (final entry in entries) { for (final entry in entries) {
final key = entry.key.toString(); final key = entry.key.toString();
final label = prefix == null final label = prefix == null
? _humanizeKey(key) ? humanizeLabel(key)
: '${_humanizeKey(prefix)} ${_humanizeKey(key)}'; : '${humanizeLabel(prefix)} ${humanizeLabel(key)}';
final value = entry.value; final value = entry.value;
if (value is Map) { if (value is Map) {
final nested = Map<String, dynamic>.from(value); final nested = Map<String, dynamic>.from(value);
final summary = _nestedSummary(nested); final summary = _nestedSummary(nested);
if (summary != null) { if (summary != null) {
rows.add(_FieldRow(label: _humanizeKey(key), value: summary)); rows.add(_FieldRow(label: humanizeLabel(key), value: summary));
} else { } else {
rows.addAll( rows.addAll(
_flattenFields( _flattenFields(
@ -330,7 +330,7 @@ String? _nestedSummary(Map<String, dynamic> nested) {
return nested.entries return nested.entries
.map( .map(
(e) => (e) =>
'${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}', '${humanizeLabel(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}',
) )
.join(', '); .join(', ');
} }
@ -393,30 +393,3 @@ String _formatValue(String key, Object? value) {
return text; 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 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/export_file_name.dart'; import '../../../../core/utils/export_file_name.dart';
import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
@ -16,7 +17,12 @@ class GrnRemoteDataSource {
ApiEndpoints.grn, ApiEndpoints.grn,
queryParameters: _queryToMap(query), 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`). /// Form-dropdown loader: all active GRNs (`dropdown_call=true`).
@ -158,58 +164,45 @@ class GrnRemoteDataSource {
PaginatedResponse<T> _parsePaginated<T>( PaginatedResponse<T> _parsePaginated<T>(
dynamic body, dynamic body,
T Function(Map<String, dynamic>) fromJson, T Function(Map<String, dynamic>) fromJson, {
) { int fallbackPage = 1,
if (body is! Map<String, dynamic>) { int fallbackLimit = 20,
return const PaginatedResponse( }) {
items: [], var items = <T>[];
page: 1, if (body is Map) {
limit: 20,
total: 0,
totalPages: 1,
);
}
final raw = body['data']; final raw = body['data'];
final meta = body['meta'] as Map<String, dynamic>? ?? {};
if (raw is List) { if (raw is List) {
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList(); items = raw
final limit = (meta['limit'] as num?)?.toInt() ?? items.length; .whereType<Map>()
final total = (meta['total'] as num?)?.toInt() ?? items.length; .map((e) => fromJson(Map<String, dynamic>.from(e)))
return PaginatedResponse( .toList();
items: items, } else if (raw is Map) {
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']; final list = raw['items'];
if (list is List) { if (list is List) {
final items = list.whereType<Map<String, dynamic>>().map(fromJson).toList(); items = list
final limit = (meta['limit'] as num?)?.toInt() ?? 20; .whereType<Map>()
final total = (meta['total'] as num?)?.toInt() ?? items.length; .map((e) => fromJson(Map<String, dynamic>.from(e)))
return PaginatedResponse( .toList();
items: items, }
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: limit,
total: total,
totalPages:
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
);
} }
} }
return const PaginatedResponse( final pagination = parsePagination(
items: [], body: body,
page: 1, fallbackPage: fallbackPage,
limit: 20, fallbackLimit: fallbackLimit,
total: 0, itemCount: items.length,
totalPages: 1, );
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/export_file_result.dart';
import '../../../../shared/models/grn_model.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 '../../data/repositories/grn_repository_impl.dart';
import 'grn_lookups_provider.dart';
class GrnListState { class GrnListState {
const GrnListState({ const GrnListState({
@ -67,11 +71,88 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
final result = await repository.getGrns(query); final result = await repository.getGrns(query);
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
final page = result.data!; 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( return GrnListState(
grns: page.items, grns: items,
query: query, query: query,
total: page.total, total: search.isEmpty ? page.total : items.length,
totalPages: page.totalPages, totalPages: search.isEmpty ? page.totalPages : 1,
); );
} }

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../../../core/utils/active_option.dart'; import '../../../../core/utils/active_option.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
import '../../domain/entities/master_definition.dart'; import '../../domain/entities/master_definition.dart';
@ -17,18 +18,14 @@ class MasterListResult {
required this.total, required this.total,
required this.page, required this.page,
required this.limit, required this.limit,
required this.totalPages,
}); });
final List<Map<String, dynamic>> items; final List<Map<String, dynamic>> items;
final int total; final int total;
final int page; final int page;
final int limit; final int limit;
final int totalPages;
int get totalPages {
if (limit <= 0) return 1;
final pages = (total / limit).ceil();
return pages < 1 ? 1 : pages;
}
} }
class MasterCrudRemoteDataSource { class MasterCrudRemoteDataSource {
@ -68,16 +65,20 @@ class MasterCrudRemoteDataSource {
.map((item) => Map<String, dynamic>.from(item)) .map((item) => Map<String, dynamic>.from(item))
.toList(); .toList();
final meta = raw is Map<String, dynamic> ? raw : body; final pagination = parsePagination(
final total = _asInt(meta['total']) ?? items.length; body: body,
final currentPage = _asInt(meta['page']) ?? page; fallbackPage: page,
final currentLimit = _asInt(meta['limit']) ?? limit; fallbackLimit: limit,
itemCount: items.length,
);
return MasterListResult( return MasterListResult(
items: items, items: items,
total: total, total: pagination.total,
page: currentPage, page: pagination.page,
limit: currentLimit, // 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); if (data is Map<String, dynamic>) return Map<String, dynamic>.from(data);
return Map<String, dynamic>.from(body); 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 page = 1,
int limit = 20, int limit = 20,
String? search, String? search,
Map<String, dynamic>? extraQueryParameters,
}) => }) =>
safeApiCall( safeApiCall(
() => remote.list( () => remote.list(
@ -28,6 +29,7 @@ class MasterRepositoryImpl implements MasterRepository {
page: page, page: page,
limit: limit, limit: limit,
search: search, search: search,
extraQueryParameters: extraQueryParameters,
), ),
); );

View File

@ -22,6 +22,7 @@ class MasterFieldDef {
this.filterByOptionKey, this.filterByOptionKey,
this.visibleWhenFieldKey, this.visibleWhenFieldKey,
this.visibleWhenValue, this.visibleWhenValue,
this.listNestedKey,
}); });
final String key; final String key;
@ -47,6 +48,8 @@ class MasterFieldDef {
/// Show this field only when [visibleWhenFieldKey] equals [visibleWhenValue]. /// Show this field only when [visibleWhenFieldKey] equals [visibleWhenValue].
final String? visibleWhenFieldKey; final String? visibleWhenFieldKey;
final String? visibleWhenValue; 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). /// Cache key for dropdown option rows (includes query params when set).
String get dropdownLookupKey { String get dropdownLookupKey {
@ -272,12 +275,17 @@ const masterDefinitions = <MasterDefinition>[
label: 'Min Order Qty', label: 'Min Order Qty',
type: MasterFieldType.number, type: MasterFieldType.number,
required: true, required: true,
// Stock items only hidden when Asset Item is checked.
visibleWhenFieldKey: 'is_asset_item',
visibleWhenValue: 'false',
), ),
MasterFieldDef( MasterFieldDef(
key: 'reorder_level', key: 'reorder_level',
label: 'Reorder Level', label: 'Reorder Level',
type: MasterFieldType.number, type: MasterFieldType.number,
required: true, required: true,
visibleWhenFieldKey: 'is_asset_item',
visibleWhenValue: 'false',
), ),
MasterFieldDef( MasterFieldDef(
key: 'tags', key: 'tags',
@ -374,6 +382,7 @@ const masterDefinitions = <MasterDefinition>[
showInList: true, showInList: true,
optionsMasterKey: 'locations', optionsMasterKey: 'locations',
optionsQueryParams: const {'type': 'plant'}, optionsQueryParams: const {'type': 'plant'},
listNestedKey: 'plant',
visibleWhenFieldKey: 'type', visibleWhenFieldKey: 'type',
visibleWhenValue: 'warehouse', visibleWhenValue: 'warehouse',
required: true, required: true,
@ -606,10 +615,16 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
if (explicitName != null && explicitName.toString().trim().isNotEmpty) { if (explicitName != null && explicitName.toString().trim().isNotEmpty) {
return explicitName.toString().trim(); return explicitName.toString().trim();
} }
final nested = row[baseKey];
final nestedKeys = <String>[
if (field.listNestedKey != null) field.listNestedKey!,
baseKey,
];
for (final nestedKey in nestedKeys) {
final nested = row[nestedKey];
if (nested is Map) { if (nested is Map) {
for (final nestedKey in ['name', 'code', 'description']) { for (final nameKey in ['name', 'code', 'description']) {
final nestedValue = nested[nestedKey]; final nestedValue = nested[nameKey];
if (nestedValue != null && if (nestedValue != null &&
nestedValue.toString().trim().isNotEmpty) { nestedValue.toString().trim().isNotEmpty) {
return nestedValue.toString().trim(); return nestedValue.toString().trim();
@ -620,6 +635,7 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
return nested.toString().trim(); return nested.toString().trim();
} }
} }
}
if (field.staticOptions != null) { if (field.staticOptions != null) {
return value.toString().replaceAll('_', ' '); return value.toString().replaceAll('_', ' ');
@ -670,6 +686,28 @@ String masterFieldDropdownLookupKey({
return '$masterKey?$query'; 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`. /// Display label for GST filled from HSN nested `gst_rate.description`.
String? gstRateDisplayFromValues(Map<String, dynamic> values) { String? gstRateDisplayFromValues(Map<String, dynamic> values) {
final nested = values['gst_rate']; final nested = values['gst_rate'];

View File

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

View File

@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart'; import '../../../../core/constants/app_constants.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/table_search.dart'; import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
import '../../../assets/data/repositories/asset_repository_impl.dart'; import '../../../assets/data/repositories/asset_repository_impl.dart';
@ -114,45 +115,68 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
final current = state.valueOrNull; final current = state.valueOrNull;
final nextPage = page ?? current?.page ?? 1; final nextPage = page ?? current?.page ?? 1;
final nextLimit = limit ?? current?.limit ?? AppConstants.defaultPageSize; 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( final result = await repository.list(
_definition, _definition,
page: nextPage, page: nextPage,
limit: nextLimit, limit: nextLimit,
search: nextSearch, search: nextSearch.isEmpty ? null : nextSearch,
); );
if (result.failure != null) throw result.failure!; 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( return MasterListState(
items: data.items, items: items,
search: nextSearch ?? '', search: nextSearch,
page: data.page, page: data.page,
// Keep the requested page size so the /page dropdown stays valid // Keep the requested page size so the /page dropdown stays valid
// even if API meta omits or mismatches `limit`. // even if API meta omits or mismatches `limit`.
limit: nextLimit, limit: nextLimit,
total: data.total, total: nextSearch.isEmpty ? data.total : items.length,
totalPages: _resolveTotalPages( totalPages: nextSearch.isEmpty
apiTotalPages: data.totalPages, ? resolveTotalPages(total: data.total, limit: nextLimit)
total: data.total, : resolveTotalPages(total: items.length, limit: nextLimit),
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 { Future<void> refresh() async {
final previous = state.valueOrNull; final previous = state.valueOrNull;
if (previous == null) { if (previous == null) {
@ -169,6 +193,13 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
await _reload(page: 1, search: search); 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 { Future<void> setPage(int page) async {
await _reload(page: page); await _reload(page: page);
} }
@ -447,10 +478,12 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
_clearHiddenFieldValues(values); _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') { if (key == 'is_asset_item' && _definition.id == 'items') {
values['item_category_id'] = null; values['item_category_id'] = null;
values['item_subcategory_id'] = null; values['item_subcategory_id'] = null;
_clearHiddenFieldValues(values);
state = AsyncData(current.copyWith(values: values)); state = AsyncData(current.copyWith(values: values));
_reloadItemCategoryOptions(values); _reloadItemCategoryOptions(values);
return; return;
@ -496,7 +529,11 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; if (current == null) return;
final options = await _loadDropdownOptions(values: current.values); 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) { Map<String, dynamic> _buildPayload(MasterFormState current) {
@ -569,8 +606,27 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
} }
state = AsyncData(current.copyWith(isSubmitting: false, clearError: true)); 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; if (createdId != null && createdId.isNotEmpty) return createdId;
return arg.recordId ?? 'created'; 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> { class _MasterListScreenState extends ConsumerState<MasterListScreen> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
bool _filtersExpanded = false; bool _filtersExpanded = false;
bool _didResetSearchOnOpen = false;
MasterDefinition get _definition { MasterDefinition get _definition {
final def = masterDefinitionById(widget.masterId); final def = masterDefinitionById(widget.masterId);
@ -44,6 +45,24 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
return def; 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 @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
@ -197,7 +216,13 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
], ],
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton.icon( 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), icon: const Icon(Icons.grid_view_outlined),
label: const Text('All Masters'), label: const Text('All Masters'),
), ),
@ -225,6 +250,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
totalPages: state.totalPages, totalPages: state.totalPages,
totalItems: state.total, totalItems: state.total,
pageSize: state.limit, pageSize: state.limit,
itemsOnPage: state.items.length,
itemLabel: def.title.toLowerCase(), itemLabel: def.title.toLowerCase(),
onPageChanged: notifier.setPage, onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize, onPageSizeChanged: notifier.setPageSize,

View File

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

View File

@ -162,7 +162,7 @@ class _MasterInlineCreateFormState
if (filterField != null) { if (filterField != null) {
for (final f in _definition.formFields) { for (final f in _definition.formFields) {
if (f.key == filterField) { if (f.key == filterField) {
parentLabel = f.label.toLowerCase(); parentLabel = masterFieldHintLabel(f.label);
break; break;
} }
} }
@ -181,8 +181,8 @@ class _MasterInlineCreateFormState
? 'Select $parentLabel first' ? 'Select $parentLabel first'
: dropdownOptions.isEmpty : dropdownOptions.isEmpty
? 'No options available' ? 'No options available'
: 'Select ${field.label.toLowerCase()}', : 'Select ${masterFieldHintLabel(field.label)}',
searchHint: 'Search ${field.label.toLowerCase()}...', searchHint: 'Search ${masterFieldHintLabel(field.label)}...',
enabled: field.staticOptions != null enabled: field.staticOptions != null
? true ? true
: parentSelected && dropdownOptions.isNotEmpty, : 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_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
import '../providers/master_provider.dart'; import '../providers/master_provider.dart';
import '../../domain/entities/master_definition.dart';
import 'master_form_panel.dart';
import 'master_inline_create_form.dart'; import 'master_inline_create_form.dart';
import '../../../../shared/widgets/app_toast.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 /// By default expands an inline create form below the field (or under a
/// create form is rendered at full row width under the row so every field and /// [QuickAddInlineHost]). Set [openInSidePanel] to open [MasterFormPanel]
/// button stays clickable. Without a host, the form expands directly below the /// in a popup side panel instead.
/// dropdown at the available column width.
class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget { class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
const MasterQuickAddDropdown({ const MasterQuickAddDropdown({
super.key, super.key,
@ -35,6 +36,7 @@ class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
this.initialValues, this.initialValues,
this.refreshLookups, this.refreshLookups,
this.addNewLabel, this.addNewLabel,
this.openInSidePanel = false,
}); });
final String masterId; final String masterId;
@ -54,6 +56,9 @@ class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
final FutureOr<void> Function()? refreshLookups; final FutureOr<void> Function()? refreshLookups;
final String? addNewLabel; final String? addNewLabel;
/// When true, Quick Add opens [MasterFormPanel] in a side panel popup.
final bool openInSidePanel;
@override @override
ConsumerState<MasterQuickAddDropdown<T>> createState() => ConsumerState<MasterQuickAddDropdown<T>> createState() =>
_MasterQuickAddDropdownState<T>(); _MasterQuickAddDropdownState<T>();
@ -75,9 +80,10 @@ class _MasterQuickAddDropdownState<T>
bool get _canQuickAdd => ref.can('masters', PermissionAction.create); bool get _canQuickAdd => ref.can('masters', PermissionAction.create);
void _openInlineForm() { Future<void> _onAddNew() async {
if (!_canQuickAdd) { if (!_canQuickAdd) {
showAppToastFromSnackBar(context, showAppToastFromSnackBar(
context,
const SnackBar( const SnackBar(
content: Text('You do not have permission to add master data'), content: Text('You do not have permission to add master data'),
), ),
@ -85,6 +91,41 @@ class _MasterQuickAddDropdownState<T>
return; 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 = final sessionId =
'inline-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}'; 'inline-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}';
ref.invalidate( ref.invalidate(
@ -119,6 +160,11 @@ class _MasterQuickAddDropdownState<T>
} }
Future<void> _onSaved(String createdId) async { Future<void> _onSaved(String createdId) async {
await _applyCreated(createdId);
_collapse();
}
Future<void> _applyCreated(String createdId) async {
try { try {
final refresh = widget.refreshLookups; final refresh = widget.refreshLookups;
if (refresh != null) await refresh(); if (refresh != null) await refresh();
@ -127,15 +173,14 @@ class _MasterQuickAddDropdownState<T>
} }
if (!mounted) return; 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); final parsed = widget.parseCreatedId(createdId);
if (parsed != null) { if (parsed != null) {
widget.onChanged(parsed); widget.onChanged(parsed);
} }
} }
_collapse();
if (!mounted) return; if (!mounted) return;
showAppToastFromSnackBar( showAppToastFromSnackBar(
context, context,
@ -168,7 +213,9 @@ class _MasterQuickAddDropdownState<T>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final addLabel = widget.addNewLabel ?? masterQuickAddLabel(widget.masterId); final addLabel = widget.addNewLabel ?? masterQuickAddLabel(widget.masterId);
if (!widget.openInSidePanel) {
_host ??= QuickAddInlineScope.maybeOf(context); _host ??= QuickAddInlineScope.maybeOf(context);
}
final dropdown = Focus( final dropdown = Focus(
focusNode: _dropdownFocus, focusNode: _dropdownFocus,
@ -179,19 +226,19 @@ class _MasterQuickAddDropdownState<T>
onChanged: widget.onChanged, onChanged: widget.onChanged,
validator: widget.validator, validator: widget.validator,
hint: widget.hint, hint: widget.hint,
searchHint: searchHint: widget.searchHint ??
widget.searchHint ?? 'Search ${widget.label.toLowerCase()}...', 'Search ${masterFieldHintLabel(widget.label.replaceAll('*', '').trim())}...',
enabled: widget.enabled, enabled: widget.enabled,
isDense: widget.isDense, isDense: widget.isDense,
addNewLabel: _canQuickAdd ? addLabel : null, addNewLabel: _canQuickAdd ? addLabel : null,
onAddNew: !_canQuickAdd || !widget.enabled onAddNew: !_canQuickAdd || !widget.enabled ? null : _onAddNew,
? null
: () async => _openInlineForm(),
), ),
); );
// Host renders the form at full row width. if (widget.openInSidePanel ||
if (_host != null || !_expanded || _sessionId == null) { _host != null ||
!_expanded ||
_sessionId == null) {
return dropdown; return dropdown;
} }

View File

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

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/export_file_name.dart'; import '../../../../core/utils/export_file_name.dart';
import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/entity_attachment_model.dart'; import '../../../../shared/models/entity_attachment_model.dart';
@ -19,7 +20,12 @@ class PurchaseOrderRemoteDataSource {
ApiEndpoints.purchaseOrders, ApiEndpoints.purchaseOrders,
queryParameters: _queryToMap(query), 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. /// Approver-only list of POs with status PENDING_APPROVAL.
@ -30,7 +36,12 @@ class PurchaseOrderRemoteDataSource {
ApiEndpoints.purchaseOrdersPendingApproval, ApiEndpoints.purchaseOrdersPendingApproval,
queryParameters: _queryToMap(query), 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. /// Form-dropdown loader (`dropdown_call=true`). Optional status filter.
@ -252,58 +263,45 @@ class PurchaseOrderRemoteDataSource {
PaginatedResponse<T> _parsePaginated<T>( PaginatedResponse<T> _parsePaginated<T>(
dynamic body, dynamic body,
T Function(Map<String, dynamic>) fromJson, T Function(Map<String, dynamic>) fromJson, {
) { int fallbackPage = 1,
if (body is! Map<String, dynamic>) { int fallbackLimit = 20,
return const PaginatedResponse( }) {
items: [], var items = <T>[];
page: 1, if (body is Map) {
limit: 20,
total: 0,
totalPages: 1,
);
}
final raw = body['data']; final raw = body['data'];
final meta = body['meta'] as Map<String, dynamic>? ?? {};
if (raw is List) { if (raw is List) {
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList(); items = raw
final limit = (meta['limit'] as num?)?.toInt() ?? items.length; .whereType<Map>()
final total = (meta['total'] as num?)?.toInt() ?? items.length; .map((e) => fromJson(Map<String, dynamic>.from(e)))
return PaginatedResponse( .toList();
items: items, } else if (raw is Map) {
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']; final list = raw['items'];
if (list is List) { if (list is List) {
final items = list.whereType<Map<String, dynamic>>().map(fromJson).toList(); items = list
final limit = (meta['limit'] as num?)?.toInt() ?? 20; .whereType<Map>()
final total = (meta['total'] as num?)?.toInt() ?? items.length; .map((e) => fromJson(Map<String, dynamic>.from(e)))
return PaginatedResponse( .toList();
items: items, }
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: limit,
total: total,
totalPages:
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
);
} }
} }
return const PaginatedResponse( final pagination = parsePagination(
items: [], body: body,
page: 1, fallbackPage: fallbackPage,
limit: 20, fallbackLimit: fallbackLimit,
total: 0, itemCount: items.length,
totalPages: 1, );
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 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/utils/table_search.dart'; import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/entity_attachment_model.dart'; import '../../../../shared/models/entity_attachment_model.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/purchase_order_model.dart';
import '../../../grn/presentation/providers/grn_lookups_provider.dart'; import '../../../grn/presentation/providers/grn_lookups_provider.dart';
import '../../data/repositories/purchase_order_repository_impl.dart'; import '../../data/repositories/purchase_order_repository_impl.dart';
import 'purchase_order_lookups_provider.dart';
class PurchaseOrdersListState { class PurchaseOrdersListState {
const PurchaseOrdersListState({ const PurchaseOrdersListState({
@ -76,11 +78,18 @@ class PurchaseOrdersListNotifier
final result = await repository.getPurchaseOrders(query); final result = await repository.getPurchaseOrders(query);
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
final page = result.data!; final page = result.data!;
return PurchaseOrdersListState( final orders = await _enrichPurchaseOrderSearch(
orders: page.items, ref: ref,
query: query, query: query,
total: page.total, items: page.items,
totalPages: page.totalPages, 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); final result = await repository.getPendingApprovalPurchaseOrders(query);
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
final page = result.data!; final page = result.data!;
return PurchaseOrdersListState( final orders = await _enrichPurchaseOrderSearch(
orders: page.items, ref: ref,
query: query, query: query,
total: page.total, items: page.items,
totalPages: page.totalPages, 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!; 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:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart'; import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../core/network/api_handler.dart'; import '../../../../core/network/api_handler.dart';
@ -9,6 +10,7 @@ import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/navigation_utils.dart'; import '../../../../shared/utils/navigation_utils.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_date_popup.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 '../../data/repositories/purchase_order_repository_impl.dart';
import '../../../grn/presentation/providers/grn_lookups_provider.dart'; import '../../../grn/presentation/providers/grn_lookups_provider.dart';
import '../../../master_data/presentation/widgets/master_quick_add.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_order_lookups_provider.dart';
import '../providers/purchase_orders_provider.dart'; import '../providers/purchase_orders_provider.dart';
import '../widgets/po_status_chip.dart'; import '../widgets/po_status_chip.dart';
@ -417,24 +420,74 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
onChanged: (v) => setState(() => _vendorId = v), onChanged: (v) => setState(() => _vendorId = v),
validator: (v) => validator: (v) =>
v == null ? 'Vendor is required' : null, 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 *', label: 'Billing *',
value: _dropdownValue(_billingId, locationIds), value: _dropdownValue(_billingId, locationIds),
hint: 'Select billing location', hint: 'Select billing location',
searchHint: 'Search plant or warehouse...', searchHint: 'Search plant or warehouse...',
options: _intOptions(lookups.locations), options: _intOptions(lookups.locations),
openInSidePanel: true,
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(
purchaseOrderLookupsProvider.future,
);
},
parseCreatedId: int.tryParse,
onChanged: (v) => onChanged: (v) =>
setState(() => _billingId = v), setState(() => _billingId = v),
validator: (v) => validator: (v) =>
v == null ? 'Billing is required' : null, v == null ? 'Billing is required' : null,
), ),
AppSearchableDropdown<int>( MasterQuickAddDropdown<int>(
masterId: 'locations',
label: 'Shipping *', label: 'Shipping *',
value: _dropdownValue(_shippingId, locationIds), value: _dropdownValue(_shippingId, locationIds),
hint: 'Select shipping location', hint: 'Select shipping location',
searchHint: 'Search plant or warehouse...', searchHint: 'Search plant or warehouse...',
options: _intOptions(lookups.locations), options: _intOptions(lookups.locations),
openInSidePanel: true,
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(
purchaseOrderLookupsProvider.future,
);
},
parseCreatedId: int.tryParse,
onChanged: (v) => onChanged: (v) =>
setState(() => _shippingId = v), setState(() => _shippingId = v),
validator: (v) => validator: (v) =>
@ -448,6 +501,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
searchHint: 'Search payment term...', searchHint: 'Search payment term...',
options: options:
_nullableIntOptions(lookups.paymentTerms), _nullableIntOptions(lookups.paymentTerms),
openInSidePanel: true,
refreshLookups: () => refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider), ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse, parseCreatedId: int.tryParse,
@ -462,6 +516,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
searchHint: 'Search delivery term...', searchHint: 'Search delivery term...',
options: options:
_nullableIntOptions(lookups.deliveryTerms), _nullableIntOptions(lookups.deliveryTerms),
openInSidePanel: true,
refreshLookups: () => refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider), ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse, parseCreatedId: int.tryParse,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -162,6 +162,7 @@ class PermissionMatrixNotifier
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; if (current == null) return;
final normalizedAction = action.toLowerCase();
final updated = current.modules.map((row) { final updated = current.modules.map((row) {
if (row.moduleId != moduleId) return row; if (row.moduleId != moduleId) return row;
if (!isPermissionActionApplicable( if (!isPermissionActionApplicable(
@ -172,7 +173,19 @@ class PermissionMatrixNotifier
return row; return row;
} }
final granted = Map<String, bool>.from(row.granted); 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); return row.copyWith(granted: granted);
}).toList(); }).toList();

View File

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

View File

@ -9,6 +9,75 @@ import '../providers/settings_provider.dart';
import '../widgets/settings_widgets.dart'; import '../widgets/settings_widgets.dart';
import '../../../../shared/widgets/app_toast.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 { class AppearanceSettingsScreen extends ConsumerWidget {
const AppearanceSettingsScreen({super.key}); const AppearanceSettingsScreen({super.key});
@ -17,6 +86,9 @@ class AppearanceSettingsScreen extends ConsumerWidget {
final themeMode = ref.watch(themeModeProvider); final themeMode = ref.watch(themeModeProvider);
final branding = ref.watch(brandingProvider); final branding = ref.watch(brandingProvider);
final uiPrefs = ref.watch(appSettingsProvider).uiPreferences; final uiPrefs = ref.watch(appSettingsProvider).uiPreferences;
final selectedPreset = _brandThemePresets
.where((preset) => preset.matches(branding))
.firstOrNull;
return SettingsPageLayout( return SettingsPageLayout(
title: 'Appearance', title: 'Appearance',
@ -43,58 +115,66 @@ class AppearanceSettingsScreen extends ConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
SettingsFormCard( SettingsFormCard(
title: 'Branding', title: 'Branding',
subtitle: 'Primary and secondary colors for the application', subtitle:
'Professionally curated primary and secondary color pairs',
children: [ children: [
ListTile( _BrandingPreviewCard(
contentPadding: EdgeInsets.zero, primary: branding.primaryColor,
leading: CircleAvatar(backgroundColor: branding.primaryColor), secondary: branding.secondaryColor,
title: const Text('Primary Color'), themeName: selectedPreset?.name ?? 'Custom',
subtitle: Text( primaryHex: _hex(branding.primaryColorValue),
'#${branding.primaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}', secondaryHex: _hex(branding.secondaryColorValue),
),
const SizedBox(height: 20),
Text(
'Color themes',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
), ),
), ),
ListTile( const SizedBox(height: 4),
contentPadding: EdgeInsets.zero, Text(
leading: CircleAvatar(backgroundColor: branding.secondaryColor), 'Choose a cohesive pair optimized for light and dark modes.',
title: const Text('Secondary Color'), style: Theme.of(context).textTheme.bodySmall?.copyWith(
subtitle: Text( color: Theme.of(context).colorScheme.onSurfaceVariant,
'#${branding.secondaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}',
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 12),
Wrap( LayoutBuilder(
spacing: 8, builder: (context, constraints) {
runSpacing: 8, 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: [ children: [
_ColorPreset( for (final preset in _brandThemePresets)
label: 'Blue', SizedBox(
primary: 0xFF1565C0, width: itemWidth,
secondary: 0xFF00897B, child: _ThemePresetCard(
branding: branding, preset: preset,
ref: ref, selected: preset.matches(branding),
onTap: () {
ref.read(brandingProvider.notifier).updateBranding(
branding.copyWith(
primaryColorValue: preset.primary,
secondaryColorValue: preset.secondary,
), ),
_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,
), ),
], ],
);
},
), ),
], ],
), ),
@ -112,10 +192,13 @@ class AppearanceSettingsScreen extends ConsumerWidget {
: 'Horizontal menu bar at the top', : 'Horizontal menu bar at the top',
), ),
value: layout, value: layout,
groupValue: NavigationLayout.fromValue(uiPrefs.navigationLayout), groupValue:
NavigationLayout.fromValue(uiPrefs.navigationLayout),
onChanged: (v) { onChanged: (v) {
if (v != null) { if (v != null) {
ref.read(appSettingsProvider.notifier).updateUiPreferences( ref
.read(appSettingsProvider.notifier)
.updateUiPreferences(
uiPrefs.copyWith(navigationLayout: v.value), uiPrefs.copyWith(navigationLayout: v.value),
); );
} }
@ -183,8 +266,11 @@ class AppearanceSettingsScreen extends ConsumerWidget {
AppButton( AppButton(
label: 'Settings Auto-Saved', label: 'Settings Auto-Saved',
onPressed: () { onPressed: () {
showAppToastFromSnackBar(context, showAppToastFromSnackBar(
const SnackBar(content: Text('Appearance settings are saved automatically')), context,
const SnackBar(
content: Text('Appearance settings are saved automatically'),
),
); );
}, },
), ),
@ -198,42 +284,348 @@ class AppearanceSettingsScreen extends ConsumerWidget {
ThemeModeOption.dark => 'Dark Theme', ThemeModeOption.dark => 'Dark Theme',
ThemeModeOption.system => 'System Theme', ThemeModeOption.system => 'System Theme',
}; };
static String _hex(int value) =>
'#${value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}';
} }
class _ColorPreset extends StatelessWidget { class _BrandingPreviewCard extends StatelessWidget {
const _ColorPreset({ const _BrandingPreviewCard({
required this.label,
required this.primary, required this.primary,
required this.secondary, required this.secondary,
required this.branding, required this.themeName,
required this.ref, required this.primaryHex,
required this.secondaryHex,
}); });
final String label; final Color primary;
final int primary; final Color secondary;
final int secondary; final String themeName;
final BrandingConfig branding; final String primaryHex;
final WidgetRef ref; final String secondaryHex;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OutlinedButton( final theme = Theme.of(context);
onPressed: () { final onPrimary =
ref.read(brandingProvider.notifier).updateBranding( ThemeData.estimateBrightnessForColor(primary) == Brightness.dark
branding.copyWith( ? Colors.white
primaryColorValue: primary, : Colors.black87;
secondaryColorValue: secondary,
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),
}, ),
child: Row( clipBehavior: Clip.antiAlias,
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
CircleAvatar(radius: 8, backgroundColor: Color(primary)), Container(
const SizedBox(width: 6), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
CircleAvatar(radius: 8, backgroundColor: Color(secondary)), color: primary,
child: Row(
children: [
Icon(Icons.dashboard_outlined, color: onPrimary, size: 18),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(label), 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 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/active_option.dart'; import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
@ -80,19 +81,22 @@ class UserRemoteDataSource {
(json) => ManagedUserModel.fromJson(json! as Map<String, dynamic>), (json) => ManagedUserModel.fromJson(json! as Map<String, dynamic>),
).items; ).items;
final meta = body['meta'] as Map<String, dynamic>? ?? {}; final pagination = parsePagination(
final page = (meta['page'] as num?)?.toInt() ?? query.page; body: body,
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit; fallbackPage: query.page,
final total = (meta['total'] as num?)?.toInt() ?? items.length; fallbackLimit: query.limit,
final totalPages = itemCount: items.length,
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1; );
return PaginatedResponse<ManagedUserModel>( return PaginatedResponse<ManagedUserModel>(
items: items, items: items,
page: page, page: pagination.page,
limit: limit, limit: query.limit,
total: total, total: pagination.total,
totalPages: totalPages, totalPages: resolveTotalPages(
total: pagination.total,
limit: query.limit,
),
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -28,9 +30,33 @@ class AppTopNav extends ConsumerWidget {
final void Function(String route) onItemTap; final void Function(String route) onItemTap;
final UserModel? user; final UserModel? user;
bool _isSelected(String route) { bool _routeMatches(String route) {
if (route == '/') return currentRoute == 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 @override
@ -72,29 +98,24 @@ class AppTopNav extends ConsumerWidget {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: menuItems.map((item) { children: menuItems.map((item) {
final selected = _isSelected(item.route); final selected = _isItemSelected(item);
return Padding( return Padding(
padding: const EdgeInsets.only(right: 4), padding: const EdgeInsets.only(right: 4),
child: TextButton( 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), 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,
),
),
), ),
); );
}).toList(), }).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 { class _TopNavUserMenu extends ConsumerWidget {
const _TopNavUserMenu({this.userName, this.avatarUrl}); const _TopNavUserMenu({this.userName, this.avatarUrl});

View File

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