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