review changes
This commit is contained in:
parent
03486ea3f4
commit
09ffc5fb6a
@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
static const Color primary = Color(0xFF1565C0);
|
||||
static const Color secondary = Color(0xFF00897B);
|
||||
static const Color primary = Color(0xFF2563EB);
|
||||
static const Color secondary = Color(0xFF0891B2);
|
||||
static const Color error = Color(0xFFD32F2F);
|
||||
static const Color warning = Color(0xFFF57C00);
|
||||
static const Color success = Color(0xFF388E3C);
|
||||
|
||||
@ -12,8 +12,9 @@ class AppTheme {
|
||||
final primary = branding?.primaryColor ?? AppColors.primary;
|
||||
final secondary = branding?.secondaryColor ?? AppColors.secondary;
|
||||
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: primary,
|
||||
final colorScheme = _brandedColorScheme(
|
||||
seed: primary,
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
brightness: Brightness.light,
|
||||
surface: AppColors.lightSurface,
|
||||
@ -26,8 +27,9 @@ class AppTheme {
|
||||
final primary = branding?.primaryColor ?? AppColors.primary;
|
||||
final secondary = branding?.secondaryColor ?? AppColors.secondary;
|
||||
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: primary,
|
||||
final colorScheme = _brandedColorScheme(
|
||||
seed: primary,
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
brightness: Brightness.dark,
|
||||
surface: AppColors.darkSurface,
|
||||
@ -36,12 +38,56 @@ class AppTheme {
|
||||
return _buildTheme(colorScheme, Brightness.dark);
|
||||
}
|
||||
|
||||
/// Keeps Material tonal containers from [seed], but locks brand [primary] /
|
||||
/// [secondary] to the exact chosen hex values (fromSeed remaps primary).
|
||||
static ColorScheme _brandedColorScheme({
|
||||
required Color seed,
|
||||
required Color primary,
|
||||
required Color secondary,
|
||||
required Brightness brightness,
|
||||
required Color surface,
|
||||
}) {
|
||||
final base = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
secondary: secondary,
|
||||
brightness: brightness,
|
||||
surface: surface,
|
||||
);
|
||||
|
||||
final onPrimary = _onColor(primary);
|
||||
final onSecondary = _onColor(secondary);
|
||||
|
||||
return base.copyWith(
|
||||
primary: primary,
|
||||
onPrimary: onPrimary,
|
||||
primaryContainer: Color.alphaBlend(
|
||||
primary.withValues(alpha: 0.18),
|
||||
surface,
|
||||
),
|
||||
onPrimaryContainer: primary,
|
||||
secondary: secondary,
|
||||
onSecondary: onSecondary,
|
||||
secondaryContainer: Color.alphaBlend(
|
||||
secondary.withValues(alpha: 0.18),
|
||||
surface,
|
||||
),
|
||||
onSecondaryContainer: secondary,
|
||||
);
|
||||
}
|
||||
|
||||
static Color _onColor(Color color) {
|
||||
return ThemeData.estimateBrightnessForColor(color) == Brightness.dark
|
||||
? Colors.white
|
||||
: const Color(0xFF212121);
|
||||
}
|
||||
|
||||
/// Theme for white cards, form fields, and picker sheets (unchanged in dark mode).
|
||||
static ThemeData cardContentTheme(ThemeData theme) {
|
||||
final scheme = theme.brightness == Brightness.light
|
||||
? theme.colorScheme
|
||||
: ColorScheme.fromSeed(
|
||||
seedColor: theme.colorScheme.primary,
|
||||
: _brandedColorScheme(
|
||||
seed: theme.colorScheme.primary,
|
||||
primary: theme.colorScheme.primary,
|
||||
secondary: theme.colorScheme.secondary,
|
||||
brightness: Brightness.light,
|
||||
surface: AppColors.card,
|
||||
|
||||
@ -8,8 +8,8 @@ part 'branding_config.g.dart';
|
||||
class BrandingConfig with _$BrandingConfig {
|
||||
const factory BrandingConfig({
|
||||
String? logoUrl,
|
||||
@Default(0xFF1565C0) int primaryColorValue,
|
||||
@Default(0xFF00897B) int secondaryColorValue,
|
||||
@Default(0xFF2563EB) int primaryColorValue,
|
||||
@Default(0xFF0891B2) int secondaryColorValue,
|
||||
String? companyName,
|
||||
}) = _BrandingConfig;
|
||||
|
||||
|
||||
@ -159,8 +159,8 @@ class __$$BrandingConfigImplCopyWithImpl<$Res>
|
||||
class _$BrandingConfigImpl implements _BrandingConfig {
|
||||
const _$BrandingConfigImpl({
|
||||
this.logoUrl,
|
||||
this.primaryColorValue = 0xFF1565C0,
|
||||
this.secondaryColorValue = 0xFF00897B,
|
||||
this.primaryColorValue = 0xFF2563EB,
|
||||
this.secondaryColorValue = 0xFF0891B2,
|
||||
this.companyName,
|
||||
});
|
||||
|
||||
|
||||
@ -10,9 +10,9 @@ _$BrandingConfigImpl _$$BrandingConfigImplFromJson(Map<String, dynamic> json) =>
|
||||
_$BrandingConfigImpl(
|
||||
logoUrl: json['logoUrl'] as String?,
|
||||
primaryColorValue:
|
||||
(json['primaryColorValue'] as num?)?.toInt() ?? 0xFF1565C0,
|
||||
(json['primaryColorValue'] as num?)?.toInt() ?? 0xFF2563EB,
|
||||
secondaryColorValue:
|
||||
(json['secondaryColorValue'] as num?)?.toInt() ?? 0xFF00897B,
|
||||
(json['secondaryColorValue'] as num?)?.toInt() ?? 0xFF0891B2,
|
||||
companyName: json['companyName'] as String?,
|
||||
);
|
||||
|
||||
|
||||
@ -58,3 +58,47 @@ class CurrencyFormatter {
|
||||
return _formatter.format(amount);
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts `snake_case` / `kebab-case` keys into readable Title Case labels.
|
||||
String humanizeLabel(String key) {
|
||||
final cleaned = key.trim().replaceAll(RegExp(r'[_\-.]+'), ' ');
|
||||
if (cleaned.isEmpty) return key;
|
||||
|
||||
const acronyms = {
|
||||
'id': 'ID',
|
||||
'amc': 'AMC',
|
||||
'gst': 'GST',
|
||||
'hsn': 'HSN',
|
||||
'uom': 'UOM',
|
||||
'po': 'PO',
|
||||
'grn': 'GRN',
|
||||
'url': 'URL',
|
||||
'api': 'API',
|
||||
};
|
||||
|
||||
return cleaned
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((part) => part.isNotEmpty)
|
||||
.map((part) {
|
||||
final lower = part.toLowerCase();
|
||||
if (acronyms.containsKey(lower)) return acronyms[lower]!;
|
||||
return '${lower[0].toUpperCase()}${lower.substring(1)}';
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/// Subject used in dropdown hints (`Select …` / `Search …`).
|
||||
/// Strips required asterisks and title-cases each word.
|
||||
String dropdownHintLabel(String label) {
|
||||
final cleaned =
|
||||
label.replaceAll('*', '').trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
if (cleaned.isEmpty) return label.trim();
|
||||
|
||||
return cleaned.split(' ').map((word) {
|
||||
if (word.isEmpty) return word;
|
||||
// Keep short all-caps tokens (GST, UOM, PO…).
|
||||
if (word.length <= 4 && word == word.toUpperCase()) return word;
|
||||
final lower = word.toLowerCase();
|
||||
return '${lower[0].toUpperCase()}${lower.substring(1)}';
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
102
lib/core/utils/pagination_meta.dart
Normal file
102
lib/core/utils/pagination_meta.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -36,6 +36,26 @@ class TableSearch {
|
||||
}
|
||||
return items.where((item) => matches(q, valuesOf(item))).toList();
|
||||
}
|
||||
|
||||
/// Merges two lists by [idOf], preferring items from [primary] on conflict.
|
||||
static List<T> mergeById<T>(
|
||||
Iterable<T> primary,
|
||||
Iterable<T> secondary,
|
||||
String Function(T item) idOf,
|
||||
) {
|
||||
final map = <String, T>{};
|
||||
for (final item in secondary) {
|
||||
final id = idOf(item);
|
||||
if (id.isEmpty) continue;
|
||||
map[id] = item;
|
||||
}
|
||||
for (final item in primary) {
|
||||
final id = idOf(item);
|
||||
if (id.isEmpty) continue;
|
||||
map[id] = item;
|
||||
}
|
||||
return map.values.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// Debounces search input so API-backed lists are not hit on every keystroke.
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/export_file_name.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
@ -17,7 +18,12 @@ class AssetRemoteDataSource {
|
||||
ApiEndpoints.assets,
|
||||
queryParameters: _queryToMap(query),
|
||||
);
|
||||
return _parsePaginated(response.data, AssetModel.fromJson);
|
||||
return _parsePaginated(
|
||||
response.data,
|
||||
AssetModel.fromJson,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<AssetModel> getAssetById(String id) async {
|
||||
@ -215,7 +221,12 @@ class AssetRemoteDataSource {
|
||||
if (type != null) 'type': type,
|
||||
},
|
||||
);
|
||||
return _parsePaginated(response.data, AssetAlertModel.fromJson);
|
||||
return _parsePaginated(
|
||||
response.data,
|
||||
AssetAlertModel.fromJson,
|
||||
fallbackPage: page,
|
||||
fallbackLimit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<AssetAlertModel>> getServiceAlerts({String? status}) async {
|
||||
@ -370,7 +381,12 @@ class AssetRemoteDataSource {
|
||||
if (dueOnly) 'due_only': true,
|
||||
},
|
||||
);
|
||||
return _parsePaginated(response.data, AssetModel.fromJson);
|
||||
return _parsePaginated(
|
||||
response.data,
|
||||
AssetModel.fromJson,
|
||||
fallbackPage: page,
|
||||
fallbackLimit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<AssetMaintenanceLogModel>> getMaintenanceLogs(String assetId) async {
|
||||
@ -464,86 +480,45 @@ class AssetRemoteDataSource {
|
||||
|
||||
PaginatedResponse<T> _parsePaginated<T>(
|
||||
dynamic body,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
if (body is! Map) {
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
);
|
||||
}
|
||||
|
||||
final map = Map<String, dynamic>.from(body);
|
||||
final raw = map['data'];
|
||||
final meta = map['meta'] is Map
|
||||
? Map<String, dynamic>.from(map['meta'] as Map)
|
||||
: <String, dynamic>{};
|
||||
|
||||
if (raw is List) {
|
||||
final items = raw
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? items.length;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
final explicitTotalPages =
|
||||
(meta['totalPages'] as num?)?.toInt() ??
|
||||
(meta['total_pages'] as num?)?.toInt();
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: (meta['page'] as num?)?.toInt() ?? 1,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages: explicitTotalPages ??
|
||||
(limit > 0
|
||||
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
|
||||
: 1),
|
||||
);
|
||||
}
|
||||
|
||||
if (raw is Map) {
|
||||
final nested = Map<String, dynamic>.from(raw);
|
||||
final list = nested['items'];
|
||||
if (list is List) {
|
||||
final items = list
|
||||
T Function(Map<String, dynamic>) fromJson, {
|
||||
int fallbackPage = 1,
|
||||
int fallbackLimit = 20,
|
||||
}) {
|
||||
var items = <T>[];
|
||||
if (body is Map) {
|
||||
final raw = body['data'];
|
||||
if (raw is List) {
|
||||
items = raw
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
final limit = (meta['limit'] as num?)?.toInt() ??
|
||||
(nested['limit'] as num?)?.toInt() ??
|
||||
20;
|
||||
final total = (meta['total'] as num?)?.toInt() ??
|
||||
(nested['total'] as num?)?.toInt() ??
|
||||
items.length;
|
||||
final explicitTotalPages =
|
||||
(meta['totalPages'] as num?)?.toInt() ??
|
||||
(meta['total_pages'] as num?)?.toInt() ??
|
||||
(nested['totalPages'] as num?)?.toInt() ??
|
||||
(nested['total_pages'] as num?)?.toInt();
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: (meta['page'] as num?)?.toInt() ??
|
||||
(nested['page'] as num?)?.toInt() ??
|
||||
1,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages: explicitTotalPages ??
|
||||
(limit > 0
|
||||
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
|
||||
: 1),
|
||||
);
|
||||
} else if (raw is Map) {
|
||||
final list = raw['items'];
|
||||
if (list is List) {
|
||||
items = list
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: fallbackPage,
|
||||
fallbackLimit: fallbackLimit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: pagination.page,
|
||||
limit: fallbackLimit,
|
||||
total: pagination.total,
|
||||
totalPages: resolveTotalPages(
|
||||
total: pagination.total,
|
||||
limit: fallbackLimit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../data/repositories/asset_repository_impl.dart';
|
||||
import 'asset_categories_provider.dart';
|
||||
|
||||
class AssetsListState {
|
||||
const AssetsListState({
|
||||
@ -68,11 +69,49 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
||||
final result = await repository.getAssets(query);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final page = result.data!;
|
||||
var items = List<AssetModel>.from(page.items);
|
||||
final search = TableSearch.normalize(query.search);
|
||||
|
||||
// API search often ignores category name — pull matching categories too.
|
||||
if (search.isNotEmpty && query.itemCategoryId == null) {
|
||||
final categories = await ref.read(itemCategoriesProvider.future);
|
||||
var categoryMatches = 0;
|
||||
for (final category in categories) {
|
||||
if (!TableSearch.matches(search, [category.name])) continue;
|
||||
if (++categoryMatches > 5) break;
|
||||
final categoryId = int.tryParse(category.id);
|
||||
if (categoryId == null) continue;
|
||||
final byCategory = await repository.getAssets(
|
||||
query.copyWith(search: null, itemCategoryId: categoryId),
|
||||
);
|
||||
if (byCategory.failure == null && byCategory.data != null) {
|
||||
items = TableSearch.mergeById(
|
||||
items,
|
||||
byCategory.data!.items,
|
||||
(asset) => asset.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
items = TableSearch.filter(
|
||||
items,
|
||||
search,
|
||||
(asset) => [
|
||||
asset.assetCode,
|
||||
asset.assetName,
|
||||
asset.assetCategoryName,
|
||||
asset.locationName,
|
||||
asset.status,
|
||||
asset.condition,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return AssetsListState(
|
||||
assets: page.items,
|
||||
assets: items,
|
||||
query: query,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
total: search.isEmpty ? page.total : items.length,
|
||||
totalPages: search.isEmpty ? page.totalPages : 1,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -193,6 +193,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemsOnPage: state.assets.length,
|
||||
itemLabel: 'assets',
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
|
||||
@ -142,6 +142,7 @@ class _MyMaintenanceBody extends ConsumerWidget {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.limit,
|
||||
itemsOnPage: state.assets.length,
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
itemLabel: 'assets',
|
||||
|
||||
@ -16,7 +16,6 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../master_data/presentation/widgets/master_quick_add.dart';
|
||||
import '../providers/asset_form_lookups_provider.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
|
||||
@ -1223,7 +1222,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
|
||||
final _reasonController = TextEditingController();
|
||||
DateTime _transferDate = DateTime.now();
|
||||
int? _toLocationId;
|
||||
int? _toDepartmentId;
|
||||
int? _toUserId;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@ -1249,10 +1247,7 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final hasDestination =
|
||||
_toLocationId != null ||
|
||||
_toDepartmentId != null ||
|
||||
_toUserId != null;
|
||||
final hasDestination = _toLocationId != null || _toUserId != null;
|
||||
if (!hasDestination) {
|
||||
showSidePanelSnackBar(
|
||||
context,
|
||||
@ -1266,7 +1261,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
|
||||
await ref.read(assetDetailProvider(widget.assetId).notifier).transferAsset({
|
||||
'transfer_date': DateFormatter.toApiDate(_transferDate),
|
||||
if (_toLocationId != null) 'to_location_id': _toLocationId,
|
||||
if (_toDepartmentId != null) 'to_department_id': _toDepartmentId,
|
||||
if (_toUserId != null) 'to_user_id': _toUserId,
|
||||
'reason': _reasonController.text.trim(),
|
||||
});
|
||||
@ -1323,25 +1317,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
|
||||
onChanged: (v) => setState(() => _toLocationId = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
MasterQuickAddDropdown<int>(
|
||||
masterId: 'departments',
|
||||
label: 'To Department',
|
||||
value: _toDepartmentId,
|
||||
searchHint: 'Search department...',
|
||||
options: lookups.departments
|
||||
.map((option) {
|
||||
final id = int.tryParse(option.id);
|
||||
if (id == null) return null;
|
||||
return AppDropdownOption(value: id, label: option.name);
|
||||
})
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList(),
|
||||
refreshLookups: () =>
|
||||
ref.invalidate(assetFormLookupsProvider),
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: (v) => setState(() => _toDepartmentId = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'To User',
|
||||
value: _toUserId,
|
||||
@ -1423,36 +1398,11 @@ class _TransferHistoryEntry extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TransferLocationBlock(
|
||||
label: 'From',
|
||||
location: item.fromLocationName,
|
||||
department: item.fromDepartmentName,
|
||||
user: item.fromUserName,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
Icons.arrow_forward_rounded,
|
||||
size: 18,
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _TransferLocationBlock(
|
||||
label: 'To',
|
||||
location: item.toLocationName,
|
||||
department: item.toDepartmentName,
|
||||
user: item.toUserName,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_TransferFromToGrid(
|
||||
fromLocation: item.fromLocationName,
|
||||
fromUser: item.fromUserName,
|
||||
toLocation: item.toLocationName,
|
||||
toUser: item.toUserName,
|
||||
),
|
||||
if (showReason) ...[
|
||||
const SizedBox(height: 16),
|
||||
@ -1484,46 +1434,86 @@ class _TransferHistoryEntry extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TransferLocationBlock extends StatelessWidget {
|
||||
const _TransferLocationBlock({
|
||||
required this.label,
|
||||
this.location,
|
||||
this.department,
|
||||
this.user,
|
||||
class _TransferFromToGrid extends StatelessWidget {
|
||||
const _TransferFromToGrid({
|
||||
this.fromLocation,
|
||||
this.fromUser,
|
||||
this.toLocation,
|
||||
this.toUser,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? location;
|
||||
final String? department;
|
||||
final String? user;
|
||||
final String? fromLocation;
|
||||
final String? fromUser;
|
||||
final String? toLocation;
|
||||
final String? toUser;
|
||||
|
||||
static const _arrowWidth = 34.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text('From', style: labelStyle)),
|
||||
const SizedBox(width: _arrowWidth),
|
||||
Expanded(child: Text('To', style: labelStyle)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_TransferLocationRow(
|
||||
icon: Icons.place_outlined,
|
||||
value: location,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TransferLocationRow(
|
||||
icon: Icons.place_outlined,
|
||||
value: fromLocation,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: _arrowWidth,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
child: Icon(
|
||||
Icons.arrow_forward_rounded,
|
||||
size: 18,
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _TransferLocationRow(
|
||||
icon: Icons.place_outlined,
|
||||
value: toLocation,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_TransferLocationRow(
|
||||
icon: Icons.apartment_outlined,
|
||||
value: department,
|
||||
),
|
||||
_TransferLocationRow(
|
||||
icon: Icons.person_outline,
|
||||
value: user,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TransferLocationRow(
|
||||
icon: Icons.person_outline,
|
||||
value: fromUser,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _arrowWidth),
|
||||
Expanded(
|
||||
child: _TransferLocationRow(
|
||||
icon: Icons.person_outline,
|
||||
value: toUser,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../shared/models/audit_log_model.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
|
||||
@ -32,20 +33,23 @@ class AuditRemoteDataSource {
|
||||
: <AuditLogEntryModel>[];
|
||||
|
||||
final meta = body['meta'] as Map<String, dynamic>? ?? {};
|
||||
final page = (meta['page'] as num?)?.toInt() ?? query.page;
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
final totalPages = limit > 0
|
||||
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
|
||||
: 1;
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
final filtersRequired = meta['filters_required'] == true;
|
||||
|
||||
return AuditLogListResult(
|
||||
items: items,
|
||||
page: page,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages: totalPages,
|
||||
page: pagination.page,
|
||||
limit: query.limit,
|
||||
total: pagination.total,
|
||||
totalPages: resolveTotalPages(
|
||||
total: pagination.total,
|
||||
limit: query.limit,
|
||||
),
|
||||
filtersRequired: filtersRequired,
|
||||
);
|
||||
}
|
||||
|
||||
@ -184,6 +184,7 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemsOnPage: state.items.length,
|
||||
itemLabel: 'audit logs',
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
@ -291,7 +292,10 @@ class _FiltersBar extends StatelessWidget {
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All Tables'),
|
||||
...filters.tableNames.map(
|
||||
(name) => AppDropdownOption(value: name, label: name),
|
||||
(name) => AppDropdownOption(
|
||||
value: name,
|
||||
label: humanizeLabel(name),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: onTableChanged,
|
||||
@ -400,8 +404,9 @@ class _AuditDataTable extends StatelessWidget {
|
||||
AppDataColumn(
|
||||
label: 'Table',
|
||||
flex: 2,
|
||||
searchText: (row) => row.tableName,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.tableName),
|
||||
searchText: (row) => humanizeLabel(row.tableName),
|
||||
cellBuilder: (_, row) =>
|
||||
AppTableCell.text(humanizeLabel(row.tableName)),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Record',
|
||||
@ -485,7 +490,7 @@ class _AuditCardList extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
log.tableName,
|
||||
humanizeLabel(log.tableName),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
|
||||
@ -57,7 +57,7 @@ class _DetailBody extends StatelessWidget {
|
||||
label: 'Action',
|
||||
child: AppStatusChip(status: detail.action, compact: true),
|
||||
),
|
||||
_DetailRow(label: 'Table', value: _humanizeKey(detail.tableName)),
|
||||
_DetailRow(label: 'Table', value: humanizeLabel(detail.tableName)),
|
||||
_DetailRow(label: 'Record ID', value: detail.recordId ?? '—'),
|
||||
_DetailRow(
|
||||
label: 'Performed At',
|
||||
@ -269,15 +269,15 @@ List<_FieldRow> _flattenFields(
|
||||
for (final entry in entries) {
|
||||
final key = entry.key.toString();
|
||||
final label = prefix == null
|
||||
? _humanizeKey(key)
|
||||
: '${_humanizeKey(prefix)} › ${_humanizeKey(key)}';
|
||||
? humanizeLabel(key)
|
||||
: '${humanizeLabel(prefix)} › ${humanizeLabel(key)}';
|
||||
final value = entry.value;
|
||||
|
||||
if (value is Map) {
|
||||
final nested = Map<String, dynamic>.from(value);
|
||||
final summary = _nestedSummary(nested);
|
||||
if (summary != null) {
|
||||
rows.add(_FieldRow(label: _humanizeKey(key), value: summary));
|
||||
rows.add(_FieldRow(label: humanizeLabel(key), value: summary));
|
||||
} else {
|
||||
rows.addAll(
|
||||
_flattenFields(
|
||||
@ -330,7 +330,7 @@ String? _nestedSummary(Map<String, dynamic> nested) {
|
||||
return nested.entries
|
||||
.map(
|
||||
(e) =>
|
||||
'${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}',
|
||||
'${humanizeLabel(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}',
|
||||
)
|
||||
.join(', ');
|
||||
}
|
||||
@ -393,30 +393,3 @@ String _formatValue(String key, Object? value) {
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
String _humanizeKey(String key) {
|
||||
final cleaned = key.trim().replaceAll(RegExp(r'[_\-.]+'), ' ');
|
||||
if (cleaned.isEmpty) return key;
|
||||
|
||||
const acronyms = {
|
||||
'id': 'ID',
|
||||
'amc': 'AMC',
|
||||
'gst': 'GST',
|
||||
'hsn': 'HSN',
|
||||
'uom': 'UOM',
|
||||
'po': 'PO',
|
||||
'grn': 'GRN',
|
||||
'url': 'URL',
|
||||
'api': 'API',
|
||||
};
|
||||
|
||||
return cleaned
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((part) => part.isNotEmpty)
|
||||
.map((part) {
|
||||
final lower = part.toLowerCase();
|
||||
if (acronyms.containsKey(lower)) return acronyms[lower]!;
|
||||
return '${lower[0].toUpperCase()}${lower.substring(1)}';
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/export_file_name.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
@ -16,7 +17,12 @@ class GrnRemoteDataSource {
|
||||
ApiEndpoints.grn,
|
||||
queryParameters: _queryToMap(query),
|
||||
);
|
||||
return _parsePaginated(response.data, GrnModel.fromJson);
|
||||
return _parsePaginated(
|
||||
response.data,
|
||||
GrnModel.fromJson,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Form-dropdown loader: all active GRNs (`dropdown_call=true`).
|
||||
@ -158,58 +164,45 @@ class GrnRemoteDataSource {
|
||||
|
||||
PaginatedResponse<T> _parsePaginated<T>(
|
||||
dynamic body,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
if (body is! Map<String, dynamic>) {
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
);
|
||||
}
|
||||
|
||||
final raw = body['data'];
|
||||
final meta = body['meta'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
if (raw is List) {
|
||||
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList();
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? items.length;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: (meta['page'] as num?)?.toInt() ?? 1,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages:
|
||||
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
|
||||
);
|
||||
}
|
||||
|
||||
if (raw is Map<String, dynamic>) {
|
||||
final list = raw['items'];
|
||||
if (list is List) {
|
||||
final items = list.whereType<Map<String, dynamic>>().map(fromJson).toList();
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? 20;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: (meta['page'] as num?)?.toInt() ?? 1,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages:
|
||||
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
|
||||
);
|
||||
T Function(Map<String, dynamic>) fromJson, {
|
||||
int fallbackPage = 1,
|
||||
int fallbackLimit = 20,
|
||||
}) {
|
||||
var items = <T>[];
|
||||
if (body is Map) {
|
||||
final raw = body['data'];
|
||||
if (raw is List) {
|
||||
items = raw
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
} else if (raw is Map) {
|
||||
final list = raw['items'];
|
||||
if (list is List) {
|
||||
items = list
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: fallbackPage,
|
||||
fallbackLimit: fallbackLimit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: pagination.page,
|
||||
limit: fallbackLimit,
|
||||
total: pagination.total,
|
||||
totalPages: resolveTotalPages(
|
||||
total: pagination.total,
|
||||
limit: fallbackLimit,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,11 @@ import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../../../shared/models/grn_model.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../purchase_orders/data/repositories/purchase_order_repository_impl.dart';
|
||||
import '../../../purchase_orders/presentation/providers/purchase_order_lookups_provider.dart';
|
||||
import '../../data/repositories/grn_repository_impl.dart';
|
||||
import 'grn_lookups_provider.dart';
|
||||
|
||||
class GrnListState {
|
||||
const GrnListState({
|
||||
@ -67,11 +71,88 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
|
||||
final result = await repository.getGrns(query);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final page = result.data!;
|
||||
var items = List<GrnModel>.from(page.items);
|
||||
final search = TableSearch.normalize(query.search);
|
||||
|
||||
// API search often ignores PO number / vendor name — enrich via filters.
|
||||
if (search.isNotEmpty &&
|
||||
query.poId == null &&
|
||||
query.vendorId == null) {
|
||||
try {
|
||||
final grnLookups = await ref.read(grnLookupsProvider.future);
|
||||
final poCandidates = <PurchaseOrderModel>[
|
||||
...grnLookups.receivablePurchaseOrders,
|
||||
];
|
||||
// Include broader PO dropdown options so search works beyond receivable.
|
||||
final allPoOptions = await ref
|
||||
.read(purchaseOrderRepositoryProvider)
|
||||
.listPurchaseOrderOptions();
|
||||
if (allPoOptions.failure == null && allPoOptions.data != null) {
|
||||
poCandidates.addAll(allPoOptions.data!);
|
||||
}
|
||||
|
||||
final seenPoIds = <String>{};
|
||||
var poMatches = 0;
|
||||
for (final po in poCandidates) {
|
||||
if (!seenPoIds.add(po.id)) continue;
|
||||
if (!TableSearch.matches(search, [po.poNo, po.vendorName])) {
|
||||
continue;
|
||||
}
|
||||
if (++poMatches > 5) break;
|
||||
final poId = int.tryParse(po.id);
|
||||
if (poId == null) continue;
|
||||
final byPo = await repository.getGrns(
|
||||
query.copyWith(search: null, poId: poId),
|
||||
);
|
||||
if (byPo.failure == null && byPo.data != null) {
|
||||
items = TableSearch.mergeById(
|
||||
items,
|
||||
byPo.data!.items,
|
||||
(grn) => grn.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final poLookups = await ref.read(purchaseOrderLookupsProvider.future);
|
||||
var vendorMatches = 0;
|
||||
for (final vendor in poLookups.vendors) {
|
||||
if (!TableSearch.matches(search, [vendor.name])) continue;
|
||||
if (++vendorMatches > 5) break;
|
||||
final vendorId = int.tryParse(vendor.id);
|
||||
if (vendorId == null) continue;
|
||||
final byVendor = await repository.getGrns(
|
||||
query.copyWith(search: null, vendorId: vendorId),
|
||||
);
|
||||
if (byVendor.failure == null && byVendor.data != null) {
|
||||
items = TableSearch.mergeById(
|
||||
items,
|
||||
byVendor.data!.items,
|
||||
(grn) => grn.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Lookups enrichment is best-effort.
|
||||
}
|
||||
|
||||
items = TableSearch.filter(
|
||||
items,
|
||||
search,
|
||||
(grn) => [
|
||||
grn.grnNumber,
|
||||
grn.poNumber,
|
||||
grn.vendorName,
|
||||
grn.locationName,
|
||||
grn.status,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return GrnListState(
|
||||
grns: page.items,
|
||||
grns: items,
|
||||
query: query,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
total: search.isEmpty ? page.total : items.length,
|
||||
totalPages: search.isEmpty ? page.totalPages : 1,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -126,6 +126,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemsOnPage: state.grns.length,
|
||||
itemLabel: 'GRNs',
|
||||
onPageChanged: ref.read(grnListProvider.notifier).setPage,
|
||||
onPageSizeChanged: ref.read(grnListProvider.notifier).setPageSize,
|
||||
|
||||
@ -332,7 +332,7 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||
const SizedBox(height: 8),
|
||||
FormRow(
|
||||
columnCount: 12,
|
||||
spans: const [2, 2, 2, 2, 2, 2],
|
||||
spans: const [2, 2, 2, 3, 3],
|
||||
spacing: 8,
|
||||
stackBelowWidth: 1100,
|
||||
children: [
|
||||
@ -398,14 +398,6 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||
readOnly: true,
|
||||
fillColor: currentBg,
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-rate'),
|
||||
controller: item.rateController,
|
||||
label: 'Rate',
|
||||
hint: '0.00',
|
||||
isDense: true,
|
||||
readOnly: true,
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-batch'),
|
||||
controller: item.batchNoController,
|
||||
|
||||
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/utils/active_option.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../domain/entities/master_definition.dart';
|
||||
|
||||
@ -17,18 +18,14 @@ class MasterListResult {
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.limit,
|
||||
required this.totalPages,
|
||||
});
|
||||
|
||||
final List<Map<String, dynamic>> items;
|
||||
final int total;
|
||||
final int page;
|
||||
final int limit;
|
||||
|
||||
int get totalPages {
|
||||
if (limit <= 0) return 1;
|
||||
final pages = (total / limit).ceil();
|
||||
return pages < 1 ? 1 : pages;
|
||||
}
|
||||
final int totalPages;
|
||||
}
|
||||
|
||||
class MasterCrudRemoteDataSource {
|
||||
@ -68,16 +65,20 @@ class MasterCrudRemoteDataSource {
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
|
||||
final meta = raw is Map<String, dynamic> ? raw : body;
|
||||
final total = _asInt(meta['total']) ?? items.length;
|
||||
final currentPage = _asInt(meta['page']) ?? page;
|
||||
final currentLimit = _asInt(meta['limit']) ?? limit;
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: page,
|
||||
fallbackLimit: limit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
|
||||
return MasterListResult(
|
||||
items: items,
|
||||
total: total,
|
||||
page: currentPage,
|
||||
limit: currentLimit,
|
||||
total: pagination.total,
|
||||
page: pagination.page,
|
||||
// Keep the requested page size so the UI dropdown matches the next request.
|
||||
limit: limit,
|
||||
totalPages: resolveTotalPages(total: pagination.total, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
@ -196,11 +197,4 @@ class MasterCrudRemoteDataSource {
|
||||
if (data is Map<String, dynamic>) return Map<String, dynamic>.from(data);
|
||||
return Map<String, dynamic>.from(body);
|
||||
}
|
||||
|
||||
int? _asInt(Object? value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ class MasterRepositoryImpl implements MasterRepository {
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
String? search,
|
||||
Map<String, dynamic>? extraQueryParameters,
|
||||
}) =>
|
||||
safeApiCall(
|
||||
() => remote.list(
|
||||
@ -28,6 +29,7 @@ class MasterRepositoryImpl implements MasterRepository {
|
||||
page: page,
|
||||
limit: limit,
|
||||
search: search,
|
||||
extraQueryParameters: extraQueryParameters,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ class MasterFieldDef {
|
||||
this.filterByOptionKey,
|
||||
this.visibleWhenFieldKey,
|
||||
this.visibleWhenValue,
|
||||
this.listNestedKey,
|
||||
});
|
||||
|
||||
final String key;
|
||||
@ -47,6 +48,8 @@ class MasterFieldDef {
|
||||
/// Show this field only when [visibleWhenFieldKey] equals [visibleWhenValue].
|
||||
final String? visibleWhenFieldKey;
|
||||
final String? visibleWhenValue;
|
||||
/// Nested object key on list rows for display (e.g. `plant` for `parent_id`).
|
||||
final String? listNestedKey;
|
||||
|
||||
/// Cache key for dropdown option rows (includes query params when set).
|
||||
String get dropdownLookupKey {
|
||||
@ -272,12 +275,17 @@ const masterDefinitions = <MasterDefinition>[
|
||||
label: 'Min Order Qty',
|
||||
type: MasterFieldType.number,
|
||||
required: true,
|
||||
// Stock items only — hidden when Asset Item is checked.
|
||||
visibleWhenFieldKey: 'is_asset_item',
|
||||
visibleWhenValue: 'false',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'reorder_level',
|
||||
label: 'Reorder Level',
|
||||
type: MasterFieldType.number,
|
||||
required: true,
|
||||
visibleWhenFieldKey: 'is_asset_item',
|
||||
visibleWhenValue: 'false',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'tags',
|
||||
@ -374,6 +382,7 @@ const masterDefinitions = <MasterDefinition>[
|
||||
showInList: true,
|
||||
optionsMasterKey: 'locations',
|
||||
optionsQueryParams: const {'type': 'plant'},
|
||||
listNestedKey: 'plant',
|
||||
visibleWhenFieldKey: 'type',
|
||||
visibleWhenValue: 'warehouse',
|
||||
required: true,
|
||||
@ -606,18 +615,25 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
|
||||
if (explicitName != null && explicitName.toString().trim().isNotEmpty) {
|
||||
return explicitName.toString().trim();
|
||||
}
|
||||
final nested = row[baseKey];
|
||||
if (nested is Map) {
|
||||
for (final nestedKey in ['name', 'code', 'description']) {
|
||||
final nestedValue = nested[nestedKey];
|
||||
if (nestedValue != null &&
|
||||
nestedValue.toString().trim().isNotEmpty) {
|
||||
return nestedValue.toString().trim();
|
||||
|
||||
final nestedKeys = <String>[
|
||||
if (field.listNestedKey != null) field.listNestedKey!,
|
||||
baseKey,
|
||||
];
|
||||
for (final nestedKey in nestedKeys) {
|
||||
final nested = row[nestedKey];
|
||||
if (nested is Map) {
|
||||
for (final nameKey in ['name', 'code', 'description']) {
|
||||
final nestedValue = nested[nameKey];
|
||||
if (nestedValue != null &&
|
||||
nestedValue.toString().trim().isNotEmpty) {
|
||||
return nestedValue.toString().trim();
|
||||
}
|
||||
}
|
||||
} else if (nested != null && nested.toString().trim().isNotEmpty) {
|
||||
// Flat denormalized value (e.g. items.hsn_code string)
|
||||
return nested.toString().trim();
|
||||
}
|
||||
} else if (nested != null && nested.toString().trim().isNotEmpty) {
|
||||
// Flat denormalized value (e.g. items.hsn_code string)
|
||||
return nested.toString().trim();
|
||||
}
|
||||
}
|
||||
|
||||
@ -670,6 +686,28 @@ String masterFieldDropdownLookupKey({
|
||||
return '$masterKey?$query';
|
||||
}
|
||||
|
||||
/// Placeholder / hint casing for master field labels (keeps UOM, HSN, GST, etc.).
|
||||
String masterFieldHintLabel(String label) {
|
||||
const acronyms = {
|
||||
'uom': 'UOM',
|
||||
'hsn': 'HSN',
|
||||
'gst': 'GST',
|
||||
'sac': 'SAC',
|
||||
'po': 'PO',
|
||||
'grn': 'GRN',
|
||||
'amc': 'AMC',
|
||||
};
|
||||
return label
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((part) => part.isNotEmpty)
|
||||
.map((part) {
|
||||
final lower = part.toLowerCase();
|
||||
return acronyms[lower] ?? lower;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/// Display label for GST filled from HSN nested `gst_rate.description`.
|
||||
String? gstRateDisplayFromValues(Map<String, dynamic> values) {
|
||||
final nested = values['gst_rate'];
|
||||
|
||||
@ -9,6 +9,7 @@ abstract class MasterRepository {
|
||||
int page,
|
||||
int limit,
|
||||
String? search,
|
||||
Map<String, dynamic>? extraQueryParameters,
|
||||
});
|
||||
|
||||
Future<Result<List<Map<String, dynamic>>>> listOptions(
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../../assets/data/repositories/asset_repository_impl.dart';
|
||||
@ -114,45 +115,68 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
||||
final current = state.valueOrNull;
|
||||
final nextPage = page ?? current?.page ?? 1;
|
||||
final nextLimit = limit ?? current?.limit ?? AppConstants.defaultPageSize;
|
||||
final nextSearch = search ?? current?.search;
|
||||
final nextSearch = TableSearch.normalize(search ?? current?.search);
|
||||
final repository = ref.read(masterRepositoryProvider);
|
||||
|
||||
final result = await ref.read(masterRepositoryProvider).list(
|
||||
_definition,
|
||||
page: nextPage,
|
||||
limit: nextLimit,
|
||||
search: nextSearch,
|
||||
);
|
||||
final result = await repository.list(
|
||||
_definition,
|
||||
page: nextPage,
|
||||
limit: nextLimit,
|
||||
search: nextSearch.isEmpty ? null : nextSearch,
|
||||
);
|
||||
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final data = result.data!;
|
||||
var items = List<Map<String, dynamic>>.from(result.data!.items);
|
||||
|
||||
// API `search` often ignores related/enum fields (e.g. category_type).
|
||||
// Supplement with dedicated filters and client-side multi-field matching.
|
||||
if (nextSearch.isNotEmpty) {
|
||||
if (_definition.id == 'item_categories') {
|
||||
var typeMatches = 0;
|
||||
for (final type in categoryTypeOptions) {
|
||||
if (!TableSearch.matches(nextSearch, [type])) continue;
|
||||
if (++typeMatches > 5) break;
|
||||
final typed = await repository.list(
|
||||
_definition,
|
||||
page: nextPage,
|
||||
limit: nextLimit,
|
||||
extraQueryParameters: {'category_type': type},
|
||||
);
|
||||
if (typed.failure == null && typed.data != null) {
|
||||
items = TableSearch.mergeById(
|
||||
items,
|
||||
typed.data!.items,
|
||||
(row) => row['id']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items = TableSearch.filter(
|
||||
items,
|
||||
nextSearch,
|
||||
(row) => [
|
||||
..._definition.listFields.map((field) => masterCellValue(row, field)),
|
||||
masterStatusValue(row),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final data = result.data!;
|
||||
return MasterListState(
|
||||
items: data.items,
|
||||
search: nextSearch ?? '',
|
||||
items: items,
|
||||
search: nextSearch,
|
||||
page: data.page,
|
||||
// Keep the requested page size so the /page dropdown stays valid
|
||||
// even if API meta omits or mismatches `limit`.
|
||||
limit: nextLimit,
|
||||
total: data.total,
|
||||
totalPages: _resolveTotalPages(
|
||||
apiTotalPages: data.totalPages,
|
||||
total: data.total,
|
||||
limit: nextLimit,
|
||||
),
|
||||
total: nextSearch.isEmpty ? data.total : items.length,
|
||||
totalPages: nextSearch.isEmpty
|
||||
? resolveTotalPages(total: data.total, limit: nextLimit)
|
||||
: resolveTotalPages(total: items.length, limit: nextLimit),
|
||||
);
|
||||
}
|
||||
|
||||
int _resolveTotalPages({
|
||||
required int apiTotalPages,
|
||||
required int total,
|
||||
required int limit,
|
||||
}) {
|
||||
if (apiTotalPages > 0) return apiTotalPages;
|
||||
if (total <= 0 || limit <= 0) return 1;
|
||||
final pages = (total / limit).ceil();
|
||||
return pages < 1 ? 1 : pages;
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final previous = state.valueOrNull;
|
||||
if (previous == null) {
|
||||
@ -169,6 +193,13 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
||||
await _reload(page: 1, search: search);
|
||||
}
|
||||
|
||||
/// Clears generic search and reloads the full list.
|
||||
Future<void> clearSearch() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current != null && current.search.isEmpty) return;
|
||||
await _reload(page: 1, search: '');
|
||||
}
|
||||
|
||||
Future<void> setPage(int page) async {
|
||||
await _reload(page: page);
|
||||
}
|
||||
@ -447,10 +478,12 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
||||
_clearHiddenFieldValues(values);
|
||||
}
|
||||
|
||||
// Asset Item toggles Items category list between STOCK / ASSET.
|
||||
// Asset Item toggles Items category list between STOCK / ASSET
|
||||
// and hides stock-only fields (min order qty / reorder level).
|
||||
if (key == 'is_asset_item' && _definition.id == 'items') {
|
||||
values['item_category_id'] = null;
|
||||
values['item_subcategory_id'] = null;
|
||||
_clearHiddenFieldValues(values);
|
||||
state = AsyncData(current.copyWith(values: values));
|
||||
_reloadItemCategoryOptions(values);
|
||||
return;
|
||||
@ -496,7 +529,11 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final options = await _loadDropdownOptions(values: current.values);
|
||||
state = AsyncData(current.copyWith(dropdownOptions: options));
|
||||
// Re-read latest state so a Quick Add selection applied while we were
|
||||
// loading is not overwritten by the snapshot from the start of this call.
|
||||
final latest = state.valueOrNull;
|
||||
if (latest == null) return;
|
||||
state = AsyncData(latest.copyWith(dropdownOptions: options));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPayload(MasterFormState current) {
|
||||
@ -569,8 +606,27 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
||||
}
|
||||
|
||||
state = AsyncData(current.copyWith(isSubmitting: false, clearError: true));
|
||||
final createdId = result.data?['id']?.toString();
|
||||
final data = result.data;
|
||||
final createdId = _readCreatedId(data);
|
||||
if (createdId != null && createdId.isNotEmpty) return createdId;
|
||||
return arg.recordId ?? 'created';
|
||||
}
|
||||
|
||||
String? _readCreatedId(Map<String, dynamic>? data) {
|
||||
if (data == null) return null;
|
||||
for (final key in const ['id', 'ID', 'gst_rate_id']) {
|
||||
final value = data[key];
|
||||
if (value != null && value.toString().trim().isNotEmpty) {
|
||||
return value.toString().trim();
|
||||
}
|
||||
}
|
||||
final nested = data['data'];
|
||||
if (nested is Map) {
|
||||
final value = nested['id'];
|
||||
if (value != null && value.toString().trim().isNotEmpty) {
|
||||
return value.toString().trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,6 +37,7 @@ class MasterListScreen extends ConsumerStatefulWidget {
|
||||
class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
bool _filtersExpanded = false;
|
||||
bool _didResetSearchOnOpen = false;
|
||||
|
||||
MasterDefinition get _definition {
|
||||
final def = masterDefinitionById(widget.masterId);
|
||||
@ -44,6 +45,24 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
return def;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// keepAlive can restore a previous search after All Masters → another master.
|
||||
// Always open list screens with a clean generic search.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _didResetSearchOnOpen) return;
|
||||
_didResetSearchOnOpen = true;
|
||||
_searchController.clear();
|
||||
final notifier = ref.read(masterListProvider(widget.masterId).notifier);
|
||||
final existing =
|
||||
ref.read(masterListProvider(widget.masterId)).valueOrNull;
|
||||
if (existing != null && existing.search.isNotEmpty) {
|
||||
notifier.clearSearch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
@ -197,7 +216,13 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.push(RouteConstants.masterData),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
ref
|
||||
.read(masterListProvider(widget.masterId).notifier)
|
||||
.clearSearch();
|
||||
context.go(RouteConstants.masterData);
|
||||
},
|
||||
icon: const Icon(Icons.grid_view_outlined),
|
||||
label: const Text('All Masters'),
|
||||
),
|
||||
@ -225,6 +250,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.limit,
|
||||
itemsOnPage: state.items.length,
|
||||
itemLabel: def.title.toLowerCase(),
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
|
||||
@ -226,7 +226,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
if (filterField != null) {
|
||||
for (final f in _definition.formFields) {
|
||||
if (f.key == filterField) {
|
||||
parentLabel = f.label.toLowerCase();
|
||||
parentLabel = masterFieldHintLabel(f.label);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -247,8 +247,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
? 'Select $parentLabel first'
|
||||
: dropdownOptions.isEmpty
|
||||
? 'No options available'
|
||||
: 'Select ${field.label.toLowerCase()}',
|
||||
searchHint: 'Search ${field.label.toLowerCase()}...',
|
||||
: 'Select ${masterFieldHintLabel(field.label)}',
|
||||
searchHint: 'Search ${masterFieldHintLabel(field.label)}...',
|
||||
enabled: parentSelected,
|
||||
initialValues: () {
|
||||
final values = <String, dynamic>{};
|
||||
@ -263,11 +263,9 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
}
|
||||
return values.isEmpty ? null : values;
|
||||
}(),
|
||||
refreshLookups: () {
|
||||
ref
|
||||
.read(masterFormProvider(_args).notifier)
|
||||
.reloadDropdownOptions();
|
||||
},
|
||||
refreshLookups: () => ref
|
||||
.read(masterFormProvider(_args).notifier)
|
||||
.reloadDropdownOptions(),
|
||||
parseCreatedId: (id) => id,
|
||||
onChanged: (selected) => notifier.updateValue(field.key, selected),
|
||||
validator: field.required
|
||||
@ -287,8 +285,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
? 'Select $parentLabel first'
|
||||
: dropdownOptions.isEmpty
|
||||
? 'No options available'
|
||||
: 'Select ${field.label.toLowerCase()}',
|
||||
searchHint: 'Search ${field.label.toLowerCase()}...',
|
||||
: 'Select ${masterFieldHintLabel(field.label)}',
|
||||
searchHint: 'Search ${masterFieldHintLabel(field.label)}...',
|
||||
enabled: field.staticOptions != null
|
||||
? true
|
||||
: parentSelected && dropdownOptions.isNotEmpty,
|
||||
|
||||
@ -162,7 +162,7 @@ class _MasterInlineCreateFormState
|
||||
if (filterField != null) {
|
||||
for (final f in _definition.formFields) {
|
||||
if (f.key == filterField) {
|
||||
parentLabel = f.label.toLowerCase();
|
||||
parentLabel = masterFieldHintLabel(f.label);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -181,8 +181,8 @@ class _MasterInlineCreateFormState
|
||||
? 'Select $parentLabel first'
|
||||
: dropdownOptions.isEmpty
|
||||
? 'No options available'
|
||||
: 'Select ${field.label.toLowerCase()}',
|
||||
searchHint: 'Search ${field.label.toLowerCase()}...',
|
||||
: 'Select ${masterFieldHintLabel(field.label)}',
|
||||
searchHint: 'Search ${masterFieldHintLabel(field.label)}...',
|
||||
enabled: field.staticOptions != null
|
||||
? true
|
||||
: parentSelected && dropdownOptions.isNotEmpty,
|
||||
|
||||
@ -9,15 +9,16 @@ import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../providers/master_provider.dart';
|
||||
import '../../domain/entities/master_definition.dart';
|
||||
import 'master_form_panel.dart';
|
||||
import 'master_inline_create_form.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
/// Dropdown with inline Quick Add that expands below the field (no dialog).
|
||||
/// Dropdown with Quick Add.
|
||||
///
|
||||
/// When placed inside a [QuickAddInlineHost] (FormRow / SidePanelFormRow), the
|
||||
/// create form is rendered at full row width under the row so every field and
|
||||
/// button stays clickable. Without a host, the form expands directly below the
|
||||
/// dropdown at the available column width.
|
||||
/// By default expands an inline create form below the field (or under a
|
||||
/// [QuickAddInlineHost]). Set [openInSidePanel] to open [MasterFormPanel]
|
||||
/// in a popup side panel instead.
|
||||
class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
|
||||
const MasterQuickAddDropdown({
|
||||
super.key,
|
||||
@ -35,6 +36,7 @@ class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
|
||||
this.initialValues,
|
||||
this.refreshLookups,
|
||||
this.addNewLabel,
|
||||
this.openInSidePanel = false,
|
||||
});
|
||||
|
||||
final String masterId;
|
||||
@ -54,6 +56,9 @@ class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
|
||||
final FutureOr<void> Function()? refreshLookups;
|
||||
final String? addNewLabel;
|
||||
|
||||
/// When true, Quick Add opens [MasterFormPanel] in a side panel popup.
|
||||
final bool openInSidePanel;
|
||||
|
||||
@override
|
||||
ConsumerState<MasterQuickAddDropdown<T>> createState() =>
|
||||
_MasterQuickAddDropdownState<T>();
|
||||
@ -75,9 +80,10 @@ class _MasterQuickAddDropdownState<T>
|
||||
|
||||
bool get _canQuickAdd => ref.can('masters', PermissionAction.create);
|
||||
|
||||
void _openInlineForm() {
|
||||
Future<void> _onAddNew() async {
|
||||
if (!_canQuickAdd) {
|
||||
showAppToastFromSnackBar(context,
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(
|
||||
content: Text('You do not have permission to add master data'),
|
||||
),
|
||||
@ -85,6 +91,41 @@ class _MasterQuickAddDropdownState<T>
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.openInSidePanel) {
|
||||
await _openSidePanelForm();
|
||||
return;
|
||||
}
|
||||
|
||||
_openInlineForm();
|
||||
}
|
||||
|
||||
Future<void> _openSidePanelForm() async {
|
||||
final sessionId =
|
||||
'panel-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}';
|
||||
ref.invalidate(
|
||||
masterFormProvider((
|
||||
masterId: widget.masterId,
|
||||
recordId: null,
|
||||
initialValues: widget.initialValues,
|
||||
formSessionId: sessionId,
|
||||
)),
|
||||
);
|
||||
|
||||
final createdId = await showSidePanel<String>(
|
||||
context,
|
||||
MasterFormPanel(
|
||||
masterId: widget.masterId,
|
||||
initialValues: widget.initialValues,
|
||||
formSessionId: sessionId,
|
||||
),
|
||||
width: 560,
|
||||
);
|
||||
|
||||
if (!mounted || createdId == null) return;
|
||||
await _applyCreated(createdId);
|
||||
}
|
||||
|
||||
void _openInlineForm() {
|
||||
final sessionId =
|
||||
'inline-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}';
|
||||
ref.invalidate(
|
||||
@ -119,6 +160,11 @@ class _MasterQuickAddDropdownState<T>
|
||||
}
|
||||
|
||||
Future<void> _onSaved(String createdId) async {
|
||||
await _applyCreated(createdId);
|
||||
_collapse();
|
||||
}
|
||||
|
||||
Future<void> _applyCreated(String createdId) async {
|
||||
try {
|
||||
final refresh = widget.refreshLookups;
|
||||
if (refresh != null) await refresh();
|
||||
@ -127,15 +173,14 @@ class _MasterQuickAddDropdownState<T>
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
if (createdId != 'created') {
|
||||
// Prefer a real id from create; fall back only when API omits it.
|
||||
if (createdId.isNotEmpty && createdId != 'created') {
|
||||
final parsed = widget.parseCreatedId(createdId);
|
||||
if (parsed != null) {
|
||||
widget.onChanged(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
_collapse();
|
||||
|
||||
if (!mounted) return;
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
@ -168,7 +213,9 @@ class _MasterQuickAddDropdownState<T>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final addLabel = widget.addNewLabel ?? masterQuickAddLabel(widget.masterId);
|
||||
_host ??= QuickAddInlineScope.maybeOf(context);
|
||||
if (!widget.openInSidePanel) {
|
||||
_host ??= QuickAddInlineScope.maybeOf(context);
|
||||
}
|
||||
|
||||
final dropdown = Focus(
|
||||
focusNode: _dropdownFocus,
|
||||
@ -179,19 +226,19 @@ class _MasterQuickAddDropdownState<T>
|
||||
onChanged: widget.onChanged,
|
||||
validator: widget.validator,
|
||||
hint: widget.hint,
|
||||
searchHint:
|
||||
widget.searchHint ?? 'Search ${widget.label.toLowerCase()}...',
|
||||
searchHint: widget.searchHint ??
|
||||
'Search ${masterFieldHintLabel(widget.label.replaceAll('*', '').trim())}...',
|
||||
enabled: widget.enabled,
|
||||
isDense: widget.isDense,
|
||||
addNewLabel: _canQuickAdd ? addLabel : null,
|
||||
onAddNew: !_canQuickAdd || !widget.enabled
|
||||
? null
|
||||
: () async => _openInlineForm(),
|
||||
onAddNew: !_canQuickAdd || !widget.enabled ? null : _onAddNew,
|
||||
),
|
||||
);
|
||||
|
||||
// Host renders the form at full row width.
|
||||
if (_host != null || !_expanded || _sessionId == null) {
|
||||
if (widget.openInSidePanel ||
|
||||
_host != null ||
|
||||
!_expanded ||
|
||||
_sessionId == null) {
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/utils/active_option.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
|
||||
final masterRemoteDataSourceProvider = Provider<MasterRemoteDataSource>((ref) {
|
||||
@ -331,20 +332,12 @@ class MasterRemoteDataSource {
|
||||
}
|
||||
|
||||
final raw = body['data'];
|
||||
final meta = body['meta'] is Map
|
||||
? Map<String, dynamic>.from(body['meta'] as Map)
|
||||
: <String, dynamic>{};
|
||||
|
||||
List list;
|
||||
Map<String, dynamic> pageMeta = meta;
|
||||
|
||||
if (raw is List) {
|
||||
list = raw;
|
||||
} else if (raw is Map) {
|
||||
final map = Map<String, dynamic>.from(raw);
|
||||
final items = map['items'];
|
||||
final items = raw['items'];
|
||||
list = items is List ? items : const [];
|
||||
pageMeta = {...meta, ...map};
|
||||
} else {
|
||||
list = const [];
|
||||
}
|
||||
@ -354,14 +347,20 @@ class MasterRemoteDataSource {
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
|
||||
final total = _asInt(pageMeta['total']) ?? items.length;
|
||||
final limit = _asInt(pageMeta['limit']) ?? fallbackLimit;
|
||||
final explicitTotalPages = _asInt(pageMeta['totalPages']) ??
|
||||
_asInt(pageMeta['total_pages']);
|
||||
final totalPages = explicitTotalPages ??
|
||||
(limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1);
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: 1,
|
||||
fallbackLimit: fallbackLimit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
|
||||
return (items: items, totalPages: totalPages < 1 ? 1 : totalPages);
|
||||
return (
|
||||
items: items,
|
||||
totalPages: resolveTotalPages(
|
||||
total: pagination.total,
|
||||
limit: fallbackLimit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int? _asInt(dynamic value) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/export_file_name.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
@ -19,7 +20,12 @@ class PurchaseOrderRemoteDataSource {
|
||||
ApiEndpoints.purchaseOrders,
|
||||
queryParameters: _queryToMap(query),
|
||||
);
|
||||
return _parsePaginated(response.data, PurchaseOrderModel.fromJson);
|
||||
return _parsePaginated(
|
||||
response.data,
|
||||
PurchaseOrderModel.fromJson,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Approver-only list of POs with status PENDING_APPROVAL.
|
||||
@ -30,7 +36,12 @@ class PurchaseOrderRemoteDataSource {
|
||||
ApiEndpoints.purchaseOrdersPendingApproval,
|
||||
queryParameters: _queryToMap(query),
|
||||
);
|
||||
return _parsePaginated(response.data, PurchaseOrderModel.fromJson);
|
||||
return _parsePaginated(
|
||||
response.data,
|
||||
PurchaseOrderModel.fromJson,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Form-dropdown loader (`dropdown_call=true`). Optional status filter.
|
||||
@ -252,58 +263,45 @@ class PurchaseOrderRemoteDataSource {
|
||||
|
||||
PaginatedResponse<T> _parsePaginated<T>(
|
||||
dynamic body,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
if (body is! Map<String, dynamic>) {
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
);
|
||||
}
|
||||
|
||||
final raw = body['data'];
|
||||
final meta = body['meta'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
if (raw is List) {
|
||||
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList();
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? items.length;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: (meta['page'] as num?)?.toInt() ?? 1,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages:
|
||||
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
|
||||
);
|
||||
}
|
||||
|
||||
if (raw is Map<String, dynamic>) {
|
||||
final list = raw['items'];
|
||||
if (list is List) {
|
||||
final items = list.whereType<Map<String, dynamic>>().map(fromJson).toList();
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? 20;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: (meta['page'] as num?)?.toInt() ?? 1,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages:
|
||||
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
|
||||
);
|
||||
T Function(Map<String, dynamic>) fromJson, {
|
||||
int fallbackPage = 1,
|
||||
int fallbackLimit = 20,
|
||||
}) {
|
||||
var items = <T>[];
|
||||
if (body is Map) {
|
||||
final raw = body['data'];
|
||||
if (raw is List) {
|
||||
items = raw
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
} else if (raw is Map) {
|
||||
final list = raw['items'];
|
||||
if (list is List) {
|
||||
items = list
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: fallbackPage,
|
||||
fallbackLimit: fallbackLimit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: pagination.page,
|
||||
limit: fallbackLimit,
|
||||
total: pagination.total,
|
||||
totalPages: resolveTotalPages(
|
||||
total: pagination.total,
|
||||
limit: fallbackLimit,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||
import 'purchase_order_lookups_provider.dart';
|
||||
|
||||
class PurchaseOrdersListState {
|
||||
const PurchaseOrdersListState({
|
||||
@ -76,11 +78,18 @@ class PurchaseOrdersListNotifier
|
||||
final result = await repository.getPurchaseOrders(query);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final page = result.data!;
|
||||
return PurchaseOrdersListState(
|
||||
orders: page.items,
|
||||
final orders = await _enrichPurchaseOrderSearch(
|
||||
ref: ref,
|
||||
query: query,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
items: page.items,
|
||||
load: repository.getPurchaseOrders,
|
||||
);
|
||||
final search = TableSearch.normalize(query.search);
|
||||
return PurchaseOrdersListState(
|
||||
orders: orders,
|
||||
query: query,
|
||||
total: search.isEmpty ? page.total : orders.length,
|
||||
totalPages: search.isEmpty ? page.totalPages : 1,
|
||||
);
|
||||
}
|
||||
|
||||
@ -187,11 +196,18 @@ class PendingApprovalPurchaseOrdersListNotifier
|
||||
final result = await repository.getPendingApprovalPurchaseOrders(query);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final page = result.data!;
|
||||
return PurchaseOrdersListState(
|
||||
orders: page.items,
|
||||
final orders = await _enrichPurchaseOrderSearch(
|
||||
ref: ref,
|
||||
query: query,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
items: page.items,
|
||||
load: repository.getPendingApprovalPurchaseOrders,
|
||||
);
|
||||
final search = TableSearch.normalize(query.search);
|
||||
return PurchaseOrdersListState(
|
||||
orders: orders,
|
||||
query: query,
|
||||
total: search.isEmpty ? page.total : orders.length,
|
||||
totalPages: search.isEmpty ? page.totalPages : 1,
|
||||
);
|
||||
}
|
||||
|
||||
@ -425,3 +441,53 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier<PurchaseOrderModel?,
|
||||
return result.data!;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PurchaseOrderModel>> _enrichPurchaseOrderSearch({
|
||||
required Ref ref,
|
||||
required PurchaseOrderListQuery query,
|
||||
required List<PurchaseOrderModel> items,
|
||||
required Future<Result<PaginatedResponse<PurchaseOrderModel>>> Function(
|
||||
PurchaseOrderListQuery query,
|
||||
) load,
|
||||
}) async {
|
||||
final search = TableSearch.normalize(query.search);
|
||||
if (search.isEmpty || query.vendorId != null) {
|
||||
return items;
|
||||
}
|
||||
|
||||
var merged = List<PurchaseOrderModel>.from(items);
|
||||
try {
|
||||
final lookups = await ref.read(purchaseOrderLookupsProvider.future);
|
||||
var vendorMatches = 0;
|
||||
for (final vendor in lookups.vendors) {
|
||||
if (!TableSearch.matches(search, [vendor.name])) continue;
|
||||
if (++vendorMatches > 5) break;
|
||||
final vendorId = int.tryParse(vendor.id);
|
||||
if (vendorId == null) continue;
|
||||
final byVendor = await load(
|
||||
query.copyWith(search: null, vendorId: vendorId),
|
||||
);
|
||||
if (byVendor.failure == null && byVendor.data != null) {
|
||||
merged = TableSearch.mergeById(
|
||||
merged,
|
||||
byVendor.data!.items,
|
||||
(order) => order.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Lookups/vendor enrichment is best-effort.
|
||||
}
|
||||
|
||||
return TableSearch.filter(
|
||||
merged,
|
||||
search,
|
||||
(order) => [
|
||||
order.poNo,
|
||||
order.vendorName,
|
||||
order.status,
|
||||
order.billingName,
|
||||
order.shippingName,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/constants/enums.dart';
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
@ -9,6 +10,7 @@ import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/utils/navigation_utils.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_date_popup.dart';
|
||||
@ -21,6 +23,7 @@ import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
||||
import '../../../master_data/presentation/widgets/master_quick_add.dart';
|
||||
import '../../../vendors/presentation/widgets/vendor_form_panel.dart';
|
||||
import '../providers/purchase_order_lookups_provider.dart';
|
||||
import '../providers/purchase_orders_provider.dart';
|
||||
import '../widgets/po_status_chip.dart';
|
||||
@ -417,24 +420,74 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
onChanged: (v) => setState(() => _vendorId = v),
|
||||
validator: (v) =>
|
||||
v == null ? 'Vendor is required' : null,
|
||||
addNewLabel: ref.can(
|
||||
'vendors',
|
||||
PermissionAction.create,
|
||||
)
|
||||
? 'Add vendor'
|
||||
: null,
|
||||
onAddNew: !ref.can(
|
||||
'vendors',
|
||||
PermissionAction.create,
|
||||
)
|
||||
? null
|
||||
: () async {
|
||||
final createdId =
|
||||
await openVendorFormPanel(
|
||||
context,
|
||||
ref,
|
||||
);
|
||||
if (!mounted || createdId == null) {
|
||||
return;
|
||||
}
|
||||
ref.invalidate(
|
||||
purchaseOrderLookupsProvider,
|
||||
);
|
||||
await ref.read(
|
||||
purchaseOrderLookupsProvider.future,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(
|
||||
() => _vendorId =
|
||||
int.tryParse(createdId),
|
||||
);
|
||||
},
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
MasterQuickAddDropdown<int>(
|
||||
masterId: 'locations',
|
||||
label: 'Billing *',
|
||||
value: _dropdownValue(_billingId, locationIds),
|
||||
hint: 'Select billing location',
|
||||
searchHint: 'Search plant or warehouse...',
|
||||
options: _intOptions(lookups.locations),
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () async {
|
||||
ref.invalidate(purchaseOrderLookupsProvider);
|
||||
await ref.read(
|
||||
purchaseOrderLookupsProvider.future,
|
||||
);
|
||||
},
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: (v) =>
|
||||
setState(() => _billingId = v),
|
||||
validator: (v) =>
|
||||
v == null ? 'Billing is required' : null,
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
MasterQuickAddDropdown<int>(
|
||||
masterId: 'locations',
|
||||
label: 'Shipping *',
|
||||
value: _dropdownValue(_shippingId, locationIds),
|
||||
hint: 'Select shipping location',
|
||||
searchHint: 'Search plant or warehouse...',
|
||||
options: _intOptions(lookups.locations),
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () async {
|
||||
ref.invalidate(purchaseOrderLookupsProvider);
|
||||
await ref.read(
|
||||
purchaseOrderLookupsProvider.future,
|
||||
);
|
||||
},
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: (v) =>
|
||||
setState(() => _shippingId = v),
|
||||
validator: (v) =>
|
||||
@ -448,6 +501,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
searchHint: 'Search payment term...',
|
||||
options:
|
||||
_nullableIntOptions(lookups.paymentTerms),
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () =>
|
||||
ref.invalidate(purchaseOrderLookupsProvider),
|
||||
parseCreatedId: int.tryParse,
|
||||
@ -462,6 +516,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
searchHint: 'Search delivery term...',
|
||||
options:
|
||||
_nullableIntOptions(lookups.deliveryTerms),
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () =>
|
||||
ref.invalidate(purchaseOrderLookupsProvider),
|
||||
parseCreatedId: int.tryParse,
|
||||
|
||||
@ -185,6 +185,7 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemsOnPage: state.orders.length,
|
||||
itemLabel: pendingOnly
|
||||
? 'pending approvals'
|
||||
: 'purchase orders',
|
||||
|
||||
@ -541,6 +541,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
hint: 'Select item',
|
||||
searchHint: 'Search item name or code...',
|
||||
options: itemOptions,
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () async {
|
||||
ref.invalidate(purchaseOrderLookupsProvider);
|
||||
await ref.read(purchaseOrderLookupsProvider.future);
|
||||
@ -570,6 +571,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
hint: 'Select UOM',
|
||||
searchHint: 'Search UOM...',
|
||||
options: uomOptions,
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () async {
|
||||
ref.invalidate(purchaseOrderLookupsProvider);
|
||||
await ref.read(purchaseOrderLookupsProvider.future);
|
||||
@ -606,6 +608,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
hint: 'Select',
|
||||
searchHint: 'Search GST %...',
|
||||
options: gstOptions,
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () async {
|
||||
ref.invalidate(purchaseOrderLookupsProvider);
|
||||
await ref.read(purchaseOrderLookupsProvider.future);
|
||||
|
||||
@ -866,6 +866,7 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
totalPages: usersState.totalPages,
|
||||
totalItems: total,
|
||||
pageSize: pageSize,
|
||||
itemsOnPage: usersState.users.length,
|
||||
itemLabel: 'users',
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
onPageChanged:
|
||||
@ -1126,29 +1127,39 @@ class _RolesTab extends ConsumerWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: appearance.color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
appearance.icon,
|
||||
color: appearance.color,
|
||||
size: 20,
|
||||
Tooltip(
|
||||
message: role.name,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
appearance.color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
appearance.icon,
|
||||
color: appearance.color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (canEditRole)
|
||||
IconButton(
|
||||
tooltip: 'Edit role',
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
onPressed: () => onEditRole(role),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
if (canDeleteRole && !isProtectedRole(role))
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
tooltip: 'Delete role',
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
onPressed: () => onDeleteRole(role),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
@ -1183,38 +1194,60 @@ class _RolesTab extends ConsumerWidget {
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people_outline,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.userCount} users',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
Tooltip(
|
||||
message: 'Assigned users',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people_outline,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.userCount} users',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Icons.vpn_key_outlined,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.permissionCount} permissions',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
Tooltip(
|
||||
message: 'Granted permissions',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.vpn_key_outlined,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.permissionCount} permissions',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -1231,6 +1264,7 @@ class _RolesTab extends ConsumerWidget {
|
||||
totalPages: rolesState.totalPages,
|
||||
totalItems: rolesState.total,
|
||||
pageSize: rolesState.limit,
|
||||
itemsOnPage: rolesState.pagedRoles.length,
|
||||
itemLabel: 'roles',
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
onPageChanged: notifier.setPage,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../domain/entities/depreciation_report.dart';
|
||||
@ -47,12 +48,16 @@ class ReportsRemoteDataSource {
|
||||
)
|
||||
.toList();
|
||||
|
||||
final page = (meta['page'] as num?)?.toInt() ?? query.page;
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
final totalPages = limit > 0
|
||||
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
|
||||
: 1;
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
final page = pagination.page;
|
||||
final limit = query.limit;
|
||||
final total = pagination.total;
|
||||
final totalPages = resolveTotalPages(total: total, limit: limit);
|
||||
|
||||
final summaryRaw = meta['summary'];
|
||||
final summary = summaryRaw is Map
|
||||
|
||||
@ -208,6 +208,7 @@ class _DepreciationReportScreenState
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemsOnPage: state.items.length,
|
||||
itemLabel: 'assets',
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
|
||||
@ -162,6 +162,7 @@ class PermissionMatrixNotifier
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
|
||||
final normalizedAction = action.toLowerCase();
|
||||
final updated = current.modules.map((row) {
|
||||
if (row.moduleId != moduleId) return row;
|
||||
if (!isPermissionActionApplicable(
|
||||
@ -172,7 +173,19 @@ class PermissionMatrixNotifier
|
||||
return row;
|
||||
}
|
||||
final granted = Map<String, bool>.from(row.granted);
|
||||
granted[action] = value;
|
||||
granted[normalizedAction] = value;
|
||||
|
||||
// CREATE / EDIT imply VIEW.
|
||||
if (value &&
|
||||
(normalizedAction == 'create' || normalizedAction == 'edit') &&
|
||||
isPermissionActionApplicable(
|
||||
row.code,
|
||||
'view',
|
||||
columnActions: current.actionColumns,
|
||||
)) {
|
||||
granted['view'] = true;
|
||||
}
|
||||
|
||||
return row.copyWith(granted: granted);
|
||||
}).toList();
|
||||
|
||||
|
||||
@ -81,6 +81,7 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.limit,
|
||||
itemsOnPage: state.roles.length,
|
||||
itemLabel: 'roles',
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
|
||||
@ -9,6 +9,75 @@ import '../providers/settings_provider.dart';
|
||||
import '../widgets/settings_widgets.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
class _BrandThemePreset {
|
||||
const _BrandThemePreset({
|
||||
required this.name,
|
||||
required this.primary,
|
||||
required this.secondary,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final int primary;
|
||||
final int secondary;
|
||||
|
||||
bool matches(BrandingConfig branding) =>
|
||||
branding.primaryColorValue == primary &&
|
||||
branding.secondaryColorValue == secondary;
|
||||
}
|
||||
|
||||
const _brandThemePresets = <_BrandThemePreset>[
|
||||
_BrandThemePreset(
|
||||
name: 'Ocean Blue',
|
||||
primary: 0xFF2563EB,
|
||||
secondary: 0xFF0891B2,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Royal Purple',
|
||||
primary: 0xFF6D28D9,
|
||||
secondary: 0xFF0F766E,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Emerald Green',
|
||||
primary: 0xFF15803D,
|
||||
secondary: 0xFF0F766E,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Sunset Orange',
|
||||
primary: 0xFFEA580C,
|
||||
secondary: 0xFFD97706,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Crimson Red',
|
||||
primary: 0xFFDC2626,
|
||||
secondary: 0xFFB45309,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Indigo Sky',
|
||||
primary: 0xFF4F46E5,
|
||||
secondary: 0xFF0284C7,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Slate Blue',
|
||||
primary: 0xFF334155,
|
||||
secondary: 0xFF2563EB,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Teal Mint',
|
||||
primary: 0xFF0F766E,
|
||||
secondary: 0xFF059669,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Rose Pink',
|
||||
primary: 0xFFDB2777,
|
||||
secondary: 0xFF7C3AED,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Charcoal Gold',
|
||||
primary: 0xFF374151,
|
||||
secondary: 0xFFD97706,
|
||||
),
|
||||
];
|
||||
|
||||
class AppearanceSettingsScreen extends ConsumerWidget {
|
||||
const AppearanceSettingsScreen({super.key});
|
||||
|
||||
@ -17,6 +86,9 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final branding = ref.watch(brandingProvider);
|
||||
final uiPrefs = ref.watch(appSettingsProvider).uiPreferences;
|
||||
final selectedPreset = _brandThemePresets
|
||||
.where((preset) => preset.matches(branding))
|
||||
.firstOrNull;
|
||||
|
||||
return SettingsPageLayout(
|
||||
title: 'Appearance',
|
||||
@ -43,58 +115,66 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
SettingsFormCard(
|
||||
title: 'Branding',
|
||||
subtitle: 'Primary and secondary colors for the application',
|
||||
subtitle:
|
||||
'Professionally curated primary and secondary color pairs',
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(backgroundColor: branding.primaryColor),
|
||||
title: const Text('Primary Color'),
|
||||
subtitle: Text(
|
||||
'#${branding.primaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}',
|
||||
),
|
||||
_BrandingPreviewCard(
|
||||
primary: branding.primaryColor,
|
||||
secondary: branding.secondaryColor,
|
||||
themeName: selectedPreset?.name ?? 'Custom',
|
||||
primaryHex: _hex(branding.primaryColorValue),
|
||||
secondaryHex: _hex(branding.secondaryColorValue),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(backgroundColor: branding.secondaryColor),
|
||||
title: const Text('Secondary Color'),
|
||||
subtitle: Text(
|
||||
'#${branding.secondaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}',
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Color themes',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_ColorPreset(
|
||||
label: 'Blue',
|
||||
primary: 0xFF1565C0,
|
||||
secondary: 0xFF00897B,
|
||||
branding: branding,
|
||||
ref: ref,
|
||||
),
|
||||
_ColorPreset(
|
||||
label: 'Purple',
|
||||
primary: 0xFF6A1B9A,
|
||||
secondary: 0xFF00838F,
|
||||
branding: branding,
|
||||
ref: ref,
|
||||
),
|
||||
_ColorPreset(
|
||||
label: 'Green',
|
||||
primary: 0xFF2E7D32,
|
||||
secondary: 0xFF558B2F,
|
||||
branding: branding,
|
||||
ref: ref,
|
||||
),
|
||||
_ColorPreset(
|
||||
label: 'Orange',
|
||||
primary: 0xFFE65100,
|
||||
secondary: 0xFFF57C00,
|
||||
branding: branding,
|
||||
ref: ref,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Choose a cohesive pair optimized for light and dark modes.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final crossAxisCount = width >= 720
|
||||
? 3
|
||||
: width >= 480
|
||||
? 2
|
||||
: 1;
|
||||
final spacing = 12.0;
|
||||
final itemWidth =
|
||||
(width - spacing * (crossAxisCount - 1)) / crossAxisCount;
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: [
|
||||
for (final preset in _brandThemePresets)
|
||||
SizedBox(
|
||||
width: itemWidth,
|
||||
child: _ThemePresetCard(
|
||||
preset: preset,
|
||||
selected: preset.matches(branding),
|
||||
onTap: () {
|
||||
ref.read(brandingProvider.notifier).updateBranding(
|
||||
branding.copyWith(
|
||||
primaryColorValue: preset.primary,
|
||||
secondaryColorValue: preset.secondary,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -112,10 +192,13 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||
: 'Horizontal menu bar at the top',
|
||||
),
|
||||
value: layout,
|
||||
groupValue: NavigationLayout.fromValue(uiPrefs.navigationLayout),
|
||||
groupValue:
|
||||
NavigationLayout.fromValue(uiPrefs.navigationLayout),
|
||||
onChanged: (v) {
|
||||
if (v != null) {
|
||||
ref.read(appSettingsProvider.notifier).updateUiPreferences(
|
||||
ref
|
||||
.read(appSettingsProvider.notifier)
|
||||
.updateUiPreferences(
|
||||
uiPrefs.copyWith(navigationLayout: v.value),
|
||||
);
|
||||
}
|
||||
@ -183,8 +266,11 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||
AppButton(
|
||||
label: 'Settings Auto-Saved',
|
||||
onPressed: () {
|
||||
showAppToastFromSnackBar(context,
|
||||
const SnackBar(content: Text('Appearance settings are saved automatically')),
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(
|
||||
content: Text('Appearance settings are saved automatically'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -198,42 +284,348 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||
ThemeModeOption.dark => 'Dark Theme',
|
||||
ThemeModeOption.system => 'System Theme',
|
||||
};
|
||||
|
||||
static String _hex(int value) =>
|
||||
'#${value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}';
|
||||
}
|
||||
|
||||
class _ColorPreset extends StatelessWidget {
|
||||
const _ColorPreset({
|
||||
required this.label,
|
||||
class _BrandingPreviewCard extends StatelessWidget {
|
||||
const _BrandingPreviewCard({
|
||||
required this.primary,
|
||||
required this.secondary,
|
||||
required this.branding,
|
||||
required this.ref,
|
||||
required this.themeName,
|
||||
required this.primaryHex,
|
||||
required this.secondaryHex,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int primary;
|
||||
final int secondary;
|
||||
final BrandingConfig branding;
|
||||
final WidgetRef ref;
|
||||
final Color primary;
|
||||
final Color secondary;
|
||||
final String themeName;
|
||||
final String primaryHex;
|
||||
final String secondaryHex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OutlinedButton(
|
||||
onPressed: () {
|
||||
ref.read(brandingProvider.notifier).updateBranding(
|
||||
branding.copyWith(
|
||||
primaryColorValue: primary,
|
||||
secondaryColorValue: secondary,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
final theme = Theme.of(context);
|
||||
final onPrimary =
|
||||
ThemeData.estimateBrightnessForColor(primary) == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black87;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.2),
|
||||
),
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
CircleAvatar(radius: 8, backgroundColor: Color(primary)),
|
||||
const SizedBox(width: 6),
|
||||
CircleAvatar(radius: 8, backgroundColor: Color(secondary)),
|
||||
const SizedBox(width: 8),
|
||||
Text(label),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
color: primary,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.dashboard_outlined, color: onPrimary, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Bharat ERP · $themeName',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: onPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: secondary,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'Accent',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: ThemeData.estimateBrightnessForColor(secondary) ==
|
||||
Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black87,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Live preview',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'How headers, primary actions, and secondary accents will look.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () {},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: onPrimary,
|
||||
),
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Add'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: secondary,
|
||||
side: BorderSide(
|
||||
color: secondary.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
child: const Text('Export'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
_ColorSwatchLabel(
|
||||
color: primary,
|
||||
label: 'Primary',
|
||||
hex: primaryHex,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_ColorSwatchLabel(
|
||||
color: secondary,
|
||||
label: 'Secondary',
|
||||
hex: secondaryHex,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorSwatchLabel extends StatelessWidget {
|
||||
const _ColorSwatchLabel({
|
||||
required this.color,
|
||||
required this.label,
|
||||
required this.hex,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final String label;
|
||||
final String hex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.25),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.28),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
hex,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemePresetCard extends StatelessWidget {
|
||||
const _ThemePresetCard({
|
||||
required this.preset,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final _BrandThemePreset preset;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final primary = Color(preset.primary);
|
||||
final secondary = Color(preset.secondary);
|
||||
final borderColor = selected
|
||||
? primary
|
||||
: theme.colorScheme.outline.withValues(alpha: 0.28);
|
||||
|
||||
return Material(
|
||||
color: selected
|
||||
? primary.withValues(alpha: 0.06)
|
||||
: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_StackedColorCircles(
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
),
|
||||
const Spacer(),
|
||||
if (selected)
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
size: 14,
|
||||
color: ThemeData.estimateBrightnessForColor(primary) ==
|
||||
Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
preset.name,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${AppearanceSettingsScreen._hex(preset.primary)} · '
|
||||
'${AppearanceSettingsScreen._hex(preset.secondary)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StackedColorCircles extends StatelessWidget {
|
||||
const _StackedColorCircles({
|
||||
required this.primary,
|
||||
required this.secondary,
|
||||
});
|
||||
|
||||
final Color primary;
|
||||
final Color secondary;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final outline = Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withValues(alpha: 0.2);
|
||||
|
||||
return SizedBox(
|
||||
width: 56,
|
||||
height: 32,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: outline, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 22,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: secondary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: outline, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/active_option.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
@ -80,19 +81,22 @@ class UserRemoteDataSource {
|
||||
(json) => ManagedUserModel.fromJson(json! as Map<String, dynamic>),
|
||||
).items;
|
||||
|
||||
final meta = body['meta'] as Map<String, dynamic>? ?? {};
|
||||
final page = (meta['page'] as num?)?.toInt() ?? query.page;
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
final totalPages =
|
||||
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1;
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
|
||||
return PaginatedResponse<ManagedUserModel>(
|
||||
items: items,
|
||||
page: page,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages: totalPages,
|
||||
page: pagination.page,
|
||||
limit: query.limit,
|
||||
total: pagination.total,
|
||||
totalPages: resolveTotalPages(
|
||||
total: pagination.total,
|
||||
limit: query.limit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -101,6 +101,7 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemsOnPage: state.users.length,
|
||||
itemLabel: 'users',
|
||||
onPageChanged: ref.read(usersListProvider.notifier).setPage,
|
||||
onPageSizeChanged:
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/export_file_name.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
@ -17,7 +18,12 @@ class VendorRemoteDataSource {
|
||||
ApiEndpoints.vendors,
|
||||
queryParameters: _queryToMap(query),
|
||||
);
|
||||
return _parsePaginated(response.data, VendorModel.fromJson);
|
||||
return _parsePaginated(
|
||||
response.data,
|
||||
VendorModel.fromJson,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Form-dropdown loader: all active vendors (`dropdown_call=true`).
|
||||
@ -246,47 +252,45 @@ class VendorRemoteDataSource {
|
||||
|
||||
PaginatedResponse<T> _parsePaginated<T>(
|
||||
dynamic body,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
if (body is! Map<String, dynamic>) {
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
);
|
||||
T Function(Map<String, dynamic>) fromJson, {
|
||||
int fallbackPage = 1,
|
||||
int fallbackLimit = 20,
|
||||
}) {
|
||||
var items = <T>[];
|
||||
if (body is Map) {
|
||||
final raw = body['data'];
|
||||
if (raw is List) {
|
||||
items = raw
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
} else if (raw is Map) {
|
||||
final list = raw['items'];
|
||||
if (list is List) {
|
||||
items = list
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final raw = body['data'];
|
||||
final meta = body['meta'] as Map<String, dynamic>? ?? {};
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: fallbackPage,
|
||||
fallbackLimit: fallbackLimit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
|
||||
if (raw is List) {
|
||||
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList();
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? items.length;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: (meta['page'] as num?)?.toInt() ?? 1,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages: limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1,
|
||||
);
|
||||
}
|
||||
|
||||
if (raw is Map<String, dynamic>) {
|
||||
return PaginatedResponse.fromJson(
|
||||
raw,
|
||||
(json) => fromJson(json! as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: pagination.page,
|
||||
limit: fallbackLimit,
|
||||
total: pagination.total,
|
||||
totalPages: resolveTotalPages(
|
||||
total: pagination.total,
|
||||
limit: fallbackLimit,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,6 +129,7 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemsOnPage: state.vendors.length,
|
||||
itemLabel: 'vendors',
|
||||
onPageChanged: ref.read(vendorsListProvider.notifier).setPage,
|
||||
onPageSizeChanged:
|
||||
|
||||
@ -19,19 +19,20 @@ import '../../data/repositories/vendor_repository_impl.dart';
|
||||
import '../providers/vendors_provider.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
Future<void> openVendorFormPanel(
|
||||
Future<String?> openVendorFormPanel(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
String? vendorId,
|
||||
}) async {
|
||||
ref.invalidate(vendorFormProvider(vendorId));
|
||||
final saved = await showSidePanel<bool>(
|
||||
final savedId = await showSidePanel<String>(
|
||||
context,
|
||||
VendorFormPanel(vendorId: vendorId),
|
||||
width: 560,
|
||||
);
|
||||
if (saved == true && context.mounted) {
|
||||
showAppToastFromSnackBar(context,
|
||||
if (savedId != null && context.mounted) {
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(
|
||||
content: Text(
|
||||
vendorId == null
|
||||
@ -41,6 +42,7 @@ Future<void> openVendorFormPanel(
|
||||
),
|
||||
);
|
||||
}
|
||||
return savedId;
|
||||
}
|
||||
|
||||
class VendorFormPanel extends ConsumerStatefulWidget {
|
||||
@ -148,12 +150,15 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
|
||||
try {
|
||||
final notifier = ref.read(vendorFormProvider(widget.vendorId).notifier);
|
||||
final payload = _buildPayload();
|
||||
final String savedId;
|
||||
if (widget.isEditing) {
|
||||
await notifier.submitUpdate(widget.vendorId!, payload);
|
||||
savedId = widget.vendorId!;
|
||||
} else {
|
||||
await notifier.submitCreate(payload);
|
||||
final created = await notifier.submitCreate(payload);
|
||||
savedId = created.id;
|
||||
}
|
||||
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
|
||||
if (mounted) Navigator.of(context, rootNavigator: true).pop(savedId);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showAppToastFromSnackBar(context,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/utils/formatters.dart';
|
||||
import 'app_searchable_dropdown.dart';
|
||||
|
||||
class AppDropdownOption<T> {
|
||||
@ -61,14 +62,15 @@ class AppDropdown<T> extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subject = dropdownHintLabel(label);
|
||||
return AppSearchableDropdown<T>(
|
||||
label: label,
|
||||
value: value,
|
||||
options: options,
|
||||
onChanged: onChanged,
|
||||
validator: validator,
|
||||
hint: hint,
|
||||
searchHint: searchHint ?? 'Search ${label.toLowerCase()}...',
|
||||
hint: hint ?? 'Select $subject',
|
||||
searchHint: searchHint ?? 'Search $subject...',
|
||||
enabled: enabled,
|
||||
isDense: isDense,
|
||||
addNewLabel: addNewLabel,
|
||||
|
||||
@ -9,6 +9,7 @@ class AppPagination extends StatelessWidget {
|
||||
required this.pageSize,
|
||||
required this.onPageChanged,
|
||||
this.onPageSizeChanged,
|
||||
this.itemsOnPage,
|
||||
this.pageSizeOptions = const [10, 20, 50],
|
||||
this.itemLabel = 'items',
|
||||
this.maxVisiblePages = 10,
|
||||
@ -21,6 +22,8 @@ class AppPagination extends StatelessWidget {
|
||||
final int pageSize;
|
||||
final ValueChanged<int> onPageChanged;
|
||||
final ValueChanged<int>? onPageSizeChanged;
|
||||
/// When set, "Showing" end index uses loaded row count instead of pageSize.
|
||||
final int? itemsOnPage;
|
||||
final List<int> pageSizeOptions;
|
||||
final String itemLabel;
|
||||
final int maxVisiblePages;
|
||||
@ -30,7 +33,11 @@ class AppPagination extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final start = totalItems == 0 ? 0 : ((currentPage - 1) * pageSize) + 1;
|
||||
final end = (currentPage * pageSize).clamp(0, totalItems);
|
||||
final end = totalItems == 0
|
||||
? 0
|
||||
: itemsOnPage != null
|
||||
? (start + itemsOnPage! - 1).clamp(0, totalItems)
|
||||
: (currentPage * pageSize).clamp(0, totalItems);
|
||||
final visiblePages = _visiblePageNumbers(currentPage, totalPages);
|
||||
final sizes = List<int>.from(pageSizeOptions);
|
||||
if (!sizes.contains(pageSize)) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/utils/formatters.dart';
|
||||
import 'app_dropdown.dart';
|
||||
|
||||
/// Dropdown that opens a searchable popup anchored to the field.
|
||||
@ -206,7 +207,8 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
||||
initialValue: widget.value,
|
||||
validator: widget.validator,
|
||||
builder: (field) {
|
||||
final effectiveHint = widget.hint ?? 'Select ${widget.label.toLowerCase()}';
|
||||
final effectiveHint =
|
||||
widget.hint ?? 'Select ${dropdownHintLabel(widget.label)}';
|
||||
final canOpen = widget.enabled &&
|
||||
(widget.options.isNotEmpty || widget.onAddNew != null);
|
||||
final colors = theme.colorScheme;
|
||||
@ -595,7 +597,7 @@ class _AppSearchableLookupFieldState<T>
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final effectiveHint =
|
||||
widget.hint ?? 'Select ${widget.label.toLowerCase()}';
|
||||
widget.hint ?? 'Select ${dropdownHintLabel(widget.label)}';
|
||||
final canOpen = widget.enabled &&
|
||||
(widget.options.isNotEmpty || widget.onAddNew != null);
|
||||
final colors = theme.colorScheme;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/utils/formatters.dart';
|
||||
import 'app_dropdown.dart';
|
||||
|
||||
/// Dropdown that opens a searchable popup with multi-select checkboxes.
|
||||
@ -167,7 +168,7 @@ class _AppSearchableMultiSelectDropdownState<T>
|
||||
validator: widget.validator,
|
||||
builder: (field) {
|
||||
final effectiveHint =
|
||||
widget.hint ?? 'Select ${widget.label.toLowerCase()}';
|
||||
widget.hint ?? 'Select ${dropdownHintLabel(widget.label)}';
|
||||
final canOpen = widget.enabled && widget.options.isNotEmpty;
|
||||
final colors = theme.colorScheme;
|
||||
final labels = _selectedLabels();
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@ -28,9 +30,33 @@ class AppTopNav extends ConsumerWidget {
|
||||
final void Function(String route) onItemTap;
|
||||
final UserModel? user;
|
||||
|
||||
bool _isSelected(String route) {
|
||||
bool _routeMatches(String route) {
|
||||
if (route == '/') return currentRoute == route;
|
||||
return currentRoute.startsWith(route);
|
||||
if (currentRoute == route) return true;
|
||||
return currentRoute.startsWith('$route/');
|
||||
}
|
||||
|
||||
bool _isSelectedAmongSiblings(
|
||||
String route,
|
||||
List<menu.MenuItem> siblings,
|
||||
) {
|
||||
if (!_routeMatches(route)) return false;
|
||||
for (final sibling in siblings) {
|
||||
if (sibling.route == route) continue;
|
||||
if (sibling.route.length > route.length && _routeMatches(sibling.route)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _isGroupActive(menu.MenuItem item) => item.children.any(
|
||||
(child) => _isSelectedAmongSiblings(child.route, item.children),
|
||||
);
|
||||
|
||||
bool _isItemSelected(menu.MenuItem item) {
|
||||
if (item.children.isNotEmpty) return _isGroupActive(item);
|
||||
return _routeMatches(item.route);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -72,30 +98,25 @@ class AppTopNav extends ConsumerWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: menuItems.map((item) {
|
||||
final selected = _isSelected(item.route);
|
||||
final selected = _isItemSelected(item);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: TextButton(
|
||||
onPressed: () => onItemTap(item.route),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: selected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
backgroundColor: selected
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.08)
|
||||
: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
item.label,
|
||||
style: TextStyle(
|
||||
fontWeight:
|
||||
selected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: item.children.isNotEmpty
|
||||
? _TopNavMenuGroup(
|
||||
item: item,
|
||||
selected: selected,
|
||||
isChildSelected: (route) =>
|
||||
_isSelectedAmongSiblings(
|
||||
route,
|
||||
item.children,
|
||||
),
|
||||
onChildTap: onItemTap,
|
||||
)
|
||||
: _TopNavMenuButton(
|
||||
label: item.label,
|
||||
selected: selected,
|
||||
onPressed: () => onItemTap(item.route),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
@ -123,6 +144,294 @@ class AppTopNav extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TopNavMenuLabel extends StatelessWidget {
|
||||
const _TopNavMenuLabel({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
this.showChevron = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final bool showChevron;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = selected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.08)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (showChevron) ...[
|
||||
const SizedBox(width: 2),
|
||||
Icon(Icons.arrow_drop_down, size: 18, color: color),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopNavMenuButton extends StatelessWidget {
|
||||
const _TopNavMenuButton({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _TopNavMenuLabel(label: label, selected: selected),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopNavMenuGroup extends StatefulWidget {
|
||||
const _TopNavMenuGroup({
|
||||
required this.item,
|
||||
required this.selected,
|
||||
required this.isChildSelected,
|
||||
required this.onChildTap,
|
||||
});
|
||||
|
||||
final menu.MenuItem item;
|
||||
final bool selected;
|
||||
final bool Function(String route) isChildSelected;
|
||||
final void Function(String route) onChildTap;
|
||||
|
||||
@override
|
||||
State<_TopNavMenuGroup> createState() => _TopNavMenuGroupState();
|
||||
}
|
||||
|
||||
class _TopNavMenuGroupState extends State<_TopNavMenuGroup> {
|
||||
final _anchorKey = GlobalKey();
|
||||
OverlayEntry? _overlayEntry;
|
||||
Timer? _hideTimer;
|
||||
bool _open = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hideTimer?.cancel();
|
||||
_removeOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _cancelHide() {
|
||||
_hideTimer?.cancel();
|
||||
_hideTimer = null;
|
||||
}
|
||||
|
||||
void _scheduleHide() {
|
||||
_cancelHide();
|
||||
_hideTimer = Timer(const Duration(milliseconds: 120), () {
|
||||
if (!mounted) return;
|
||||
_removeOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
if (mounted && _open) setState(() => _open = false);
|
||||
}
|
||||
|
||||
void _showOverlay() {
|
||||
_cancelHide();
|
||||
if (_overlayEntry != null) return;
|
||||
_insertOverlay();
|
||||
if (_overlayEntry == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _overlayEntry != null) return;
|
||||
_insertOverlay();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _insertOverlay() {
|
||||
if (_overlayEntry != null) return;
|
||||
|
||||
final renderBox =
|
||||
_anchorKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null || !renderBox.hasSize) return;
|
||||
|
||||
final anchorTopLeft = renderBox.localToGlobal(Offset.zero);
|
||||
final anchorSize = renderBox.size;
|
||||
const panelWidth = 220.0;
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (overlayContext) {
|
||||
final theme = Theme.of(overlayContext);
|
||||
final primary = theme.colorScheme.primary;
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final panelColor = isDark ? theme.colorScheme.surface : Colors.white;
|
||||
|
||||
return Positioned(
|
||||
left: anchorTopLeft.dx,
|
||||
top: anchorTopLeft.dy,
|
||||
child: TapRegion(
|
||||
onTapOutside: (_) => _removeOverlay(),
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => _cancelHide(),
|
||||
onExit: (_) => _scheduleHide(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: anchorSize.width,
|
||||
height: anchorSize.height,
|
||||
),
|
||||
Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: panelColor,
|
||||
shadowColor: Colors.black26,
|
||||
child: SizedBox(
|
||||
width: panelWidth,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final child in widget.item.children)
|
||||
_TopNavSubmenuItem(
|
||||
icon: child.icon,
|
||||
label: child.label,
|
||||
selected: widget.isChildSelected(child.route),
|
||||
primary: primary,
|
||||
onTap: () {
|
||||
_removeOverlay();
|
||||
widget.onChildTap(child.route);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
setState(() => _open = true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => _showOverlay(),
|
||||
onExit: (_) => _scheduleHide(),
|
||||
child: GestureDetector(
|
||||
onTap: _showOverlay,
|
||||
child: KeyedSubtree(
|
||||
key: _anchorKey,
|
||||
child: _TopNavMenuLabel(
|
||||
label: widget.item.label,
|
||||
selected: widget.selected || _open,
|
||||
showChevron: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopNavSubmenuItem extends StatefulWidget {
|
||||
const _TopNavSubmenuItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.primary,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final Color primary;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_TopNavSubmenuItem> createState() => _TopNavSubmenuItemState();
|
||||
}
|
||||
|
||||
class _TopNavSubmenuItemState extends State<_TopNavSubmenuItem> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final highlight = widget.selected || _hovered;
|
||||
final color = highlight ? widget.primary : theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
height: 40,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: highlight
|
||||
? widget.primary.withValues(alpha: 0.08)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(widget.icon, size: 18, color: color),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: color,
|
||||
fontWeight:
|
||||
highlight ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopNavUserMenu extends ConsumerWidget {
|
||||
const _TopNavUserMenu({this.userName, this.avatarUrl});
|
||||
|
||||
|
||||
@ -17,10 +17,13 @@ class ThemeKeyedSubtree extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final branding = ref.watch(brandingProvider);
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final brandKey =
|
||||
'${branding.primaryColorValue}-${branding.secondaryColorValue}';
|
||||
final key = pageKey == null
|
||||
? '$themeMode-$brightness'
|
||||
: '$pageKey-$themeMode-$brightness';
|
||||
? '$themeMode-$brightness-$brandKey'
|
||||
: '$pageKey-$themeMode-$brightness-$brandKey';
|
||||
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(key),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user