masters done

This commit is contained in:
Surendiran 2026-06-22 12:35:37 +05:30
parent 0d79d5a514
commit 1ea70bf93a
32 changed files with 2951 additions and 369 deletions

View File

@ -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';

View File

@ -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';

View File

@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -25,7 +26,7 @@ final dioProvider = Provider<Dio>((ref) {
dio.interceptors.addAll([
AuthInterceptor(ref),
ErrorInterceptor(),
LogInterceptor(requestBody: true, responseBody: true),
if (kDebugMode) LogInterceptor(requestBody: true, responseBody: true),
]);
return dio;

View File

@ -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'),

View File

@ -0,0 +1,193 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../domain/entities/master_definition.dart';
final masterCrudRemoteDataSourceProvider =
Provider<MasterCrudRemoteDataSource>((ref) {
return MasterCrudRemoteDataSource(dio: ref.watch(dioProvider));
});
class MasterListResult {
const MasterListResult({
required this.items,
required this.total,
required this.page,
required this.limit,
});
final List<Map<String, dynamic>> items;
final int total;
final int page;
final int limit;
int get totalPages {
if (limit <= 0) return 1;
final pages = (total / limit).ceil();
return pages < 1 ? 1 : pages;
}
}
class MasterCrudRemoteDataSource {
MasterCrudRemoteDataSource({required this.dio});
final Dio dio;
Future<MasterListResult> list(
MasterDefinition definition, {
int page = 1,
int limit = 20,
String? search,
}) async {
final response = await dio.get(
definition.apiPath,
queryParameters: {
'page': page,
'limit': limit,
if (search != null && search.isNotEmpty) 'search': search,
},
);
final body = response.data as Map<String, dynamic>;
final raw = body['data'];
final list = raw is List<dynamic>
? raw
: raw is Map<String, dynamic>
? raw['items'] as List<dynamic>? ?? const []
: const [];
final items = list
.whereType<Map<String, dynamic>>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
final meta = raw is Map<String, dynamic> ? raw : body;
final total = _asInt(meta['total']) ?? items.length;
final currentPage = _asInt(meta['page']) ?? page;
final currentLimit = _asInt(meta['limit']) ?? limit;
return MasterListResult(
items: items,
total: total,
page: currentPage,
limit: currentLimit,
);
}
Future<List<Map<String, dynamic>>> listOptions(MasterDefinition definition) async {
final allItems = <Map<String, dynamic>>[];
var page = 1;
while (true) {
final result = await list(
definition,
page: page,
limit: AppConstants.maxPageSize,
);
allItems.addAll(
result.items.where((item) => item['is_active'] != false),
);
if (page >= result.totalPages) break;
page++;
}
return allItems;
}
Future<Map<String, dynamic>> getById(
MasterDefinition definition,
String id,
) async {
final response = await dio.get('${definition.apiPath}/$id');
return _extractData(response.data);
}
Future<Map<String, dynamic>> create(
MasterDefinition definition,
Map<String, dynamic> payload,
) async {
final response = await dio.post(
definition.apiPath,
data: payload..removeWhere((_, value) => value == null),
);
return _extractData(response.data);
}
Future<Map<String, dynamic>> update(
MasterDefinition definition,
String id,
Map<String, dynamic> payload,
) async {
final response = await dio.put(
'${definition.apiPath}/$id',
data: payload..removeWhere((_, value) => value == null),
);
return _extractData(response.data);
}
Future<void> delete(MasterDefinition definition, String id) async {
await dio.delete('${definition.apiPath}/$id');
}
Future<ExportFileResult> export(
MasterDefinition definition, {
String? search,
}) async {
final response = await dio.get<List<int>>(
'${definition.apiPath}/export',
queryParameters: {
if (search != null && search.isNotEmpty) 'search': search,
},
options: Options(responseType: ResponseType.bytes),
);
final bytes = response.data ?? <int>[];
return ExportFileResult(
bytes: bytes,
fileName: _fileNameFromResponse(response, definition),
);
}
String _fileNameFromResponse(
Response<List<int>> response,
MasterDefinition definition,
) {
final disposition = response.headers.value('content-disposition');
if (disposition != null) {
final utf8Match = RegExp(
r"filename\*=UTF-8''([^;\n]+)",
caseSensitive: false,
).firstMatch(disposition);
if (utf8Match != null) {
return Uri.decodeComponent(utf8Match.group(1)!);
}
final match = RegExp(r'filename="?([^";\n]+)"?').firstMatch(disposition);
if (match != null) {
return match.group(1)!.trim();
}
}
final slug = definition.routeKey.replaceAll('-', '_');
final contentType =
response.headers.value('content-type')?.toLowerCase() ?? '';
if (contentType.contains('csv')) return '${slug}_export.csv';
return '${slug}_export.xlsx';
}
Map<String, dynamic> _extractData(dynamic body) {
if (body is! Map<String, dynamic>) {
throw FormatException('Unexpected master response');
}
final data = body['data'];
if (data is Map<String, dynamic>) return Map<String, dynamic>.from(data);
return Map<String, dynamic>.from(body);
}
int? _asInt(Object? value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
}

View File

@ -0,0 +1,72 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../domain/entities/master_definition.dart';
import '../../domain/repositories/master_repository.dart';
import '../datasources/master_crud_remote_data_source.dart';
final masterRepositoryProvider = Provider<MasterRepository>((ref) {
return MasterRepositoryImpl(remote: ref.watch(masterCrudRemoteDataSourceProvider));
});
class MasterRepositoryImpl implements MasterRepository {
MasterRepositoryImpl({required this.remote});
final MasterCrudRemoteDataSource remote;
@override
Future<Result<MasterListResult>> list(
MasterDefinition definition, {
int page = 1,
int limit = 20,
String? search,
}) =>
safeApiCall(
() => remote.list(
definition,
page: page,
limit: limit,
search: search,
),
);
@override
Future<Result<List<Map<String, dynamic>>>> listOptions(
MasterDefinition definition,
) =>
safeApiCall(() => remote.listOptions(definition));
@override
Future<Result<Map<String, dynamic>>> getById(
MasterDefinition definition,
String id,
) =>
safeApiCall(() => remote.getById(definition, id));
@override
Future<Result<Map<String, dynamic>>> create(
MasterDefinition definition,
Map<String, dynamic> payload,
) =>
safeApiCall(() => remote.create(definition, payload));
@override
Future<Result<Map<String, dynamic>>> update(
MasterDefinition definition,
String id,
Map<String, dynamic> payload,
) =>
safeApiCall(() => remote.update(definition, id, payload));
@override
Future<Result<void>> delete(MasterDefinition definition, String id) =>
safeApiCall(() => remote.delete(definition, id));
@override
Future<Result<ExportFileResult>> export(
MasterDefinition definition, {
String? search,
}) =>
safeApiCall(() => remote.export(definition, search: search));
}

View File

@ -0,0 +1,444 @@
import 'package:flutter/material.dart';
enum MasterFieldType { text, number, boolean, dropdown }
class MasterFieldDef {
const MasterFieldDef({
required this.key,
required this.label,
this.type = MasterFieldType.text,
this.required = false,
this.showInList = false,
this.optionsMasterKey,
this.multiline = false,
});
final String key;
final String label;
final MasterFieldType type;
final bool required;
final bool showInList;
/// Master key used to populate dropdown options (e.g. `plants` for plant_id).
final String? optionsMasterKey;
final bool multiline;
}
class MasterDefinition {
const MasterDefinition({
required this.id,
required this.title,
required this.subtitle,
required this.category,
required this.routeKey,
required this.apiPath,
required this.module,
required this.icon,
required this.fields,
});
final String id;
final String title;
final String subtitle;
final String category;
final String routeKey;
final String apiPath;
final String module;
final IconData icon;
final List<MasterFieldDef> fields;
List<MasterFieldDef> get listFields =>
fields.where((field) => field.showInList).toList();
List<MasterFieldDef> get formFields => fields;
String listRoute(String base) => '$base/$routeKey';
String addRoute(String base) => '$base/$routeKey/add';
String editRoute(String base, String id) => '$base/$routeKey/$id/edit';
}
const _activeField = MasterFieldDef(
key: 'is_active',
label: 'Active',
type: MasterFieldType.boolean,
);
MasterDefinition? masterDefinitionById(String id) {
for (final def in masterDefinitions) {
if (def.id == id) return def;
}
return null;
}
MasterDefinition? masterDefinitionByRouteKey(String routeKey) {
for (final def in masterDefinitions) {
if (def.routeKey == routeKey) return def;
}
return null;
}
const masterDefinitions = <MasterDefinition>[
MasterDefinition(
id: 'uom',
title: 'UOM',
subtitle: 'Units of measure',
category: 'Inventory & Items',
routeKey: 'uom',
apiPath: '/masters/uom',
module: 'uom',
icon: Icons.straighten_outlined,
fields: [
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
_activeField,
],
),
MasterDefinition(
id: 'item_categories',
title: 'Item Categories',
subtitle: 'Top-level item grouping',
category: 'Inventory & Items',
routeKey: 'item-categories',
apiPath: '/masters/item-categories',
module: 'item_categories',
icon: Icons.category_outlined,
fields: [
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
_activeField,
],
),
MasterDefinition(
id: 'item_subcategories',
title: 'Item Sub Categories',
subtitle: 'Sub-grouping under item categories',
category: 'Inventory & Items',
routeKey: 'item-subcategories',
apiPath: '/masters/item-subcategories',
module: 'item_subcategories',
icon: Icons.subdirectory_arrow_right,
fields: [
MasterFieldDef(
key: 'item_category_id',
label: 'Item Category',
type: MasterFieldType.dropdown,
required: true,
showInList: true,
optionsMasterKey: 'item_categories',
),
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
_activeField,
],
),
MasterDefinition(
id: 'items',
title: 'Items',
subtitle: 'Material and product master',
category: 'Inventory & Items',
routeKey: 'items',
apiPath: '/masters/items',
module: 'items',
icon: Icons.inventory_outlined,
fields: [
MasterFieldDef(key: 'item_code', label: 'Item Code', required: true, showInList: true),
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
MasterFieldDef(
key: 'item_category_id',
label: 'Category',
type: MasterFieldType.dropdown,
required: true,
optionsMasterKey: 'item_categories',
),
MasterFieldDef(
key: 'item_subcategory_id',
label: 'Sub Category',
type: MasterFieldType.dropdown,
optionsMasterKey: 'item_subcategories',
),
MasterFieldDef(
key: 'uom_id',
label: 'UOM',
type: MasterFieldType.dropdown,
required: true,
optionsMasterKey: 'uom',
),
MasterFieldDef(
key: 'gst_rate_id',
label: 'GST Rate',
type: MasterFieldType.dropdown,
optionsMasterKey: 'gst_rates',
),
MasterFieldDef(
key: 'brand_id',
label: 'Brand',
type: MasterFieldType.dropdown,
optionsMasterKey: 'brands',
),
MasterFieldDef(
key: 'is_asset_item',
label: 'Asset Item',
type: MasterFieldType.boolean,
required: true,
),
MasterFieldDef(
key: 'min_order_qty',
label: 'Min Order Qty',
type: MasterFieldType.number,
required: true,
),
MasterFieldDef(
key: 'reorder_level',
label: 'Reorder Level',
type: MasterFieldType.number,
required: true,
),
MasterFieldDef(key: 'description', label: 'Description', multiline: true),
MasterFieldDef(key: 'specification', label: 'Specification', multiline: true),
_activeField,
],
),
MasterDefinition(
id: 'brands',
title: 'Brands',
subtitle: 'Own and third-party brands',
category: 'Inventory & Items',
routeKey: 'brands',
apiPath: '/masters/brands',
module: 'brands',
icon: Icons.loyalty_outlined,
fields: [
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
MasterFieldDef(key: 'brand_type', label: 'Brand Type', required: true, showInList: true),
MasterFieldDef(key: 'contact_person', label: 'Contact Person'),
MasterFieldDef(key: 'phone', label: 'Phone'),
MasterFieldDef(key: 'email', label: 'Email'),
_activeField,
],
),
MasterDefinition(
id: 'document_series',
title: 'Document Series',
subtitle: 'Numbering series for documents',
category: 'Finance & Terms',
routeKey: 'document-series',
apiPath: '/masters/document-series',
module: 'document_series',
icon: Icons.numbers_outlined,
fields: [
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'prefix', label: 'Prefix', required: true, showInList: true),
MasterFieldDef(
key: 'current_number',
label: 'Current Number',
type: MasterFieldType.number,
required: true,
showInList: true,
),
MasterFieldDef(
key: 'padding',
label: 'Padding',
type: MasterFieldType.number,
required: true,
),
MasterFieldDef(key: 'description', label: 'Description', multiline: true),
_activeField,
],
),
MasterDefinition(
id: 'plants',
title: 'Plants',
subtitle: 'Manufacturing plants and units',
category: 'Organization',
routeKey: 'plants',
apiPath: '/masters/plants',
module: 'plants',
icon: Icons.factory_outlined,
fields: [
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
MasterFieldDef(key: 'gstin', label: 'GSTIN', showInList: true),
MasterFieldDef(key: 'city', label: 'City', showInList: true),
MasterFieldDef(key: 'state', label: 'State'),
MasterFieldDef(key: 'address', label: 'Address', multiline: true),
MasterFieldDef(key: 'pincode', label: 'Pincode'),
MasterFieldDef(key: 'phone', label: 'Phone'),
_activeField,
],
),
MasterDefinition(
id: 'warehouses',
title: 'Warehouse',
subtitle: 'Storage locations by plant',
category: 'Organization',
routeKey: 'warehouses',
apiPath: '/masters/warehouses',
module: 'warehouses',
icon: Icons.warehouse_outlined,
fields: [
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
MasterFieldDef(
key: 'plant_id',
label: 'Plant',
type: MasterFieldType.dropdown,
required: true,
showInList: true,
optionsMasterKey: 'plants',
),
MasterFieldDef(key: 'location', label: 'Location'),
_activeField,
],
),
MasterDefinition(
id: 'designations',
title: 'Designations',
subtitle: 'Employee designations',
category: 'Organization',
routeKey: 'designations',
apiPath: '/masters/designations',
module: 'designations',
icon: Icons.badge_outlined,
fields: [
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
_activeField,
],
),
MasterDefinition(
id: 'departments',
title: 'Departments',
subtitle: 'Organisation departments',
category: 'Organization',
routeKey: 'departments',
apiPath: '/masters/departments',
module: 'departments',
icon: Icons.apartment_outlined,
fields: [
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
_activeField,
],
),
MasterDefinition(
id: 'asset_categories',
title: 'Asset Categories',
subtitle: 'Fixed asset classification',
category: 'Assets',
routeKey: 'asset-categories',
apiPath: '/masters/asset-categories',
module: 'asset_categories',
icon: Icons.precision_manufacturing_outlined,
fields: [
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
MasterFieldDef(key: 'code_prefix', label: 'Code Prefix', required: true, showInList: true),
MasterFieldDef(
key: 'default_useful_life_years',
label: 'Useful Life (Years)',
type: MasterFieldType.number,
required: true,
),
MasterFieldDef(
key: 'default_depreciation_method',
label: 'Depreciation Method',
required: true,
showInList: true,
),
_activeField,
],
),
MasterDefinition(
id: 'delivery_terms',
title: 'Delivery Terms',
subtitle: 'Delivery incoterms and terms',
category: 'Finance & Terms',
routeKey: 'delivery-terms',
apiPath: '/masters/delivery-terms',
module: 'delivery_terms',
icon: Icons.local_shipping_outlined,
fields: [
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
MasterFieldDef(key: 'description', label: 'Description', multiline: true),
_activeField,
],
),
MasterDefinition(
id: 'payment_terms',
title: 'Payment Terms',
subtitle: 'Vendor payment terms',
category: 'Finance & Terms',
routeKey: 'payment-terms',
apiPath: '/masters/payment-terms',
module: 'payment_terms',
icon: Icons.payments_outlined,
fields: [
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
MasterFieldDef(
key: 'credit_days',
label: 'Credit Days',
type: MasterFieldType.number,
required: true,
showInList: true,
),
MasterFieldDef(key: 'description', label: 'Description', multiline: true),
_activeField,
],
),
MasterDefinition(
id: 'gst_rates',
title: 'GST Rates',
subtitle: 'Tax rate master',
category: 'Finance & Terms',
routeKey: 'gst-rates',
apiPath: '/masters/gst-rates',
module: 'gst_rates',
icon: Icons.percent_outlined,
fields: [
MasterFieldDef(
key: 'rate_pct',
label: 'Rate (%)',
type: MasterFieldType.number,
required: true,
showInList: true,
),
MasterFieldDef(key: 'description', label: 'Description', showInList: true),
_activeField,
],
),
];
List<String> get masterCategories =>
masterDefinitions.map((def) => def.category).toSet().toList();
String masterRecordLabel(Map<String, dynamic> row) {
for (final key in ['name', 'item_name', 'code', 'item_code', 'description']) {
final value = row[key];
if (value != null && value.toString().trim().isNotEmpty) {
return value.toString();
}
}
return row['id']?.toString() ?? 'Record';
}
String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
final value = row[field.key];
if (value == null || value == '') return '';
if (field.type == MasterFieldType.boolean) {
return value == true ? 'Yes' : 'No';
}
if (field.type == MasterFieldType.dropdown) {
final label = row['${field.key}_label'];
if (label != null && label.toString().isNotEmpty) return label.toString();
}
return value.toString();
}
String masterStatusValue(Map<String, dynamic> row) {
final active = row['is_active'];
if (active == false) return 'inactive';
return 'active';
}

View File

@ -0,0 +1,38 @@
import '../../../../core/network/api_handler.dart';
import '../../../../shared/models/export_file_result.dart';
import '../entities/master_definition.dart';
import '../../data/datasources/master_crud_remote_data_source.dart';
abstract class MasterRepository {
Future<Result<MasterListResult>> list(
MasterDefinition definition, {
int page,
int limit,
String? search,
});
Future<Result<List<Map<String, dynamic>>>> listOptions(MasterDefinition definition);
Future<Result<Map<String, dynamic>>> getById(
MasterDefinition definition,
String id,
);
Future<Result<Map<String, dynamic>>> create(
MasterDefinition definition,
Map<String, dynamic> payload,
);
Future<Result<Map<String, dynamic>>> update(
MasterDefinition definition,
String id,
Map<String, dynamic> payload,
);
Future<Result<void>> delete(MasterDefinition definition, String id);
Future<Result<ExportFileResult>> export(
MasterDefinition definition, {
String? search,
});
}

View File

@ -0,0 +1,312 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../data/repositories/master_repository_impl.dart';
import '../../domain/entities/master_definition.dart';
class MasterListState {
const MasterListState({
this.items = const [],
this.search = '',
this.page = 1,
this.limit = 20,
this.total = 0,
this.totalPages = 1,
this.isDeleting = false,
this.isExporting = false,
this.actionError,
});
final List<Map<String, dynamic>> items;
final String search;
final int page;
final int limit;
final int total;
final int totalPages;
final bool isDeleting;
final bool isExporting;
final String? actionError;
MasterListState copyWith({
List<Map<String, dynamic>>? items,
String? search,
int? page,
int? limit,
int? total,
int? totalPages,
bool? isDeleting,
bool? isExporting,
String? actionError,
bool clearError = false,
}) {
return MasterListState(
items: items ?? this.items,
search: search ?? this.search,
page: page ?? this.page,
limit: limit ?? this.limit,
total: total ?? this.total,
totalPages: totalPages ?? this.totalPages,
isDeleting: isDeleting ?? this.isDeleting,
isExporting: isExporting ?? this.isExporting,
actionError: clearError ? null : actionError ?? this.actionError,
);
}
}
class MasterFormState {
const MasterFormState({
this.values = const {},
this.dropdownOptions = const {},
this.isSubmitting = false,
this.errorMessage,
});
final Map<String, dynamic> values;
final Map<String, List<Map<String, dynamic>>> dropdownOptions;
final bool isSubmitting;
final String? errorMessage;
MasterFormState copyWith({
Map<String, dynamic>? values,
Map<String, List<Map<String, dynamic>>>? dropdownOptions,
bool? isSubmitting,
String? errorMessage,
bool clearError = false,
}) {
return MasterFormState(
values: values ?? this.values,
dropdownOptions: dropdownOptions ?? this.dropdownOptions,
isSubmitting: isSubmitting ?? this.isSubmitting,
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
);
}
}
final masterListProvider = AsyncNotifierProvider.family<
MasterListNotifier, MasterListState, String>(MasterListNotifier.new);
class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
MasterDefinition get _definition {
final def = masterDefinitionById(arg);
if (def == null) throw StateError('Unknown master: $arg');
return def;
}
@override
Future<MasterListState> build(String arg) async {
ref.keepAlive();
return _load();
}
Future<MasterListState> _load({int? page, String? search}) async {
final current = state.valueOrNull;
final result = await ref.read(masterRepositoryProvider).list(
_definition,
page: page ?? current?.page ?? 1,
limit: current?.limit ?? 20,
search: search ?? current?.search,
);
if (result.failure != null) throw result.failure!;
final data = result.data!;
return MasterListState(
items: data.items,
search: search ?? current?.search ?? '',
page: data.page,
limit: data.limit,
total: data.total,
totalPages: data.totalPages,
);
}
Future<void> refresh() async {
final previous = state.valueOrNull;
if (previous == null) {
state = const AsyncLoading();
}
state = AsyncData(await _load());
}
Future<void> setSearch(String search) async {
await _reload(page: 1, search: search);
}
Future<void> setPage(int page) async {
await _reload(page: page);
}
Future<void> _reload({int? page, String? search}) async {
final previous = state.valueOrNull;
if (previous == null) {
state = const AsyncLoading();
}
state = AsyncData(await _load(page: page, search: search));
}
Future<void> setPageSize(int limit) async {
final current = state.valueOrNull ?? const MasterListState();
final result = await ref.read(masterRepositoryProvider).list(
_definition,
page: 1,
limit: limit,
search: current.search,
);
if (result.failure != null) throw result.failure!;
final data = result.data!;
state = AsyncData(
current.copyWith(
items: data.items,
page: data.page,
limit: data.limit,
total: data.total,
totalPages: data.totalPages,
),
);
}
Future<bool> deleteRecord(String id) async {
final current = state.valueOrNull ?? const MasterListState();
state = AsyncData(current.copyWith(isDeleting: true));
final result = await ref.read(masterRepositoryProvider).delete(_definition, id);
if (result.failure != null) {
state = AsyncData(current.copyWith(isDeleting: false));
return false;
}
await refresh();
return true;
}
Future<ExportFileResult?> exportRecords() async {
final current = state.valueOrNull;
if (current == null) return null;
state = AsyncData(current.copyWith(isExporting: true, clearError: true));
final result = await ref.read(masterRepositoryProvider).export(
_definition,
search: current.search.isEmpty ? null : current.search,
);
final latest = state.valueOrNull ?? current;
if (result.failure != null) {
state = AsyncData(
latest.copyWith(
isExporting: false,
actionError: result.failure!.message,
),
);
return null;
}
state = AsyncData(latest.copyWith(isExporting: false));
return result.data;
}
}
typedef MasterFormArgs = ({String masterId, String? recordId});
final masterFormProvider = AsyncNotifierProvider.family<
MasterFormNotifier, MasterFormState, MasterFormArgs>(MasterFormNotifier.new);
class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterFormArgs> {
MasterDefinition get _definition {
final def = masterDefinitionById(arg.masterId);
if (def == null) throw StateError('Unknown master: ${arg.masterId}');
return def;
}
@override
Future<MasterFormState> build(MasterFormArgs arg) async {
final dropdownOptions = await _loadDropdownOptions();
Map<String, dynamic> values = {};
if (arg.recordId != null) {
final result = await ref
.read(masterRepositoryProvider)
.getById(_definition, arg.recordId!);
if (result.failure != null) throw result.failure!;
values = Map<String, dynamic>.from(result.data ?? const {});
} else {
for (final field in _definition.formFields) {
if (field.type == MasterFieldType.boolean) {
values[field.key] = field.key == 'is_active' ? true : false;
}
}
}
return MasterFormState(
values: values,
dropdownOptions: dropdownOptions,
);
}
Future<Map<String, List<Map<String, dynamic>>>>
_loadDropdownOptions() async {
final options = <String, List<Map<String, dynamic>>>{};
final keys = _definition.formFields
.where((field) => field.optionsMasterKey != null)
.map((field) => field.optionsMasterKey!)
.toSet();
for (final key in keys) {
final def = masterDefinitionById(key);
if (def == null) continue;
final result = await ref.read(masterRepositoryProvider).listOptions(def);
if (result.failure != null) continue;
options[key] = result.data ?? const [];
}
return options;
}
void updateValue(String key, dynamic value) {
final current = state.valueOrNull;
if (current == null) return;
final values = Map<String, dynamic>.from(current.values);
values[key] = value;
state = AsyncData(current.copyWith(values: values));
}
Map<String, dynamic> _buildPayload(MasterFormState current) {
final payload = <String, dynamic>{};
for (final field in _definition.formFields) {
final value = current.values[field.key];
if (value == null || value == '') continue;
payload[field.key] = switch (field.type) {
MasterFieldType.number => num.tryParse(value.toString()) ?? value,
MasterFieldType.dropdown => int.tryParse(value.toString()) ?? value,
MasterFieldType.boolean => value == true,
MasterFieldType.text => value.toString().trim(),
};
}
return payload;
}
Future<bool> submit() async {
final current = state.valueOrNull ?? const MasterFormState();
state = AsyncData(current.copyWith(isSubmitting: true, clearError: true));
final payload = _buildPayload(current);
final result = arg.recordId == null
? await ref.read(masterRepositoryProvider).create(_definition, payload)
: await ref
.read(masterRepositoryProvider)
.update(_definition, arg.recordId!, payload);
if (result.failure != null) {
state = AsyncData(
current.copyWith(
isSubmitting: false,
errorMessage: result.failure!.message,
),
);
return false;
}
state = AsyncData(current.copyWith(isSubmitting: false, clearError: true));
return true;
}
}

