diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index 5687089..83b4e87 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -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'; diff --git a/lib/core/constants/route_constants.dart b/lib/core/constants/route_constants.dart index abe245a..3335ecf 100644 --- a/lib/core/constants/route_constants.dart +++ b/lib/core/constants/route_constants.dart @@ -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'; diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index 197a80a..bc84bbc 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -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((ref) { dio.interceptors.addAll([ AuthInterceptor(ref), ErrorInterceptor(), - LogInterceptor(requestBody: true, responseBody: true), + if (kDebugMode) LogInterceptor(requestBody: true, responseBody: true), ]); return dio; diff --git a/lib/modules/dev/presentation/screens/screen_gallery_screen.dart b/lib/modules/dev/presentation/screens/screen_gallery_screen.dart index 6368355..68cf414 100644 --- a/lib/modules/dev/presentation/screens/screen_gallery_screen.dart +++ b/lib/modules/dev/presentation/screens/screen_gallery_screen.dart @@ -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'), diff --git a/lib/modules/master_data/data/datasources/master_crud_remote_data_source.dart b/lib/modules/master_data/data/datasources/master_crud_remote_data_source.dart new file mode 100644 index 0000000..908f3b4 --- /dev/null +++ b/lib/modules/master_data/data/datasources/master_crud_remote_data_source.dart @@ -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((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> 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 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; + final raw = body['data']; + final list = raw is List + ? raw + : raw is Map + ? raw['items'] as List? ?? const [] + : const []; + + final items = list + .whereType>() + .map((item) => Map.from(item)) + .toList(); + + final meta = raw is Map ? 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>> listOptions(MasterDefinition definition) async { + final allItems = >[]; + 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> getById( + MasterDefinition definition, + String id, + ) async { + final response = await dio.get('${definition.apiPath}/$id'); + return _extractData(response.data); + } + + Future> create( + MasterDefinition definition, + Map payload, + ) async { + final response = await dio.post( + definition.apiPath, + data: payload..removeWhere((_, value) => value == null), + ); + return _extractData(response.data); + } + + Future> update( + MasterDefinition definition, + String id, + Map payload, + ) async { + final response = await dio.put( + '${definition.apiPath}/$id', + data: payload..removeWhere((_, value) => value == null), + ); + return _extractData(response.data); + } + + Future delete(MasterDefinition definition, String id) async { + await dio.delete('${definition.apiPath}/$id'); + } + + Future export( + MasterDefinition definition, { + String? search, + }) async { + final response = await dio.get>( + '${definition.apiPath}/export', + queryParameters: { + if (search != null && search.isNotEmpty) 'search': search, + }, + options: Options(responseType: ResponseType.bytes), + ); + final bytes = response.data ?? []; + return ExportFileResult( + bytes: bytes, + fileName: _fileNameFromResponse(response, definition), + ); + } + + String _fileNameFromResponse( + Response> 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 _extractData(dynamic body) { + if (body is! Map) { + throw FormatException('Unexpected master response'); + } + final data = body['data']; + if (data is Map) return Map.from(data); + return Map.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; + } +} diff --git a/lib/modules/master_data/data/repositories/master_repository_impl.dart b/lib/modules/master_data/data/repositories/master_repository_impl.dart new file mode 100644 index 0000000..d5813b1 --- /dev/null +++ b/lib/modules/master_data/data/repositories/master_repository_impl.dart @@ -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((ref) { + return MasterRepositoryImpl(remote: ref.watch(masterCrudRemoteDataSourceProvider)); +}); + +class MasterRepositoryImpl implements MasterRepository { + MasterRepositoryImpl({required this.remote}); + + final MasterCrudRemoteDataSource remote; + + @override + Future> list( + MasterDefinition definition, { + int page = 1, + int limit = 20, + String? search, + }) => + safeApiCall( + () => remote.list( + definition, + page: page, + limit: limit, + search: search, + ), + ); + + @override + Future>>> listOptions( + MasterDefinition definition, + ) => + safeApiCall(() => remote.listOptions(definition)); + + @override + Future>> getById( + MasterDefinition definition, + String id, + ) => + safeApiCall(() => remote.getById(definition, id)); + + @override + Future>> create( + MasterDefinition definition, + Map payload, + ) => + safeApiCall(() => remote.create(definition, payload)); + + @override + Future>> update( + MasterDefinition definition, + String id, + Map payload, + ) => + safeApiCall(() => remote.update(definition, id, payload)); + + @override + Future> delete(MasterDefinition definition, String id) => + safeApiCall(() => remote.delete(definition, id)); + + @override + Future> export( + MasterDefinition definition, { + String? search, + }) => + safeApiCall(() => remote.export(definition, search: search)); +} diff --git a/lib/modules/master_data/domain/entities/master_definition.dart b/lib/modules/master_data/domain/entities/master_definition.dart new file mode 100644 index 0000000..e80b03a --- /dev/null +++ b/lib/modules/master_data/domain/entities/master_definition.dart @@ -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 fields; + + List get listFields => + fields.where((field) => field.showInList).toList(); + + List 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( + 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 get masterCategories => + masterDefinitions.map((def) => def.category).toSet().toList(); + +String masterRecordLabel(Map 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 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 row) { + final active = row['is_active']; + if (active == false) return 'inactive'; + return 'active'; +} diff --git a/lib/modules/master_data/domain/repositories/master_repository.dart b/lib/modules/master_data/domain/repositories/master_repository.dart new file mode 100644 index 0000000..8142fac --- /dev/null +++ b/lib/modules/master_data/domain/repositories/master_repository.dart @@ -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> list( + MasterDefinition definition, { + int page, + int limit, + String? search, + }); + + Future>>> listOptions(MasterDefinition definition); + + Future>> getById( + MasterDefinition definition, + String id, + ); + + Future>> create( + MasterDefinition definition, + Map payload, + ); + + Future>> update( + MasterDefinition definition, + String id, + Map payload, + ); + + Future> delete(MasterDefinition definition, String id); + + Future> export( + MasterDefinition definition, { + String? search, + }); +} diff --git a/lib/modules/master_data/presentation/providers/master_provider.dart b/lib/modules/master_data/presentation/providers/master_provider.dart new file mode 100644 index 0000000..60c716a --- /dev/null +++ b/lib/modules/master_data/presentation/providers/master_provider.dart @@ -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> 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>? 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 values; + final Map>> dropdownOptions; + final bool isSubmitting; + final String? errorMessage; + + MasterFormState copyWith({ + Map? values, + Map>>? 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 { + MasterDefinition get _definition { + final def = masterDefinitionById(arg); + if (def == null) throw StateError('Unknown master: $arg'); + return def; + } + + @override + Future build(String arg) async { + ref.keepAlive(); + return _load(); + } + + Future _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 refresh() async { + final previous = state.valueOrNull; + if (previous == null) { + state = const AsyncLoading(); + } + state = AsyncData(await _load()); + } + + Future setSearch(String search) async { + await _reload(page: 1, search: search); + } + + Future setPage(int page) async { + await _reload(page: page); + } + + Future _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 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 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 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 { + MasterDefinition get _definition { + final def = masterDefinitionById(arg.masterId); + if (def == null) throw StateError('Unknown master: ${arg.masterId}'); + return def; + } + + @override + Future build(MasterFormArgs arg) async { + final dropdownOptions = await _loadDropdownOptions(); + Map values = {}; + + if (arg.recordId != null) { + final result = await ref + .read(masterRepositoryProvider) + .getById(_definition, arg.recordId!); + if (result.failure != null) throw result.failure!; + values = Map.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>>> + _loadDropdownOptions() async { + final options = >>{}; + 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.from(current.values); + values[key] = value; + state = AsyncData(current.copyWith(values: values)); + } + + Map _buildPayload(MasterFormState current) { + final payload = {}; + 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 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; + } +} diff --git a/lib/modules/master_data/presentation/screens/departments_screen.dart b/lib/modules/master_data/presentation/screens/departments_screen.dart deleted file mode 100644 index 2a19e9e..0000000 --- a/lib/modules/master_data/presentation/screens/departments_screen.dart +++ /dev/null @@ -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'); -} diff --git a/lib/modules/master_data/presentation/screens/locations_screen.dart b/lib/modules/master_data/presentation/screens/locations_screen.dart deleted file mode 100644 index a152d13..0000000 --- a/lib/modules/master_data/presentation/screens/locations_screen.dart +++ /dev/null @@ -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'); -} diff --git a/lib/modules/master_data/presentation/screens/master_list_screen.dart b/lib/modules/master_data/presentation/screens/master_list_screen.dart new file mode 100644 index 0000000..e95a8a2 --- /dev/null +++ b/lib/modules/master_data/presentation/screens/master_list_screen.dart @@ -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 createState() => _MasterListScreenState(); +} + +class _MasterListScreenState extends ConsumerState { + 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 _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 _openFormPanel({String? recordId}) async { + ref.invalidate(masterFormProvider((masterId: widget.masterId, recordId: recordId))); + final saved = await showSidePanel( + 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 _deleteRecord(Map 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> items; + final bool isDeleting; + final ValueChanged onEdit; + final ValueChanged> 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 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 = []; + + 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 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(), + ), + ), + ), + ], + ); + } +} diff --git a/lib/modules/master_data/presentation/screens/masters_hub_screen.dart b/lib/modules/master_data/presentation/screens/masters_hub_screen.dart new file mode 100644 index 0000000..b134b84 --- /dev/null +++ b/lib/modules/master_data/presentation/screens/masters_hub_screen.dart @@ -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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/modules/master_data/presentation/screens/uom_screen.dart b/lib/modules/master_data/presentation/screens/uom_screen.dart deleted file mode 100644 index 6b9dd29..0000000 --- a/lib/modules/master_data/presentation/screens/uom_screen.dart +++ /dev/null @@ -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'); -} diff --git a/lib/modules/master_data/presentation/widgets/master_form_panel.dart b/lib/modules/master_data/presentation/widgets/master_form_panel.dart new file mode 100644 index 0000000..c7ed639 --- /dev/null +++ b/lib/modules/master_data/presentation/widgets/master_form_panel.dart @@ -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 createState() => _MasterFormPanelState(); +} + +class _MasterFormPanelState extends ConsumerState { + final _formKey = GlobalKey(); + + 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 _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 >[]; + final dropdownOptions = >[]; + 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( + 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 _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 = []; + + 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), + ), + ); + }, + ), + ); + } +} diff --git a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart index 7e03750..e9b97a0 100644 --- a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart +++ b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart @@ -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( diff --git a/lib/modules/rbac/presentation/widgets/add_user_panel.dart b/lib/modules/rbac/presentation/widgets/add_user_panel.dart index 6ee341e..1063363 100644 --- a/lib/modules/rbac/presentation/widgets/add_user_panel.dart +++ b/lib/modules/rbac/presentation/widgets/add_user_panel.dart @@ -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 { final _emailController = TextEditingController(); final _mobileController = TextEditingController(); final _passwordController = TextEditingController(); - String? _selectedRoleId; + List _selectedRoleIds = []; String _selectedStatus = 'Active'; String? _selectedDepartmentId; String? _selectedPlantId; @@ -76,7 +77,7 @@ class _AddUserPanelState extends ConsumerState { _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 { Future _save() async { if (!_formKey.currentState!.validate()) return; - final roleId = int.tryParse(_selectedRoleId ?? ''); - if (roleId == null) { + final roleIds = _selectedRoleIds + .map((id) => int.tryParse(id)) + .whereType() + .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 { 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 { 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 { setState(() => _prefillFromUser(formState.editingUser!)); }); } - } else { - _selectedRoleId ??= - formState.roles.isNotEmpty ? formState.roles.first.id : null; } return Form( @@ -269,12 +272,21 @@ class _AddUserPanelState extends ConsumerState { title: 'ROLE & ACCESS', children: [ SidePanelFormRow( - left: _buildDropdown( - label: 'Role', - value: _selectedRoleId, - options: formState.roles, - required: true, - onChanged: (v) => setState(() => _selectedRoleId = v), + left: AppSearchableMultiSelectDropdown( + 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( label: 'Status', diff --git a/lib/modules/rbac/presentation/widgets/create_role_panel.dart b/lib/modules/rbac/presentation/widgets/create_role_panel.dart index 7aabaa1..6d3cf42 100644 --- a/lib/modules/rbac/presentation/widgets/create_role_panel.dart +++ b/lib/modules/rbac/presentation/widgets/create_role_panel.dart @@ -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'; diff --git a/lib/modules/rbac/presentation/widgets/rbac_widgets.dart b/lib/modules/rbac/presentation/widgets/rbac_widgets.dart index f23c582..accc1d6 100644 --- a/lib/modules/rbac/presentation/widgets/rbac_widgets.dart +++ b/lib/modules/rbac/presentation/widgets/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 showSidePanel( - 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( - 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( - 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 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, diff --git a/lib/modules/roles/presentation/providers/roles_provider.dart b/lib/modules/roles/presentation/providers/roles_provider.dart index f94e739..68bdb33 100644 --- a/lib/modules/roles/presentation/providers/roles_provider.dart +++ b/lib/modules/roles/presentation/providers/roles_provider.dart @@ -70,6 +70,7 @@ final rolesListProvider = class RolesListNotifier extends AsyncNotifier { @override Future build() async { + ref.keepAlive(); return _load(); } @@ -81,7 +82,9 @@ class RolesListNotifier extends AsyncNotifier { Future 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) { diff --git a/lib/modules/users/presentation/providers/users_provider.dart b/lib/modules/users/presentation/providers/users_provider.dart index 7b3abd4..cfad2ac 100644 --- a/lib/modules/users/presentation/providers/users_provider.dart +++ b/lib/modules/users/presentation/providers/users_provider.dart @@ -100,6 +100,7 @@ final usersListProvider = class UsersListNotifier extends AsyncNotifier { @override Future build() async { + ref.keepAlive(); return _loadAll(const UserListQuery(limit: 10)); } @@ -132,7 +133,10 @@ class UsersListNotifier extends AsyncNotifier { } Future 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) { diff --git a/lib/shared/models/user_management_models.dart b/lib/shared/models/user_management_models.dart index 7bab802..4190b01 100644 --- a/lib/shared/models/user_management_models.dart +++ b/lib/shared/models/user_management_models.dart @@ -58,6 +58,26 @@ Object? _readRoleId(Map json, String key) { return null; } +Object? _readRoleIds(Map 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 _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 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 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 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? roleIds, @JsonKey(name: 'department_id') int? departmentId, @JsonKey(name: 'designation_id') int? designationId, @JsonKey(name: 'plant_id') int? plantId, diff --git a/lib/shared/models/user_management_models.freezed.dart b/lib/shared/models/user_management_models.freezed.dart index 24304d1..8dba4f8 100644 --- a/lib/shared/models/user_management_models.freezed.dart +++ b/lib/shared/models/user_management_models.freezed.dart @@ -749,6 +749,12 @@ mixin _$ManagedUserModel { readValue: _readRoleId, ) String? get roleId => throw _privateConstructorUsedError; + @JsonKey( + name: 'role_ids', + readValue: _readRoleIds, + fromJson: _roleIdsFromJson, + ) + List 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 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, 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 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, 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 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 json) => _$$ManagedUserModelImplFromJson(json); @@ -1239,6 +1273,19 @@ class _$ManagedUserModelImpl implements _ManagedUserModel { readValue: _readRoleId, ) final String? roleId; + final List _roleIds; + @override + @JsonKey( + name: 'role_ids', + readValue: _readRoleIds, + fromJson: _roleIdsFromJson, + ) + List 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 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 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 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 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, 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 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, 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 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 json) => _$$CreateUserRequestImplFromJson(json); @@ -1802,6 +1879,15 @@ class _$CreateUserRequestImpl implements _CreateUserRequest { @override @JsonKey(name: 'role_id') final int roleId; + final List _roleIds; + @override + @JsonKey(name: 'role_ids') + List 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 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 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? 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? 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?, 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? 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?, 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? 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 json) => _$$UpdateUserRequestImplFromJson(json); @@ -2234,6 +2341,17 @@ class _$UpdateUserRequestImpl implements _UpdateUserRequest { @override @JsonKey(name: 'role_id') final int? roleId; + final List? _roleIds; + @override + @JsonKey(name: 'role_ids') + List? 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? 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? get roleIds; + @override @JsonKey(name: 'department_id') int? get departmentId; @override diff --git a/lib/shared/models/user_management_models.g.dart b/lib/shared/models/user_management_models.g.dart index 6266fca..85390da 100644 --- a/lib/shared/models/user_management_models.g.dart +++ b/lib/shared/models/user_management_models.g.dart @@ -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 _$$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?) + ?.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 _$$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?) + ?.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 _$$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, diff --git a/lib/shared/routes/app_router.dart b/lib/shared/routes/app_router.dart index 5600b2c..a2ab977 100644 --- a/lib/shared/routes/app_router.dart +++ b/lib/shared/routes/app_router.dart @@ -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((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, diff --git a/lib/shared/routes/menu_config.dart b/lib/shared/routes/menu_config.dart index e885345..cbf3a4b 100644 --- a/lib/shared/routes/menu_config.dart +++ b/lib/shared/routes/menu_config.dart @@ -101,28 +101,8 @@ const List 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', diff --git a/lib/shared/utils/navigation_utils.dart b/lib/shared/utils/navigation_utils.dart index ce870d8..d84ccad 100644 --- a/lib/shared/utils/navigation_utils.dart +++ b/lib/shared/utils/navigation_utils.dart @@ -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); diff --git a/lib/shared/widgets/app_card.dart b/lib/shared/widgets/app_card.dart index 63964b5..6ed8747 100644 --- a/lib/shared/widgets/app_card.dart +++ b/lib/shared/widgets/app_card.dart @@ -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 { @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, + ), ); } diff --git a/lib/shared/widgets/app_hover_effect.dart b/lib/shared/widgets/app_hover_effect.dart index 581db52..0fef504 100644 --- a/lib/shared/widgets/app_hover_effect.dart +++ b/lib/shared/widgets/app_hover_effect.dart @@ -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 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 createState() => _AppHoverEffectState(); @@ -58,34 +95,22 @@ class _AppHoverEffectState extends State { @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 { 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 { 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, ); } diff --git a/lib/shared/widgets/app_search_export_bar.dart b/lib/shared/widgets/app_search_export_bar.dart new file mode 100644 index 0000000..5ba1d1f --- /dev/null +++ b/lib/shared/widgets/app_search_export_bar.dart @@ -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 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, + ], + ); + } +} diff --git a/lib/shared/widgets/app_searchable_multi_select_dropdown.dart b/lib/shared/widgets/app_searchable_multi_select_dropdown.dart new file mode 100644 index 0000000..6e1efa4 --- /dev/null +++ b/lib/shared/widgets/app_searchable_multi_select_dropdown.dart @@ -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 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 values; + final List> options; + final ValueChanged> onChanged; + final String? Function(List?)? validator; + final String? hint; + final String searchHint; + final bool enabled; + final bool isDense; + + @override + State> createState() => + _AppSearchableMultiSelectDropdownState(); +} + +class _AppSearchableMultiSelectDropdownState + extends State> { + final _layerLink = LayerLink(); + final _fieldKey = GlobalKey(); + OverlayEntry? _overlayEntry; + + @override + void dispose() { + _removeOverlay(); + super.dispose(); + } + + String _displayText() { + if (widget.values.isEmpty) return ''; + final labels = []; + 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> 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.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( + 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>( + 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 extends StatefulWidget { + const _SearchableMultiSelectPanel({ + required this.maxHeight, + required this.options, + required this.selected, + required this.searchHint, + required this.onToggle, + }); + + final double maxHeight; + final List> options; + final List selected; + final String searchHint; + final void Function(T value, bool checked) onToggle; + + @override + State<_SearchableMultiSelectPanel> createState() => + _SearchableMultiSelectPanelState(); +} + +class _SearchableMultiSelectPanelState + extends State<_SearchableMultiSelectPanel> { + final _searchController = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List> 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), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/app_side_panel.dart b/lib/shared/widgets/app_side_panel.dart new file mode 100644 index 0000000..4de1592 --- /dev/null +++ b/lib/shared/widgets/app_side_panel.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; + +Future showSidePanel( + 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( + 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( + 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 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), + ], + ), + ); + }, + ); + } +}