masters done
This commit is contained in:
parent
0d79d5a514
commit
1ea70bf93a
@ -50,6 +50,23 @@ class ApiEndpoints {
|
||||
static String plantById(String id) => '/masters/plants/$id';
|
||||
static const String uom = '/masters/uom';
|
||||
static String uomById(String id) => '/masters/uom/$id';
|
||||
static const String itemCategories = '/masters/item-categories';
|
||||
static String itemCategoryById(String id) => '/masters/item-categories/$id';
|
||||
static const String itemSubcategories = '/masters/item-subcategories';
|
||||
static String itemSubcategoryById(String id) =>
|
||||
'/masters/item-subcategories/$id';
|
||||
static const String items = '/masters/items';
|
||||
static String itemById(String id) => '/masters/items/$id';
|
||||
static const String brands = '/masters/brands';
|
||||
static String brandById(String id) => '/masters/brands/$id';
|
||||
static const String documentSeries = '/masters/document-series';
|
||||
static String documentSeriesById(String id) => '/masters/document-series/$id';
|
||||
static const String deliveryTerms = '/masters/delivery-terms';
|
||||
static String deliveryTermById(String id) => '/masters/delivery-terms/$id';
|
||||
static const String paymentTerms = '/masters/payment-terms';
|
||||
static String paymentTermById(String id) => '/masters/payment-terms/$id';
|
||||
static const String gstRates = '/masters/gst-rates';
|
||||
static String gstRateById(String id) => '/masters/gst-rates/$id';
|
||||
static const String warehouses = '/masters/warehouses';
|
||||
static String warehouseById(String id) => '/masters/warehouses/$id';
|
||||
|
||||
|
||||
@ -53,9 +53,14 @@ class RouteConstants {
|
||||
|
||||
// Master Data
|
||||
static const String masterData = '/master-data';
|
||||
static const String departments = '/master/departments';
|
||||
static const String locations = '/master/locations';
|
||||
static const String uom = '/master/uom';
|
||||
static String masterList(String key) => '$masterData/$key';
|
||||
static String masterAdd(String key) => '$masterData/$key/add';
|
||||
static String masterEdit(String key, String id) =>
|
||||
'$masterData/$key/$id/edit';
|
||||
|
||||
/// Legacy aliases
|
||||
static const String departments = '/master-data/departments';
|
||||
static const String uom = '/master-data/uom';
|
||||
|
||||
// Reports
|
||||
static const String reports = '/reports';
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@ -25,7 +26,7 @@ final dioProvider = Provider<Dio>((ref) {
|
||||
dio.interceptors.addAll([
|
||||
AuthInterceptor(ref),
|
||||
ErrorInterceptor(),
|
||||
LogInterceptor(requestBody: true, responseBody: true),
|
||||
if (kDebugMode) LogInterceptor(requestBody: true, responseBody: true),
|
||||
]);
|
||||
|
||||
return dio;
|
||||
|
||||
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../master_data/domain/entities/master_definition.dart';
|
||||
import '../../../../shared/providers/auth_provider.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
|
||||
@ -25,7 +26,7 @@ String _route(String path, {bool preview = true}) {
|
||||
return path.contains('?') ? '$path&preview=true' : '$path?preview=true';
|
||||
}
|
||||
|
||||
const _entries = [
|
||||
final _entries = [
|
||||
// Auth
|
||||
_GalleryEntry(
|
||||
title: 'Login',
|
||||
@ -86,9 +87,14 @@ const _entries = [
|
||||
_GalleryEntry(title: 'QR Scan', route: RouteConstants.assetQrScan, group: 'Assets'),
|
||||
_GalleryEntry(title: 'QR Generate', route: RouteConstants.assetQrGenerate, group: 'Assets'),
|
||||
// Master data
|
||||
_GalleryEntry(title: 'Departments', route: RouteConstants.departments, group: 'Master Data'),
|
||||
_GalleryEntry(title: 'Locations', route: RouteConstants.locations, group: 'Master Data'),
|
||||
_GalleryEntry(title: 'UOM', route: RouteConstants.uom, group: 'Master Data'),
|
||||
_GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'),
|
||||
...masterDefinitions.map(
|
||||
(def) => _GalleryEntry(
|
||||
title: def.title,
|
||||
route: RouteConstants.masterList(def.routeKey),
|
||||
group: 'Master Data',
|
||||
),
|
||||
),
|
||||
// Settings
|
||||
_GalleryEntry(title: 'Settings Hub', route: RouteConstants.settings, group: 'Settings'),
|
||||
_GalleryEntry(title: 'General Settings', route: RouteConstants.settingsGeneral, group: 'Settings'),
|
||||
|
||||
@ -0,0 +1,193 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../domain/entities/master_definition.dart';
|
||||
|
||||
final masterCrudRemoteDataSourceProvider =
|
||||
Provider<MasterCrudRemoteDataSource>((ref) {
|
||||
return MasterCrudRemoteDataSource(dio: ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
class MasterListResult {
|
||||
const MasterListResult({
|
||||
required this.items,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.limit,
|
||||
});
|
||||
|
||||
final List<Map<String, dynamic>> items;
|
||||
final int total;
|
||||
final int page;
|
||||
final int limit;
|
||||
|
||||
int get totalPages {
|
||||
if (limit <= 0) return 1;
|
||||
final pages = (total / limit).ceil();
|
||||
return pages < 1 ? 1 : pages;
|
||||
}
|
||||
}
|
||||
|
||||
class MasterCrudRemoteDataSource {
|
||||
MasterCrudRemoteDataSource({required this.dio});
|
||||
|
||||
final Dio dio;
|
||||
|
||||
Future<MasterListResult> list(
|
||||
MasterDefinition definition, {
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
String? search,
|
||||
}) async {
|
||||
final response = await dio.get(
|
||||
definition.apiPath,
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
if (search != null && search.isNotEmpty) 'search': search,
|
||||
},
|
||||
);
|
||||
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
final raw = body['data'];
|
||||
final list = raw is List<dynamic>
|
||||
? raw
|
||||
: raw is Map<String, dynamic>
|
||||
? raw['items'] as List<dynamic>? ?? const []
|
||||
: const [];
|
||||
|
||||
final items = list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
|
||||
final meta = raw is Map<String, dynamic> ? raw : body;
|
||||
final total = _asInt(meta['total']) ?? items.length;
|
||||
final currentPage = _asInt(meta['page']) ?? page;
|
||||
final currentLimit = _asInt(meta['limit']) ?? limit;
|
||||
|
||||
return MasterListResult(
|
||||
items: items,
|
||||
total: total,
|
||||
page: currentPage,
|
||||
limit: currentLimit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listOptions(MasterDefinition definition) async {
|
||||
final allItems = <Map<String, dynamic>>[];
|
||||
var page = 1;
|
||||
while (true) {
|
||||
final result = await list(
|
||||
definition,
|
||||
page: page,
|
||||
limit: AppConstants.maxPageSize,
|
||||
);
|
||||
allItems.addAll(
|
||||
result.items.where((item) => item['is_active'] != false),
|
||||
);
|
||||
if (page >= result.totalPages) break;
|
||||
page++;
|
||||
}
|
||||
return allItems;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getById(
|
||||
MasterDefinition definition,
|
||||
String id,
|
||||
) async {
|
||||
final response = await dio.get('${definition.apiPath}/$id');
|
||||
return _extractData(response.data);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> create(
|
||||
MasterDefinition definition,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
final response = await dio.post(
|
||||
definition.apiPath,
|
||||
data: payload..removeWhere((_, value) => value == null),
|
||||
);
|
||||
return _extractData(response.data);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> update(
|
||||
MasterDefinition definition,
|
||||
String id,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
final response = await dio.put(
|
||||
'${definition.apiPath}/$id',
|
||||
data: payload..removeWhere((_, value) => value == null),
|
||||
);
|
||||
return _extractData(response.data);
|
||||
}
|
||||
|
||||
Future<void> delete(MasterDefinition definition, String id) async {
|
||||
await dio.delete('${definition.apiPath}/$id');
|
||||
}
|
||||
|
||||
Future<ExportFileResult> export(
|
||||
MasterDefinition definition, {
|
||||
String? search,
|
||||
}) async {
|
||||
final response = await dio.get<List<int>>(
|
||||
'${definition.apiPath}/export',
|
||||
queryParameters: {
|
||||
if (search != null && search.isNotEmpty) 'search': search,
|
||||
},
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
final bytes = response.data ?? <int>[];
|
||||
return ExportFileResult(
|
||||
bytes: bytes,
|
||||
fileName: _fileNameFromResponse(response, definition),
|
||||
);
|
||||
}
|
||||
|
||||
String _fileNameFromResponse(
|
||||
Response<List<int>> response,
|
||||
MasterDefinition definition,
|
||||
) {
|
||||
final disposition = response.headers.value('content-disposition');
|
||||
if (disposition != null) {
|
||||
final utf8Match = RegExp(
|
||||
r"filename\*=UTF-8''([^;\n]+)",
|
||||
caseSensitive: false,
|
||||
).firstMatch(disposition);
|
||||
if (utf8Match != null) {
|
||||
return Uri.decodeComponent(utf8Match.group(1)!);
|
||||
}
|
||||
|
||||
final match = RegExp(r'filename="?([^";\n]+)"?').firstMatch(disposition);
|
||||
if (match != null) {
|
||||
return match.group(1)!.trim();
|
||||
}
|
||||
}
|
||||
|
||||
final slug = definition.routeKey.replaceAll('-', '_');
|
||||
final contentType =
|
||||
response.headers.value('content-type')?.toLowerCase() ?? '';
|
||||
if (contentType.contains('csv')) return '${slug}_export.csv';
|
||||
return '${slug}_export.xlsx';
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractData(dynamic body) {
|
||||
if (body is! Map<String, dynamic>) {
|
||||
throw FormatException('Unexpected master response');
|
||||
}
|
||||
final data = body['data'];
|
||||
if (data is Map<String, dynamic>) return Map<String, dynamic>.from(data);
|
||||
return Map<String, dynamic>.from(body);
|
||||
}
|
||||
|
||||
int? _asInt(Object? value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../domain/entities/master_definition.dart';
|
||||
import '../../domain/repositories/master_repository.dart';
|
||||
import '../datasources/master_crud_remote_data_source.dart';
|
||||
|
||||
final masterRepositoryProvider = Provider<MasterRepository>((ref) {
|
||||
return MasterRepositoryImpl(remote: ref.watch(masterCrudRemoteDataSourceProvider));
|
||||
});
|
||||
|
||||
class MasterRepositoryImpl implements MasterRepository {
|
||||
MasterRepositoryImpl({required this.remote});
|
||||
|
||||
final MasterCrudRemoteDataSource remote;
|
||||
|
||||
@override
|
||||
Future<Result<MasterListResult>> list(
|
||||
MasterDefinition definition, {
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
String? search,
|
||||
}) =>
|
||||
safeApiCall(
|
||||
() => remote.list(
|
||||
definition,
|
||||
page: page,
|
||||
limit: limit,
|
||||
search: search,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Result<List<Map<String, dynamic>>>> listOptions(
|
||||
MasterDefinition definition,
|
||||
) =>
|
||||
safeApiCall(() => remote.listOptions(definition));
|
||||
|
||||
@override
|
||||
Future<Result<Map<String, dynamic>>> getById(
|
||||
MasterDefinition definition,
|
||||
String id,
|
||||
) =>
|
||||
safeApiCall(() => remote.getById(definition, id));
|
||||
|
||||
@override
|
||||
Future<Result<Map<String, dynamic>>> create(
|
||||
MasterDefinition definition,
|
||||
Map<String, dynamic> payload,
|
||||
) =>
|
||||
safeApiCall(() => remote.create(definition, payload));
|
||||
|
||||
@override
|
||||
Future<Result<Map<String, dynamic>>> update(
|
||||
MasterDefinition definition,
|
||||
String id,
|
||||
Map<String, dynamic> payload,
|
||||
) =>
|
||||
safeApiCall(() => remote.update(definition, id, payload));
|
||||
|
||||
@override
|
||||
Future<Result<void>> delete(MasterDefinition definition, String id) =>
|
||||
safeApiCall(() => remote.delete(definition, id));
|
||||
|
||||
@override
|
||||
Future<Result<ExportFileResult>> export(
|
||||
MasterDefinition definition, {
|
||||
String? search,
|
||||
}) =>
|
||||
safeApiCall(() => remote.export(definition, search: search));
|
||||
}
|
||||
444
lib/modules/master_data/domain/entities/master_definition.dart
Normal file
444
lib/modules/master_data/domain/entities/master_definition.dart
Normal file
@ -0,0 +1,444 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum MasterFieldType { text, number, boolean, dropdown }
|
||||
|
||||
class MasterFieldDef {
|
||||
const MasterFieldDef({
|
||||
required this.key,
|
||||
required this.label,
|
||||
this.type = MasterFieldType.text,
|
||||
this.required = false,
|
||||
this.showInList = false,
|
||||
this.optionsMasterKey,
|
||||
this.multiline = false,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String label;
|
||||
final MasterFieldType type;
|
||||
final bool required;
|
||||
final bool showInList;
|
||||
/// Master key used to populate dropdown options (e.g. `plants` for plant_id).
|
||||
final String? optionsMasterKey;
|
||||
final bool multiline;
|
||||
}
|
||||
|
||||
class MasterDefinition {
|
||||
const MasterDefinition({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.category,
|
||||
required this.routeKey,
|
||||
required this.apiPath,
|
||||
required this.module,
|
||||
required this.icon,
|
||||
required this.fields,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String category;
|
||||
final String routeKey;
|
||||
final String apiPath;
|
||||
final String module;
|
||||
final IconData icon;
|
||||
final List<MasterFieldDef> fields;
|
||||
|
||||
List<MasterFieldDef> get listFields =>
|
||||
fields.where((field) => field.showInList).toList();
|
||||
|
||||
List<MasterFieldDef> get formFields => fields;
|
||||
|
||||
String listRoute(String base) => '$base/$routeKey';
|
||||
String addRoute(String base) => '$base/$routeKey/add';
|
||||
String editRoute(String base, String id) => '$base/$routeKey/$id/edit';
|
||||
}
|
||||
|
||||
const _activeField = MasterFieldDef(
|
||||
key: 'is_active',
|
||||
label: 'Active',
|
||||
type: MasterFieldType.boolean,
|
||||
);
|
||||
|
||||
MasterDefinition? masterDefinitionById(String id) {
|
||||
for (final def in masterDefinitions) {
|
||||
if (def.id == id) return def;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
MasterDefinition? masterDefinitionByRouteKey(String routeKey) {
|
||||
for (final def in masterDefinitions) {
|
||||
if (def.routeKey == routeKey) return def;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const masterDefinitions = <MasterDefinition>[
|
||||
MasterDefinition(
|
||||
id: 'uom',
|
||||
title: 'UOM',
|
||||
subtitle: 'Units of measure',
|
||||
category: 'Inventory & Items',
|
||||
routeKey: 'uom',
|
||||
apiPath: '/masters/uom',
|
||||
module: 'uom',
|
||||
icon: Icons.straighten_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'item_categories',
|
||||
title: 'Item Categories',
|
||||
subtitle: 'Top-level item grouping',
|
||||
category: 'Inventory & Items',
|
||||
routeKey: 'item-categories',
|
||||
apiPath: '/masters/item-categories',
|
||||
module: 'item_categories',
|
||||
icon: Icons.category_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'item_subcategories',
|
||||
title: 'Item Sub Categories',
|
||||
subtitle: 'Sub-grouping under item categories',
|
||||
category: 'Inventory & Items',
|
||||
routeKey: 'item-subcategories',
|
||||
apiPath: '/masters/item-subcategories',
|
||||
module: 'item_subcategories',
|
||||
icon: Icons.subdirectory_arrow_right,
|
||||
fields: [
|
||||
MasterFieldDef(
|
||||
key: 'item_category_id',
|
||||
label: 'Item Category',
|
||||
type: MasterFieldType.dropdown,
|
||||
required: true,
|
||||
showInList: true,
|
||||
optionsMasterKey: 'item_categories',
|
||||
),
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'items',
|
||||
title: 'Items',
|
||||
subtitle: 'Material and product master',
|
||||
category: 'Inventory & Items',
|
||||
routeKey: 'items',
|
||||
apiPath: '/masters/items',
|
||||
module: 'items',
|
||||
icon: Icons.inventory_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'item_code', label: 'Item Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'item_category_id',
|
||||
label: 'Category',
|
||||
type: MasterFieldType.dropdown,
|
||||
required: true,
|
||||
optionsMasterKey: 'item_categories',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'item_subcategory_id',
|
||||
label: 'Sub Category',
|
||||
type: MasterFieldType.dropdown,
|
||||
optionsMasterKey: 'item_subcategories',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'uom_id',
|
||||
label: 'UOM',
|
||||
type: MasterFieldType.dropdown,
|
||||
required: true,
|
||||
optionsMasterKey: 'uom',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'gst_rate_id',
|
||||
label: 'GST Rate',
|
||||
type: MasterFieldType.dropdown,
|
||||
optionsMasterKey: 'gst_rates',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'brand_id',
|
||||
label: 'Brand',
|
||||
type: MasterFieldType.dropdown,
|
||||
optionsMasterKey: 'brands',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'is_asset_item',
|
||||
label: 'Asset Item',
|
||||
type: MasterFieldType.boolean,
|
||||
required: true,
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'min_order_qty',
|
||||
label: 'Min Order Qty',
|
||||
type: MasterFieldType.number,
|
||||
required: true,
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'reorder_level',
|
||||
label: 'Reorder Level',
|
||||
type: MasterFieldType.number,
|
||||
required: true,
|
||||
),
|
||||
MasterFieldDef(key: 'description', label: 'Description', multiline: true),
|
||||
MasterFieldDef(key: 'specification', label: 'Specification', multiline: true),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'brands',
|
||||
title: 'Brands',
|
||||
subtitle: 'Own and third-party brands',
|
||||
category: 'Inventory & Items',
|
||||
routeKey: 'brands',
|
||||
apiPath: '/masters/brands',
|
||||
module: 'brands',
|
||||
icon: Icons.loyalty_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'brand_type', label: 'Brand Type', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'contact_person', label: 'Contact Person'),
|
||||
MasterFieldDef(key: 'phone', label: 'Phone'),
|
||||
MasterFieldDef(key: 'email', label: 'Email'),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'document_series',
|
||||
title: 'Document Series',
|
||||
subtitle: 'Numbering series for documents',
|
||||
category: 'Finance & Terms',
|
||||
routeKey: 'document-series',
|
||||
apiPath: '/masters/document-series',
|
||||
module: 'document_series',
|
||||
icon: Icons.numbers_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'prefix', label: 'Prefix', required: true, showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'current_number',
|
||||
label: 'Current Number',
|
||||
type: MasterFieldType.number,
|
||||
required: true,
|
||||
showInList: true,
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'padding',
|
||||
label: 'Padding',
|
||||
type: MasterFieldType.number,
|
||||
required: true,
|
||||
),
|
||||
MasterFieldDef(key: 'description', label: 'Description', multiline: true),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'plants',
|
||||
title: 'Plants',
|
||||
subtitle: 'Manufacturing plants and units',
|
||||
category: 'Organization',
|
||||
routeKey: 'plants',
|
||||
apiPath: '/masters/plants',
|
||||
module: 'plants',
|
||||
icon: Icons.factory_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'gstin', label: 'GSTIN', showInList: true),
|
||||
MasterFieldDef(key: 'city', label: 'City', showInList: true),
|
||||
MasterFieldDef(key: 'state', label: 'State'),
|
||||
MasterFieldDef(key: 'address', label: 'Address', multiline: true),
|
||||
MasterFieldDef(key: 'pincode', label: 'Pincode'),
|
||||
MasterFieldDef(key: 'phone', label: 'Phone'),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'warehouses',
|
||||
title: 'Warehouse',
|
||||
subtitle: 'Storage locations by plant',
|
||||
category: 'Organization',
|
||||
routeKey: 'warehouses',
|
||||
apiPath: '/masters/warehouses',
|
||||
module: 'warehouses',
|
||||
icon: Icons.warehouse_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'plant_id',
|
||||
label: 'Plant',
|
||||
type: MasterFieldType.dropdown,
|
||||
required: true,
|
||||
showInList: true,
|
||||
optionsMasterKey: 'plants',
|
||||
),
|
||||
MasterFieldDef(key: 'location', label: 'Location'),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'designations',
|
||||
title: 'Designations',
|
||||
subtitle: 'Employee designations',
|
||||
category: 'Organization',
|
||||
routeKey: 'designations',
|
||||
apiPath: '/masters/designations',
|
||||
module: 'designations',
|
||||
icon: Icons.badge_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'departments',
|
||||
title: 'Departments',
|
||||
subtitle: 'Organisation departments',
|
||||
category: 'Organization',
|
||||
routeKey: 'departments',
|
||||
apiPath: '/masters/departments',
|
||||
module: 'departments',
|
||||
icon: Icons.apartment_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'asset_categories',
|
||||
title: 'Asset Categories',
|
||||
subtitle: 'Fixed asset classification',
|
||||
category: 'Assets',
|
||||
routeKey: 'asset-categories',
|
||||
apiPath: '/masters/asset-categories',
|
||||
module: 'asset_categories',
|
||||
icon: Icons.precision_manufacturing_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'code_prefix', label: 'Code Prefix', required: true, showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'default_useful_life_years',
|
||||
label: 'Useful Life (Years)',
|
||||
type: MasterFieldType.number,
|
||||
required: true,
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'default_depreciation_method',
|
||||
label: 'Depreciation Method',
|
||||
required: true,
|
||||
showInList: true,
|
||||
),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'delivery_terms',
|
||||
title: 'Delivery Terms',
|
||||
subtitle: 'Delivery incoterms and terms',
|
||||
category: 'Finance & Terms',
|
||||
routeKey: 'delivery-terms',
|
||||
apiPath: '/masters/delivery-terms',
|
||||
module: 'delivery_terms',
|
||||
icon: Icons.local_shipping_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'description', label: 'Description', multiline: true),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'payment_terms',
|
||||
title: 'Payment Terms',
|
||||
subtitle: 'Vendor payment terms',
|
||||
category: 'Finance & Terms',
|
||||
routeKey: 'payment-terms',
|
||||
apiPath: '/masters/payment-terms',
|
||||
module: 'payment_terms',
|
||||
icon: Icons.payments_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'credit_days',
|
||||
label: 'Credit Days',
|
||||
type: MasterFieldType.number,
|
||||
required: true,
|
||||
showInList: true,
|
||||
),
|
||||
MasterFieldDef(key: 'description', label: 'Description', multiline: true),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
MasterDefinition(
|
||||
id: 'gst_rates',
|
||||
title: 'GST Rates',
|
||||
subtitle: 'Tax rate master',
|
||||
category: 'Finance & Terms',
|
||||
routeKey: 'gst-rates',
|
||||
apiPath: '/masters/gst-rates',
|
||||
module: 'gst_rates',
|
||||
icon: Icons.percent_outlined,
|
||||
fields: [
|
||||
MasterFieldDef(
|
||||
key: 'rate_pct',
|
||||
label: 'Rate (%)',
|
||||
type: MasterFieldType.number,
|
||||
required: true,
|
||||
showInList: true,
|
||||
),
|
||||
MasterFieldDef(key: 'description', label: 'Description', showInList: true),
|
||||
_activeField,
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
List<String> get masterCategories =>
|
||||
masterDefinitions.map((def) => def.category).toSet().toList();
|
||||
|
||||
String masterRecordLabel(Map<String, dynamic> row) {
|
||||
for (final key in ['name', 'item_name', 'code', 'item_code', 'description']) {
|
||||
final value = row[key];
|
||||
if (value != null && value.toString().trim().isNotEmpty) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
return row['id']?.toString() ?? 'Record';
|
||||
}
|
||||
|
||||
String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
|
||||
final value = row[field.key];
|
||||
if (value == null || value == '') return '—';
|
||||
|
||||
if (field.type == MasterFieldType.boolean) {
|
||||
return value == true ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
if (field.type == MasterFieldType.dropdown) {
|
||||
final label = row['${field.key}_label'];
|
||||
if (label != null && label.toString().isNotEmpty) return label.toString();
|
||||
}
|
||||
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
String masterStatusValue(Map<String, dynamic> row) {
|
||||
final active = row['is_active'];
|
||||
if (active == false) return 'inactive';
|
||||
return 'active';
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../entities/master_definition.dart';
|
||||
import '../../data/datasources/master_crud_remote_data_source.dart';
|
||||
|
||||
abstract class MasterRepository {
|
||||
Future<Result<MasterListResult>> list(
|
||||
MasterDefinition definition, {
|
||||
int page,
|
||||
int limit,
|
||||
String? search,
|
||||
});
|
||||
|
||||
Future<Result<List<Map<String, dynamic>>>> listOptions(MasterDefinition definition);
|
||||
|
||||
Future<Result<Map<String, dynamic>>> getById(
|
||||
MasterDefinition definition,
|
||||
String id,
|
||||
);
|
||||
|
||||
Future<Result<Map<String, dynamic>>> create(
|
||||
MasterDefinition definition,
|
||||
Map<String, dynamic> payload,
|
||||
);
|
||||
|
||||
Future<Result<Map<String, dynamic>>> update(
|
||||
MasterDefinition definition,
|
||||
String id,
|
||||
Map<String, dynamic> payload,
|
||||
);
|
||||
|
||||
Future<Result<void>> delete(MasterDefinition definition, String id);
|
||||
|
||||
Future<Result<ExportFileResult>> export(
|
||||
MasterDefinition definition, {
|
||||
String? search,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,312 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../data/repositories/master_repository_impl.dart';
|
||||
import '../../domain/entities/master_definition.dart';
|
||||
|
||||
class MasterListState {
|
||||
const MasterListState({
|
||||
this.items = const [],
|
||||
this.search = '',
|
||||
this.page = 1,
|
||||
this.limit = 20,
|
||||
this.total = 0,
|
||||
this.totalPages = 1,
|
||||
this.isDeleting = false,
|
||||
this.isExporting = false,
|
||||
this.actionError,
|
||||
});
|
||||
|
||||
final List<Map<String, dynamic>> items;
|
||||
final String search;
|
||||
final int page;
|
||||
final int limit;
|
||||
final int total;
|
||||
final int totalPages;
|
||||
final bool isDeleting;
|
||||
final bool isExporting;
|
||||
final String? actionError;
|
||||
|
||||
MasterListState copyWith({
|
||||
List<Map<String, dynamic>>? items,
|
||||
String? search,
|
||||
int? page,
|
||||
int? limit,
|
||||
int? total,
|
||||
int? totalPages,
|
||||
bool? isDeleting,
|
||||
bool? isExporting,
|
||||
String? actionError,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return MasterListState(
|
||||
items: items ?? this.items,
|
||||
search: search ?? this.search,
|
||||
page: page ?? this.page,
|
||||
limit: limit ?? this.limit,
|
||||
total: total ?? this.total,
|
||||
totalPages: totalPages ?? this.totalPages,
|
||||
isDeleting: isDeleting ?? this.isDeleting,
|
||||
isExporting: isExporting ?? this.isExporting,
|
||||
actionError: clearError ? null : actionError ?? this.actionError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MasterFormState {
|
||||
const MasterFormState({
|
||||
this.values = const {},
|
||||
this.dropdownOptions = const {},
|
||||
this.isSubmitting = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> values;
|
||||
final Map<String, List<Map<String, dynamic>>> dropdownOptions;
|
||||
final bool isSubmitting;
|
||||
final String? errorMessage;
|
||||
|
||||
MasterFormState copyWith({
|
||||
Map<String, dynamic>? values,
|
||||
Map<String, List<Map<String, dynamic>>>? dropdownOptions,
|
||||
bool? isSubmitting,
|
||||
String? errorMessage,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return MasterFormState(
|
||||
values: values ?? this.values,
|
||||
dropdownOptions: dropdownOptions ?? this.dropdownOptions,
|
||||
isSubmitting: isSubmitting ?? this.isSubmitting,
|
||||
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final masterListProvider = AsyncNotifierProvider.family<
|
||||
MasterListNotifier, MasterListState, String>(MasterListNotifier.new);
|
||||
|
||||
class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
||||
MasterDefinition get _definition {
|
||||
final def = masterDefinitionById(arg);
|
||||
if (def == null) throw StateError('Unknown master: $arg');
|
||||
return def;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<MasterListState> build(String arg) async {
|
||||
ref.keepAlive();
|
||||
return _load();
|
||||
}
|
||||
|
||||
Future<MasterListState> _load({int? page, String? search}) async {
|
||||
final current = state.valueOrNull;
|
||||
final result = await ref.read(masterRepositoryProvider).list(
|
||||
_definition,
|
||||
page: page ?? current?.page ?? 1,
|
||||
limit: current?.limit ?? 20,
|
||||
search: search ?? current?.search,
|
||||
);
|
||||
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final data = result.data!;
|
||||
|
||||
return MasterListState(
|
||||
items: data.items,
|
||||
search: search ?? current?.search ?? '',
|
||||
page: data.page,
|
||||
limit: data.limit,
|
||||
total: data.total,
|
||||
totalPages: data.totalPages,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final previous = state.valueOrNull;
|
||||
if (previous == null) {
|
||||
state = const AsyncLoading();
|
||||
}
|
||||
state = AsyncData(await _load());
|
||||
}
|
||||
|
||||
Future<void> setSearch(String search) async {
|
||||
await _reload(page: 1, search: search);
|
||||
}
|
||||
|
||||
Future<void> setPage(int page) async {
|
||||
await _reload(page: page);
|
||||
}
|
||||
|
||||
Future<void> _reload({int? page, String? search}) async {
|
||||
final previous = state.valueOrNull;
|
||||
if (previous == null) {
|
||||
state = const AsyncLoading();
|
||||
}
|
||||
state = AsyncData(await _load(page: page, search: search));
|
||||
}
|
||||
|
||||
Future<void> setPageSize(int limit) async {
|
||||
final current = state.valueOrNull ?? const MasterListState();
|
||||
final result = await ref.read(masterRepositoryProvider).list(
|
||||
_definition,
|
||||
page: 1,
|
||||
limit: limit,
|
||||
search: current.search,
|
||||
);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final data = result.data!;
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
items: data.items,
|
||||
page: data.page,
|
||||
limit: data.limit,
|
||||
total: data.total,
|
||||
totalPages: data.totalPages,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> deleteRecord(String id) async {
|
||||
final current = state.valueOrNull ?? const MasterListState();
|
||||
state = AsyncData(current.copyWith(isDeleting: true));
|
||||
|
||||
final result = await ref.read(masterRepositoryProvider).delete(_definition, id);
|
||||
if (result.failure != null) {
|
||||
state = AsyncData(current.copyWith(isDeleting: false));
|
||||
return false;
|
||||
}
|
||||
|
||||
await refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<ExportFileResult?> exportRecords() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return null;
|
||||
|
||||
state = AsyncData(current.copyWith(isExporting: true, clearError: true));
|
||||
|
||||
final result = await ref.read(masterRepositoryProvider).export(
|
||||
_definition,
|
||||
search: current.search.isEmpty ? null : current.search,
|
||||
);
|
||||
final latest = state.valueOrNull ?? current;
|
||||
|
||||
if (result.failure != null) {
|
||||
state = AsyncData(
|
||||
latest.copyWith(
|
||||
isExporting: false,
|
||||
actionError: result.failure!.message,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
state = AsyncData(latest.copyWith(isExporting: false));
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
|
||||
typedef MasterFormArgs = ({String masterId, String? recordId});
|
||||
|
||||
final masterFormProvider = AsyncNotifierProvider.family<
|
||||
MasterFormNotifier, MasterFormState, MasterFormArgs>(MasterFormNotifier.new);
|
||||
|
||||
class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterFormArgs> {
|
||||
MasterDefinition get _definition {
|
||||
final def = masterDefinitionById(arg.masterId);
|
||||
if (def == null) throw StateError('Unknown master: ${arg.masterId}');
|
||||
return def;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<MasterFormState> build(MasterFormArgs arg) async {
|
||||
final dropdownOptions = await _loadDropdownOptions();
|
||||
Map<String, dynamic> values = {};
|
||||
|
||||
if (arg.recordId != null) {
|
||||
final result = await ref
|
||||
.read(masterRepositoryProvider)
|
||||
.getById(_definition, arg.recordId!);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
values = Map<String, dynamic>.from(result.data ?? const {});
|
||||
} else {
|
||||
for (final field in _definition.formFields) {
|
||||
if (field.type == MasterFieldType.boolean) {
|
||||
values[field.key] = field.key == 'is_active' ? true : false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MasterFormState(
|
||||
values: values,
|
||||
dropdownOptions: dropdownOptions,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, List<Map<String, dynamic>>>>
|
||||
_loadDropdownOptions() async {
|
||||
final options = <String, List<Map<String, dynamic>>>{};
|
||||
final keys = _definition.formFields
|
||||
.where((field) => field.optionsMasterKey != null)
|
||||
.map((field) => field.optionsMasterKey!)
|
||||
.toSet();
|
||||
|
||||
for (final key in keys) {
|
||||
final def = masterDefinitionById(key);
|
||||
if (def == null) continue;
|
||||
final result = await ref.read(masterRepositoryProvider).listOptions(def);
|
||||
if (result.failure != null) continue;
|
||||
options[key] = result.data ?? const [];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
void updateValue(String key, dynamic value) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final values = Map<String, dynamic>.from(current.values);
|
||||
values[key] = value;
|
||||
state = AsyncData(current.copyWith(values: values));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPayload(MasterFormState current) {
|
||||
final payload = <String, dynamic>{};
|
||||
for (final field in _definition.formFields) {
|
||||
final value = current.values[field.key];
|
||||
if (value == null || value == '') continue;
|
||||
|
||||
payload[field.key] = switch (field.type) {
|
||||
MasterFieldType.number => num.tryParse(value.toString()) ?? value,
|
||||
MasterFieldType.dropdown => int.tryParse(value.toString()) ?? value,
|
||||
MasterFieldType.boolean => value == true,
|
||||
MasterFieldType.text => value.toString().trim(),
|
||||
};
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
Future<bool> submit() async {
|
||||
final current = state.valueOrNull ?? const MasterFormState();
|
||||
state = AsyncData(current.copyWith(isSubmitting: true, clearError: true));
|
||||
|
||||
final payload = _buildPayload(current);
|
||||
final result = arg.recordId == null
|
||||
? await ref.read(masterRepositoryProvider).create(_definition, payload)
|
||||
: await ref
|
||||
.read(masterRepositoryProvider)
|
||||
.update(_definition, arg.recordId!, payload);
|
||||
|
||||
if (result.failure != null) {
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
isSubmitting: false,
|
||||
errorMessage: result.failure!.message,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
state = AsyncData(current.copyWith(isSubmitting: false, clearError: true));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import '../../../../shared/widgets/placeholder_screen.dart';
|
||||
|
||||
class DepartmentsScreen extends PlaceholderScreen {
|
||||
const DepartmentsScreen({super.key})
|
||||
: super(title: 'Departments', description: 'Master data — departments');
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import '../../../../shared/widgets/placeholder_screen.dart';
|
||||
|
||||
class LocationsScreen extends PlaceholderScreen {
|
||||
const LocationsScreen({super.key})
|
||||
: super(title: 'Locations', description: 'Master data — locations');
|
||||
}
|
||||
@ -0,0 +1,491 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_search_export_bar.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../domain/entities/master_definition.dart';
|
||||
import '../providers/master_provider.dart';
|
||||
import '../widgets/master_form_panel.dart';
|
||||
|
||||
class MasterListScreen extends ConsumerStatefulWidget {
|
||||
const MasterListScreen({super.key, required this.masterId});
|
||||
|
||||
final String masterId;
|
||||
|
||||
@override
|
||||
ConsumerState<MasterListScreen> createState() => _MasterListScreenState();
|
||||
}
|
||||
|
||||
class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
MasterDefinition get _definition {
|
||||
final def = masterDefinitionById(widget.masterId);
|
||||
if (def == null) throw StateError('Unknown master: ${widget.masterId}');
|
||||
return def;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _searchHint(MasterDefinition def) {
|
||||
final fields = def.listFields.map((field) => field.label.toLowerCase()).toList();
|
||||
if (fields.isEmpty) return 'Search ${def.title.toLowerCase()}...';
|
||||
if (fields.length <= 3) return 'Search by ${fields.join(', ')}...';
|
||||
return 'Search by ${fields.take(3).join(', ')}...';
|
||||
}
|
||||
|
||||
Future<void> _exportRecords() async {
|
||||
final file = await ref
|
||||
.read(masterListProvider(widget.masterId).notifier)
|
||||
.exportRecords();
|
||||
if (!mounted) return;
|
||||
|
||||
if (file == null) {
|
||||
final error = ref.read(masterListProvider(widget.masterId)).valueOrNull?.actionError;
|
||||
if (error != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final saved = await downloadFile(
|
||||
bytes: file.bytes,
|
||||
fileName: file.fileName,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
saved ? 'Downloaded ${file.fileName}' : 'Export cancelled',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openFormPanel({String? recordId}) async {
|
||||
ref.invalidate(masterFormProvider((masterId: widget.masterId, recordId: recordId)));
|
||||
final saved = await showSidePanel<bool>(
|
||||
context,
|
||||
MasterFormPanel(masterId: widget.masterId, recordId: recordId),
|
||||
width: 560,
|
||||
);
|
||||
if (saved == true && mounted) {
|
||||
ref.invalidate(masterListProvider(widget.masterId));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
recordId == null
|
||||
? '${_definition.title} added successfully'
|
||||
: '${_definition.title} updated successfully',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteRecord(Map<String, dynamic> row) async {
|
||||
final id = row['id']?.toString();
|
||||
if (id == null) return;
|
||||
|
||||
final confirmed = await showAppConfirmationDialog(
|
||||
context: context,
|
||||
title: 'Delete ${_definition.title}',
|
||||
message: 'Delete "${masterRecordLabel(row)}"?',
|
||||
confirmLabel: 'Delete',
|
||||
isDestructive: true,
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
final success =
|
||||
await ref.read(masterListProvider(widget.masterId).notifier).deleteRecord(id);
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success
|
||||
? '${_definition.title} deleted successfully'
|
||||
: 'Failed to delete ${_definition.title.toLowerCase()}',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final listAsync = ref.watch(masterListProvider(widget.masterId));
|
||||
final def = _definition;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: listAsync.when(
|
||||
loading: () => AppLoadingView(message: 'Loading ${def.title}...'),
|
||||
error: (error, _) => ErrorView.fromFailure(
|
||||
error is Failure ? error : Failure.unknown(message: error.toString()),
|
||||
onRetry: () => ref.invalidate(masterListProvider(widget.masterId)),
|
||||
),
|
||||
data: (state) {
|
||||
final page = state.page;
|
||||
final pageSize = state.limit;
|
||||
final total = state.total;
|
||||
final start = total == 0 ? 0 : ((page - 1) * pageSize) + 1;
|
||||
final end = (page * pageSize).clamp(0, total);
|
||||
final notifier = ref.read(masterListProvider(widget.masterId).notifier);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PageHeader(
|
||||
title: def.title,
|
||||
subtitle: def.subtitle,
|
||||
actions: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.push(RouteConstants.masterData),
|
||||
icon: const Icon(Icons.grid_view_outlined),
|
||||
label: const Text('All Masters'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _openFormPanel(),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text('Add ${def.title}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: AppCard(
|
||||
enableHover: false,
|
||||
clipBehavior: Clip.none,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return AppSearchExportBar(
|
||||
wrapped: constraints.maxWidth < 640,
|
||||
searchController: _searchController,
|
||||
searchHint: _searchHint(def),
|
||||
isExporting: state.isExporting,
|
||||
onSearch: notifier.setSearch,
|
||||
onExport: _exportRecords,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: state.items.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No ${def.title.toLowerCase()} found',
|
||||
description:
|
||||
'Add your first ${def.title.toLowerCase()} record to get started.',
|
||||
icon: def.icon,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _MasterListTable(
|
||||
definition: def,
|
||||
items: state.items,
|
||||
isDeleting: state.isDeleting,
|
||||
onEdit: (id) => _openFormPanel(recordId: id),
|
||||
onDelete: _deleteRecord,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Showing $start–$end of $total ${def.title.toLowerCase()}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: page > 1
|
||||
? () => notifier.setPage(page - 1)
|
||||
: null,
|
||||
child: const Text('Previous'),
|
||||
),
|
||||
...List.generate(state.totalPages.clamp(0, 4), (i) {
|
||||
final pageIndex = i + 1;
|
||||
final selected = page == pageIndex;
|
||||
return Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Material(
|
||||
color: selected
|
||||
? theme.colorScheme.primary
|
||||
: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () => notifier.setPage(pageIndex),
|
||||
child: SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$pageIndex',
|
||||
style: TextStyle(
|
||||
color: selected
|
||||
? theme.colorScheme.onPrimary
|
||||
: null,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
TextButton(
|
||||
onPressed: page < state.totalPages
|
||||
? () => notifier.setPage(page + 1)
|
||||
: null,
|
||||
child: const Text('Next'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MasterListTable extends StatelessWidget {
|
||||
const _MasterListTable({
|
||||
required this.definition,
|
||||
required this.items,
|
||||
required this.isDeleting,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final MasterDefinition definition;
|
||||
final List<Map<String, dynamic>> items;
|
||||
final bool isDeleting;
|
||||
final ValueChanged<String> onEdit;
|
||||
final ValueChanged<Map<String, dynamic>> onDelete;
|
||||
|
||||
static const double _horizontalPadding = 16;
|
||||
static const double _columnSpacing = 16;
|
||||
static const double _statusWidth = 110;
|
||||
static const double _actionsWidth = 96;
|
||||
|
||||
int _columnFlex(MasterFieldDef field) {
|
||||
return switch (field.key) {
|
||||
'code' || 'item_code' || 'series_code' => 1,
|
||||
'name' ||
|
||||
'item_name' ||
|
||||
'description' ||
|
||||
'term_name' =>
|
||||
3,
|
||||
_ => 2,
|
||||
};
|
||||
}
|
||||
|
||||
TextStyle? _headerStyle(BuildContext context) {
|
||||
return Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tableRow({
|
||||
required BuildContext context,
|
||||
required List<Widget> fieldCells,
|
||||
required Widget statusCell,
|
||||
required Widget actionsCell,
|
||||
Color? backgroundColor,
|
||||
BoxDecoration? decoration,
|
||||
EdgeInsetsGeometry padding = const EdgeInsets.symmetric(
|
||||
horizontal: _horizontalPadding,
|
||||
vertical: 12,
|
||||
),
|
||||
}) {
|
||||
final fields = definition.listFields;
|
||||
final children = <Widget>[];
|
||||
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
if (i > 0) {
|
||||
children.add(const SizedBox(width: _columnSpacing));
|
||||
}
|
||||
children.add(
|
||||
Expanded(
|
||||
flex: _columnFlex(fields[i]),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: fieldCells[i],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
children.addAll([
|
||||
const SizedBox(width: _columnSpacing),
|
||||
SizedBox(
|
||||
width: _statusWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: statusCell,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _columnSpacing),
|
||||
SizedBox(
|
||||
width: _actionsWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: actionsCell,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: padding,
|
||||
color: backgroundColor,
|
||||
decoration: decoration,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _headerRow(BuildContext context, Color headerColor) {
|
||||
return _tableRow(
|
||||
context: context,
|
||||
backgroundColor: headerColor,
|
||||
fieldCells: definition.listFields
|
||||
.map(
|
||||
(field) => Text(
|
||||
field.label.toUpperCase(),
|
||||
style: _headerStyle(context),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
statusCell: Text('STATUS', style: _headerStyle(context)),
|
||||
actionsCell: Text('ACTIONS', style: _headerStyle(context)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dataRow(BuildContext context, Map<String, dynamic> row, ThemeData theme) {
|
||||
final id = row['id']?.toString();
|
||||
|
||||
return _tableRow(
|
||||
context: context,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _horizontalPadding,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
fieldCells: definition.listFields
|
||||
.map(
|
||||
(field) => Text(masterCellValue(row, field)),
|
||||
)
|
||||
.toList(),
|
||||
statusCell: AppStatusChip(
|
||||
status: masterStatusValue(row),
|
||||
compact: true,
|
||||
),
|
||||
actionsCell: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||
onPressed: id == null ? null : () => onEdit(id),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete',
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
size: 20,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
onPressed: isDeleting ? null : () => onDelete(row),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final headerColor = theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.4);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_headerRow(context, headerColor),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: items
|
||||
.map((row) => _dataRow(context, row, theme))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,148 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../shared/widgets/app_hover_effect.dart';
|
||||
import '../../domain/entities/master_definition.dart';
|
||||
|
||||
class MastersHubScreen extends StatelessWidget {
|
||||
const MastersHubScreen({super.key});
|
||||
|
||||
static const double _tileMaxWidth = 120;
|
||||
static const double _tileHeight = 92;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final categories = masterCategories;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Master Data',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Browse and manage all master records by category.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
...categories.map((category) {
|
||||
final items = masterDefinitions
|
||||
.where((def) => def.category == category)
|
||||
.toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
category.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: _tileMaxWidth,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisExtent: _tileHeight,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final def = items[index];
|
||||
return _MasterAppTile(
|
||||
title: def.title,
|
||||
icon: def.icon,
|
||||
color: _masterIconColor(index),
|
||||
onTap: () => context.push(
|
||||
RouteConstants.masterList(def.routeKey),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Color _masterIconColor(int index) {
|
||||
const colors = [
|
||||
Color(0xFF2563EB),
|
||||
Color(0xFF16A34A),
|
||||
Color(0xFFDC2626),
|
||||
Color(0xFFCA8A04),
|
||||
Color(0xFF7C3AED),
|
||||
Color(0xFF0891B2),
|
||||
Color(0xFFEA580C),
|
||||
];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
|
||||
class _MasterAppTile extends StatelessWidget {
|
||||
const _MasterAppTile({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AppHoverEffect(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 30, color: color),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import '../../../../shared/widgets/placeholder_screen.dart';
|
||||
|
||||
class UomScreen extends PlaceholderScreen {
|
||||
const UomScreen({super.key})
|
||||
: super(title: 'Units of Measure', description: 'Master data — UOM');
|
||||
}
|
||||
@ -0,0 +1,249 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/utils/validators.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../domain/entities/master_definition.dart';
|
||||
import '../providers/master_provider.dart';
|
||||
|
||||
class MasterFormPanel extends ConsumerStatefulWidget {
|
||||
const MasterFormPanel({
|
||||
super.key,
|
||||
required this.masterId,
|
||||
this.recordId,
|
||||
});
|
||||
|
||||
final String masterId;
|
||||
final String? recordId;
|
||||
|
||||
bool get isEditing => recordId != null;
|
||||
|
||||
@override
|
||||
ConsumerState<MasterFormPanel> createState() => _MasterFormPanelState();
|
||||
}
|
||||
|
||||
class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
MasterDefinition get _definition {
|
||||
final def = masterDefinitionById(widget.masterId);
|
||||
if (def == null) throw StateError('Unknown master: ${widget.masterId}');
|
||||
return def;
|
||||
}
|
||||
|
||||
MasterFormArgs get _args =>
|
||||
(masterId: widget.masterId, recordId: widget.recordId);
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final success =
|
||||
await ref.read(masterFormProvider(_args).notifier).submit();
|
||||
if (!mounted) return;
|
||||
|
||||
if (success) {
|
||||
Navigator.of(context, rootNavigator: true).pop(true);
|
||||
return;
|
||||
}
|
||||
|
||||
final error = ref.read(masterFormProvider(_args)).valueOrNull?.errorMessage;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
error ??
|
||||
(widget.isEditing
|
||||
? 'Failed to update ${_definition.title.toLowerCase()}'
|
||||
: 'Failed to create ${_definition.title.toLowerCase()}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fieldLabel(MasterFieldDef field) =>
|
||||
field.required ? '${field.label} *' : field.label;
|
||||
|
||||
Widget _buildField(
|
||||
BuildContext context, {
|
||||
required MasterFieldDef field,
|
||||
required MasterFormState formState,
|
||||
}) {
|
||||
final notifier = ref.read(masterFormProvider(_args).notifier);
|
||||
final value = formState.values[field.key];
|
||||
|
||||
switch (field.type) {
|
||||
case MasterFieldType.boolean:
|
||||
if (field.key == 'is_active') {
|
||||
return SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(field.label),
|
||||
value: value == true,
|
||||
onChanged: (checked) => notifier.updateValue(field.key, checked),
|
||||
);
|
||||
}
|
||||
return CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(field.label),
|
||||
value: value == true,
|
||||
onChanged: (checked) =>
|
||||
notifier.updateValue(field.key, checked ?? false),
|
||||
);
|
||||
|
||||
case MasterFieldType.dropdown:
|
||||
final options = formState.dropdownOptions[field.optionsMasterKey] ??
|
||||
const <Map<String, dynamic>>[];
|
||||
final dropdownOptions = <AppDropdownOption<String>>[];
|
||||
for (final item in options) {
|
||||
final id = item['id']?.toString();
|
||||
if (id == null || id.isEmpty) continue;
|
||||
dropdownOptions.add(
|
||||
AppDropdownOption(value: id, label: masterRecordLabel(item)),
|
||||
);
|
||||
}
|
||||
|
||||
return AppSearchableDropdown<String>(
|
||||
label: _fieldLabel(field),
|
||||
value: value?.toString(),
|
||||
options: dropdownOptions,
|
||||
hint: dropdownOptions.isEmpty
|
||||
? 'No options available'
|
||||
: 'Select ${field.label.toLowerCase()}',
|
||||
searchHint: 'Search ${field.label.toLowerCase()}...',
|
||||
enabled: dropdownOptions.isNotEmpty,
|
||||
onChanged: (selected) => notifier.updateValue(field.key, selected),
|
||||
validator: field.required
|
||||
? (v) => v == null ? '${field.label} is required' : null
|
||||
: null,
|
||||
);
|
||||
|
||||
case MasterFieldType.number:
|
||||
return TextFormField(
|
||||
key: ValueKey('${field.key}-${value ?? ''}'),
|
||||
initialValue: value?.toString(),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(labelText: _fieldLabel(field)),
|
||||
validator: field.required
|
||||
? (v) => Validators.required(v, fieldName: field.label)
|
||||
: null,
|
||||
onChanged: (text) => notifier.updateValue(field.key, text),
|
||||
);
|
||||
|
||||
case MasterFieldType.text:
|
||||
return TextFormField(
|
||||
key: ValueKey('${field.key}-${value ?? ''}'),
|
||||
initialValue: value?.toString(),
|
||||
maxLines: field.multiline ? 3 : 1,
|
||||
decoration: InputDecoration(labelText: _fieldLabel(field)),
|
||||
validator: field.required
|
||||
? (v) => Validators.required(v, fieldName: field.label)
|
||||
: null,
|
||||
onChanged: (text) => notifier.updateValue(field.key, text),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildFieldLayout(
|
||||
BuildContext context,
|
||||
MasterFormState formState,
|
||||
) {
|
||||
final def = _definition;
|
||||
final regularFields =
|
||||
def.formFields.where((field) => field.key != 'is_active').toList();
|
||||
MasterFieldDef? activeField;
|
||||
for (final field in def.formFields) {
|
||||
if (field.key == 'is_active') {
|
||||
activeField = field;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final widgets = <Widget>[];
|
||||
|
||||
for (var i = 0; i < regularFields.length; i += 2) {
|
||||
final left = regularFields[i];
|
||||
if (i + 1 < regularFields.length) {
|
||||
final right = regularFields[i + 1];
|
||||
widgets.add(
|
||||
SidePanelFormRow(
|
||||
left: _buildField(context, field: left, formState: formState),
|
||||
right: _buildField(context, field: right, formState: formState),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildField(context, field: left, formState: formState),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeField != null) {
|
||||
widgets.add(
|
||||
SidePanelSection(
|
||||
title: 'STATUS',
|
||||
children: [
|
||||
_buildField(context, field: activeField, formState: formState),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formAsync = ref.watch(masterFormProvider(_args));
|
||||
final def = _definition;
|
||||
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
|
||||
|
||||
return SidePanelScaffold(
|
||||
title: widget.isEditing ? 'Edit ${def.title.toLowerCase()}' : 'Add ${def.title.toLowerCase()}',
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: isSubmitting
|
||||
? null
|
||||
: () => Navigator.of(context, rootNavigator: true).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AppButton(
|
||||
label: widget.isEditing ? 'Update ${def.title.toLowerCase()}' : 'Save ${def.title.toLowerCase()}',
|
||||
expand: false,
|
||||
icon: Icons.check,
|
||||
isLoading: isSubmitting,
|
||||
onPressed: isSubmitting ? null : _submit,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: formAsync.when(
|
||||
loading: () => AppLoadingView(
|
||||
message:
|
||||
widget.isEditing ? 'Loading ${def.title}...' : 'Preparing form...',
|
||||
),
|
||||
error: (error, _) => ErrorView.fromFailure(
|
||||
error is Failure ? error : Failure.unknown(message: error.toString()),
|
||||
onRetry: () => ref.invalidate(masterFormProvider(_args)),
|
||||
),
|
||||
data: (formState) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: _buildFieldLayout(context, formState),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -20,10 +20,11 @@ import '../../domain/entities/rbac_entities.dart';
|
||||
import '../providers/add_user_form_provider.dart';
|
||||
import '../providers/role_form_provider.dart';
|
||||
import '../providers/rbac_provider.dart';
|
||||
import '../../../../shared/widgets/app_hover_effect.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../roles/presentation/providers/roles_provider.dart';
|
||||
import '../widgets/add_user_panel.dart';
|
||||
import '../widgets/create_role_panel.dart';
|
||||
import '../../../roles/presentation/providers/roles_provider.dart';
|
||||
import '../../../../shared/widgets/app_hover_effect.dart';
|
||||
import '../widgets/rbac_widgets.dart';
|
||||
|
||||
RbacTab rbacTabFromLocation(String location) {
|
||||
@ -219,18 +220,25 @@ class _UsersRoleManagementScreenState
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: switch (state.selectedTab) {
|
||||
RbacTab.users => _UsersTab(
|
||||
child: IndexedStack(
|
||||
index: switch (state.selectedTab) {
|
||||
RbacTab.users => 0,
|
||||
RbacTab.roles => 1,
|
||||
RbacTab.permissions => 2,
|
||||
},
|
||||
children: [
|
||||
_UsersTab(
|
||||
onAddUser: _openAddUser,
|
||||
onEditUser: (user) => _openUserPanel(userId: user.id),
|
||||
),
|
||||
RbacTab.roles => _RolesTab(
|
||||
_RolesTab(
|
||||
onNewRole: _openCreateRole,
|
||||
onEditRole: _editRole,
|
||||
onDeleteRole: _deleteRole,
|
||||
),
|
||||
RbacTab.permissions => const _PermissionMatrixTab(),
|
||||
},
|
||||
const _PermissionMatrixTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -1043,6 +1051,7 @@ class _RolesTab extends ConsumerWidget {
|
||||
|
||||
return AppCard(
|
||||
elevation: 0,
|
||||
enableHover: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
|
||||
@ -8,10 +8,11 @@ import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_searchable_multi_select_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../providers/add_user_form_provider.dart';
|
||||
import 'rbac_widgets.dart';
|
||||
|
||||
class AddUserPanel extends ConsumerStatefulWidget {
|
||||
const AddUserPanel({super.key, this.userId});
|
||||
@ -31,7 +32,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
final _emailController = TextEditingController();
|
||||
final _mobileController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
String? _selectedRoleId;
|
||||
List<String> _selectedRoleIds = [];
|
||||
String _selectedStatus = 'Active';
|
||||
String? _selectedDepartmentId;
|
||||
String? _selectedPlantId;
|
||||
@ -76,7 +77,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
_employeeCodeController.text = user.employeeCode;
|
||||
_emailController.text = user.email;
|
||||
_mobileController.text = user.mobile;
|
||||
_selectedRoleId = user.roleId;
|
||||
_selectedRoleIds = user.effectiveRoleIds;
|
||||
_selectedDepartmentId = user.departmentId;
|
||||
_selectedDesignationId = user.designationId;
|
||||
_selectedPlantId = user.plantId;
|
||||
@ -93,10 +94,13 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final roleId = int.tryParse(_selectedRoleId ?? '');
|
||||
if (roleId == null) {
|
||||
final roleIds = _selectedRoleIds
|
||||
.map((id) => int.tryParse(id))
|
||||
.whereType<int>()
|
||||
.toList();
|
||||
if (roleIds.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please select a role')),
|
||||
const SnackBar(content: Text('Please select at least one role')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -115,7 +119,8 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
mobile: _mobileController.text.trim().isEmpty
|
||||
? null
|
||||
: _mobileController.text.trim(),
|
||||
roleId: roleId,
|
||||
roleId: roleIds.first,
|
||||
roleIds: roleIds,
|
||||
departmentId: int.tryParse(_selectedDepartmentId ?? ''),
|
||||
designationId: int.tryParse(_selectedDesignationId ?? ''),
|
||||
plantId: int.tryParse(_selectedPlantId ?? ''),
|
||||
@ -134,7 +139,8 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
mobile: _mobileController.text.trim().isEmpty
|
||||
? null
|
||||
: _mobileController.text.trim(),
|
||||
roleId: roleId,
|
||||
roleId: roleIds.first,
|
||||
roleIds: roleIds,
|
||||
departmentId: int.tryParse(_selectedDepartmentId ?? ''),
|
||||
designationId: int.tryParse(_selectedDesignationId ?? ''),
|
||||
plantId: int.tryParse(_selectedPlantId ?? ''),
|
||||
@ -224,9 +230,6 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
setState(() => _prefillFromUser(formState.editingUser!));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_selectedRoleId ??=
|
||||
formState.roles.isNotEmpty ? formState.roles.first.id : null;
|
||||
}
|
||||
|
||||
return Form(
|
||||
@ -269,12 +272,21 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
title: 'ROLE & ACCESS',
|
||||
children: [
|
||||
SidePanelFormRow(
|
||||
left: _buildDropdown(
|
||||
label: 'Role',
|
||||
value: _selectedRoleId,
|
||||
options: formState.roles,
|
||||
required: true,
|
||||
onChanged: (v) => setState(() => _selectedRoleId = v),
|
||||
left: AppSearchableMultiSelectDropdown<String>(
|
||||
label: 'Role *',
|
||||
values: _selectedRoleIds,
|
||||
hint: formState.roles.isEmpty
|
||||
? 'No roles available'
|
||||
: 'Select roles',
|
||||
searchHint: 'Search role...',
|
||||
enabled: formState.roles.isNotEmpty,
|
||||
options: _toOptions(formState.roles),
|
||||
onChanged: (ids) =>
|
||||
setState(() => _selectedRoleIds = ids),
|
||||
validator: (ids) =>
|
||||
ids == null || ids.isEmpty
|
||||
? 'Please select at least one role'
|
||||
: null,
|
||||
),
|
||||
right: AppSearchableDropdown<String>(
|
||||
label: 'Status',
|
||||
|
||||
@ -9,6 +9,7 @@ import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../providers/role_form_provider.dart';
|
||||
import 'rbac_widgets.dart';
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ class RbacStatCard extends StatelessWidget {
|
||||
|
||||
return AppCard(
|
||||
elevation: 0,
|
||||
enableHover: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
@ -341,192 +342,6 @@ class RolePill extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<T?> showSidePanel<T>(
|
||||
BuildContext context,
|
||||
Widget panel, {
|
||||
double? width,
|
||||
}) {
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final defaultWidth = screenWidth > 1200
|
||||
? 480.0
|
||||
: (screenWidth * 0.38).clamp(360.0, 480.0);
|
||||
final panelWidth = (width ?? defaultWidth).clamp(360.0, screenWidth * 0.95);
|
||||
|
||||
return showGeneralDialog<T>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Dismiss',
|
||||
barrierColor: Colors.black.withValues(alpha: 0.35),
|
||||
transitionDuration: const Duration(milliseconds: 280),
|
||||
pageBuilder: (context, _, __) {
|
||||
final theme = Theme.of(context);
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Material(
|
||||
elevation: 16,
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.horizontal(left: Radius.circular(16)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SizedBox(
|
||||
width: panelWidth,
|
||||
height: MediaQuery.sizeOf(context).height,
|
||||
child: panel,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
transitionBuilder: (context, anim, _, child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class SidePanelScaffold extends StatelessWidget {
|
||||
const SidePanelScaffold({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.footer,
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
final Widget? footer;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 12, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: onClose ?? () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
if (footer != null) ...[
|
||||
const Divider(height: 1),
|
||||
Padding(padding: const EdgeInsets.all(24), child: footer),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SidePanelSection extends StatelessWidget {
|
||||
const SidePanelSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dividerColor =
|
||||
Theme.of(context).colorScheme.outline.withValues(alpha: 0.2);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Divider(height: 1, color: dividerColor),
|
||||
const SizedBox(height: 16),
|
||||
...children,
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-column row for side panel forms (desktop layout).
|
||||
class SidePanelFormRow extends StatelessWidget {
|
||||
const SidePanelFormRow({
|
||||
super.key,
|
||||
required this.left,
|
||||
required this.right,
|
||||
this.spacing = 16,
|
||||
});
|
||||
|
||||
final Widget left;
|
||||
final Widget right;
|
||||
final double spacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final stack = constraints.maxWidth < 420;
|
||||
|
||||
if (stack) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
left,
|
||||
const SizedBox(height: 12),
|
||||
right,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: left),
|
||||
SizedBox(width: spacing),
|
||||
Expanded(child: right),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ModulePermissionRow extends StatelessWidget {
|
||||
const ModulePermissionRow({
|
||||
super.key,
|
||||
|
||||
@ -70,6 +70,7 @@ final rolesListProvider =
|
||||
class RolesListNotifier extends AsyncNotifier<RolesListState> {
|
||||
@override
|
||||
Future<RolesListState> build() async {
|
||||
ref.keepAlive();
|
||||
return _load();
|
||||
}
|
||||
|
||||
@ -81,7 +82,9 @@ class RolesListNotifier extends AsyncNotifier<RolesListState> {
|
||||
|
||||
Future<void> refresh() async {
|
||||
final current = state.valueOrNull;
|
||||
state = const AsyncLoading();
|
||||
if (current == null) {
|
||||
state = const AsyncLoading();
|
||||
}
|
||||
try {
|
||||
state = AsyncData(await _load(search: current?.search));
|
||||
} catch (e, st) {
|
||||
|
||||
@ -100,6 +100,7 @@ final usersListProvider =
|
||||
class UsersListNotifier extends AsyncNotifier<UsersListState> {
|
||||
@override
|
||||
Future<UsersListState> build() async {
|
||||
ref.keepAlive();
|
||||
return _loadAll(const UserListQuery(limit: 10));
|
||||
}
|
||||
|
||||
@ -132,7 +133,10 @@ class UsersListNotifier extends AsyncNotifier<UsersListState> {
|
||||
}
|
||||
|
||||
Future<void> applyQuery(UserListQuery query) async {
|
||||
state = const AsyncLoading();
|
||||
final previous = state.valueOrNull;
|
||||
if (previous == null) {
|
||||
state = const AsyncLoading();
|
||||
}
|
||||
try {
|
||||
state = AsyncData(await _loadAll(query));
|
||||
} catch (e, st) {
|
||||
|
||||
@ -58,6 +58,26 @@ Object? _readRoleId(Map<dynamic, dynamic> json, String key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _readRoleIds(Map<dynamic, dynamic> json, String key) {
|
||||
final roleIds = json['role_ids'];
|
||||
if (roleIds is List && roleIds.isNotEmpty) return roleIds;
|
||||
final roles = json['roles'];
|
||||
if (roles is List && roles.isNotEmpty) {
|
||||
return roles
|
||||
.map((role) => role is Map ? role['id'] : role)
|
||||
.where((id) => id != null)
|
||||
.toList();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> _roleIdsFromJson(dynamic value) {
|
||||
if (value is List) {
|
||||
return value.map((item) => item.toString()).toList();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
@freezed
|
||||
class UserSummaryModel with _$UserSummaryModel {
|
||||
const factory UserSummaryModel({
|
||||
@ -109,6 +129,13 @@ class ManagedUserModel with _$ManagedUserModel {
|
||||
@Default('') String mobile,
|
||||
@JsonKey(fromJson: _idFromJsonNullable, name: 'role_id', readValue: _readRoleId)
|
||||
String? roleId,
|
||||
@JsonKey(
|
||||
name: 'role_ids',
|
||||
readValue: _readRoleIds,
|
||||
fromJson: _roleIdsFromJson,
|
||||
)
|
||||
@Default([])
|
||||
List<String> roleIds,
|
||||
@JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName,
|
||||
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
||||
String? departmentId,
|
||||
@ -141,6 +168,12 @@ extension ManagedUserModelX on ManagedUserModel {
|
||||
String get departmentLabel => departmentName ?? '—';
|
||||
String get plantLabel => plantName ?? '—';
|
||||
|
||||
List<String> get effectiveRoleIds {
|
||||
if (roleIds.isNotEmpty) return roleIds;
|
||||
if (roleId != null && roleId!.isNotEmpty) return [roleId!];
|
||||
return const [];
|
||||
}
|
||||
|
||||
String get initialsDisplay {
|
||||
if (initials != null && initials!.trim().isNotEmpty) {
|
||||
return initials!.trim().toUpperCase();
|
||||
@ -162,6 +195,7 @@ class CreateUserRequest with _$CreateUserRequest {
|
||||
required String password,
|
||||
String? mobile,
|
||||
@JsonKey(name: 'role_id') required int roleId,
|
||||
@JsonKey(name: 'role_ids') @Default([]) List<int> roleIds,
|
||||
@JsonKey(name: 'department_id') int? departmentId,
|
||||
@JsonKey(name: 'designation_id') int? designationId,
|
||||
@JsonKey(name: 'plant_id') int? plantId,
|
||||
@ -183,6 +217,7 @@ class UpdateUserRequest with _$UpdateUserRequest {
|
||||
String? password,
|
||||
String? mobile,
|
||||
@JsonKey(name: 'role_id') int? roleId,
|
||||
@JsonKey(name: 'role_ids') List<int>? roleIds,
|
||||
@JsonKey(name: 'department_id') int? departmentId,
|
||||
@JsonKey(name: 'designation_id') int? designationId,
|
||||
@JsonKey(name: 'plant_id') int? plantId,
|
||||
|
||||
@ -749,6 +749,12 @@ mixin _$ManagedUserModel {
|
||||
readValue: _readRoleId,
|
||||
)
|
||||
String? get roleId => throw _privateConstructorUsedError;
|
||||
@JsonKey(
|
||||
name: 'role_ids',
|
||||
readValue: _readRoleIds,
|
||||
fromJson: _roleIdsFromJson,
|
||||
)
|
||||
List<String> get roleIds => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'role_name', readValue: _readRoleName)
|
||||
String? get roleName => throw _privateConstructorUsedError;
|
||||
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
||||
@ -812,6 +818,12 @@ abstract class $ManagedUserModelCopyWith<$Res> {
|
||||
readValue: _readRoleId,
|
||||
)
|
||||
String? roleId,
|
||||
@JsonKey(
|
||||
name: 'role_ids',
|
||||
readValue: _readRoleIds,
|
||||
fromJson: _roleIdsFromJson,
|
||||
)
|
||||
List<String> roleIds,
|
||||
@JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName,
|
||||
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
||||
String? departmentId,
|
||||
@ -858,6 +870,7 @@ class _$ManagedUserModelCopyWithImpl<$Res, $Val extends ManagedUserModel>
|
||||
Object? email = null,
|
||||
Object? mobile = null,
|
||||
Object? roleId = freezed,
|
||||
Object? roleIds = null,
|
||||
Object? roleName = freezed,
|
||||
Object? departmentId = freezed,
|
||||
Object? departmentName = freezed,
|
||||
@ -909,6 +922,10 @@ class _$ManagedUserModelCopyWithImpl<$Res, $Val extends ManagedUserModel>
|
||||
? _value.roleId
|
||||
: roleId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
roleIds: null == roleIds
|
||||
? _value.roleIds
|
||||
: roleIds // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
roleName: freezed == roleName
|
||||
? _value.roleName
|
||||
: roleName // ignore: cast_nullable_to_non_nullable
|
||||
@ -1003,6 +1020,12 @@ abstract class _$$ManagedUserModelImplCopyWith<$Res>
|
||||
readValue: _readRoleId,
|
||||
)
|
||||
String? roleId,
|
||||
@JsonKey(
|
||||
name: 'role_ids',
|
||||
readValue: _readRoleIds,
|
||||
fromJson: _roleIdsFromJson,
|
||||
)
|
||||
List<String> roleIds,
|
||||
@JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName,
|
||||
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
||||
String? departmentId,
|
||||
@ -1048,6 +1071,7 @@ class __$$ManagedUserModelImplCopyWithImpl<$Res>
|
||||
Object? email = null,
|
||||
Object? mobile = null,
|
||||
Object? roleId = freezed,
|
||||
Object? roleIds = null,
|
||||
Object? roleName = freezed,
|
||||
Object? departmentId = freezed,
|
||||
Object? departmentName = freezed,
|
||||
@ -1099,6 +1123,10 @@ class __$$ManagedUserModelImplCopyWithImpl<$Res>
|
||||
? _value.roleId
|
||||
: roleId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
roleIds: null == roleIds
|
||||
? _value._roleIds
|
||||
: roleIds // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
roleName: freezed == roleName
|
||||
? _value.roleName
|
||||
: roleName // ignore: cast_nullable_to_non_nullable
|
||||
@ -1187,6 +1215,12 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
|
||||
readValue: _readRoleId,
|
||||
)
|
||||
this.roleId,
|
||||
@JsonKey(
|
||||
name: 'role_ids',
|
||||
readValue: _readRoleIds,
|
||||
fromJson: _roleIdsFromJson,
|
||||
)
|
||||
final List<String> roleIds = const [],
|
||||
@JsonKey(name: 'role_name', readValue: _readRoleName) this.roleName,
|
||||
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
||||
this.departmentId,
|
||||
@ -1207,7 +1241,7 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
|
||||
@JsonKey(name: 'avatar_url') this.avatarUrl,
|
||||
@JsonKey(name: 'created_at') this.createdAt,
|
||||
@JsonKey(name: 'updated_at') this.updatedAt,
|
||||
});
|
||||
}) : _roleIds = roleIds;
|
||||
|
||||
factory _$ManagedUserModelImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$ManagedUserModelImplFromJson(json);
|
||||
@ -1239,6 +1273,19 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
|
||||
readValue: _readRoleId,
|
||||
)
|
||||
final String? roleId;
|
||||
final List<String> _roleIds;
|
||||
@override
|
||||
@JsonKey(
|
||||
name: 'role_ids',
|
||||
readValue: _readRoleIds,
|
||||
fromJson: _roleIdsFromJson,
|
||||
)
|
||||
List<String> get roleIds {
|
||||
if (_roleIds is EqualUnmodifiableListView) return _roleIds;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_roleIds);
|
||||
}
|
||||
|
||||
@override
|
||||
@JsonKey(name: 'role_name', readValue: _readRoleName)
|
||||
final String? roleName;
|
||||
@ -1289,7 +1336,7 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ManagedUserModel(id: $id, employeeCode: $employeeCode, fullName: $fullName, firstName: $firstName, lastName: $lastName, email: $email, mobile: $mobile, roleId: $roleId, roleName: $roleName, departmentId: $departmentId, departmentName: $departmentName, designationId: $designationId, designationName: $designationName, plantId: $plantId, plantName: $plantName, reportingTo: $reportingTo, reportingToName: $reportingToName, lastLoginAt: $lastLoginAt, initials: $initials, status: $status, isActive: $isActive, avatarUrl: $avatarUrl, createdAt: $createdAt, updatedAt: $updatedAt)';
|
||||
return 'ManagedUserModel(id: $id, employeeCode: $employeeCode, fullName: $fullName, firstName: $firstName, lastName: $lastName, email: $email, mobile: $mobile, roleId: $roleId, roleIds: $roleIds, roleName: $roleName, departmentId: $departmentId, departmentName: $departmentName, designationId: $designationId, designationName: $designationName, plantId: $plantId, plantName: $plantName, reportingTo: $reportingTo, reportingToName: $reportingToName, lastLoginAt: $lastLoginAt, initials: $initials, status: $status, isActive: $isActive, avatarUrl: $avatarUrl, createdAt: $createdAt, updatedAt: $updatedAt)';
|
||||
}
|
||||
|
||||
@override
|
||||
@ -1309,6 +1356,7 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
|
||||
(identical(other.email, email) || other.email == email) &&
|
||||
(identical(other.mobile, mobile) || other.mobile == mobile) &&
|
||||
(identical(other.roleId, roleId) || other.roleId == roleId) &&
|
||||
const DeepCollectionEquality().equals(other._roleIds, _roleIds) &&
|
||||
(identical(other.roleName, roleName) ||
|
||||
other.roleName == roleName) &&
|
||||
(identical(other.departmentId, departmentId) ||
|
||||
@ -1353,6 +1401,7 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
|
||||
email,
|
||||
mobile,
|
||||
roleId,
|
||||
const DeepCollectionEquality().hash(_roleIds),
|
||||
roleName,
|
||||
departmentId,
|
||||
departmentName,
|
||||
@ -1406,6 +1455,12 @@ abstract class _ManagedUserModel implements ManagedUserModel {
|
||||
readValue: _readRoleId,
|
||||
)
|
||||
final String? roleId,
|
||||
@JsonKey(
|
||||
name: 'role_ids',
|
||||
readValue: _readRoleIds,
|
||||
fromJson: _roleIdsFromJson,
|
||||
)
|
||||
final List<String> roleIds,
|
||||
@JsonKey(name: 'role_name', readValue: _readRoleName)
|
||||
final String? roleName,
|
||||
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
|
||||
@ -1461,6 +1516,13 @@ abstract class _ManagedUserModel implements ManagedUserModel {
|
||||
)
|
||||
String? get roleId;
|
||||
@override
|
||||
@JsonKey(
|
||||
name: 'role_ids',
|
||||
readValue: _readRoleIds,
|
||||
fromJson: _roleIdsFromJson,
|
||||
)
|
||||
List<String> get roleIds;
|
||||
@override
|
||||
@JsonKey(name: 'role_name', readValue: _readRoleName)
|
||||
String? get roleName;
|
||||
@override
|
||||
@ -1530,6 +1592,8 @@ mixin _$CreateUserRequest {
|
||||
String? get mobile => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'role_id')
|
||||
int get roleId => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'role_ids')
|
||||
List<int> get roleIds => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'department_id')
|
||||
int? get departmentId => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'designation_id')
|
||||
@ -1566,6 +1630,7 @@ abstract class $CreateUserRequestCopyWith<$Res> {
|
||||
String password,
|
||||
String? mobile,
|
||||
@JsonKey(name: 'role_id') int roleId,
|
||||
@JsonKey(name: 'role_ids') List<int> roleIds,
|
||||
@JsonKey(name: 'department_id') int? departmentId,
|
||||
@JsonKey(name: 'designation_id') int? designationId,
|
||||
@JsonKey(name: 'plant_id') int? plantId,
|
||||
@ -1596,6 +1661,7 @@ class _$CreateUserRequestCopyWithImpl<$Res, $Val extends CreateUserRequest>
|
||||
Object? password = null,
|
||||
Object? mobile = freezed,
|
||||
Object? roleId = null,
|
||||
Object? roleIds = null,
|
||||
Object? departmentId = freezed,
|
||||
Object? designationId = freezed,
|
||||
Object? plantId = freezed,
|
||||
@ -1629,6 +1695,10 @@ class _$CreateUserRequestCopyWithImpl<$Res, $Val extends CreateUserRequest>
|
||||
? _value.roleId
|
||||
: roleId // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
roleIds: null == roleIds
|
||||
? _value.roleIds
|
||||
: roleIds // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,
|
||||
departmentId: freezed == departmentId
|
||||
? _value.departmentId
|
||||
: departmentId // ignore: cast_nullable_to_non_nullable
|
||||
@ -1675,6 +1745,7 @@ abstract class _$$CreateUserRequestImplCopyWith<$Res>
|
||||
String password,
|
||||
String? mobile,
|
||||
@JsonKey(name: 'role_id') int roleId,
|
||||
@JsonKey(name: 'role_ids') List<int> roleIds,
|
||||
@JsonKey(name: 'department_id') int? departmentId,
|
||||
@JsonKey(name: 'designation_id') int? designationId,
|
||||
@JsonKey(name: 'plant_id') int? plantId,
|
||||
@ -1704,6 +1775,7 @@ class __$$CreateUserRequestImplCopyWithImpl<$Res>
|
||||
Object? password = null,
|
||||
Object? mobile = freezed,
|
||||
Object? roleId = null,
|
||||
Object? roleIds = null,
|
||||
Object? departmentId = freezed,
|
||||
Object? designationId = freezed,
|
||||
Object? plantId = freezed,
|
||||
@ -1737,6 +1809,10 @@ class __$$CreateUserRequestImplCopyWithImpl<$Res>
|
||||
? _value.roleId
|
||||
: roleId // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
roleIds: null == roleIds
|
||||
? _value._roleIds
|
||||
: roleIds // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,
|
||||
departmentId: freezed == departmentId
|
||||
? _value.departmentId
|
||||
: departmentId // ignore: cast_nullable_to_non_nullable
|
||||
@ -1776,13 +1852,14 @@ class _$CreateUserRequestImpl implements _CreateUserRequest {
|
||||
required this.password,
|
||||
this.mobile,
|
||||
@JsonKey(name: 'role_id') required this.roleId,
|
||||
@JsonKey(name: 'role_ids') final List<int> roleIds = const [],
|
||||
@JsonKey(name: 'department_id') this.departmentId,
|
||||
@JsonKey(name: 'designation_id') this.designationId,
|
||||
@JsonKey(name: 'plant_id') this.plantId,
|
||||
@JsonKey(name: 'reporting_to') this.reportingTo,
|
||||
this.status = 'active',
|
||||
@JsonKey(name: 'is_active') this.isActive = true,
|
||||
});
|
||||
}) : _roleIds = roleIds;
|
||||
|
||||
factory _$CreateUserRequestImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$CreateUserRequestImplFromJson(json);
|
||||
@ -1802,6 +1879,15 @@ class _$CreateUserRequestImpl implements _CreateUserRequest {
|
||||
@override
|
||||
@JsonKey(name: 'role_id')
|
||||
final int roleId;
|
||||
final List<int> _roleIds;
|
||||
@override
|
||||
@JsonKey(name: 'role_ids')
|
||||
List<int> get roleIds {
|
||||
if (_roleIds is EqualUnmodifiableListView) return _roleIds;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_roleIds);
|
||||
}
|
||||
|
||||
@override
|
||||
@JsonKey(name: 'department_id')
|
||||
final int? departmentId;
|
||||
@ -1823,7 +1909,7 @@ class _$CreateUserRequestImpl implements _CreateUserRequest {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CreateUserRequest(employeeCode: $employeeCode, fullName: $fullName, email: $email, password: $password, mobile: $mobile, roleId: $roleId, departmentId: $departmentId, designationId: $designationId, plantId: $plantId, reportingTo: $reportingTo, status: $status, isActive: $isActive)';
|
||||
return 'CreateUserRequest(employeeCode: $employeeCode, fullName: $fullName, email: $email, password: $password, mobile: $mobile, roleId: $roleId, roleIds: $roleIds, departmentId: $departmentId, designationId: $designationId, plantId: $plantId, reportingTo: $reportingTo, status: $status, isActive: $isActive)';
|
||||
}
|
||||
|
||||
@override
|
||||
@ -1840,6 +1926,7 @@ class _$CreateUserRequestImpl implements _CreateUserRequest {
|
||||
other.password == password) &&
|
||||
(identical(other.mobile, mobile) || other.mobile == mobile) &&
|
||||
(identical(other.roleId, roleId) || other.roleId == roleId) &&
|
||||
const DeepCollectionEquality().equals(other._roleIds, _roleIds) &&
|
||||
(identical(other.departmentId, departmentId) ||
|
||||
other.departmentId == departmentId) &&
|
||||
(identical(other.designationId, designationId) ||
|
||||
@ -1862,6 +1949,7 @@ class _$CreateUserRequestImpl implements _CreateUserRequest {
|
||||
password,
|
||||
mobile,
|
||||
roleId,
|
||||
const DeepCollectionEquality().hash(_roleIds),
|
||||
departmentId,
|
||||
designationId,
|
||||
plantId,
|
||||
@ -1895,6 +1983,7 @@ abstract class _CreateUserRequest implements CreateUserRequest {
|
||||
required final String password,
|
||||
final String? mobile,
|
||||
@JsonKey(name: 'role_id') required final int roleId,
|
||||
@JsonKey(name: 'role_ids') final List<int> roleIds,
|
||||
@JsonKey(name: 'department_id') final int? departmentId,
|
||||
@JsonKey(name: 'designation_id') final int? designationId,
|
||||
@JsonKey(name: 'plant_id') final int? plantId,
|
||||
@ -1922,6 +2011,9 @@ abstract class _CreateUserRequest implements CreateUserRequest {
|
||||
@JsonKey(name: 'role_id')
|
||||
int get roleId;
|
||||
@override
|
||||
@JsonKey(name: 'role_ids')
|
||||
List<int> get roleIds;
|
||||
@override
|
||||
@JsonKey(name: 'department_id')
|
||||
int? get departmentId;
|
||||
@override
|
||||
@ -1962,6 +2054,8 @@ mixin _$UpdateUserRequest {
|
||||
String? get mobile => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'role_id')
|
||||
int? get roleId => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'role_ids')
|
||||
List<int>? get roleIds => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'department_id')
|
||||
int? get departmentId => throw _privateConstructorUsedError;
|
||||
@JsonKey(name: 'designation_id')
|
||||
@ -1998,6 +2092,7 @@ abstract class $UpdateUserRequestCopyWith<$Res> {
|
||||
String? password,
|
||||
String? mobile,
|
||||
@JsonKey(name: 'role_id') int? roleId,
|
||||
@JsonKey(name: 'role_ids') List<int>? roleIds,
|
||||
@JsonKey(name: 'department_id') int? departmentId,
|
||||
@JsonKey(name: 'designation_id') int? designationId,
|
||||
@JsonKey(name: 'plant_id') int? plantId,
|
||||
@ -2028,6 +2123,7 @@ class _$UpdateUserRequestCopyWithImpl<$Res, $Val extends UpdateUserRequest>
|
||||
Object? password = freezed,
|
||||
Object? mobile = freezed,
|
||||
Object? roleId = freezed,
|
||||
Object? roleIds = freezed,
|
||||
Object? departmentId = freezed,
|
||||
Object? designationId = freezed,
|
||||
Object? plantId = freezed,
|
||||
@ -2061,6 +2157,10 @@ class _$UpdateUserRequestCopyWithImpl<$Res, $Val extends UpdateUserRequest>
|
||||
? _value.roleId
|
||||
: roleId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
roleIds: freezed == roleIds
|
||||
? _value.roleIds
|
||||
: roleIds // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>?,
|
||||
departmentId: freezed == departmentId
|
||||
? _value.departmentId
|
||||
: departmentId // ignore: cast_nullable_to_non_nullable
|
||||
@ -2107,6 +2207,7 @@ abstract class _$$UpdateUserRequestImplCopyWith<$Res>
|
||||
String? password,
|
||||
String? mobile,
|
||||
@JsonKey(name: 'role_id') int? roleId,
|
||||
@JsonKey(name: 'role_ids') List<int>? roleIds,
|
||||
@JsonKey(name: 'department_id') int? departmentId,
|
||||
@JsonKey(name: 'designation_id') int? designationId,
|
||||
@JsonKey(name: 'plant_id') int? plantId,
|
||||
@ -2136,6 +2237,7 @@ class __$$UpdateUserRequestImplCopyWithImpl<$Res>
|
||||
Object? password = freezed,
|
||||
Object? mobile = freezed,
|
||||
Object? roleId = freezed,
|
||||
Object? roleIds = freezed,
|
||||
Object? departmentId = freezed,
|
||||
Object? designationId = freezed,
|
||||
Object? plantId = freezed,
|
||||
@ -2169,6 +2271,10 @@ class __$$UpdateUserRequestImplCopyWithImpl<$Res>
|
||||
? _value.roleId
|
||||
: roleId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
roleIds: freezed == roleIds
|
||||
? _value._roleIds
|
||||
: roleIds // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>?,
|
||||
departmentId: freezed == departmentId
|
||||
? _value.departmentId
|
||||
: departmentId // ignore: cast_nullable_to_non_nullable
|
||||
@ -2208,13 +2314,14 @@ class _$UpdateUserRequestImpl implements _UpdateUserRequest {
|
||||
this.password,
|
||||
this.mobile,
|
||||
@JsonKey(name: 'role_id') this.roleId,
|
||||
@JsonKey(name: 'role_ids') final List<int>? roleIds,
|
||||
@JsonKey(name: 'department_id') this.departmentId,
|
||||
@JsonKey(name: 'designation_id') this.designationId,
|
||||
@JsonKey(name: 'plant_id') this.plantId,
|
||||
@JsonKey(name: 'reporting_to') this.reportingTo,
|
||||
this.status,
|
||||
@JsonKey(name: 'is_active') this.isActive,
|
||||
});
|
||||
}) : _roleIds = roleIds;
|
||||
|
||||
factory _$UpdateUserRequestImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$UpdateUserRequestImplFromJson(json);
|
||||
@ -2234,6 +2341,17 @@ class _$UpdateUserRequestImpl implements _UpdateUserRequest {
|
||||
@override
|
||||
@JsonKey(name: 'role_id')
|
||||
final int? roleId;
|
||||
final List<int>? _roleIds;
|
||||
@override
|
||||
@JsonKey(name: 'role_ids')
|
||||
List<int>? get roleIds {
|
||||
final value = _roleIds;
|
||||
if (value == null) return null;
|
||||
if (_roleIds is EqualUnmodifiableListView) return _roleIds;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(value);
|
||||
}
|
||||
|
||||
@override
|
||||
@JsonKey(name: 'department_id')
|
||||
final int? departmentId;
|
||||
@ -2254,7 +2372,7 @@ class _$UpdateUserRequestImpl implements _UpdateUserRequest {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UpdateUserRequest(employeeCode: $employeeCode, fullName: $fullName, email: $email, password: $password, mobile: $mobile, roleId: $roleId, departmentId: $departmentId, designationId: $designationId, plantId: $plantId, reportingTo: $reportingTo, status: $status, isActive: $isActive)';
|
||||
return 'UpdateUserRequest(employeeCode: $employeeCode, fullName: $fullName, email: $email, password: $password, mobile: $mobile, roleId: $roleId, roleIds: $roleIds, departmentId: $departmentId, designationId: $designationId, plantId: $plantId, reportingTo: $reportingTo, status: $status, isActive: $isActive)';
|
||||
}
|
||||
|
||||
@override
|
||||
@ -2271,6 +2389,7 @@ class _$UpdateUserRequestImpl implements _UpdateUserRequest {
|
||||
other.password == password) &&
|
||||
(identical(other.mobile, mobile) || other.mobile == mobile) &&
|
||||
(identical(other.roleId, roleId) || other.roleId == roleId) &&
|
||||
const DeepCollectionEquality().equals(other._roleIds, _roleIds) &&
|
||||
(identical(other.departmentId, departmentId) ||
|
||||
other.departmentId == departmentId) &&
|
||||
(identical(other.designationId, designationId) ||
|
||||
@ -2293,6 +2412,7 @@ class _$UpdateUserRequestImpl implements _UpdateUserRequest {
|
||||
password,
|
||||
mobile,
|
||||
roleId,
|
||||
const DeepCollectionEquality().hash(_roleIds),
|
||||
departmentId,
|
||||
designationId,
|
||||
plantId,
|
||||
@ -2326,6 +2446,7 @@ abstract class _UpdateUserRequest implements UpdateUserRequest {
|
||||
final String? password,
|
||||
final String? mobile,
|
||||
@JsonKey(name: 'role_id') final int? roleId,
|
||||
@JsonKey(name: 'role_ids') final List<int>? roleIds,
|
||||
@JsonKey(name: 'department_id') final int? departmentId,
|
||||
@JsonKey(name: 'designation_id') final int? designationId,
|
||||
@JsonKey(name: 'plant_id') final int? plantId,
|
||||
@ -2353,6 +2474,9 @@ abstract class _UpdateUserRequest implements UpdateUserRequest {
|
||||
@JsonKey(name: 'role_id')
|
||||
int? get roleId;
|
||||
@override
|
||||
@JsonKey(name: 'role_ids')
|
||||
List<int>? get roleIds;
|
||||
@override
|
||||
@JsonKey(name: 'department_id')
|
||||
int? get departmentId;
|
||||
@override
|
||||
|
||||
@ -81,6 +81,9 @@ _$ManagedUserModelImpl _$$ManagedUserModelImplFromJson(
|
||||
email: json['email'] as String,
|
||||
mobile: json['mobile'] as String? ?? '',
|
||||
roleId: _idFromJsonNullable(_readRoleId(json, 'role_id')),
|
||||
roleIds: _readRoleIds(json, 'role_ids') == null
|
||||
? const []
|
||||
: _roleIdsFromJson(_readRoleIds(json, 'role_ids')),
|
||||
roleName: _readRoleName(json, 'role_name') as String?,
|
||||
departmentId: _idFromJsonNullable(json['department_id']),
|
||||
departmentName: _readDepartmentName(json, 'department_name') as String?,
|
||||
@ -116,6 +119,7 @@ Map<String, dynamic> _$$ManagedUserModelImplToJson(
|
||||
'email': instance.email,
|
||||
'mobile': instance.mobile,
|
||||
'role_id': instance.roleId,
|
||||
'role_ids': instance.roleIds,
|
||||
'role_name': instance.roleName,
|
||||
'department_id': instance.departmentId,
|
||||
'department_name': instance.departmentName,
|
||||
@ -143,6 +147,11 @@ _$CreateUserRequestImpl _$$CreateUserRequestImplFromJson(
|
||||
password: json['password'] as String,
|
||||
mobile: json['mobile'] as String?,
|
||||
roleId: (json['role_id'] as num).toInt(),
|
||||
roleIds:
|
||||
(json['role_ids'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toInt())
|
||||
.toList() ??
|
||||
const [],
|
||||
departmentId: (json['department_id'] as num?)?.toInt(),
|
||||
designationId: (json['designation_id'] as num?)?.toInt(),
|
||||
plantId: (json['plant_id'] as num?)?.toInt(),
|
||||
@ -160,6 +169,7 @@ Map<String, dynamic> _$$CreateUserRequestImplToJson(
|
||||
'password': instance.password,
|
||||
'mobile': instance.mobile,
|
||||
'role_id': instance.roleId,
|
||||
'role_ids': instance.roleIds,
|
||||
'department_id': instance.departmentId,
|
||||
'designation_id': instance.designationId,
|
||||
'plant_id': instance.plantId,
|
||||
@ -177,6 +187,9 @@ _$UpdateUserRequestImpl _$$UpdateUserRequestImplFromJson(
|
||||
password: json['password'] as String?,
|
||||
mobile: json['mobile'] as String?,
|
||||
roleId: (json['role_id'] as num?)?.toInt(),
|
||||
roleIds: (json['role_ids'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toInt())
|
||||
.toList(),
|
||||
departmentId: (json['department_id'] as num?)?.toInt(),
|
||||
designationId: (json['designation_id'] as num?)?.toInt(),
|
||||
plantId: (json['plant_id'] as num?)?.toInt(),
|
||||
@ -194,6 +207,7 @@ Map<String, dynamic> _$$UpdateUserRequestImplToJson(
|
||||
'password': instance.password,
|
||||
'mobile': instance.mobile,
|
||||
'role_id': instance.roleId,
|
||||
'role_ids': instance.roleIds,
|
||||
'department_id': instance.departmentId,
|
||||
'designation_id': instance.designationId,
|
||||
'plant_id': instance.plantId,
|
||||
|
||||
@ -19,9 +19,9 @@ import '../../modules/auth/presentation/screens/forgot_password_screen.dart';
|
||||
import '../../modules/auth/presentation/screens/login_screen.dart';
|
||||
import '../../modules/auth/presentation/screens/reset_password_screen.dart';
|
||||
import '../../modules/auth/presentation/screens/verify_otp_screen.dart';
|
||||
import '../../modules/master_data/presentation/screens/departments_screen.dart';
|
||||
import '../../modules/master_data/presentation/screens/locations_screen.dart';
|
||||
import '../../modules/master_data/presentation/screens/uom_screen.dart';
|
||||
import '../../modules/master_data/domain/entities/master_definition.dart';
|
||||
import '../../modules/master_data/presentation/screens/master_list_screen.dart';
|
||||
import '../../modules/master_data/presentation/screens/masters_hub_screen.dart';
|
||||
import '../../modules/reports/presentation/screens/reports_screen.dart';
|
||||
import '../../modules/audit/presentation/screens/audit_logs_screen.dart';
|
||||
import '../../modules/branch/presentation/screens/branch_form_screen.dart';
|
||||
@ -251,19 +251,20 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.departments,
|
||||
path: RouteConstants.masterData,
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const DepartmentsScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.locations,
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const LocationsScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.uom,
|
||||
pageBuilder: (context, state) =>
|
||||
shellPage(state, const UomScreen()),
|
||||
shellPage(state, const MastersHubScreen()),
|
||||
routes: [
|
||||
...masterDefinitions.map(
|
||||
(def) => GoRoute(
|
||||
path: def.routeKey,
|
||||
pageBuilder: (context, state) => shellPage(
|
||||
state,
|
||||
MasterListScreen(masterId: def.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.reports,
|
||||
|
||||
@ -101,28 +101,8 @@ const List<MenuItem> appMenuItems = [
|
||||
MenuItem(
|
||||
label: 'Master Data',
|
||||
icon: Icons.dataset_outlined,
|
||||
route: RouteConstants.departments,
|
||||
route: RouteConstants.masterData,
|
||||
module: 'master_data',
|
||||
children: [
|
||||
MenuItem(
|
||||
label: 'Departments',
|
||||
icon: Icons.apartment_outlined,
|
||||
route: RouteConstants.departments,
|
||||
module: 'departments',
|
||||
),
|
||||
MenuItem(
|
||||
label: 'Locations',
|
||||
icon: Icons.location_on_outlined,
|
||||
route: RouteConstants.locations,
|
||||
module: 'locations',
|
||||
),
|
||||
MenuItem(
|
||||
label: 'UOM',
|
||||
icon: Icons.straighten_outlined,
|
||||
route: RouteConstants.uom,
|
||||
module: 'uom',
|
||||
),
|
||||
],
|
||||
),
|
||||
MenuItem(
|
||||
label: 'Reports',
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Navigates to [location] after closing open dialogs, sheets, and overlays.
|
||||
/// Navigates to [location] after closing open dialogs and side panels only.
|
||||
void goAndDismissOverlays(BuildContext context, String location) {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
|
||||
if (navigator.canPop()) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
// Close overlay routes (side panels, dialogs) without resetting the page stack.
|
||||
navigator.popUntil((route) => route.isFirst || route is! PopupRoute);
|
||||
|
||||
if (context.mounted) {
|
||||
context.go(location);
|
||||
|
||||
@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_hover_effect.dart';
|
||||
|
||||
/// Card that follows the active theme with a shared hover animation.
|
||||
/// Card that follows the active theme. Hover is off by default; enable it only
|
||||
/// on navigation tiles (settings, role cards, etc.).
|
||||
class AppCard extends StatefulWidget {
|
||||
const AppCard({
|
||||
super.key,
|
||||
@ -12,7 +13,7 @@ class AppCard extends StatefulWidget {
|
||||
this.shape,
|
||||
this.margin,
|
||||
this.color,
|
||||
this.enableHover = true,
|
||||
this.enableHover = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@ -52,64 +53,66 @@ class _AppCardState extends State<AppCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final baseElevation = widget.elevation ?? 0;
|
||||
final radius = _borderRadius(widget.shape) ?? 12.0;
|
||||
final idleBorder = theme.colorScheme.outline.withValues(alpha: 0.12);
|
||||
final hoverBorder = theme.colorScheme.primary.withValues(alpha: 0.28);
|
||||
final radius = _borderRadius(widget.shape) ?? AppHoverStyle.borderRadius;
|
||||
|
||||
final shape = widget.shape ??
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
side: BorderSide(
|
||||
color: widget.enableHover && _hovered ? hoverBorder : idleBorder,
|
||||
),
|
||||
);
|
||||
|
||||
Widget card = AnimatedScale(
|
||||
scale: widget.enableHover && _hovered ? AppHoverStyle.scale : 1,
|
||||
duration: AppHoverStyle.duration,
|
||||
curve: AppHoverStyle.curve,
|
||||
child: AnimatedContainer(
|
||||
if (widget.enableHover) {
|
||||
final hovered = _hovered;
|
||||
Widget card = AnimatedContainer(
|
||||
duration: AppHoverStyle.duration,
|
||||
curve: AppHoverStyle.curve,
|
||||
margin: widget.margin,
|
||||
transform: Matrix4.translationValues(
|
||||
0,
|
||||
widget.enableHover && _hovered ? -AppHoverStyle.lift : 0,
|
||||
0,
|
||||
decoration: AppHoverStyle.decoration(
|
||||
theme,
|
||||
hovered: hovered,
|
||||
radius: radius,
|
||||
backgroundColor: widget.color ?? theme.colorScheme.surface,
|
||||
),
|
||||
child: Card(
|
||||
color: widget.color,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
elevation: widget.enableHover && _hovered
|
||||
? baseElevation + AppHoverStyle.elevationDelta
|
||||
: baseElevation,
|
||||
shape: shape is RoundedRectangleBorder && widget.enableHover
|
||||
? shape.copyWith(
|
||||
side: BorderSide(
|
||||
color: _hovered ? hoverBorder : idleBorder,
|
||||
),
|
||||
)
|
||||
: shape,
|
||||
child: widget.onTap == null
|
||||
? widget.child
|
||||
: InkWell(
|
||||
clipBehavior: widget.clipBehavior ?? Clip.antiAlias,
|
||||
child: widget.onTap == null
|
||||
? widget.child
|
||||
: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
splashColor: theme.colorScheme.primary.withValues(alpha: 0.06),
|
||||
hoverColor: theme.colorScheme.primary.withValues(alpha: 0.04),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
|
||||
if (!widget.enableHover) return card;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => _setHovered(true),
|
||||
onExit: (_) => _setHovered(false),
|
||||
cursor: widget.onTap != null
|
||||
? SystemMouseCursors.click
|
||||
: SystemMouseCursors.basic,
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => _setHovered(true),
|
||||
onExit: (_) => _setHovered(false),
|
||||
cursor: widget.onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||
child: card,
|
||||
final idleBorder = theme.colorScheme.outline.withValues(alpha: 0.12);
|
||||
final shape = widget.shape ??
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
side: BorderSide(color: idleBorder),
|
||||
);
|
||||
|
||||
return Card(
|
||||
margin: widget.margin,
|
||||
color: widget.color,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
elevation: widget.elevation ?? 0,
|
||||
shape: shape,
|
||||
child: widget.onTap == null
|
||||
? widget.child
|
||||
: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,14 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared hover animation for cards and tappable surfaces (web/desktop).
|
||||
/// Shared hover styling for navigation tiles (settings, roles, masters).
|
||||
class AppHoverStyle {
|
||||
AppHoverStyle._();
|
||||
|
||||
static const Duration duration = Duration(milliseconds: 200);
|
||||
static const Duration duration = Duration(milliseconds: 180);
|
||||
static const Curve curve = Curves.easeOutCubic;
|
||||
static const double lift = 2;
|
||||
static const double scale = 1.01;
|
||||
static const double elevationDelta = 3;
|
||||
static const double borderRadius = 12;
|
||||
static const double idleBorderWidth = 1;
|
||||
static const double hoverBorderWidth = 1.5;
|
||||
|
||||
static Color idleBorderColor(ColorScheme scheme) =>
|
||||
scheme.outline.withValues(alpha: 0.18);
|
||||
|
||||
static Color hoverBorderColor(ColorScheme scheme) =>
|
||||
scheme.primary.withValues(alpha: 0.45);
|
||||
|
||||
static List<BoxShadow> hoverShadow(ColorScheme scheme) => [
|
||||
BoxShadow(
|
||||
color: scheme.primary.withValues(alpha: 0.2),
|
||||
blurRadius: 14,
|
||||
spreadRadius: 0,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
];
|
||||
|
||||
static BoxDecoration decoration(
|
||||
ThemeData theme, {
|
||||
required bool hovered,
|
||||
double radius = borderRadius,
|
||||
Color? backgroundColor,
|
||||
bool showBorder = true,
|
||||
bool showShadow = true,
|
||||
}) {
|
||||
final scheme = theme.colorScheme;
|
||||
return BoxDecoration(
|
||||
color: backgroundColor ?? scheme.surface,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
border: showBorder
|
||||
? Border.all(
|
||||
color: hovered ? hoverBorderColor(scheme) : idleBorderColor(scheme),
|
||||
width: hovered ? hoverBorderWidth : idleBorderWidth,
|
||||
)
|
||||
: null,
|
||||
boxShadow: hovered && showShadow ? hoverShadow(scheme) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppHoverEffect extends StatefulWidget {
|
||||
@ -17,19 +54,19 @@ class AppHoverEffect extends StatefulWidget {
|
||||
required this.child,
|
||||
this.enabled = true,
|
||||
this.onTap,
|
||||
this.borderRadius = 12,
|
||||
this.hoverBorderColor,
|
||||
this.idleBorderColor,
|
||||
this.borderRadius = AppHoverStyle.borderRadius,
|
||||
this.backgroundColor,
|
||||
this.showHoverBorder = true,
|
||||
this.showHoverShadow = true,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool enabled;
|
||||
final VoidCallback? onTap;
|
||||
final double borderRadius;
|
||||
final Color? hoverBorderColor;
|
||||
final Color? idleBorderColor;
|
||||
final Color? backgroundColor;
|
||||
final bool showHoverBorder;
|
||||
final bool showHoverShadow;
|
||||
|
||||
@override
|
||||
State<AppHoverEffect> createState() => _AppHoverEffectState();
|
||||
@ -58,34 +95,22 @@ class _AppHoverEffectState extends State<AppHoverEffect> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final borderRadius = BorderRadius.circular(widget.borderRadius);
|
||||
final idleBorder = widget.idleBorderColor ??
|
||||
theme.colorScheme.outline.withValues(alpha: 0.12);
|
||||
final hoverBorder = widget.hoverBorderColor ??
|
||||
theme.colorScheme.primary.withValues(alpha: 0.28);
|
||||
final radius = BorderRadius.circular(widget.borderRadius);
|
||||
final hovered = widget.enabled && _hovered;
|
||||
|
||||
Widget content = AnimatedScale(
|
||||
scale: widget.enabled && _hovered ? AppHoverStyle.scale : 1,
|
||||
Widget content = AnimatedContainer(
|
||||
duration: AppHoverStyle.duration,
|
||||
curve: AppHoverStyle.curve,
|
||||
child: AnimatedContainer(
|
||||
duration: AppHoverStyle.duration,
|
||||
curve: AppHoverStyle.curve,
|
||||
transform: Matrix4.translationValues(
|
||||
0,
|
||||
widget.enabled && _hovered ? -AppHoverStyle.lift : 0,
|
||||
0,
|
||||
),
|
||||
decoration: widget.showHoverBorder
|
||||
? BoxDecoration(
|
||||
borderRadius: borderRadius,
|
||||
border: Border.all(
|
||||
color: widget.enabled && _hovered ? hoverBorder : idleBorder,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: widget.child,
|
||||
decoration: AppHoverStyle.decoration(
|
||||
theme,
|
||||
hovered: hovered,
|
||||
radius: widget.borderRadius,
|
||||
backgroundColor: widget.backgroundColor,
|
||||
showBorder: widget.showHoverBorder,
|
||||
showShadow: widget.showHoverShadow,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: widget.child,
|
||||
);
|
||||
|
||||
if (widget.onTap != null) {
|
||||
@ -93,7 +118,9 @@ class _AppHoverEffectState extends State<AppHoverEffect> {
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: borderRadius,
|
||||
borderRadius: radius,
|
||||
splashColor: theme.colorScheme.primary.withValues(alpha: 0.06),
|
||||
hoverColor: theme.colorScheme.primary.withValues(alpha: 0.04),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
@ -104,7 +131,9 @@ class _AppHoverEffectState extends State<AppHoverEffect> {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => _setHovered(true),
|
||||
onExit: (_) => _setHovered(false),
|
||||
cursor: widget.onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||
cursor: widget.onTap != null
|
||||
? SystemMouseCursors.click
|
||||
: SystemMouseCursors.basic,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
72
lib/shared/widgets/app_search_export_bar.dart
Normal file
72
lib/shared/widgets/app_search_export_bar.dart
Normal file
@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Search field with export action — matches the users list toolbar (no filters).
|
||||
class AppSearchExportBar extends StatelessWidget {
|
||||
const AppSearchExportBar({
|
||||
super.key,
|
||||
required this.searchHint,
|
||||
required this.onSearch,
|
||||
required this.onExport,
|
||||
this.isExporting = false,
|
||||
this.wrapped = false,
|
||||
this.searchController,
|
||||
this.searchWidth = 320,
|
||||
});
|
||||
|
||||
final String searchHint;
|
||||
final ValueChanged<String> onSearch;
|
||||
final VoidCallback onExport;
|
||||
final bool isExporting;
|
||||
final bool wrapped;
|
||||
final TextEditingController? searchController;
|
||||
final double searchWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final searchField = TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: searchHint,
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: onSearch,
|
||||
);
|
||||
|
||||
final exportButton = OutlinedButton.icon(
|
||||
onPressed: isExporting ? null : onExport,
|
||||
icon: isExporting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.download_outlined, size: 18),
|
||||
label: Text(isExporting ? 'Exporting...' : 'Export'),
|
||||
);
|
||||
|
||||
if (wrapped) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
searchField,
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
exportButton,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(width: searchWidth, child: searchField),
|
||||
const Spacer(),
|
||||
exportButton,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
329
lib/shared/widgets/app_searchable_multi_select_dropdown.dart
Normal file
329
lib/shared/widgets/app_searchable_multi_select_dropdown.dart
Normal file
@ -0,0 +1,329 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_dropdown.dart';
|
||||
|
||||
/// Dropdown that opens a searchable popup with multi-select checkboxes.
|
||||
class AppSearchableMultiSelectDropdown<T> extends StatefulWidget {
|
||||
const AppSearchableMultiSelectDropdown({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.values,
|
||||
required this.options,
|
||||
required this.onChanged,
|
||||
this.validator,
|
||||
this.hint,
|
||||
this.searchHint = 'Search...',
|
||||
this.enabled = true,
|
||||
this.isDense = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final List<T> values;
|
||||
final List<AppDropdownOption<T>> options;
|
||||
final ValueChanged<List<T>> onChanged;
|
||||
final String? Function(List<T>?)? validator;
|
||||
final String? hint;
|
||||
final String searchHint;
|
||||
final bool enabled;
|
||||
final bool isDense;
|
||||
|
||||
@override
|
||||
State<AppSearchableMultiSelectDropdown<T>> createState() =>
|
||||
_AppSearchableMultiSelectDropdownState<T>();
|
||||
}
|
||||
|
||||
class _AppSearchableMultiSelectDropdownState<T>
|
||||
extends State<AppSearchableMultiSelectDropdown<T>> {
|
||||
final _layerLink = LayerLink();
|
||||
final _fieldKey = GlobalKey();
|
||||
OverlayEntry? _overlayEntry;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _displayText() {
|
||||
if (widget.values.isEmpty) return '';
|
||||
final labels = <String>[];
|
||||
for (final value in widget.values) {
|
||||
for (final option in widget.options) {
|
||||
if (option.value == value) {
|
||||
labels.add(option.label);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return labels.join(', ');
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
if (_overlayEntry == null) return;
|
||||
_overlayEntry!.remove();
|
||||
_overlayEntry = null;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _openPicker(FormFieldState<List<T>> field) {
|
||||
if (!widget.enabled || widget.options.isEmpty) return;
|
||||
if (_overlayEntry != null) {
|
||||
_removeOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
final renderBox =
|
||||
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
|
||||
final fieldSize = renderBox.size;
|
||||
final fieldTopLeft = renderBox.localToGlobal(Offset.zero);
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final viewInsets = MediaQuery.viewInsetsOf(context);
|
||||
|
||||
final spaceBelow =
|
||||
screenSize.height - viewInsets.bottom - fieldTopLeft.dy - fieldSize.height;
|
||||
final spaceAbove = fieldTopLeft.dy - viewInsets.top;
|
||||
final showAbove = spaceBelow < 180 && spaceAbove > spaceBelow;
|
||||
|
||||
final availableSpace = (showAbove ? spaceAbove : spaceBelow) - 8;
|
||||
final maxPanelHeight = availableSpace.clamp(120.0, screenSize.height * 0.45);
|
||||
|
||||
var selected = List<T>.from(widget.values);
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (overlayContext) {
|
||||
final theme = Theme.of(overlayContext);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
_removeOverlay();
|
||||
field.didChange(selected);
|
||||
widget.onChanged(selected);
|
||||
},
|
||||
behavior: HitTestBehavior.translucent,
|
||||
),
|
||||
),
|
||||
CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
targetAnchor:
|
||||
showAbove ? Alignment.topLeft : Alignment.bottomLeft,
|
||||
followerAnchor:
|
||||
showAbove ? Alignment.bottomLeft : Alignment.topLeft,
|
||||
offset: Offset(0, showAbove ? -4 : 4),
|
||||
child: TapRegion(
|
||||
onTapOutside: (_) {
|
||||
_removeOverlay();
|
||||
field.didChange(selected);
|
||||
widget.onChanged(selected);
|
||||
},
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
color: theme.colorScheme.surface,
|
||||
shadowColor: Colors.black45,
|
||||
child: SizedBox(
|
||||
width: fieldSize.width,
|
||||
child: _SearchableMultiSelectPanel<T>(
|
||||
maxHeight: maxPanelHeight,
|
||||
options: widget.options,
|
||||
selected: selected,
|
||||
searchHint: widget.searchHint,
|
||||
onToggle: (value, checked) {
|
||||
if (checked) {
|
||||
if (!selected.contains(value)) {
|
||||
selected = [...selected, value];
|
||||
}
|
||||
} else {
|
||||
selected = selected.where((v) => v != value).toList();
|
||||
}
|
||||
_overlayEntry?.markNeedsBuild();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final displayText = _displayText();
|
||||
|
||||
return FormField<List<T>>(
|
||||
initialValue: widget.values,
|
||||
validator: widget.validator,
|
||||
builder: (field) {
|
||||
final effectiveHint =
|
||||
widget.hint ?? 'Select ${widget.label.toLowerCase()}';
|
||||
final canOpen = widget.enabled && widget.options.isNotEmpty;
|
||||
final colors = theme.colorScheme;
|
||||
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: KeyedSubtree(
|
||||
key: _fieldKey,
|
||||
child: InkWell(
|
||||
onTap: canOpen ? () => _openPicker(field) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InputDecorator(
|
||||
isFocused: _overlayEntry != null,
|
||||
isEmpty: displayText.isEmpty,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.label,
|
||||
hintText: displayText.isEmpty ? effectiveHint : null,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
isDense: widget.isDense,
|
||||
errorText: field.errorText,
|
||||
suffixIcon: Icon(
|
||||
_overlayEntry != null
|
||||
? Icons.arrow_drop_up
|
||||
: Icons.arrow_drop_down,
|
||||
color:
|
||||
canOpen ? colors.onSurfaceVariant : theme.disabledColor,
|
||||
),
|
||||
enabled: canOpen,
|
||||
),
|
||||
child: displayText.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Text(
|
||||
displayText,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchableMultiSelectPanel<T> extends StatefulWidget {
|
||||
const _SearchableMultiSelectPanel({
|
||||
required this.maxHeight,
|
||||
required this.options,
|
||||
required this.selected,
|
||||
required this.searchHint,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
final double maxHeight;
|
||||
final List<AppDropdownOption<T>> options;
|
||||
final List<T> selected;
|
||||
final String searchHint;
|
||||
final void Function(T value, bool checked) onToggle;
|
||||
|
||||
@override
|
||||
State<_SearchableMultiSelectPanel<T>> createState() =>
|
||||
_SearchableMultiSelectPanelState<T>();
|
||||
}
|
||||
|
||||
class _SearchableMultiSelectPanelState<T>
|
||||
extends State<_SearchableMultiSelectPanel<T>> {
|
||||
final _searchController = TextEditingController();
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<AppDropdownOption<T>> get _filtered {
|
||||
final q = _query.trim().toLowerCase();
|
||||
if (q.isEmpty) return widget.options;
|
||||
return widget.options
|
||||
.where((option) => option.label.toLowerCase().contains(q))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final filtered = _filtered;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.searchHint,
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
onChanged: (value) => setState(() => _query = value),
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: widget.maxHeight - 56),
|
||||
child: filtered.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text(
|
||||
'No options found',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
height: 1,
|
||||
color: theme.colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final option = filtered[index];
|
||||
final isSelected = widget.selected.contains(option.value);
|
||||
return CheckboxListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
title: Text(
|
||||
option.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
value: isSelected,
|
||||
onChanged: (checked) =>
|
||||
widget.onToggle(option.value, checked ?? false),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
194
lib/shared/widgets/app_side_panel.dart
Normal file
194
lib/shared/widgets/app_side_panel.dart
Normal file
@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<T?> showSidePanel<T>(
|
||||
BuildContext context,
|
||||
Widget panel, {
|
||||
double? width,
|
||||
}) {
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final defaultWidth = screenWidth > 1200
|
||||
? 480.0
|
||||
: (screenWidth * 0.38).clamp(360.0, 480.0);
|
||||
final panelWidth = (width ?? defaultWidth).clamp(360.0, screenWidth * 0.95);
|
||||
|
||||
return showGeneralDialog<T>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Dismiss',
|
||||
barrierColor: Colors.black.withValues(alpha: 0.35),
|
||||
transitionDuration: const Duration(milliseconds: 280),
|
||||
pageBuilder: (context, _, __) {
|
||||
final theme = Theme.of(context);
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Material(
|
||||
elevation: 16,
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.horizontal(left: Radius.circular(16)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SizedBox(
|
||||
width: panelWidth,
|
||||
height: MediaQuery.sizeOf(context).height,
|
||||
child: panel,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
transitionBuilder: (context, anim, _, child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class SidePanelScaffold extends StatelessWidget {
|
||||
const SidePanelScaffold({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.footer,
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
final Widget? footer;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
void _close(BuildContext context) {
|
||||
if (onClose != null) {
|
||||
onClose!();
|
||||
return;
|
||||
}
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 12, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _close(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
if (footer != null) ...[
|
||||
const Divider(height: 1),
|
||||
Padding(padding: const EdgeInsets.all(24), child: footer),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SidePanelSection extends StatelessWidget {
|
||||
const SidePanelSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dividerColor =
|
||||
Theme.of(context).colorScheme.outline.withValues(alpha: 0.2);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Divider(height: 1, color: dividerColor),
|
||||
const SizedBox(height: 16),
|
||||
...children,
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SidePanelFormRow extends StatelessWidget {
|
||||
const SidePanelFormRow({
|
||||
super.key,
|
||||
required this.left,
|
||||
required this.right,
|
||||
this.spacing = 16,
|
||||
});
|
||||
|
||||
final Widget left;
|
||||
final Widget right;
|
||||
final double spacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final stack = constraints.maxWidth < 420;
|
||||
|
||||
if (stack) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
left,
|
||||
const SizedBox(height: 12),
|
||||
right,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: left),
|
||||
SizedBox(width: spacing),
|
||||
Expanded(child: right),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user