View File

@ -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');
}

View File

@ -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');
}

View File

@ -0,0 +1,491 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_search_export_bar.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../domain/entities/master_definition.dart';
import '../providers/master_provider.dart';
import '../widgets/master_form_panel.dart';
class MasterListScreen extends ConsumerStatefulWidget {
const MasterListScreen({super.key, required this.masterId});
final String masterId;
@override
ConsumerState<MasterListScreen> createState() => _MasterListScreenState();
}
class _MasterListScreenState extends ConsumerState<MasterListScreen> {
final _searchController = TextEditingController();
MasterDefinition get _definition {
final def = masterDefinitionById(widget.masterId);
if (def == null) throw StateError('Unknown master: ${widget.masterId}');
return def;
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
String _searchHint(MasterDefinition def) {
final fields = def.listFields.map((field) => field.label.toLowerCase()).toList();
if (fields.isEmpty) return 'Search ${def.title.toLowerCase()}...';
if (fields.length <= 3) return 'Search by ${fields.join(', ')}...';
return 'Search by ${fields.take(3).join(', ')}...';
}
Future<void> _exportRecords() async {
final file = await ref
.read(masterListProvider(widget.masterId).notifier)
.exportRecords();
if (!mounted) return;
if (file == null) {
final error = ref.read(masterListProvider(widget.masterId)).valueOrNull?.actionError;
if (error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
}
return;
}
final saved = await downloadFile(
bytes: file.bytes,
fileName: file.fileName,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
saved ? 'Downloaded ${file.fileName}' : 'Export cancelled',
),
),
);
}
Future<void> _openFormPanel({String? recordId}) async {
ref.invalidate(masterFormProvider((masterId: widget.masterId, recordId: recordId)));
final saved = await showSidePanel<bool>(
context,
MasterFormPanel(masterId: widget.masterId, recordId: recordId),
width: 560,
);
if (saved == true && mounted) {
ref.invalidate(masterListProvider(widget.masterId));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
recordId == null
? '${_definition.title} added successfully'
: '${_definition.title} updated successfully',
),
),
);
}
}
Future<void> _deleteRecord(Map<String, dynamic> row) async {
final id = row['id']?.toString();
if (id == null) return;
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete ${_definition.title}',
message: 'Delete "${masterRecordLabel(row)}"?',
confirmLabel: 'Delete',
isDestructive: true,
);
if (confirmed != true || !mounted) return;
final success =
await ref.read(masterListProvider(widget.masterId).notifier).deleteRecord(id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
success
? '${_definition.title} deleted successfully'
: 'Failed to delete ${_definition.title.toLowerCase()}',
),
),
);
}
@override
Widget build(BuildContext context) {
final listAsync = ref.watch(masterListProvider(widget.masterId));
final def = _definition;
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(24),
child: listAsync.when(
loading: () => AppLoadingView(message: 'Loading ${def.title}...'),
error: (error, _) => ErrorView.fromFailure(
error is Failure ? error : Failure.unknown(message: error.toString()),
onRetry: () => ref.invalidate(masterListProvider(widget.masterId)),
),
data: (state) {
final page = state.page;
final pageSize = state.limit;
final total = state.total;
final start = total == 0 ? 0 : ((page - 1) * pageSize) + 1;
final end = (page * pageSize).clamp(0, total);
final notifier = ref.read(masterListProvider(widget.masterId).notifier);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: def.title,
subtitle: def.subtitle,
actions: [
OutlinedButton.icon(
onPressed: () => context.push(RouteConstants.masterData),
icon: const Icon(Icons.grid_view_outlined),
label: const Text('All Masters'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () => _openFormPanel(),
icon: const Icon(Icons.add),
label: Text('Add ${def.title}'),
),
],
),
const SizedBox(height: 16),
Expanded(
child: AppCard(
enableHover: false,
clipBehavior: Clip.none,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.12),
),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: LayoutBuilder(
builder: (context, constraints) {
return AppSearchExportBar(
wrapped: constraints.maxWidth < 640,
searchController: _searchController,
searchHint: _searchHint(def),
isExporting: state.isExporting,
onSearch: notifier.setSearch,
onExport: _exportRecords,
);
},
),
),
const Divider(height: 1),
Expanded(
child: RefreshIndicator(
onRefresh: notifier.refresh,
child: state.items.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No ${def.title.toLowerCase()} found',
description:
'Add your first ${def.title.toLowerCase()} record to get started.',
icon: def.icon,
),
),
],
)
: _MasterListTable(
definition: def,
items: state.items,
isDeleting: state.isDeleting,
onEdit: (id) => _openFormPanel(recordId: id),
onDelete: _deleteRecord,
),
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Text(
'Showing $start$end of $total ${def.title.toLowerCase()}',
style: theme.textTheme.bodySmall,
),
const Spacer(),
TextButton(
onPressed: page > 1
? () => notifier.setPage(page - 1)
: null,
child: const Text('Previous'),
),
...List.generate(state.totalPages.clamp(0, 4), (i) {
final pageIndex = i + 1;
final selected = page == pageIndex;
return Padding(
padding:
const EdgeInsets.symmetric(horizontal: 2),
child: Material(
color: selected
? theme.colorScheme.primary
: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => notifier.setPage(pageIndex),
child: SizedBox(
width: 36,
height: 36,
child: Center(
child: Text(
'$pageIndex',
style: TextStyle(
color: selected
? theme.colorScheme.onPrimary
: null,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
);
}),
TextButton(
onPressed: page < state.totalPages
? () => notifier.setPage(page + 1)
: null,
child: const Text('Next'),
),
],
),
),
],
),
),
),
],
);
},
),
);
}
}
class _MasterListTable extends StatelessWidget {
const _MasterListTable({
required this.definition,
required this.items,
required this.isDeleting,
required this.onEdit,
required this.onDelete,
});
final MasterDefinition definition;
final List<Map<String, dynamic>> items;
final bool isDeleting;
final ValueChanged<String> onEdit;
final ValueChanged<Map<String, dynamic>> onDelete;
static const double _horizontalPadding = 16;
static const double _columnSpacing = 16;
static const double _statusWidth = 110;
static const double _actionsWidth = 96;
int _columnFlex(MasterFieldDef field) {
return switch (field.key) {
'code' || 'item_code' || 'series_code' => 1,
'name' ||
'item_name' ||
'description' ||
'term_name' =>
3,
_ => 2,
};
}
TextStyle? _headerStyle(BuildContext context) {
return Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
}
Widget _tableRow({
required BuildContext context,
required List<Widget> fieldCells,
required Widget statusCell,
required Widget actionsCell,
Color? backgroundColor,
BoxDecoration? decoration,
EdgeInsetsGeometry padding = const EdgeInsets.symmetric(
horizontal: _horizontalPadding,
vertical: 12,
),
}) {
final fields = definition.listFields;
final children = <Widget>[];
for (var i = 0; i < fields.length; i++) {
if (i > 0) {
children.add(const SizedBox(width: _columnSpacing));
}
children.add(
Expanded(
flex: _columnFlex(fields[i]),
child: Align(
alignment: Alignment.centerLeft,
child: fieldCells[i],
),
),
);
}
children.addAll([
const SizedBox(width: _columnSpacing),
SizedBox(
width: _statusWidth,
child: Align(
alignment: Alignment.centerLeft,
child: statusCell,
),
),
const SizedBox(width: _columnSpacing),
SizedBox(
width: _actionsWidth,
child: Align(
alignment: Alignment.centerLeft,
child: actionsCell,
),
),
]);
return Container(
width: double.infinity,
padding: padding,
color: backgroundColor,
decoration: decoration,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: children,
),
);
}
Widget _headerRow(BuildContext context, Color headerColor) {
return _tableRow(
context: context,
backgroundColor: headerColor,
fieldCells: definition.listFields
.map(
(field) => Text(
field.label.toUpperCase(),
style: _headerStyle(context),
),
)
.toList(),
statusCell: Text('STATUS', style: _headerStyle(context)),
actionsCell: Text('ACTIONS', style: _headerStyle(context)),
);
}
Widget _dataRow(BuildContext context, Map<String, dynamic> row, ThemeData theme) {
final id = row['id']?.toString();
return _tableRow(
context: context,
padding: const EdgeInsets.symmetric(
horizontal: _horizontalPadding,
vertical: 4,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.08),
),
),
),
fieldCells: definition.listFields
.map(
(field) => Text(masterCellValue(row, field)),
)
.toList(),
statusCell: AppStatusChip(
status: masterStatusValue(row),
compact: true,
),
actionsCell: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined, size: 20),
onPressed: id == null ? null : () => onEdit(id),
),
IconButton(
tooltip: 'Delete',
icon: Icon(
Icons.delete_outline,
size: 20,
color: theme.colorScheme.error,
),
onPressed: isDeleting ? null : () => onDelete(row),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final headerColor = theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.4);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_headerRow(context, headerColor),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: items
.map((row) => _dataRow(context, row, theme))
.toList(),
),
),
),
],
);
}
}

