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