View File

@ -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,
),
),
],
),
),
);
}
}

View File

@ -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');
}

View File

@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../domain/entities/master_definition.dart';
import '../providers/master_provider.dart';
class MasterFormPanel extends ConsumerStatefulWidget {
const MasterFormPanel({
super.key,
required this.masterId,
this.recordId,
});
final String masterId;
final String? recordId;
bool get isEditing => recordId != null;
@override
ConsumerState<MasterFormPanel> createState() => _MasterFormPanelState();
}
class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
final _formKey = GlobalKey<FormState>();
MasterDefinition get _definition {
final def = masterDefinitionById(widget.masterId);
if (def == null) throw StateError('Unknown master: ${widget.masterId}');
return def;
}
MasterFormArgs get _args =>
(masterId: widget.masterId, recordId: widget.recordId);
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final success =
await ref.read(masterFormProvider(_args).notifier).submit();
if (!mounted) return;
if (success) {
Navigator.of(context, rootNavigator: true).pop(true);
return;
}
final error = ref.read(masterFormProvider(_args)).valueOrNull?.errorMessage;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
error ??
(widget.isEditing
? 'Failed to update ${_definition.title.toLowerCase()}'
: 'Failed to create ${_definition.title.toLowerCase()}'),
),
),
);
}
String _fieldLabel(MasterFieldDef field) =>
field.required ? '${field.label} *' : field.label;
Widget _buildField(
BuildContext context, {
required MasterFieldDef field,
required MasterFormState formState,
}) {
final notifier = ref.read(masterFormProvider(_args).notifier);
final value = formState.values[field.key];
switch (field.type) {
case MasterFieldType.boolean:
if (field.key == 'is_active') {
return SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(field.label),
value: value == true,
onChanged: (checked) => notifier.updateValue(field.key, checked),
);
}
return CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: Text(field.label),
value: value == true,
onChanged: (checked) =>
notifier.updateValue(field.key, checked ?? false),
);
case MasterFieldType.dropdown:
final options = formState.dropdownOptions[field.optionsMasterKey] ??
const <Map<String, dynamic>>[];
final dropdownOptions = <AppDropdownOption<String>>[];
for (final item in options) {
final id = item['id']?.toString();
if (id == null || id.isEmpty) continue;
dropdownOptions.add(
AppDropdownOption(value: id, label: masterRecordLabel(item)),
);
}
return AppSearchableDropdown<String>(
label: _fieldLabel(field),
value: value?.toString(),
options: dropdownOptions,
hint: dropdownOptions.isEmpty
? 'No options available'
: 'Select ${field.label.toLowerCase()}',
searchHint: 'Search ${field.label.toLowerCase()}...',
enabled: dropdownOptions.isNotEmpty,
onChanged: (selected) => notifier.updateValue(field.key, selected),
validator: field.required
? (v) => v == null ? '${field.label} is required' : null
: null,
);
case MasterFieldType.number:
return TextFormField(
key: ValueKey('${field.key}-${value ?? ''}'),
initialValue: value?.toString(),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(labelText: _fieldLabel(field)),
validator: field.required
? (v) => Validators.required(v, fieldName: field.label)
: null,
onChanged: (text) => notifier.updateValue(field.key, text),
);
case MasterFieldType.text:
return TextFormField(
key: ValueKey('${field.key}-${value ?? ''}'),
initialValue: value?.toString(),
maxLines: field.multiline ? 3 : 1,
decoration: InputDecoration(labelText: _fieldLabel(field)),
validator: field.required
? (v) => Validators.required(v, fieldName: field.label)
: null,
onChanged: (text) => notifier.updateValue(field.key, text),
);
}
}
List<Widget> _buildFieldLayout(
BuildContext context,
MasterFormState formState,
) {
final def = _definition;
final regularFields =
def.formFields.where((field) => field.key != 'is_active').toList();
MasterFieldDef? activeField;
for (final field in def.formFields) {
if (field.key == 'is_active') {
activeField = field;
break;
}
}
final widgets = <Widget>[];
for (var i = 0; i < regularFields.length; i += 2) {
final left = regularFields[i];
if (i + 1 < regularFields.length) {
final right = regularFields[i + 1];
widgets.add(
SidePanelFormRow(
left: _buildField(context, field: left, formState: formState),
right: _buildField(context, field: right, formState: formState),
),
);
} else {
widgets.add(
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildField(context, field: left, formState: formState),
),
);
}
}
if (activeField != null) {
widgets.add(
SidePanelSection(
title: 'STATUS',
children: [
_buildField(context, field: activeField, formState: formState),
],
),
);
}
return widgets;
}
@override
Widget build(BuildContext context) {
final formAsync = ref.watch(masterFormProvider(_args));
final def = _definition;
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
return SidePanelScaffold(
title: widget.isEditing ? 'Edit ${def.title.toLowerCase()}' : 'Add ${def.title.toLowerCase()}',
footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: isSubmitting
? null
: () => Navigator.of(context, rootNavigator: true).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 12),
AppButton(
label: widget.isEditing ? 'Update ${def.title.toLowerCase()}' : 'Save ${def.title.toLowerCase()}',
expand: false,
icon: Icons.check,
isLoading: isSubmitting,
onPressed: isSubmitting ? null : _submit,
),
],
),
child: formAsync.when(
loading: () => AppLoadingView(
message:
widget.isEditing ? 'Loading ${def.title}...' : 'Preparing form...',
),
error: (error, _) => ErrorView.fromFailure(
error is Failure ? error : Failure.unknown(message: error.toString()),
onRetry: () => ref.invalidate(masterFormProvider(_args)),
),
data: (formState) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _buildFieldLayout(context, formState),
),
);
},
),
);
}
}

View File

@ -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(

View File

@ -8,10 +8,11 @@ import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_searchable_multi_select_dropdown.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../providers/add_user_form_provider.dart';
import 'rbac_widgets.dart';
class AddUserPanel extends ConsumerStatefulWidget {
const AddUserPanel({super.key, this.userId});
@ -31,7 +32,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
final _emailController = TextEditingController();
final _mobileController = TextEditingController();
final _passwordController = TextEditingController();
String? _selectedRoleId;
List<String> _selectedRoleIds = [];
String _selectedStatus = 'Active';
String? _selectedDepartmentId;
String? _selectedPlantId;
@ -76,7 +77,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
_employeeCodeController.text = user.employeeCode;
_emailController.text = user.email;
_mobileController.text = user.mobile;
_selectedRoleId = user.roleId;
_selectedRoleIds = user.effectiveRoleIds;
_selectedDepartmentId = user.departmentId;
_selectedDesignationId = user.designationId;
_selectedPlantId = user.plantId;
@ -93,10 +94,13 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final roleId = int.tryParse(_selectedRoleId ?? '');
if (roleId == null) {
final roleIds = _selectedRoleIds
.map((id) => int.tryParse(id))
.whereType<int>()
.toList();
if (roleIds.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a role')),
const SnackBar(content: Text('Please select at least one role')),
);
return;
}
@ -115,7 +119,8 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
mobile: _mobileController.text.trim().isEmpty
? null
: _mobileController.text.trim(),
roleId: roleId,
roleId: roleIds.first,
roleIds: roleIds,
departmentId: int.tryParse(_selectedDepartmentId ?? ''),
designationId: int.tryParse(_selectedDesignationId ?? ''),
plantId: int.tryParse(_selectedPlantId ?? ''),
@ -134,7 +139,8 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
mobile: _mobileController.text.trim().isEmpty
? null
: _mobileController.text.trim(),
roleId: roleId,
roleId: roleIds.first,
roleIds: roleIds,
departmentId: int.tryParse(_selectedDepartmentId ?? ''),
designationId: int.tryParse(_selectedDesignationId ?? ''),
plantId: int.tryParse(_selectedPlantId ?? ''),
@ -224,9 +230,6 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
setState(() => _prefillFromUser(formState.editingUser!));
});
}
} else {
_selectedRoleId ??=
formState.roles.isNotEmpty ? formState.roles.first.id : null;
}
return Form(
@ -269,12 +272,21 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
title: 'ROLE & ACCESS',
children: [
SidePanelFormRow(
left: _buildDropdown(
label: 'Role',
value: _selectedRoleId,
options: formState.roles,
required: true,
onChanged: (v) => setState(() => _selectedRoleId = v),
left: AppSearchableMultiSelectDropdown<String>(
label: 'Role *',
values: _selectedRoleIds,
hint: formState.roles.isEmpty
? 'No roles available'
: 'Select roles',
searchHint: 'Search role...',
enabled: formState.roles.isNotEmpty,
options: _toOptions(formState.roles),
onChanged: (ids) =>
setState(() => _selectedRoleIds = ids),
validator: (ids) =>
ids == null || ids.isEmpty
? 'Please select at least one role'
: null,
),
right: AppSearchableDropdown<String>(
label: 'Status',

View File

@ -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';

View File

@ -38,6 +38,7 @@ class RbacStatCard extends StatelessWidget {
return AppCard(
elevation: 0,
enableHover: true,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
@ -341,192 +342,6 @@ class RolePill extends StatelessWidget {
}
}
Future<T?> showSidePanel<T>(
BuildContext context,
Widget panel, {
double? width,
}) {
final screenWidth = MediaQuery.sizeOf(context).width;
final defaultWidth = screenWidth > 1200
? 480.0
: (screenWidth * 0.38).clamp(360.0, 480.0);
final panelWidth = (width ?? defaultWidth).clamp(360.0, screenWidth * 0.95);
return showGeneralDialog<T>(
context: context,
useRootNavigator: true,
barrierDismissible: true,
barrierLabel: 'Dismiss',
barrierColor: Colors.black.withValues(alpha: 0.35),
transitionDuration: const Duration(milliseconds: 280),
pageBuilder: (context, _, __) {
final theme = Theme.of(context);
return Align(
alignment: Alignment.centerRight,
child: Material(
elevation: 16,
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.horizontal(left: Radius.circular(16)),
clipBehavior: Clip.antiAlias,
child: SizedBox(
width: panelWidth,
height: MediaQuery.sizeOf(context).height,
child: panel,
),
),
);
},
transitionBuilder: (context, anim, _, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)),
child: child,
);
},
);
}
class SidePanelScaffold extends StatelessWidget {
const SidePanelScaffold({
super.key,
required this.title,
required this.child,
this.footer,
this.onClose,
});
final String title;
final Widget child;
final Widget? footer;
final VoidCallback? onClose;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 12, 16),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: onClose ?? () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: child,
),
),
if (footer != null) ...[
const Divider(height: 1),
Padding(padding: const EdgeInsets.all(24), child: footer),
],
],
);
}
}
class SidePanelSection extends StatelessWidget {
const SidePanelSection({
super.key,
required this.title,
required this.children,
});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final dividerColor =
Theme.of(context).colorScheme.outline.withValues(alpha: 0.2);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Divider(height: 1, color: dividerColor),
const SizedBox(height: 16),
...children,
const SizedBox(height: 24),
],
);
}
}
/// Two-column row for side panel forms (desktop layout).
class SidePanelFormRow extends StatelessWidget {
const SidePanelFormRow({
super.key,
required this.left,
required this.right,
this.spacing = 16,
});
final Widget left;
final Widget right;
final double spacing;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 420;
if (stack) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
left,
const SizedBox(height: 12),
right,
],
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: left),
SizedBox(width: spacing),
Expanded(child: right),
],
),
);
},
);
}
}
class ModulePermissionRow extends StatelessWidget {
const ModulePermissionRow({
super.key,

View File

@ -70,6 +70,7 @@ final rolesListProvider =
class RolesListNotifier extends AsyncNotifier<RolesListState> {
@override
Future<RolesListState> build() async {
ref.keepAlive();
return _load();
}
@ -81,7 +82,9 @@ class RolesListNotifier extends AsyncNotifier<RolesListState> {
Future<void> refresh() async {
final current = state.valueOrNull;
state = const AsyncLoading();
if (current == null) {
state = const AsyncLoading();
}
try {
state = AsyncData(await _load(search: current?.search));
} catch (e, st) {

View File

@ -100,6 +100,7 @@ final usersListProvider =
class UsersListNotifier extends AsyncNotifier<UsersListState> {
@override
Future<UsersListState> build() async {
ref.keepAlive();
return _loadAll(const UserListQuery(limit: 10));
}
@ -132,7 +133,10 @@ class UsersListNotifier extends AsyncNotifier<UsersListState> {
}
Future<void> applyQuery(UserListQuery query) async {
state = const AsyncLoading();
final previous = state.valueOrNull;
if (previous == null) {
state = const AsyncLoading();
}
try {
state = AsyncData(await _loadAll(query));
} catch (e, st) {

View File

@ -58,6 +58,26 @@ Object? _readRoleId(Map<dynamic, dynamic> json, String key) {
return null;
}
Object? _readRoleIds(Map<dynamic, dynamic> json, String key) {
final roleIds = json['role_ids'];
if (roleIds is List && roleIds.isNotEmpty) return roleIds;
final roles = json['roles'];
if (roles is List && roles.isNotEmpty) {
return roles
.map((role) => role is Map ? role['id'] : role)
.where((id) => id != null)
.toList();
}
return null;
}
List<String> _roleIdsFromJson(dynamic value) {
if (value is List) {
return value.map((item) => item.toString()).toList();
}
return const [];
}
@freezed
class UserSummaryModel with _$UserSummaryModel {
const factory UserSummaryModel({
@ -109,6 +129,13 @@ class ManagedUserModel with _$ManagedUserModel {
@Default('') String mobile,
@JsonKey(fromJson: _idFromJsonNullable, name: 'role_id', readValue: _readRoleId)
String? roleId,
@JsonKey(
name: 'role_ids',
readValue: _readRoleIds,
fromJson: _roleIdsFromJson,
)
@Default([])
List<String> roleIds,
@JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName,
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
String? departmentId,
@ -141,6 +168,12 @@ extension ManagedUserModelX on ManagedUserModel {
String get departmentLabel => departmentName ?? '';
String get plantLabel => plantName ?? '';
List<String> get effectiveRoleIds {
if (roleIds.isNotEmpty) return roleIds;
if (roleId != null && roleId!.isNotEmpty) return [roleId!];
return const [];
}
String get initialsDisplay {
if (initials != null && initials!.trim().isNotEmpty) {
return initials!.trim().toUpperCase();
@ -162,6 +195,7 @@ class CreateUserRequest with _$CreateUserRequest {
required String password,
String? mobile,
@JsonKey(name: 'role_id') required int roleId,
@JsonKey(name: 'role_ids') @Default([]) List<int> roleIds,
@JsonKey(name: 'department_id') int? departmentId,
@JsonKey(name: 'designation_id') int? designationId,
@JsonKey(name: 'plant_id') int? plantId,
@ -183,6 +217,7 @@ class UpdateUserRequest with _$UpdateUserRequest {
String? password,
String? mobile,
@JsonKey(name: 'role_id') int? roleId,
@JsonKey(name: 'role_ids') List<int>? roleIds,
@JsonKey(name: 'department_id') int? departmentId,
@JsonKey(name: 'designation_id') int? designationId,
@JsonKey(name: 'plant_id') int? plantId,

View File

@ -749,6 +749,12 @@ mixin _$ManagedUserModel {
readValue: _readRoleId,
)
String? get roleId => throw _privateConstructorUsedError;
@JsonKey(
name: 'role_ids',
readValue: _readRoleIds,
fromJson: _roleIdsFromJson,
)
List<String> get roleIds => throw _privateConstructorUsedError;
@JsonKey(name: 'role_name', readValue: _readRoleName)
String? get roleName => throw _privateConstructorUsedError;
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
@ -812,6 +818,12 @@ abstract class $ManagedUserModelCopyWith<$Res> {
readValue: _readRoleId,
)
String? roleId,
@JsonKey(
name: 'role_ids',
readValue: _readRoleIds,
fromJson: _roleIdsFromJson,
)
List<String> roleIds,
@JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName,
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
String? departmentId,
@ -858,6 +870,7 @@ class _$ManagedUserModelCopyWithImpl<$Res, $Val extends ManagedUserModel>
Object? email = null,
Object? mobile = null,
Object? roleId = freezed,
Object? roleIds = null,
Object? roleName = freezed,
Object? departmentId = freezed,
Object? departmentName = freezed,
@ -909,6 +922,10 @@ class _$ManagedUserModelCopyWithImpl<$Res, $Val extends ManagedUserModel>
? _value.roleId
: roleId // ignore: cast_nullable_to_non_nullable
as String?,
roleIds: null == roleIds
? _value.roleIds
: roleIds // ignore: cast_nullable_to_non_nullable
as List<String>,
roleName: freezed == roleName
? _value.roleName
: roleName // ignore: cast_nullable_to_non_nullable
@ -1003,6 +1020,12 @@ abstract class _$$ManagedUserModelImplCopyWith<$Res>
readValue: _readRoleId,
)
String? roleId,
@JsonKey(
name: 'role_ids',
readValue: _readRoleIds,
fromJson: _roleIdsFromJson,
)
List<String> roleIds,
@JsonKey(name: 'role_name', readValue: _readRoleName) String? roleName,
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
String? departmentId,
@ -1048,6 +1071,7 @@ class __$$ManagedUserModelImplCopyWithImpl<$Res>
Object? email = null,
Object? mobile = null,
Object? roleId = freezed,
Object? roleIds = null,
Object? roleName = freezed,
Object? departmentId = freezed,
Object? departmentName = freezed,
@ -1099,6 +1123,10 @@ class __$$ManagedUserModelImplCopyWithImpl<$Res>
? _value.roleId
: roleId // ignore: cast_nullable_to_non_nullable
as String?,
roleIds: null == roleIds
? _value._roleIds
: roleIds // ignore: cast_nullable_to_non_nullable
as List<String>,
roleName: freezed == roleName
? _value.roleName
: roleName // ignore: cast_nullable_to_non_nullable
@ -1187,6 +1215,12 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
readValue: _readRoleId,
)
this.roleId,
@JsonKey(
name: 'role_ids',
readValue: _readRoleIds,
fromJson: _roleIdsFromJson,
)
final List<String> roleIds = const [],
@JsonKey(name: 'role_name', readValue: _readRoleName) this.roleName,
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
this.departmentId,
@ -1207,7 +1241,7 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
@JsonKey(name: 'avatar_url') this.avatarUrl,
@JsonKey(name: 'created_at') this.createdAt,
@JsonKey(name: 'updated_at') this.updatedAt,
});
}) : _roleIds = roleIds;
factory _$ManagedUserModelImpl.fromJson(Map<String, dynamic> json) =>
_$$ManagedUserModelImplFromJson(json);
@ -1239,6 +1273,19 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
readValue: _readRoleId,
)
final String? roleId;
final List<String> _roleIds;
@override
@JsonKey(
name: 'role_ids',
readValue: _readRoleIds,
fromJson: _roleIdsFromJson,
)
List<String> get roleIds {
if (_roleIds is EqualUnmodifiableListView) return _roleIds;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_roleIds);
}
@override
@JsonKey(name: 'role_name', readValue: _readRoleName)
final String? roleName;
@ -1289,7 +1336,7 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
@override
String toString() {
return 'ManagedUserModel(id: $id, employeeCode: $employeeCode, fullName: $fullName, firstName: $firstName, lastName: $lastName, email: $email, mobile: $mobile, roleId: $roleId, roleName: $roleName, departmentId: $departmentId, departmentName: $departmentName, designationId: $designationId, designationName: $designationName, plantId: $plantId, plantName: $plantName, reportingTo: $reportingTo, reportingToName: $reportingToName, lastLoginAt: $lastLoginAt, initials: $initials, status: $status, isActive: $isActive, avatarUrl: $avatarUrl, createdAt: $createdAt, updatedAt: $updatedAt)';
return 'ManagedUserModel(id: $id, employeeCode: $employeeCode, fullName: $fullName, firstName: $firstName, lastName: $lastName, email: $email, mobile: $mobile, roleId: $roleId, roleIds: $roleIds, roleName: $roleName, departmentId: $departmentId, departmentName: $departmentName, designationId: $designationId, designationName: $designationName, plantId: $plantId, plantName: $plantName, reportingTo: $reportingTo, reportingToName: $reportingToName, lastLoginAt: $lastLoginAt, initials: $initials, status: $status, isActive: $isActive, avatarUrl: $avatarUrl, createdAt: $createdAt, updatedAt: $updatedAt)';
}
@override
@ -1309,6 +1356,7 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
(identical(other.email, email) || other.email == email) &&
(identical(other.mobile, mobile) || other.mobile == mobile) &&
(identical(other.roleId, roleId) || other.roleId == roleId) &&
const DeepCollectionEquality().equals(other._roleIds, _roleIds) &&
(identical(other.roleName, roleName) ||
other.roleName == roleName) &&
(identical(other.departmentId, departmentId) ||
@ -1353,6 +1401,7 @@ class _$ManagedUserModelImpl implements _ManagedUserModel {
email,
mobile,
roleId,
const DeepCollectionEquality().hash(_roleIds),
roleName,
departmentId,
departmentName,
@ -1406,6 +1455,12 @@ abstract class _ManagedUserModel implements ManagedUserModel {
readValue: _readRoleId,
)
final String? roleId,
@JsonKey(
name: 'role_ids',
readValue: _readRoleIds,
fromJson: _roleIdsFromJson,
)
final List<String> roleIds,
@JsonKey(name: 'role_name', readValue: _readRoleName)
final String? roleName,
@JsonKey(fromJson: _idFromJsonNullable, name: 'department_id')
@ -1461,6 +1516,13 @@ abstract class _ManagedUserModel implements ManagedUserModel {
)
String? get roleId;
@override
@JsonKey(
name: 'role_ids',
readValue: _readRoleIds,
fromJson: _roleIdsFromJson,
)
List<String> get roleIds;
@override
@JsonKey(name: 'role_name', readValue: _readRoleName)
String? get roleName;
@override
@ -1530,6 +1592,8 @@ mixin _$CreateUserRequest {
String? get mobile => throw _privateConstructorUsedError;
@JsonKey(name: 'role_id')
int get roleId => throw _privateConstructorUsedError;
@JsonKey(name: 'role_ids')
List<int> get roleIds => throw _privateConstructorUsedError;
@JsonKey(name: 'department_id')
int? get departmentId => throw _privateConstructorUsedError;
@JsonKey(name: 'designation_id')
@ -1566,6 +1630,7 @@ abstract class $CreateUserRequestCopyWith<$Res> {
String password,
String? mobile,
@JsonKey(name: 'role_id') int roleId,
@JsonKey(name: 'role_ids') List<int> roleIds,
@JsonKey(name: 'department_id') int? departmentId,
@JsonKey(name: 'designation_id') int? designationId,
@JsonKey(name: 'plant_id') int? plantId,
@ -1596,6 +1661,7 @@ class _$CreateUserRequestCopyWithImpl<$Res, $Val extends CreateUserRequest>
Object? password = null,
Object? mobile = freezed,
Object? roleId = null,
Object? roleIds = null,
Object? departmentId = freezed,
Object? designationId = freezed,
Object? plantId = freezed,
@ -1629,6 +1695,10 @@ class _$CreateUserRequestCopyWithImpl<$Res, $Val extends CreateUserRequest>
? _value.roleId
: roleId // ignore: cast_nullable_to_non_nullable
as int,
roleIds: null == roleIds
? _value.roleIds
: roleIds // ignore: cast_nullable_to_non_nullable
as List<int>,
departmentId: freezed == departmentId
? _value.departmentId
: departmentId // ignore: cast_nullable_to_non_nullable
@ -1675,6 +1745,7 @@ abstract class _$$CreateUserRequestImplCopyWith<$Res>
String password,
String? mobile,
@JsonKey(name: 'role_id') int roleId,
@JsonKey(name: 'role_ids') List<int> roleIds,
@JsonKey(name: 'department_id') int? departmentId,
@JsonKey(name: 'designation_id') int? designationId,
@JsonKey(name: 'plant_id') int? plantId,
@ -1704,6 +1775,7 @@ class __$$CreateUserRequestImplCopyWithImpl<$Res>
Object? password = null,
Object? mobile = freezed,
Object? roleId = null,
Object? roleIds = null,
Object? departmentId = freezed,
Object? designationId = freezed,
Object? plantId = freezed,
@ -1737,6 +1809,10 @@ class __$$CreateUserRequestImplCopyWithImpl<$Res>
? _value.roleId
: roleId // ignore: cast_nullable_to_non_nullable
as int,
roleIds: null == roleIds
? _value._roleIds
: roleIds // ignore: cast_nullable_to_non_nullable
as List<int>,
departmentId: freezed == departmentId
? _value.departmentId
: departmentId // ignore: cast_nullable_to_non_nullable
@ -1776,13 +1852,14 @@ class _$CreateUserRequestImpl implements _CreateUserRequest {
required this.password,
this.mobile,
@JsonKey(name: 'role_id') required this.roleId,
@JsonKey(name: 'role_ids') final List<int> roleIds = const [],
@JsonKey(name: 'department_id') this.departmentId,
@JsonKey(name: 'designation_id') this.designationId,
@JsonKey(name: 'plant_id') this.plantId,
@JsonKey(name: 'reporting_to') this.reportingTo,
this.status = 'active',
@JsonKey(name: 'is_active') this.isActive = true,
});
}) : _roleIds = roleIds;
factory _$CreateUserRequestImpl.fromJson(Map<String, dynamic> json) =>
_$$CreateUserRequestImplFromJson(json);
@ -1802,6 +1879,15 @@ class _$CreateUserRequestImpl implements _CreateUserRequest {
@override
@JsonKey(name: 'role_id')
final int roleId;
final List<int> _roleIds;
@override
@JsonKey(name: 'role_ids')
List<int> get roleIds {
if (_roleIds is EqualUnmodifiableListView) return _roleIds;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_roleIds);
}
@override
@JsonKey(name: 'department_id')
final int? departmentId;
@ -1823,7 +1909,7 @@ class _$CreateUserRequestImpl implements _CreateUserRequest {
@override
String toString() {
return 'CreateUserRequest(employeeCode: $employeeCode, fullName: $fullName, email: $email, password: $password, mobile: $mobile, roleId: $roleId, departmentId: $departmentId, designationId: $designationId, plantId: $plantId, reportingTo: $reportingTo, status: $status, isActive: $isActive)';
return 'CreateUserRequest(employeeCode: $employeeCode, fullName: $fullName, email: $email, password: $password, mobile: $mobile, roleId: $roleId, roleIds: $roleIds, departmentId: $departmentId, designationId: $designationId, plantId: $plantId, reportingTo: $reportingTo, status: $status, isActive: $isActive)';
}
@override
@ -1840,6 +1926,7 @@ class _$CreateUserRequestImpl implements _CreateUserRequest {
other.password == password) &&
(identical(other.mobile, mobile) || other.mobile == mobile) &&
(identical(other.roleId, roleId) || other.roleId == roleId) &&
const DeepCollectionEquality().equals(other._roleIds, _roleIds) &&
(identical(other.departmentId, departmentId) ||
other.departmentId == departmentId) &&
(identical(other.designationId, designationId) ||
@ -1862,6 +1949,7 @@ class _$CreateUserRequestImpl implements _CreateUserRequest {
password,
mobile,
roleId,
const DeepCollectionEquality().hash(_roleIds),
departmentId,
designationId,
plantId,
@ -1895,6 +1983,7 @@ abstract class _CreateUserRequest implements CreateUserRequest {
required final String password,
final String? mobile,
@JsonKey(name: 'role_id') required final int roleId,
@JsonKey(name: 'role_ids') final List<int> roleIds,
@JsonKey(name: 'department_id') final int? departmentId,
@JsonKey(name: 'designation_id') final int? designationId,
@JsonKey(name: 'plant_id') final int? plantId,
@ -1922,6 +2011,9 @@ abstract class _CreateUserRequest implements CreateUserRequest {
@JsonKey(name: 'role_id')
int get roleId;
@override
@JsonKey(name: 'role_ids')
List<int> get roleIds;
@override
@JsonKey(name: 'department_id')
int? get departmentId;
@override
@ -1962,6 +2054,8 @@ mixin _$UpdateUserRequest {
String? get mobile => throw _privateConstructorUsedError;
@JsonKey(name: 'role_id')
int? get roleId => throw _privateConstructorUsedError;
@JsonKey(name: 'role_ids')
List<int>? get roleIds => throw _privateConstructorUsedError;
@JsonKey(name: 'department_id')
int? get departmentId => throw _privateConstructorUsedError;
@JsonKey(name: 'designation_id')
@ -1998,6 +2092,7 @@ abstract class $UpdateUserRequestCopyWith<$Res> {
String? password,
String? mobile,
@JsonKey(name: 'role_id') int? roleId,
@JsonKey(name: 'role_ids') List<int>? roleIds,
@JsonKey(name: 'department_id') int? departmentId,
@JsonKey(name: 'designation_id') int? designationId,
@JsonKey(name: 'plant_id') int? plantId,
@ -2028,6 +2123,7 @@ class _$UpdateUserRequestCopyWithImpl<$Res, $Val extends UpdateUserRequest>
Object? password = freezed,
Object? mobile = freezed,
Object? roleId = freezed,
Object? roleIds = freezed,
Object? departmentId = freezed,
Object? designationId = freezed,
Object? plantId = freezed,
@ -2061,6 +2157,10 @@ class _$UpdateUserRequestCopyWithImpl<$Res, $Val extends UpdateUserRequest>
? _value.roleId
: roleId // ignore: cast_nullable_to_non_nullable
as int?,
roleIds: freezed == roleIds
? _value.roleIds
: roleIds // ignore: cast_nullable_to_non_nullable
as List<int>?,
departmentId: freezed == departmentId
? _value.departmentId
: departmentId // ignore: cast_nullable_to_non_nullable
@ -2107,6 +2207,7 @@ abstract class _$$UpdateUserRequestImplCopyWith<$Res>
String? password,
String? mobile,
@JsonKey(name: 'role_id') int? roleId,
@JsonKey(name: 'role_ids') List<int>? roleIds,
@JsonKey(name: 'department_id') int? departmentId,
@JsonKey(name: 'designation_id') int? designationId,
@JsonKey(name: 'plant_id') int? plantId,
@ -2136,6 +2237,7 @@ class __$$UpdateUserRequestImplCopyWithImpl<$Res>
Object? password = freezed,
Object? mobile = freezed,
Object? roleId = freezed,
Object? roleIds = freezed,
Object? departmentId = freezed,
Object? designationId = freezed,
Object? plantId = freezed,
@ -2169,6 +2271,10 @@ class __$$UpdateUserRequestImplCopyWithImpl<$Res>
? _value.roleId
: roleId // ignore: cast_nullable_to_non_nullable
as int?,
roleIds: freezed == roleIds
? _value._roleIds
: roleIds // ignore: cast_nullable_to_non_nullable
as List<int>?,
departmentId: freezed == departmentId
? _value.departmentId
: departmentId // ignore: cast_nullable_to_non_nullable
@ -2208,13 +2314,14 @@ class _$UpdateUserRequestImpl implements _UpdateUserRequest {
this.password,
this.mobile,
@JsonKey(name: 'role_id') this.roleId,
@JsonKey(name: 'role_ids') final List<int>? roleIds,
@JsonKey(name: 'department_id') this.departmentId,
@JsonKey(name: 'designation_id') this.designationId,
@JsonKey(name: 'plant_id') this.plantId,
@JsonKey(name: 'reporting_to') this.reportingTo,
this.status,
@JsonKey(name: 'is_active') this.isActive,
});
}) : _roleIds = roleIds;
factory _$UpdateUserRequestImpl.fromJson(Map<String, dynamic> json) =>
_$$UpdateUserRequestImplFromJson(json);
@ -2234,6 +2341,17 @@ class _$UpdateUserRequestImpl implements _UpdateUserRequest {
@override
@JsonKey(name: 'role_id')
final int? roleId;
final List<int>? _roleIds;
@override
@JsonKey(name: 'role_ids')
List<int>? get roleIds {
final value = _roleIds;
if (value == null) return null;
if (_roleIds is EqualUnmodifiableListView) return _roleIds;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
@override
@JsonKey(name: 'department_id')
final int? departmentId;
@ -2254,7 +2372,7 @@ class _$UpdateUserRequestImpl implements _UpdateUserRequest {
@override
String toString() {
return 'UpdateUserRequest(employeeCode: $employeeCode, fullName: $fullName, email: $email, password: $password, mobile: $mobile, roleId: $roleId, departmentId: $departmentId, designationId: $designationId, plantId: $plantId, reportingTo: $reportingTo, status: $status, isActive: $isActive)';
return 'UpdateUserRequest(employeeCode: $employeeCode, fullName: $fullName, email: $email, password: $password, mobile: $mobile, roleId: $roleId, roleIds: $roleIds, departmentId: $departmentId, designationId: $designationId, plantId: $plantId, reportingTo: $reportingTo, status: $status, isActive: $isActive)';
}
@override
@ -2271,6 +2389,7 @@ class _$UpdateUserRequestImpl implements _UpdateUserRequest {
other.password == password) &&
(identical(other.mobile, mobile) || other.mobile == mobile) &&
(identical(other.roleId, roleId) || other.roleId == roleId) &&
const DeepCollectionEquality().equals(other._roleIds, _roleIds) &&
(identical(other.departmentId, departmentId) ||
other.departmentId == departmentId) &&
(identical(other.designationId, designationId) ||
@ -2293,6 +2412,7 @@ class _$UpdateUserRequestImpl implements _UpdateUserRequest {
password,
mobile,
roleId,
const DeepCollectionEquality().hash(_roleIds),
departmentId,
designationId,
plantId,
@ -2326,6 +2446,7 @@ abstract class _UpdateUserRequest implements UpdateUserRequest {
final String? password,
final String? mobile,
@JsonKey(name: 'role_id') final int? roleId,
@JsonKey(name: 'role_ids') final List<int>? roleIds,
@JsonKey(name: 'department_id') final int? departmentId,
@JsonKey(name: 'designation_id') final int? designationId,
@JsonKey(name: 'plant_id') final int? plantId,
@ -2353,6 +2474,9 @@ abstract class _UpdateUserRequest implements UpdateUserRequest {
@JsonKey(name: 'role_id')
int? get roleId;
@override
@JsonKey(name: 'role_ids')
List<int>? get roleIds;
@override
@JsonKey(name: 'department_id')
int? get departmentId;
@override

View File

@ -81,6 +81,9 @@ _$ManagedUserModelImpl _$$ManagedUserModelImplFromJson(
email: json['email'] as String,
mobile: json['mobile'] as String? ?? '',
roleId: _idFromJsonNullable(_readRoleId(json, 'role_id')),
roleIds: _readRoleIds(json, 'role_ids') == null
? const []
: _roleIdsFromJson(_readRoleIds(json, 'role_ids')),
roleName: _readRoleName(json, 'role_name') as String?,
departmentId: _idFromJsonNullable(json['department_id']),
departmentName: _readDepartmentName(json, 'department_name') as String?,
@ -116,6 +119,7 @@ Map<String, dynamic> _$$ManagedUserModelImplToJson(
'email': instance.email,
'mobile': instance.mobile,
'role_id': instance.roleId,
'role_ids': instance.roleIds,
'role_name': instance.roleName,
'department_id': instance.departmentId,
'department_name': instance.departmentName,
@ -143,6 +147,11 @@ _$CreateUserRequestImpl _$$CreateUserRequestImplFromJson(
password: json['password'] as String,
mobile: json['mobile'] as String?,
roleId: (json['role_id'] as num).toInt(),
roleIds:
(json['role_ids'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
const [],
departmentId: (json['department_id'] as num?)?.toInt(),
designationId: (json['designation_id'] as num?)?.toInt(),
plantId: (json['plant_id'] as num?)?.toInt(),
@ -160,6 +169,7 @@ Map<String, dynamic> _$$CreateUserRequestImplToJson(
'password': instance.password,
'mobile': instance.mobile,
'role_id': instance.roleId,
'role_ids': instance.roleIds,
'department_id': instance.departmentId,
'designation_id': instance.designationId,
'plant_id': instance.plantId,
@ -177,6 +187,9 @@ _$UpdateUserRequestImpl _$$UpdateUserRequestImplFromJson(
password: json['password'] as String?,
mobile: json['mobile'] as String?,
roleId: (json['role_id'] as num?)?.toInt(),
roleIds: (json['role_ids'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList(),
departmentId: (json['department_id'] as num?)?.toInt(),
designationId: (json['designation_id'] as num?)?.toInt(),
plantId: (json['plant_id'] as num?)?.toInt(),
@ -194,6 +207,7 @@ Map<String, dynamic> _$$UpdateUserRequestImplToJson(
'password': instance.password,
'mobile': instance.mobile,
'role_id': instance.roleId,
'role_ids': instance.roleIds,
'department_id': instance.departmentId,
'designation_id': instance.designationId,
'plant_id': instance.plantId,

View File

@ -19,9 +19,9 @@ import '../../modules/auth/presentation/screens/forgot_password_screen.dart';
import '../../modules/auth/presentation/screens/login_screen.dart';
import '../../modules/auth/presentation/screens/reset_password_screen.dart';
import '../../modules/auth/presentation/screens/verify_otp_screen.dart';
import '../../modules/master_data/presentation/screens/departments_screen.dart';
import '../../modules/master_data/presentation/screens/locations_screen.dart';
import '../../modules/master_data/presentation/screens/uom_screen.dart';
import '../../modules/master_data/domain/entities/master_definition.dart';
import '../../modules/master_data/presentation/screens/master_list_screen.dart';
import '../../modules/master_data/presentation/screens/masters_hub_screen.dart';
import '../../modules/reports/presentation/screens/reports_screen.dart';
import '../../modules/audit/presentation/screens/audit_logs_screen.dart';
import '../../modules/branch/presentation/screens/branch_form_screen.dart';
@ -251,19 +251,20 @@ final routerProvider = Provider<GoRouter>((ref) {
],
),
GoRoute(
path: RouteConstants.departments,
path: RouteConstants.masterData,
pageBuilder: (context, state) =>
shellPage(state, const DepartmentsScreen()),
),
GoRoute(
path: RouteConstants.locations,
pageBuilder: (context, state) =>
shellPage(state, const LocationsScreen()),
),
GoRoute(
path: RouteConstants.uom,
pageBuilder: (context, state) =>
shellPage(state, const UomScreen()),
shellPage(state, const MastersHubScreen()),
routes: [
...masterDefinitions.map(
(def) => GoRoute(
path: def.routeKey,
pageBuilder: (context, state) => shellPage(
state,
MasterListScreen(masterId: def.id),
),
),
),
],
),
GoRoute(
path: RouteConstants.reports,

View File

@ -101,28 +101,8 @@ const List<MenuItem> appMenuItems = [
MenuItem(
label: 'Master Data',
icon: Icons.dataset_outlined,
route: RouteConstants.departments,
route: RouteConstants.masterData,
module: 'master_data',
children: [
MenuItem(
label: 'Departments',
icon: Icons.apartment_outlined,
route: RouteConstants.departments,
module: 'departments',
),
MenuItem(
label: 'Locations',
icon: Icons.location_on_outlined,
route: RouteConstants.locations,
module: 'locations',
),
MenuItem(
label: 'UOM',
icon: Icons.straighten_outlined,
route: RouteConstants.uom,
module: 'uom',
),
],
),
MenuItem(
label: 'Reports',

View File

@ -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);

View File

@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'app_hover_effect.dart';
/// Card that follows the active theme with a shared hover animation.
/// Card that follows the active theme. Hover is off by default; enable it only
/// on navigation tiles (settings, role cards, etc.).
class AppCard extends StatefulWidget {
const AppCard({
super.key,
@ -12,7 +13,7 @@ class AppCard extends StatefulWidget {
this.shape,
this.margin,
this.color,
this.enableHover = true,
this.enableHover = false,
this.onTap,
});
@ -52,64 +53,66 @@ class _AppCardState extends State<AppCard> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final baseElevation = widget.elevation ?? 0;
final radius = _borderRadius(widget.shape) ?? 12.0;
final idleBorder = theme.colorScheme.outline.withValues(alpha: 0.12);
final hoverBorder = theme.colorScheme.primary.withValues(alpha: 0.28);
final radius = _borderRadius(widget.shape) ?? AppHoverStyle.borderRadius;
final shape = widget.shape ??
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
side: BorderSide(
color: widget.enableHover && _hovered ? hoverBorder : idleBorder,
),
);
Widget card = AnimatedScale(
scale: widget.enableHover && _hovered ? AppHoverStyle.scale : 1,
duration: AppHoverStyle.duration,
curve: AppHoverStyle.curve,
child: AnimatedContainer(
if (widget.enableHover) {
final hovered = _hovered;
Widget card = AnimatedContainer(
duration: AppHoverStyle.duration,
curve: AppHoverStyle.curve,
margin: widget.margin,
transform: Matrix4.translationValues(
0,
widget.enableHover && _hovered ? -AppHoverStyle.lift : 0,
0,
decoration: AppHoverStyle.decoration(
theme,
hovered: hovered,
radius: radius,
backgroundColor: widget.color ?? theme.colorScheme.surface,
),
child: Card(
color: widget.color,
surfaceTintColor: Colors.transparent,
clipBehavior: widget.clipBehavior,
elevation: widget.enableHover && _hovered
? baseElevation + AppHoverStyle.elevationDelta
: baseElevation,
shape: shape is RoundedRectangleBorder && widget.enableHover
? shape.copyWith(
side: BorderSide(
color: _hovered ? hoverBorder : idleBorder,
),
)
: shape,
child: widget.onTap == null
? widget.child
: InkWell(
clipBehavior: widget.clipBehavior ?? Clip.antiAlias,
child: widget.onTap == null
? widget.child
: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(radius),
splashColor: theme.colorScheme.primary.withValues(alpha: 0.06),
hoverColor: theme.colorScheme.primary.withValues(alpha: 0.04),
child: widget.child,
),
),
),
);
),
);
if (!widget.enableHover) return card;
return MouseRegion(
onEnter: (_) => _setHovered(true),
onExit: (_) => _setHovered(false),
cursor: widget.onTap != null
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: card,
);
}
return MouseRegion(
onEnter: (_) => _setHovered(true),
onExit: (_) => _setHovered(false),
cursor: widget.onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic,
child: card,
final idleBorder = theme.colorScheme.outline.withValues(alpha: 0.12);
final shape = widget.shape ??
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
side: BorderSide(color: idleBorder),
);
return Card(
margin: widget.margin,
color: widget.color,
surfaceTintColor: Colors.transparent,
clipBehavior: widget.clipBehavior,
elevation: widget.elevation ?? 0,
shape: shape,
child: widget.onTap == null
? widget.child
: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(radius),
child: widget.child,
),
);
}

View File

@ -1,14 +1,51 @@
import 'package:flutter/material.dart';
/// Shared hover animation for cards and tappable surfaces (web/desktop).
/// Shared hover styling for navigation tiles (settings, roles, masters).
class AppHoverStyle {
AppHoverStyle._();
static const Duration duration = Duration(milliseconds: 200);
static const Duration duration = Duration(milliseconds: 180);
static const Curve curve = Curves.easeOutCubic;
static const double lift = 2;
static const double scale = 1.01;
static const double elevationDelta = 3;
static const double borderRadius = 12;
static const double idleBorderWidth = 1;
static const double hoverBorderWidth = 1.5;
static Color idleBorderColor(ColorScheme scheme) =>
scheme.outline.withValues(alpha: 0.18);
static Color hoverBorderColor(ColorScheme scheme) =>
scheme.primary.withValues(alpha: 0.45);
static List<BoxShadow> hoverShadow(ColorScheme scheme) => [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.2),
blurRadius: 14,
spreadRadius: 0,
offset: const Offset(0, 4),
),
];
static BoxDecoration decoration(
ThemeData theme, {
required bool hovered,
double radius = borderRadius,
Color? backgroundColor,
bool showBorder = true,
bool showShadow = true,
}) {
final scheme = theme.colorScheme;
return BoxDecoration(
color: backgroundColor ?? scheme.surface,
borderRadius: BorderRadius.circular(radius),
border: showBorder
? Border.all(
color: hovered ? hoverBorderColor(scheme) : idleBorderColor(scheme),
width: hovered ? hoverBorderWidth : idleBorderWidth,
)
: null,
boxShadow: hovered && showShadow ? hoverShadow(scheme) : null,
);
}
}
class AppHoverEffect extends StatefulWidget {
@ -17,19 +54,19 @@ class AppHoverEffect extends StatefulWidget {
required this.child,
this.enabled = true,
this.onTap,
this.borderRadius = 12,
this.hoverBorderColor,
this.idleBorderColor,
this.borderRadius = AppHoverStyle.borderRadius,
this.backgroundColor,
this.showHoverBorder = true,
this.showHoverShadow = true,
});
final Widget child;
final bool enabled;
final VoidCallback? onTap;
final double borderRadius;
final Color? hoverBorderColor;
final Color? idleBorderColor;
final Color? backgroundColor;
final bool showHoverBorder;
final bool showHoverShadow;
@override
State<AppHoverEffect> createState() => _AppHoverEffectState();
@ -58,34 +95,22 @@ class _AppHoverEffectState extends State<AppHoverEffect> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final borderRadius = BorderRadius.circular(widget.borderRadius);
final idleBorder = widget.idleBorderColor ??
theme.colorScheme.outline.withValues(alpha: 0.12);
final hoverBorder = widget.hoverBorderColor ??
theme.colorScheme.primary.withValues(alpha: 0.28);
final radius = BorderRadius.circular(widget.borderRadius);
final hovered = widget.enabled && _hovered;
Widget content = AnimatedScale(
scale: widget.enabled && _hovered ? AppHoverStyle.scale : 1,
Widget content = AnimatedContainer(
duration: AppHoverStyle.duration,
curve: AppHoverStyle.curve,
child: AnimatedContainer(
duration: AppHoverStyle.duration,
curve: AppHoverStyle.curve,
transform: Matrix4.translationValues(
0,
widget.enabled && _hovered ? -AppHoverStyle.lift : 0,
0,
),
decoration: widget.showHoverBorder
? BoxDecoration(
borderRadius: borderRadius,
border: Border.all(
color: widget.enabled && _hovered ? hoverBorder : idleBorder,
),
)
: null,
child: widget.child,
decoration: AppHoverStyle.decoration(
theme,
hovered: hovered,
radius: widget.borderRadius,
backgroundColor: widget.backgroundColor,
showBorder: widget.showHoverBorder,
showShadow: widget.showHoverShadow,
),
clipBehavior: Clip.antiAlias,
child: widget.child,
);
if (widget.onTap != null) {
@ -93,7 +118,9 @@ class _AppHoverEffectState extends State<AppHoverEffect> {
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
borderRadius: borderRadius,
borderRadius: radius,
splashColor: theme.colorScheme.primary.withValues(alpha: 0.06),
hoverColor: theme.colorScheme.primary.withValues(alpha: 0.04),
child: content,
),
);
@ -104,7 +131,9 @@ class _AppHoverEffectState extends State<AppHoverEffect> {
return MouseRegion(
onEnter: (_) => _setHovered(true),
onExit: (_) => _setHovered(false),
cursor: widget.onTap != null ? SystemMouseCursors.click : SystemMouseCursors.basic,
cursor: widget.onTap != null
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: content,
);
}

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
/// Search field with export action matches the users list toolbar (no filters).
class AppSearchExportBar extends StatelessWidget {
const AppSearchExportBar({
super.key,
required this.searchHint,
required this.onSearch,
required this.onExport,
this.isExporting = false,
this.wrapped = false,
this.searchController,
this.searchWidth = 320,
});
final String searchHint;
final ValueChanged<String> onSearch;
final VoidCallback onExport;
final bool isExporting;
final bool wrapped;
final TextEditingController? searchController;
final double searchWidth;
@override
Widget build(BuildContext context) {
final searchField = TextField(
controller: searchController,
decoration: InputDecoration(
hintText: searchHint,
prefixIcon: const Icon(Icons.search, size: 20),
isDense: true,
),
onChanged: onSearch,
);
final exportButton = OutlinedButton.icon(
onPressed: isExporting ? null : onExport,
icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
);
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
Row(
children: [
const Spacer(),
exportButton,
],
),
],
);
}
return Row(
children: [
SizedBox(width: searchWidth, child: searchField),
const Spacer(),
exportButton,
],
);
}
}

View File

@ -0,0 +1,329 @@
import 'package:flutter/material.dart';
import 'app_dropdown.dart';
/// Dropdown that opens a searchable popup with multi-select checkboxes.
class AppSearchableMultiSelectDropdown<T> extends StatefulWidget {
const AppSearchableMultiSelectDropdown({
super.key,
required this.label,
required this.values,
required this.options,
required this.onChanged,
this.validator,
this.hint,
this.searchHint = 'Search...',
this.enabled = true,
this.isDense = false,
});
final String label;
final List<T> values;
final List<AppDropdownOption<T>> options;
final ValueChanged<List<T>> onChanged;
final String? Function(List<T>?)? validator;
final String? hint;
final String searchHint;
final bool enabled;
final bool isDense;
@override
State<AppSearchableMultiSelectDropdown<T>> createState() =>
_AppSearchableMultiSelectDropdownState<T>();
}
class _AppSearchableMultiSelectDropdownState<T>
extends State<AppSearchableMultiSelectDropdown<T>> {
final _layerLink = LayerLink();
final _fieldKey = GlobalKey();
OverlayEntry? _overlayEntry;
@override
void dispose() {
_removeOverlay();
super.dispose();
}
String _displayText() {
if (widget.values.isEmpty) return '';
final labels = <String>[];
for (final value in widget.values) {
for (final option in widget.options) {
if (option.value == value) {
labels.add(option.label);
break;
}
}
}
return labels.join(', ');
}
void _removeOverlay() {
if (_overlayEntry == null) return;
_overlayEntry!.remove();
_overlayEntry = null;
if (mounted) setState(() {});
}
void _openPicker(FormFieldState<List<T>> field) {
if (!widget.enabled || widget.options.isEmpty) return;
if (_overlayEntry != null) {
_removeOverlay();
return;
}
final renderBox =
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final fieldSize = renderBox.size;
final fieldTopLeft = renderBox.localToGlobal(Offset.zero);
final screenSize = MediaQuery.sizeOf(context);
final viewInsets = MediaQuery.viewInsetsOf(context);
final spaceBelow =
screenSize.height - viewInsets.bottom - fieldTopLeft.dy - fieldSize.height;
final spaceAbove = fieldTopLeft.dy - viewInsets.top;
final showAbove = spaceBelow < 180 && spaceAbove > spaceBelow;
final availableSpace = (showAbove ? spaceAbove : spaceBelow) - 8;
final maxPanelHeight = availableSpace.clamp(120.0, screenSize.height * 0.45);
var selected = List<T>.from(widget.values);
_overlayEntry = OverlayEntry(
builder: (overlayContext) {
final theme = Theme.of(overlayContext);
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: () {
_removeOverlay();
field.didChange(selected);
widget.onChanged(selected);
},
behavior: HitTestBehavior.translucent,
),
),
CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
targetAnchor:
showAbove ? Alignment.topLeft : Alignment.bottomLeft,
followerAnchor:
showAbove ? Alignment.bottomLeft : Alignment.topLeft,
offset: Offset(0, showAbove ? -4 : 4),
child: TapRegion(
onTapOutside: (_) {
_removeOverlay();
field.didChange(selected);
widget.onChanged(selected);
},
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
color: theme.colorScheme.surface,
shadowColor: Colors.black45,
child: SizedBox(
width: fieldSize.width,
child: _SearchableMultiSelectPanel<T>(
maxHeight: maxPanelHeight,
options: widget.options,
selected: selected,
searchHint: widget.searchHint,
onToggle: (value, checked) {
if (checked) {
if (!selected.contains(value)) {
selected = [...selected, value];
}
} else {
selected = selected.where((v) => v != value).toList();
}
_overlayEntry?.markNeedsBuild();
},
),
),
),
),
),
],
);
},
);
Overlay.of(context).insert(_overlayEntry!);
setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final displayText = _displayText();
return FormField<List<T>>(
initialValue: widget.values,
validator: widget.validator,
builder: (field) {
final effectiveHint =
widget.hint ?? 'Select ${widget.label.toLowerCase()}';
final canOpen = widget.enabled && widget.options.isNotEmpty;
final colors = theme.colorScheme;
return CompositedTransformTarget(
link: _layerLink,
child: KeyedSubtree(
key: _fieldKey,
child: InkWell(
onTap: canOpen ? () => _openPicker(field) : null,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
isFocused: _overlayEntry != null,
isEmpty: displayText.isEmpty,
decoration: InputDecoration(
labelText: widget.label,
hintText: displayText.isEmpty ? effectiveHint : null,
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: widget.isDense,
errorText: field.errorText,
suffixIcon: Icon(
_overlayEntry != null
? Icons.arrow_drop_up
: Icons.arrow_drop_down,
color:
canOpen ? colors.onSurfaceVariant : theme.disabledColor,
),
enabled: canOpen,
),
child: displayText.isEmpty
? const SizedBox.shrink()
: Text(
displayText,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color: colors.onSurface,
),
),
),
),
),
);
},
);
}
}
class _SearchableMultiSelectPanel<T> extends StatefulWidget {
const _SearchableMultiSelectPanel({
required this.maxHeight,
required this.options,
required this.selected,
required this.searchHint,
required this.onToggle,
});
final double maxHeight;
final List<AppDropdownOption<T>> options;
final List<T> selected;
final String searchHint;
final void Function(T value, bool checked) onToggle;
@override
State<_SearchableMultiSelectPanel<T>> createState() =>
_SearchableMultiSelectPanelState<T>();
}
class _SearchableMultiSelectPanelState<T>
extends State<_SearchableMultiSelectPanel<T>> {
final _searchController = TextEditingController();
String _query = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<AppDropdownOption<T>> get _filtered {
final q = _query.trim().toLowerCase();
if (q.isEmpty) return widget.options;
return widget.options
.where((option) => option.label.toLowerCase().contains(q))
.toList();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final filtered = _filtered;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: widget.searchHint,
prefixIcon: const Icon(Icons.search, size: 20),
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onChanged: (value) => setState(() => _query = value),
),
),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: widget.maxHeight - 56),
child: filtered.isEmpty
? Padding(
padding: const EdgeInsets.all(20),
child: Text(
'No options found',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 4),
itemCount: filtered.length,
separatorBuilder: (_, __) => Divider(
height: 1,
color: theme.colorScheme.outlineVariant.withValues(
alpha: 0.5,
),
),
itemBuilder: (context, index) {
final option = filtered[index];
final isSelected = widget.selected.contains(option.value);
return CheckboxListTile(
dense: true,
visualDensity: VisualDensity.compact,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(
option.label,
overflow: TextOverflow.ellipsis,
),
value: isSelected,
onChanged: (checked) =>
widget.onToggle(option.value, checked ?? false),
);
},
),
),
],
);
}
}

View File

@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
Future<T?> showSidePanel<T>(
BuildContext context,
Widget panel, {
double? width,
}) {
final screenWidth = MediaQuery.sizeOf(context).width;
final defaultWidth = screenWidth > 1200
? 480.0
: (screenWidth * 0.38).clamp(360.0, 480.0);
final panelWidth = (width ?? defaultWidth).clamp(360.0, screenWidth * 0.95);
return showGeneralDialog<T>(
context: context,
useRootNavigator: true,
barrierDismissible: true,
barrierLabel: 'Dismiss',
barrierColor: Colors.black.withValues(alpha: 0.35),
transitionDuration: const Duration(milliseconds: 280),
pageBuilder: (context, _, __) {
final theme = Theme.of(context);
return Align(
alignment: Alignment.centerRight,
child: Material(
elevation: 16,
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.horizontal(left: Radius.circular(16)),
clipBehavior: Clip.antiAlias,
child: SizedBox(
width: panelWidth,
height: MediaQuery.sizeOf(context).height,
child: panel,
),
),
);
},
transitionBuilder: (context, anim, _, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)),
child: child,
);
},
);
}
class SidePanelScaffold extends StatelessWidget {
const SidePanelScaffold({
super.key,
required this.title,
required this.child,
this.footer,
this.onClose,
});
final String title;
final Widget child;
final Widget? footer;
final VoidCallback? onClose;
void _close(BuildContext context) {
if (onClose != null) {
onClose!();
return;
}
Navigator.of(context, rootNavigator: true).pop();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 12, 16),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => _close(context),
),
],
),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: child,
),
),
if (footer != null) ...[
const Divider(height: 1),
Padding(padding: const EdgeInsets.all(24), child: footer),
],
],
);
}
}
class SidePanelSection extends StatelessWidget {
const SidePanelSection({
super.key,
required this.title,
required this.children,
});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final dividerColor =
Theme.of(context).colorScheme.outline.withValues(alpha: 0.2);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Divider(height: 1, color: dividerColor),
const SizedBox(height: 16),
...children,
const SizedBox(height: 24),
],
);
}
}
class SidePanelFormRow extends StatelessWidget {
const SidePanelFormRow({
super.key,
required this.left,
required this.right,
this.spacing = 16,
});
final Widget left;
final Widget right;
final double spacing;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 420;
if (stack) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
left,
const SizedBox(height: 12),
right,
],
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: left),
SizedBox(width: spacing),
Expanded(child: right),
],
),
);
},
);
}
}