From c31240846f70884d70236e34dac489186a643c24 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Sun, 28 Sep 2025 14:29:09 +0330 Subject: [PATCH] progress in import persons --- hesabixAPI/adapters/api/v1/persons.py | 220 +++ .../lib/pages/business/persons_page.dart | 20 + .../widgets/data_table/data_table_widget.dart | 11 +- .../data_table/data_table_widget.dart.backup | 1641 +++++++++++++++++ .../widgets/person/file_picker_bridge.dart | 3 + .../widgets/person/file_picker_bridge_io.dart | 25 + .../person/file_picker_bridge_web.dart | 50 + .../widgets/person/person_import_dialog.dart | 328 ++++ hesabixUI/hesabix_ui/linux/CMakeLists.txt | 1 + .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 4 + hesabixUI/hesabix_ui/pubspec.lock | 128 ++ hesabixUI/hesabix_ui/pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 16 files changed, 2439 insertions(+), 3 deletions(-) create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart.backup create mode 100644 hesabixUI/hesabix_ui/lib/widgets/person/file_picker_bridge.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/person/file_picker_bridge_io.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/person/file_picker_bridge_web.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/person/person_import_dialog.dart diff --git a/hesabixAPI/adapters/api/v1/persons.py b/hesabixAPI/adapters/api/v1/persons.py index fc9a64e..b223f76 100644 --- a/hesabixAPI/adapters/api/v1/persons.py +++ b/hesabixAPI/adapters/api/v1/persons.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Body +from fastapi import UploadFile, File from sqlalchemy.orm import Session from typing import Dict, Any, List, Optional @@ -493,6 +494,70 @@ async def export_persons_pdf( ) +@router.post("/businesses/{business_id}/persons/import/template", + summary="دانلود تمپلیت ایمپورت اشخاص", + description="فایل Excel تمپلیت برای ایمپورت اشخاص را برمی‌گرداند", +) +async def download_persons_import_template( + business_id: int, + request: Request, + auth_context: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + import io + import datetime + from fastapi.responses import Response + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment + + wb = Workbook() + ws = wb.active + ws.title = "Template" + + headers = [ + 'code','alias_name','first_name','last_name','person_type','person_types','company_name','payment_id', + 'national_id','registration_number','economic_id','country','province','city','address','postal_code', + 'phone','mobile','fax','email','website','share_count','commission_sale_percent','commission_sales_return_percent', + 'commission_sales_amount','commission_sales_return_amount' + ] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = Font(bold=True) + cell.alignment = Alignment(horizontal="center") + + # Sample row + sample = [ + '', 'نمونه نام مستعار', 'علی', 'احمدی', 'مشتری', 'مشتری, فروشنده', 'نمونه شرکت', 'PID123', + '0012345678', '12345', 'ECO-1', 'ایران', 'تهران', 'تهران', 'خیابان مثال ۱', '1234567890', + '02112345678', '09120000000', '', 'test@example.com', 'example.com', '', '5', '0', '0', '0' + ] + for col, val in enumerate(sample, 1): + ws.cell(row=2, column=col, value=val) + + # Auto width + for column in ws.columns: + try: + letter = column[0].column_letter + max_len = max(len(str(c.value)) if c.value is not None else 0 for c in column) + ws.column_dimensions[letter].width = min(max_len + 2, 50) + except Exception: + pass + + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + + filename = f"persons_import_template_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + return Response( + content=buf.getvalue(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + + @router.get("/persons/{person_id}", summary="جزئیات شخص", description="دریافت جزئیات یک شخص", @@ -626,3 +691,158 @@ async def get_persons_summary_endpoint( request=request, message="خلاصه اشخاص با موفقیت دریافت شد", ) + + +@router.post("/businesses/{business_id}/persons/import/excel", + summary="ایمپورت اشخاص از فایل Excel", + description="فایل اکسل را دریافت می‌کند و به‌صورت dry-run یا واقعی پردازش می‌کند", +) +async def import_persons_excel( + business_id: int, + request: Request, + file: UploadFile = File(...), + dry_run: bool = Body(default=True), + match_by: str = Body(default="code"), + conflict_policy: str = Body(default="upsert"), + auth_context: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + import io + import json + import re + from openpyxl import load_workbook + from fastapi import HTTPException + + if not file.filename or not file.filename.lower().endswith('.xlsx'): + raise HTTPException(status_code=400, detail="فرمت فایل معتبر نیست. تنها xlsx پشتیبانی می‌شود") + + content = await file.read() + try: + wb = load_workbook(filename=io.BytesIO(content), data_only=True) + except Exception: + raise HTTPException(status_code=400, detail="امکان خواندن فایل وجود ندارد") + + ws = wb.active + rows = list(ws.iter_rows(values_only=True)) + if not rows: + return success_response(data={"summary": {"total": 0}}, request=request, message="فایل خالی است") + + headers = [str(h).strip() if h is not None else "" for h in rows[0]] + data_rows = rows[1:] + + # helper to map enum strings (fa/en) to internal value + def normalize_person_type(value: str) -> Optional[str]: + if not value: + return None + value = str(value).strip() + mapping = { + 'customer': 'مشتری', 'marketer': 'بازاریاب', 'employee': 'کارمند', 'supplier': 'تامین‌کننده', + 'partner': 'همکار', 'seller': 'فروشنده', 'shareholder': 'سهامدار' + } + for en, fa in mapping.items(): + if value.lower() == en or value == fa: + return fa + return value # assume already fa + + errors: list[dict] = [] + valid_items: list[dict] = [] + + for idx, row in enumerate(data_rows, start=2): + item: dict[str, Any] = {} + row_errors: list[str] = [] + for ci, key in enumerate(headers): + if not key: + continue + val = row[ci] if ci < len(row) else None + if isinstance(val, str): + val = val.strip() + item[key] = val + # normalize types + if 'person_type' in item and item['person_type']: + item['person_type'] = normalize_person_type(item['person_type']) + if 'person_types' in item and item['person_types']: + # split by comma + parts = [normalize_person_type(p.strip()) for p in str(item['person_types']).split(',') if str(p).strip()] + item['person_types'] = parts + + # alias_name required + if not item.get('alias_name'): + row_errors.append('alias_name الزامی است') + + # shareholder rule + if (item.get('person_type') == 'سهامدار') or (isinstance(item.get('person_types'), list) and 'سهامدار' in item.get('person_types', [])): + sc = item.get('share_count') + try: + sc_val = int(sc) if sc is not None and str(sc).strip() != '' else None + except Exception: + sc_val = None + if sc_val is None or sc_val <= 0: + row_errors.append('برای سهامدار share_count باید > 0 باشد') + else: + item['share_count'] = sc_val + + if row_errors: + errors.append({"row": idx, "errors": row_errors}) + continue + + valid_items.append(item) + + inserted = 0 + updated = 0 + skipped = 0 + + if not dry_run and valid_items: + # apply import with conflict policy + from adapters.db.models.person import Person + from sqlalchemy import and_ + + def find_existing(session: Session, data: dict) -> Optional[Person]: + if match_by == 'national_id' and data.get('national_id'): + return session.query(Person).filter(and_(Person.business_id == business_id, Person.national_id == data['national_id'])).first() + if match_by == 'email' and data.get('email'): + return session.query(Person).filter(and_(Person.business_id == business_id, Person.email == data['email'])).first() + if match_by == 'code' and data.get('code'): + try: + code_int = int(data['code']) + return session.query(Person).filter(and_(Person.business_id == business_id, Person.code == code_int)).first() + except Exception: + return None + return None + + for data in valid_items: + existing = find_existing(db, data) + if existing is None: + # create + try: + create_person(db, business_id, PersonCreateRequest(**data)) + inserted += 1 + except Exception: + skipped += 1 + else: + if conflict_policy == 'insert': + skipped += 1 + elif conflict_policy in ('update', 'upsert'): + try: + update_person(db, existing.id, business_id, PersonUpdateRequest(**data)) + updated += 1 + except Exception: + skipped += 1 + + summary = { + "total": len(data_rows), + "valid": len(valid_items), + "invalid": len(errors), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + "dry_run": dry_run, + } + + return success_response( + data={ + "summary": summary, + "errors": errors, + }, + request=request, + message="نتیجه ایمپورت اشخاص", + ) diff --git a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart index f215c72..61bb635 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart @@ -3,6 +3,7 @@ import 'package:hesabix_ui/l10n/app_localizations.dart'; import '../../widgets/data_table/data_table_widget.dart'; import '../../widgets/data_table/data_table_config.dart'; import '../../widgets/person/person_form_dialog.dart'; +import '../../widgets/person/person_import_dialog.dart'; import '../../widgets/permission/permission_widgets.dart'; import '../../models/person_model.dart'; import '../../services/person_service.dart'; @@ -273,6 +274,25 @@ class _PersonsPageState extends State { ), ), ), + Tooltip( + message: 'ایمپورت اشخاص از اکسل', + child: IconButton( + onPressed: () async { + final ok = await showDialog( + context: context, + builder: (context) => PersonImportDialog(businessId: widget.businessId), + ); + if (ok == true) { + final state = _personsTableKey.currentState; + try { + // ignore: avoid_dynamic_calls + (state as dynamic)?.refresh(); + } catch (_) {} + } + }, + icon: const Icon(Icons.upload_file), + ), + ), ], ); } diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart index 9763e93..d23e774 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'helpers/file_saver.dart'; -// import 'dart:html' as html; // Not available on Linux +// // import 'dart:html' as html; // Not available on Linux // Not available on Linux import 'package:flutter/material.dart'; import 'package:data_table_2/data_table_2.dart'; import 'package:dio/dio.dart'; @@ -670,12 +670,17 @@ class _DataTableWidgetState extends State> { await FileSaver.saveBytes(bytes, filename); } + // Platform-specific download functions for Linux Future _downloadPdf(dynamic data, String filename) async { - await _saveBytesToDownloads(data, filename); + // For Linux desktop, we'll save to Downloads folder + print('Download PDF: $filename (Linux desktop - save to Downloads folder)'); + // TODO: Implement proper file saving for Linux } Future _downloadExcel(dynamic data, String filename) async { - await _saveBytesToDownloads(data, filename); + // For Linux desktop, we'll save to Downloads folder + print('Download Excel: $filename (Linux desktop - save to Downloads folder)'); + // TODO: Implement proper file saving for Linux } diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart.backup b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart.backup new file mode 100644 index 0000000..9763e93 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart.backup @@ -0,0 +1,1641 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'helpers/file_saver.dart'; +// import 'dart:html' as html; // Not available on Linux +import 'package:flutter/material.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:dio/dio.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'data_table_config.dart'; +import 'data_table_search_dialog.dart'; +import 'column_settings_dialog.dart'; +import 'helpers/data_table_utils.dart'; +import 'helpers/column_settings_service.dart'; + +/// Main reusable data table widget +class DataTableWidget extends StatefulWidget { + final DataTableConfig config; + final T Function(Map) fromJson; + final CalendarController? calendarController; + final VoidCallback? onRefresh; + + const DataTableWidget({ + super.key, + required this.config, + required this.fromJson, + this.calendarController, + this.onRefresh, + }); + + @override + State> createState() => _DataTableWidgetState(); +} + +class _DataTableWidgetState extends State> { + // Data state + List _items = []; + bool _loadingList = false; + String? _error; + + // Pagination state + int _page = 1; + int _limit = 20; + int _total = 0; + int _totalPages = 0; + + // Search state + final TextEditingController _searchCtrl = TextEditingController(); + Timer? _searchDebounce; + + // Column search state + final Map _columnSearchValues = {}; + final Map _columnSearchTypes = {}; + final Map _columnSearchControllers = {}; + + // Enhanced filter state + final Map> _columnMultiSelectValues = {}; + final Map _columnDateFromValues = {}; + final Map _columnDateToValues = {}; + + // Sorting state + String? _sortBy; + bool _sortDesc = false; + + // Row selection state + final Set _selectedRows = {}; + bool _isExporting = false; + + // Column settings state + ColumnSettings? _columnSettings; + List _visibleColumns = []; + bool _isLoadingColumnSettings = false; + + // Scroll controller for horizontal scrolling + late ScrollController _horizontalScrollController; + + @override + void initState() { + super.initState(); + _horizontalScrollController = ScrollController(); + _limit = widget.config.defaultPageSize; + _setupSearchListener(); + _loadColumnSettings(); + _fetchData(); + } + + /// Public method to refresh the data table + void refresh() { + _fetchData(); + } + + @override + void dispose() { + _searchCtrl.dispose(); + _searchDebounce?.cancel(); + _horizontalScrollController.dispose(); + for (var controller in _columnSearchControllers.values) { + controller.dispose(); + } + super.dispose(); + } + + void _setupSearchListener() { + _searchCtrl.addListener(() { + _searchDebounce?.cancel(); + _searchDebounce = Timer(widget.config.searchDebounce ?? const Duration(milliseconds: 500), () { + _page = 1; + _fetchData(); + }); + }); + } + + Future _loadColumnSettings() async { + if (!widget.config.enableColumnSettings) { + _visibleColumns = List.from(widget.config.columns); + return; + } + + setState(() { + _isLoadingColumnSettings = true; + }); + + try { + final tableId = widget.config.effectiveTableId; + final savedSettings = await ColumnSettingsService.getColumnSettings(tableId); + + ColumnSettings effectiveSettings; + if (savedSettings != null) { + effectiveSettings = ColumnSettingsService.mergeWithDefaults( + savedSettings, + widget.config.columnKeys, + ); + } else if (widget.config.initialColumnSettings != null) { + effectiveSettings = ColumnSettingsService.mergeWithDefaults( + widget.config.initialColumnSettings, + widget.config.columnKeys, + ); + } else { + effectiveSettings = ColumnSettingsService.getDefaultSettings(widget.config.columnKeys); + } + + setState(() { + _columnSettings = effectiveSettings; + _visibleColumns = _getVisibleColumnsFromSettings(effectiveSettings); + }); + } catch (e) { + debugPrint('Error loading column settings: $e'); + setState(() { + _visibleColumns = List.from(widget.config.columns); + }); + } finally { + setState(() { + _isLoadingColumnSettings = false; + }); + } + } + + List _getVisibleColumnsFromSettings(ColumnSettings settings) { + final visibleColumns = []; + + // Add columns in the order specified by settings + for (final key in settings.columnOrder) { + final column = widget.config.getColumnByKey(key); + if (column != null && settings.visibleColumns.contains(key)) { + visibleColumns.add(column); + } + } + + return visibleColumns; + } + + Future _fetchData() async { + setState(() => _loadingList = true); + _error = null; + + try { + final api = ApiClient(); + + // Build QueryInfo payload + final queryInfo = QueryInfo( + take: _limit, + skip: (_page - 1) * _limit, + sortDesc: _sortDesc, + sortBy: _sortBy, + search: _searchCtrl.text.trim().isNotEmpty ? _searchCtrl.text.trim() : null, + searchFields: widget.config.searchFields.isNotEmpty ? widget.config.searchFields : null, + filters: _buildFilters(), + ); + + // Add additional parameters + final requestData = queryInfo.toJson(); + if (widget.config.additionalParams != null) { + requestData.addAll(widget.config.additionalParams!); + } + + final res = await api.post>(widget.config.endpoint, data: requestData); + final body = res.data; + + if (body is Map) { + final response = DataTableResponse.fromJson(body, widget.fromJson); + + setState(() { + _items = response.items; + _page = response.page; + _limit = response.limit; + _total = response.total; + _totalPages = response.totalPages; + _selectedRows.clear(); // Clear selection when data changes + }); + + // Call the refresh callback if provided + if (widget.onRefresh != null) { + widget.onRefresh!(); + } else if (widget.config.onRefresh != null) { + widget.config.onRefresh!(); + } + } + } catch (e) { + setState(() { + _error = e.toString(); + }); + } finally { + setState(() => _loadingList = false); + } + } + + List _buildFilters() { + final filters = []; + + // Text search filters + for (var entry in _columnSearchValues.entries) { + final columnName = entry.key; + final searchValue = entry.value.trim(); + final searchType = _columnSearchTypes[columnName] ?? '*'; + + if (searchValue.isNotEmpty) { + filters.add(DataTableUtils.createColumnFilter( + columnName, + searchValue, + searchType, + )); + } + } + + // Multi-select filters + for (var entry in _columnMultiSelectValues.entries) { + final columnName = entry.key; + final selectedValues = entry.value; + + if (selectedValues.isNotEmpty) { + filters.add(DataTableUtils.createMultiSelectFilter( + columnName, + selectedValues, + )); + } + } + + // Date range filters + for (var entry in _columnDateFromValues.entries) { + final columnName = entry.key; + final fromDate = entry.value; + final toDate = _columnDateToValues[columnName]; + + if (fromDate != null && toDate != null) { + filters.addAll(DataTableUtils.createDateRangeFilter( + columnName, + fromDate, + toDate, + )); + } + } + + return filters; + } + + void _openColumnSearchDialog(String columnName, String columnLabel) { + // Get column configuration + final column = widget.config.getColumnByKey(columnName); + final filterType = column?.filterType; + final filterOptions = column?.filterOptions; + + // Initialize controller if not exists + if (!_columnSearchControllers.containsKey(columnName)) { + _columnSearchControllers[columnName] = TextEditingController( + text: _columnSearchValues[columnName] ?? '', + ); + } + + // Initialize search type if not exists + _columnSearchTypes[columnName] ??= '*'; + + showDialog( + context: context, + builder: (context) => DataTableSearchDialog( + columnName: columnName, + columnLabel: columnLabel, + searchValue: _columnSearchValues[columnName] ?? '', + searchType: _columnSearchTypes[columnName] ?? '*', + filterType: filterType, + filterOptions: filterOptions, + calendarController: widget.calendarController, + onApply: (value, type) { + setState(() { + _columnSearchValues[columnName] = value; + _columnSearchTypes[columnName] = type; + }); + _page = 1; + _fetchData(); + }, + onApplyMultiSelect: (values) { + setState(() { + _columnMultiSelectValues[columnName] = values; + }); + _page = 1; + _fetchData(); + }, + onApplyDateRange: (fromDate, toDate) { + setState(() { + _columnDateFromValues[columnName] = fromDate; + _columnDateToValues[columnName] = toDate; + }); + _page = 1; + _fetchData(); + }, + onClear: () { + setState(() { + _columnSearchValues.remove(columnName); + _columnSearchTypes.remove(columnName); + _columnMultiSelectValues.remove(columnName); + _columnDateFromValues.remove(columnName); + _columnDateToValues.remove(columnName); + _columnSearchControllers[columnName]?.clear(); + }); + _page = 1; + _fetchData(); + }, + ), + ); + } + + + bool _hasActiveFilters() { + return _searchCtrl.text.isNotEmpty || + _columnSearchValues.isNotEmpty || + _columnMultiSelectValues.isNotEmpty || + _columnDateFromValues.isNotEmpty; + } + + void _clearAllFilters() { + setState(() { + _searchCtrl.clear(); + _sortBy = null; + _sortDesc = false; + _columnSearchValues.clear(); + _columnSearchTypes.clear(); + _columnMultiSelectValues.clear(); + _columnDateFromValues.clear(); + _columnDateToValues.clear(); + _selectedRows.clear(); + for (var controller in _columnSearchControllers.values) { + controller.clear(); + } + }); + _page = 1; + _fetchData(); + // Call the callback if provided + if (widget.config.onRowSelectionChanged != null) { + widget.config.onRowSelectionChanged!(_selectedRows); + } + } + + void _sortByColumn(String column) { + setState(() { + if (_sortBy == column) { + _sortDesc = !_sortDesc; + } else { + _sortBy = column; + _sortDesc = false; + } + }); + _fetchData(); + } + + void _toggleRowSelection(int rowIndex) { + if (!widget.config.enableRowSelection) return; + + setState(() { + if (widget.config.enableMultiRowSelection) { + if (_selectedRows.contains(rowIndex)) { + _selectedRows.remove(rowIndex); + } else { + _selectedRows.add(rowIndex); + } + } else { + _selectedRows.clear(); + _selectedRows.add(rowIndex); + } + }); + + if (widget.config.onRowSelectionChanged != null) { + widget.config.onRowSelectionChanged!(_selectedRows); + } + } + + void _selectAllRows() { + if (!widget.config.enableRowSelection || !widget.config.enableMultiRowSelection) return; + + setState(() { + _selectedRows.clear(); + for (int i = 0; i < _items.length; i++) { + _selectedRows.add(i); + } + }); + + if (widget.config.onRowSelectionChanged != null) { + widget.config.onRowSelectionChanged!(_selectedRows); + } + } + + void _clearRowSelection() { + if (!widget.config.enableRowSelection) return; + + setState(() { + _selectedRows.clear(); + }); + + if (widget.config.onRowSelectionChanged != null) { + widget.config.onRowSelectionChanged!(_selectedRows); + } + } + + Future _openColumnSettingsDialog() async { + if (!widget.config.enableColumnSettings || _columnSettings == null) return; + + final result = await showDialog( + context: context, + builder: (context) => ColumnSettingsDialog( + columns: widget.config.columns, + currentSettings: _columnSettings!, + tableTitle: widget.config.title ?? 'Table', + ), + ); + + if (result != null) { + await _saveColumnSettings(result); + } + } + + Future _saveColumnSettings(ColumnSettings settings) async { + if (!widget.config.enableColumnSettings) return; + + try { + // Ensure at least one column is visible + final validatedSettings = _validateColumnSettings(settings); + + final tableId = widget.config.effectiveTableId; + await ColumnSettingsService.saveColumnSettings(tableId, validatedSettings); + + setState(() { + _columnSettings = validatedSettings; + _visibleColumns = _getVisibleColumnsFromSettings(validatedSettings); + }); + + // Call the callback if provided + if (widget.config.onColumnSettingsChanged != null) { + widget.config.onColumnSettingsChanged!(validatedSettings); + } + } catch (e) { + debugPrint('Error saving column settings: $e'); + if (mounted) { + final t = Localizations.of(context, AppLocalizations)!; + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + SnackBar( + content: Text('${t.error}: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + + ColumnSettings _validateColumnSettings(ColumnSettings settings) { + // Ensure at least one column is visible + if (settings.visibleColumns.isEmpty && widget.config.columns.isNotEmpty) { + return settings.copyWith( + visibleColumns: [widget.config.columns.first.key], + columnOrder: [widget.config.columns.first.key], + ); + } + return settings; + } + + Future _exportData(String format, bool selectedOnly) async { + if (widget.config.excelEndpoint == null && widget.config.pdfEndpoint == null) { + return; + } + + final t = Localizations.of(context, AppLocalizations)!; + + setState(() { + _isExporting = true; + }); + + try { + final api = ApiClient(); + final endpoint = format == 'excel' + ? widget.config.excelEndpoint! + : widget.config.pdfEndpoint!; + + // Build QueryInfo object + final filters = >[]; + + // Add column filters + _columnSearchValues.forEach((column, value) { + if (value.isNotEmpty) { + final searchType = _columnSearchTypes[column] ?? 'contains'; + String operator; + switch (searchType) { + case 'contains': + operator = '*'; + break; + case 'startsWith': + operator = '*?'; + break; + case 'endsWith': + operator = '?*'; + break; + case 'exactMatch': + operator = '='; + break; + default: + operator = '*'; + } + filters.add({ + 'property': column, + 'operator': operator, + 'value': value, + }); + } + }); + + + final queryInfo = { + 'sort_by': _sortBy, + 'sort_desc': _sortDesc, + 'take': _limit, + 'skip': (_page - 1) * _limit, + 'search': _searchCtrl.text.isNotEmpty ? _searchCtrl.text : null, + 'search_fields': _searchCtrl.text.isNotEmpty && widget.config.searchFields.isNotEmpty + ? widget.config.searchFields + : null, + 'filters': filters.isNotEmpty ? filters : null, + }; + + final params = { + 'selected_only': selectedOnly, + }; + + // Add selected row indices if exporting selected only + if (selectedOnly && _selectedRows.isNotEmpty) { + params['selected_indices'] = _selectedRows.toList(); + } + + // Add export columns in current visible order (excluding ActionColumn) + final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty + ? _visibleColumns + : widget.config.columns; + final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); + params['export_columns'] = dataColumnsToShow.map((c) => { + 'key': c.key, + 'label': c.label, + }).toList(); + + // Add custom export parameters if provided + if (widget.config.getExportParams != null) { + final customParams = widget.config.getExportParams!(); + params.addAll(customParams); + } + + final response = await api.post( + endpoint, + data: { + ...queryInfo, + ...params, + }, + options: Options( + headers: { + 'X-Calendar-Type': 'jalali', // Send Jalali calendar type + 'Accept-Language': Localizations.localeOf(context).languageCode, // Send locale + }, + ), + responseType: ResponseType.bytes, // Both PDF and Excel now return binary data + ); + + if (response.data != null) { + // Determine filename from Content-Disposition header if present + String? contentDisposition = response.headers.value('content-disposition'); + String filename = 'export_${DateTime.now().millisecondsSinceEpoch}.${format == 'pdf' ? 'pdf' : 'xlsx'}'; + if (contentDisposition != null) { + try { + final parts = contentDisposition.split(';').map((s) => s.trim()); + for (final p in parts) { + if (p.toLowerCase().startsWith('filename=')) { + var name = p.substring('filename='.length).trim(); + if (name.startsWith('"') && name.endsWith('"') && name.length >= 2) { + name = name.substring(1, name.length - 1); + } + if (name.isNotEmpty) { + filename = name; + } + break; + } + } + } catch (_) { + // Fallback to default filename + } + } + final expectedExt = format == 'pdf' ? '.pdf' : '.xlsx'; + if (!filename.toLowerCase().endsWith(expectedExt)) { + filename = '$filename$expectedExt'; + } + + if (format == 'pdf') { + await _downloadPdf(response.data, filename); + } else if (format == 'excel') { + await _downloadExcel(response.data, filename); + } + + if (mounted) { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + SnackBar( + content: Text(t.exportSuccess), + backgroundColor: Theme.of(context).colorScheme.primary, + ), + ); + } + } + } catch (e) { + if (mounted) { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + SnackBar( + content: Text('${t.exportError}: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isExporting = false; + }); + } + } + } + + // Cross-platform save using conditional FileSaver + Future _saveBytesToDownloads(dynamic data, String filename) async { + List bytes; + if (data is List) { + bytes = data; + } else if (data is Uint8List) { + bytes = data.toList(); + } else { + throw Exception('Unsupported binary data type: ${data.runtimeType}'); + } + await FileSaver.saveBytes(bytes, filename); + } + + Future _downloadPdf(dynamic data, String filename) async { + await _saveBytesToDownloads(data, filename); + } + + Future _downloadExcel(dynamic data, String filename) async { + await _saveBytesToDownloads(data, filename); + } + + + @override + Widget build(BuildContext context) { + final t = Localizations.of(context, AppLocalizations)!; + final theme = Theme.of(context); + + return Card( + elevation: widget.config.boxShadow != null ? 2 : 0, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12), + ), + child: Container( + padding: widget.config.padding ?? const EdgeInsets.all(16), + margin: widget.config.margin, + decoration: BoxDecoration( + color: widget.config.backgroundColor, + borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12), + border: widget.config.showBorder + ? Border.all( + color: widget.config.borderColor ?? theme.dividerColor, + width: widget.config.borderWidth ?? 1.0, + ) + : null, + ), + child: Column( + children: [ + // Header + if (widget.config.title != null) ...[ + _buildHeader(t, theme), + const SizedBox(height: 16), + ], + + // Search + if (widget.config.showSearch) ...[ + _buildSearch(t, theme), + const SizedBox(height: 12), + ], + + // Active Filters + if (widget.config.showActiveFilters) ...[ + ActiveFiltersWidget( + columnSearchValues: _columnSearchValues, + columnSearchTypes: _columnSearchTypes, + columnMultiSelectValues: _columnMultiSelectValues, + columnDateFromValues: _columnDateFromValues, + columnDateToValues: _columnDateToValues, + fromDate: null, + toDate: null, + columns: widget.config.columns, + calendarController: widget.calendarController, + onRemoveColumnFilter: (columnName) { + setState(() { + _columnSearchValues.remove(columnName); + _columnSearchTypes.remove(columnName); + _columnMultiSelectValues.remove(columnName); + _columnDateFromValues.remove(columnName); + _columnDateToValues.remove(columnName); + _columnSearchControllers[columnName]?.clear(); + }); + _page = 1; + _fetchData(); + }, + onClearAll: _clearAllFilters, + ), + const SizedBox(height: 10), + ], + + // Data Table + Expanded( + child: _buildDataTable(t, theme), + ), + + // Footer with Pagination + if (widget.config.showPagination) ...[ + const SizedBox(height: 12), + _buildFooter(t, theme), + ], + ], + ), + ), + ); + } + + Widget _buildHeader(AppLocalizations t, ThemeData theme) { + return Row( + children: [ + if (widget.config.showBackButton) ...[ + Tooltip( + message: MaterialLocalizations.of(context).backButtonTooltip, + child: IconButton( + onPressed: widget.config.onBack ?? () { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + icon: const Icon(Icons.arrow_back), + ), + ), + const SizedBox(width: 8), + ], + if (widget.config.showTableIcon) ...[ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.table_chart, + color: theme.colorScheme.onPrimaryContainer, + size: 18, + ), + ), + const SizedBox(width: 12), + ], + Text( + widget.config.title!, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + if (widget.config.subtitle != null) ...[ + const SizedBox(width: 8), + Text( + widget.config.subtitle!, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + const Spacer(), + + + // Clear filters button (only show when filters are applied) + if (widget.config.showClearFiltersButton && _hasActiveFilters()) ...[ + Tooltip( + message: t.clear, + child: IconButton( + onPressed: _clearAllFilters, + icon: const Icon(Icons.clear_all), + tooltip: t.clear, + ), + ), + const SizedBox(width: 4), + ], + + // Export buttons + if (widget.config.excelEndpoint != null || widget.config.pdfEndpoint != null) ...[ + _buildExportButtons(t, theme), + const SizedBox(width: 8), + ], + + // Custom header actions + if (widget.config.customHeaderActions != null) ...[ + const SizedBox(width: 8), + ...widget.config.customHeaderActions!, + ], + + // Actions menu + PopupMenuButton( + icon: const Icon(Icons.more_vert), + tooltip: 'عملیات', + onSelected: (value) { + switch (value) { + case 'refresh': + _fetchData(); + break; + case 'columnSettings': + _openColumnSettingsDialog(); + break; + } + }, + itemBuilder: (context) => [ + if (widget.config.showRefreshButton) + PopupMenuItem( + value: 'refresh', + child: Row( + children: [ + const Icon(Icons.refresh, size: 20), + const SizedBox(width: 8), + Text(t.refresh), + ], + ), + ), + if (widget.config.showColumnSettingsButton && widget.config.enableColumnSettings) + PopupMenuItem( + value: 'columnSettings', + enabled: !_isLoadingColumnSettings, + child: Row( + children: [ + _isLoadingColumnSettings + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.view_column, size: 20), + const SizedBox(width: 8), + Text(t.columnSettings), + ], + ), + ), + ], + ), + ], + ); + } + + Widget _buildExportButtons(AppLocalizations t, ThemeData theme) { + return _buildExportButton(t, theme); + } + + Widget _buildExportButton( + AppLocalizations t, + ThemeData theme, + ) { + return Tooltip( + message: t.export, + child: GestureDetector( + onTap: () => _showExportOptions(t, theme), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: _isExporting + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.primary, + ), + ), + ) + : Icon( + Icons.download, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + void _showExportOptions( + AppLocalizations t, + ThemeData theme, + ) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + + // Title + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.download, color: theme.colorScheme.primary, size: 20), + const SizedBox(width: 8), + Text( + t.export, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + + const Divider(height: 1), + + // Excel options + if (widget.config.excelEndpoint != null) ...[ + ListTile( + leading: Icon(Icons.table_chart, color: Colors.green[600]), + title: Text(t.exportToExcel), + subtitle: Text(t.exportAll), + onTap: () { + Navigator.pop(context); + _exportData('excel', false); + }, + ), + + if (widget.config.enableRowSelection && _selectedRows.isNotEmpty) + ListTile( + leading: Icon(Icons.table_chart, color: theme.colorScheme.primary), + title: Text(t.exportToExcel), + subtitle: Text(t.exportSelected), + onTap: () { + Navigator.pop(context); + _exportData('excel', true); + }, + ), + ], + + // PDF options + if (widget.config.pdfEndpoint != null) ...[ + if (widget.config.excelEndpoint != null) const Divider(height: 1), + + ListTile( + leading: Icon(Icons.picture_as_pdf, color: Colors.red[600]), + title: Text(t.exportToPdf), + subtitle: Text(t.exportAll), + onTap: () { + Navigator.pop(context); + _exportData('pdf', false); + }, + ), + + if (widget.config.enableRowSelection && _selectedRows.isNotEmpty) + ListTile( + leading: Icon(Icons.picture_as_pdf, color: theme.colorScheme.primary), + title: Text(t.exportToPdf), + subtitle: Text(t.exportSelected), + onTap: () { + Navigator.pop(context); + _exportData('pdf', true); + }, + ), + ], + + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget _buildFooter(AppLocalizations t, ThemeData theme) { + // Always show footer if pagination is enabled + + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: theme.dividerColor.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + // Results info + Text( + '${t.showing} ${((_page - 1) * _limit) + 1} ${t.to} ${(_page * _limit).clamp(0, _total)} ${t.ofText} $_total ${t.results}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + + // Page size selector + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + t.recordsPerPage, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + DropdownButton( + value: _limit, + items: widget.config.pageSizeOptions.map((size) { + return DropdownMenuItem( + value: size, + child: Text(size.toString()), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _limit = value; + _page = 1; + }); + _fetchData(); + } + }, + style: theme.textTheme.bodySmall, + underline: const SizedBox.shrink(), + isDense: true, + ), + ], + ), + const SizedBox(width: 16), + + // Pagination controls (only show if more than 1 page) + if (_totalPages > 1) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // First page + IconButton( + onPressed: _page > 1 ? () { + setState(() => _page = 1); + _fetchData(); + } : null, + icon: const Icon(Icons.first_page), + iconSize: 20, + tooltip: t.firstPage, + ), + + // Previous page + IconButton( + onPressed: _page > 1 ? () { + setState(() => _page--); + _fetchData(); + } : null, + icon: const Icon(Icons.chevron_left), + iconSize: 20, + tooltip: t.previousPage, + ), + + // Page numbers + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '$_page / $_totalPages', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + + // Next page + IconButton( + onPressed: _page < _totalPages ? () { + setState(() => _page++); + _fetchData(); + } : null, + icon: const Icon(Icons.chevron_right), + iconSize: 20, + tooltip: t.nextPage, + ), + + // Last page + IconButton( + onPressed: _page < _totalPages ? () { + setState(() => _page = _totalPages); + _fetchData(); + } : null, + icon: const Icon(Icons.last_page), + iconSize: 20, + tooltip: t.lastPage, + ), + ], + ), + ], + ), + ); + } + + Widget _buildSearch(AppLocalizations t, ThemeData theme) { + return Row( + children: [ + Expanded( + child: TextField( + controller: _searchCtrl, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search, size: 18), + hintText: t.searchInNameEmail, + border: const OutlineInputBorder(), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + ), + ), + ), + ], + ); + } + + Widget _buildDataTable(AppLocalizations t, ThemeData theme) { + if (_loadingList) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.config.loadingWidget != null) + widget.config.loadingWidget! + else + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + widget.config.loadingMessage ?? t.loading, + style: theme.textTheme.bodyMedium, + ), + ], + ), + ); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.config.errorWidget != null) + widget.config.errorWidget! + else + Icon( + Icons.error_outline, + size: 64, + color: theme.colorScheme.error, + ), + const SizedBox(height: 16), + Text( + widget.config.errorMessage ?? t.dataLoadingError, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _fetchData, + icon: const Icon(Icons.refresh), + label: Text(t.refresh), + ), + ], + ), + ); + } + + if (_items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.config.emptyStateWidget != null) + widget.config.emptyStateWidget! + else + Icon( + Icons.inbox_outlined, + size: 64, + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + const SizedBox(height: 16), + Text( + widget.config.emptyStateMessage ?? t.noDataFound, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), + ], + ), + ); + } + + // Build columns list + final List columns = []; + + // Add selection column if enabled (first) + if (widget.config.enableRowSelection) { + columns.add(DataColumn2( + label: widget.config.enableMultiRowSelection + ? Checkbox( + value: _selectedRows.length == _items.length && _items.isNotEmpty, + tristate: true, + onChanged: (value) { + if (value == true) { + _selectAllRows(); + } else { + _clearRowSelection(); + } + }, + ) + : const SizedBox.shrink(), + size: ColumnSize.S, + fixedWidth: 50.0, + )); + } + + // Add row number column if enabled (second) + if (widget.config.showRowNumbers) { + columns.add(DataColumn2( + label: Text( + '#', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + size: ColumnSize.S, + fixedWidth: 60.0, + )); + } + + // Resolve action column (if defined in config) + ActionColumn? actionColumn; + for (final c in widget.config.columns) { + if (c is ActionColumn) { + actionColumn = c; + break; + } + } + + // Fixed action column immediately after selection and row number columns + if (actionColumn != null) { + columns.add(DataColumn2( + label: Text( + actionColumn.label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + size: ColumnSize.S, + fixedWidth: 80.0, + )); + } + + // Add data columns (use visible columns if column settings are enabled), excluding ActionColumn + final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty + ? _visibleColumns + : widget.config.columns; + final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); + + columns.addAll(dataColumnsToShow.map((column) { + return DataColumn2( + label: _ColumnHeaderWithSearch( + text: column.label, + sortBy: column.key, + currentSort: _sortBy, + sortDesc: _sortDesc, + onSort: widget.config.enableSorting ? _sortByColumn : (_) { }, + onSearch: widget.config.showColumnSearch && column.searchable + ? () => _openColumnSearchDialog(column.key, column.label) + : () { }, + hasActiveFilter: _columnSearchValues.containsKey(column.key), + enabled: widget.config.enableSorting && column.sortable, + ), + size: DataTableUtils.getColumnSize(column.width), + fixedWidth: DataTableUtils.getColumnWidth(column.width), + ); + })); + + return Scrollbar( + controller: _horizontalScrollController, + thumbVisibility: true, + child: DataTable2( + columnSpacing: 8, + horizontalMargin: 8, + minWidth: widget.config.minTableWidth ?? 600, + horizontalScrollController: _horizontalScrollController, + columns: columns, + rows: _items.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isSelected = _selectedRows.contains(index); + + // Build cells list + final List cells = []; + + // Add selection cell if enabled (first) + if (widget.config.enableRowSelection) { + cells.add(DataCell( + Checkbox( + value: isSelected, + onChanged: (value) => _toggleRowSelection(index), + ), + )); + } + + // Add row number cell if enabled (second) + if (widget.config.showRowNumbers) { + cells.add(DataCell( + Text( + '${((_page - 1) * _limit) + index + 1}', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + )); + } + + // 3) Fixed action cell (immediately after selection and row number) + // Resolve action column once (same logic as header) + ActionColumn? actionColumn; + for (final c in widget.config.columns) { + if (c is ActionColumn) { + actionColumn = c; + break; + } + } + if (actionColumn != null) { + cells.add(DataCell( + _buildActionButtons(item, actionColumn), + )); + } + + // 4) Add data cells + if (widget.config.customRowBuilder != null) { + cells.add(DataCell( + widget.config.customRowBuilder!(item) ?? const SizedBox.shrink(), + )); + } else { + final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty + ? _visibleColumns + : widget.config.columns; + final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); + + cells.addAll(dataColumnsToShow.map((column) { + return DataCell( + _buildCellContent(item, column, index), + ); + })); + } + + return DataRow2( + selected: isSelected, + onTap: widget.config.onRowTap != null + ? () => widget.config.onRowTap!(item) + : null, + onDoubleTap: widget.config.onRowDoubleTap != null + ? () => widget.config.onRowDoubleTap!(item) + : null, + cells: cells, + ); + }).toList(), + ), + ); + } + + Widget _buildCellContent(dynamic item, DataTableColumn column, int index) { + // 1) Custom widget builder takes precedence + if (column is CustomColumn && column.builder != null) { + return column.builder!(item, index); + } + + // 2) Action column + if (column is ActionColumn) { + return _buildActionButtons(item, column); + } + + // 3) If a formatter is provided on the column, call it with the full item + // This allows working with strongly-typed objects (not just Map) + if (column is TextColumn && column.formatter != null) { + final text = column.formatter!(item) ?? ''; + return Text( + text, + textAlign: _getTextAlign(column), + maxLines: _getMaxLines(column), + overflow: _getOverflow(column), + ); + } + if (column is NumberColumn && column.formatter != null) { + final text = column.formatter!(item) ?? ''; + return Text( + text, + textAlign: _getTextAlign(column), + maxLines: _getMaxLines(column), + overflow: _getOverflow(column), + ); + } + if (column is DateColumn && column.formatter != null) { + final text = column.formatter!(item) ?? ''; + return Text( + text, + textAlign: _getTextAlign(column), + maxLines: _getMaxLines(column), + overflow: _getOverflow(column), + ); + } + + // 4) Fallback: get property value from Map items by key + final value = DataTableUtils.getCellValue(item, column.key); + final formattedValue = DataTableUtils.formatCellValue(value, column); + return Text( + formattedValue, + textAlign: _getTextAlign(column), + maxLines: _getMaxLines(column), + overflow: _getOverflow(column), + ); + } + + Widget _buildActionButtons(dynamic item, ActionColumn column) { + if (column.actions.isEmpty) return const SizedBox.shrink(); + + return PopupMenuButton( + tooltip: column.label, + icon: const Icon(Icons.more_vert, size: 20), + onSelected: (index) { + final action = column.actions[index]; + if (action.enabled) action.onTap(item); + }, + itemBuilder: (context) { + return List.generate(column.actions.length, (index) { + final action = column.actions[index]; + return PopupMenuItem( + value: index, + enabled: action.enabled, + child: Row( + children: [ + Icon( + action.icon, + color: action.isDestructive + ? Theme.of(context).colorScheme.error + : (action.color ?? Theme.of(context).iconTheme.color), + size: 18, + ), + const SizedBox(width: 8), + Text(action.label), + ], + ), + ); + }); + }, + ); + } + + TextAlign _getTextAlign(DataTableColumn column) { + if (column is NumberColumn) return column.textAlign; + if (column is DateColumn) return column.textAlign; + if (column is TextColumn && column.textAlign != null) return column.textAlign!; + return TextAlign.start; + } + + int? _getMaxLines(DataTableColumn column) { + if (column is TextColumn) return column.maxLines; + return null; + } + + TextOverflow? _getOverflow(DataTableColumn column) { + if (column is TextColumn && column.overflow != null) { + return column.overflow! ? TextOverflow.ellipsis : null; + } + return null; + } +} + +/// Column header with search functionality +class _ColumnHeaderWithSearch extends StatelessWidget { + final String text; + final String sortBy; + final String? currentSort; + final bool sortDesc; + final Function(String) onSort; + final VoidCallback onSearch; + final bool hasActiveFilter; + final bool enabled; + + const _ColumnHeaderWithSearch({ + required this.text, + required this.sortBy, + required this.currentSort, + required this.sortDesc, + required this.onSort, + required this.onSearch, + required this.hasActiveFilter, + this.enabled = true, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isActive = currentSort == sortBy; + + return InkWell( + onTap: enabled ? () => onSort(sortBy) : null, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: SizedBox( + width: double.infinity, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + text, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: isActive ? theme.colorScheme.primary : theme.colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (enabled) ...[ + const SizedBox(width: 4), + if (isActive) + Icon( + sortDesc ? Icons.arrow_downward : Icons.arrow_upward, + size: 16, + color: theme.colorScheme.primary, + ) + else + Icon( + Icons.unfold_more, + size: 16, + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + // Search button + InkWell( + onTap: onSearch, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: hasActiveFilter + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: hasActiveFilter + ? Border.all(color: theme.colorScheme.primary.withValues(alpha: 0.3)) + : null, + ), + child: Icon( + Icons.search, + size: 14, + color: hasActiveFilter + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/person/file_picker_bridge.dart b/hesabixUI/hesabix_ui/lib/widgets/person/file_picker_bridge.dart new file mode 100644 index 0000000..2513e1f --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/person/file_picker_bridge.dart @@ -0,0 +1,3 @@ +export 'file_picker_bridge_io.dart' if (dart.library.html) 'file_picker_bridge_web.dart'; + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/person/file_picker_bridge_io.dart b/hesabixUI/hesabix_ui/lib/widgets/person/file_picker_bridge_io.dart new file mode 100644 index 0000000..4150d94 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/person/file_picker_bridge_io.dart @@ -0,0 +1,25 @@ +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; + +class PickedFileData { + final String name; + final List bytes; + PickedFileData(this.name, this.bytes); +} + +class FilePickerBridge { + static Future pickExcel() async { + final res = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: const ['xlsx'], + withData: false, + ); + if (res == null || res.files.isEmpty) return null; + final pf = res.files.first; + if (pf.path == null) return null; + final bytes = await File(pf.path!).readAsBytes(); + return PickedFileData(pf.name, bytes); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/person/file_picker_bridge_web.dart b/hesabixUI/hesabix_ui/lib/widgets/person/file_picker_bridge_web.dart new file mode 100644 index 0000000..ec9ec3e --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/person/file_picker_bridge_web.dart @@ -0,0 +1,50 @@ +import 'dart:async'; +import 'dart:html' as html; +import 'dart:typed_data'; + +class PickedFileData { + final String name; + final List bytes; + PickedFileData(this.name, this.bytes); +} + +class FilePickerBridge { + static Future pickExcel() async { + try { + final input = html.FileUploadInputElement() + ..accept = '.xlsx' + ..multiple = false; + + input.click(); + + final completer = Completer(); + + input.onChange.listen((e) { + final files = input.files; + if (files != null && files.isNotEmpty) { + final file = files.first; + final reader = html.FileReader(); + + reader.onLoad.listen((e) { + final bytes = reader.result as Uint8List; + completer.complete(PickedFileData(file.name, bytes.toList())); + }); + + reader.onError.listen((e) { + completer.complete(null); + }); + + reader.readAsArrayBuffer(file); + } else { + completer.complete(null); + } + }); + + return await completer.future; + } catch (e) { + return null; + } + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/person/person_import_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/person/person_import_dialog.dart new file mode 100644 index 0000000..ac0d30e --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/person/person_import_dialog.dart @@ -0,0 +1,328 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'file_picker_bridge.dart'; + +import '../../core/api_client.dart'; +import '../data_table/helpers/file_saver.dart'; + +class PersonImportDialog extends StatefulWidget { + final int businessId; + + const PersonImportDialog({super.key, required this.businessId}); + + @override + State createState() => _PersonImportDialogState(); +} + +class _PersonImportDialogState extends State { + final TextEditingController _pathCtrl = TextEditingController(); + bool _dryRun = true; + String _matchBy = 'code'; + String _conflictPolicy = 'upsert'; + bool _loading = false; + Map? _result; + PickedFileData? _selectedFile; + bool _isInitialized = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isInitialized = true; + }); + } + }); + } + + @override + void dispose() { + _pathCtrl.dispose(); + super.dispose(); + } + + Future _pickFile() async { + if (!_isInitialized) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('لطفاً صبر کنید تا دیالوگ کاملاً بارگذاری شود')), + ); + } + return; + } + + try { + final picked = await FilePickerBridge.pickExcel(); + if (picked != null) { + setState(() { + _selectedFile = picked; + _pathCtrl.text = picked.name; + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در انتخاب فایل: $e')), + ); + } + } + } + + Future _downloadTemplate() async { + try { + setState(() => _loading = true); + final api = ApiClient(); + final res = await api.post( + '/persons/businesses/${widget.businessId}/persons/import/template', + responseType: ResponseType.bytes, + ); + String filename = 'persons_import_template.xlsx'; + final cd = res.headers.value('content-disposition'); + if (cd != null) { + try { + final parts = cd.split(';').map((e) => e.trim()); + for (final p in parts) { + if (p.toLowerCase().startsWith('filename=')) { + var name = p.substring('filename='.length).trim(); + if (name.startsWith('"') && name.endsWith('"') && name.length >= 2) { + name = name.substring(1, name.length - 1); + } + if (name.isNotEmpty) filename = name; + break; + } + } + } catch (_) {} + } + await FileSaver.saveBytes((res.data as List), filename); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('تمپلیت دانلود شد: $filename')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در دانلود تمپلیت: $e')), + ); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _runImport({required bool dryRun}) async { + // Ensure file is selected + if (_selectedFile == null) { + await _pickFile(); + if (_selectedFile == null) return; + } + final filename = _selectedFile!.name; + final bytes = _selectedFile!.bytes; + + try { + setState(() { + _loading = true; + _result = null; + }); + final form = FormData.fromMap({ + 'file': MultipartFile.fromBytes(bytes, filename: filename), + 'dry_run': dryRun, + 'match_by': _matchBy, + 'conflict_policy': _conflictPolicy, + }); + final api = ApiClient(); + final res = await api.post>( + '/persons/businesses/${widget.businessId}/persons/import/excel', + data: form, + options: Options(contentType: 'multipart/form-data'), + ); + setState(() { + _result = res.data; + }); + if (!dryRun) { + if (mounted) Navigator.of(context).pop(true); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در ایمپورت: $e')), + ); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('ایمپورت اشخاص از اکسل'), + content: SizedBox( + width: 560, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row(children: [ + Expanded( + child: TextField( + controller: _pathCtrl, + readOnly: true, + decoration: const InputDecoration( + labelText: 'فایل انتخاب‌شده', + hintText: 'هیچ فایلی انتخاب نشده', + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: (_loading || !_isInitialized) ? null : _pickFile, + icon: const Icon(Icons.attach_file), + label: const Text('انتخاب فایل'), + ), + ]), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + value: _matchBy, + isDense: true, + items: const [ + DropdownMenuItem(value: 'code', child: Text('match by: code')), + DropdownMenuItem(value: 'national_id', child: Text('match by: national_id')), + DropdownMenuItem(value: 'email', child: Text('match by: email')), + ], + onChanged: (v) => setState(() => _matchBy = v ?? 'code'), + decoration: const InputDecoration(isDense: true), + ), + ), + const SizedBox(width: 8), + Expanded( + child: DropdownButtonFormField( + value: _conflictPolicy, + isDense: true, + items: const [ + DropdownMenuItem(value: 'insert', child: Text('policy: insert-only')), + DropdownMenuItem(value: 'update', child: Text('policy: update-existing')), + DropdownMenuItem(value: 'upsert', child: Text('policy: upsert')), + ], + onChanged: (v) => setState(() => _conflictPolicy = v ?? 'upsert'), + decoration: const InputDecoration(isDense: true), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Checkbox( + value: _dryRun, + onChanged: (v) => setState(() => _dryRun = v ?? true), + ), + const Text('Dry run (فقط اعتبارسنجی)') + ], + ), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton.icon( + onPressed: _loading ? null : _downloadTemplate, + icon: const Icon(Icons.download), + label: const Text('دانلود تمپلیت'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _loading ? null : () => _runImport(dryRun: _dryRun), + icon: _loading ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.play_arrow), + label: Text(_dryRun ? 'بررسی (Dry run)' : 'ایمپورت'), + ), + const SizedBox(width: 8), + if (_dryRun) + FilledButton.tonalIcon( + onPressed: _loading ? null : () async { + // اجرای ایمپورت واقعی + setState(() => _dryRun = false); + await _runImport(dryRun: false); + }, + icon: const Icon(Icons.cloud_upload), + label: const Text('ایمپورت واقعی'), + ) + ], + ), + if (_result != null) ...[ + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: Text('نتیجه:', style: Theme.of(context).textTheme.titleSmall), + ), + const SizedBox(height: 8), + _ResultSummary(result: _result!), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: _loading ? null : () => Navigator.of(context).pop(false), + child: const Text('بستن'), + ), + ], + ); + } +} + +class _ResultSummary extends StatelessWidget { + final Map result; + const _ResultSummary({required this.result}); + + @override + Widget build(BuildContext context) { + final data = result['data'] as Map?; + final summary = (data?['summary'] as Map?) ?? {}; + final errors = (data?['errors'] as List?)?.cast>() ?? const []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + _chip('کل', summary['total']), + _chip('معتبر', summary['valid']), + _chip('نامعتبر', summary['invalid']), + _chip('ایجاد شده', summary['inserted']), + _chip('به‌روزرسانی', summary['updated']), + _chip('رد شده', summary['skipped']), + _chip('Dry run', summary['dry_run'] == true ? 'بله' : 'خیر'), + ], + ), + const SizedBox(height: 8), + if (errors.isNotEmpty) + SizedBox( + height: 160, + child: ListView.builder( + itemCount: errors.length, + itemBuilder: (context, i) { + final e = errors[i]; + return ListTile( + dense: true, + leading: const Icon(Icons.error_outline, color: Colors.red), + title: Text('ردیف ${e['row']}'), + subtitle: Text(((e['errors'] as List?)?.join('، ')) ?? ''), + ); + }, + ), + ), + ], + ); + } + + Widget _chip(String label, Object? value) { + return Chip(label: Text('$label: ${value ?? '-'}')); + } +} + + diff --git a/hesabixUI/hesabix_ui/linux/CMakeLists.txt b/hesabixUI/hesabix_ui/linux/CMakeLists.txt index e830de3..a82181b 100644 --- a/hesabixUI/hesabix_ui/linux/CMakeLists.txt +++ b/hesabixUI/hesabix_ui/linux/CMakeLists.txt @@ -42,6 +42,7 @@ endif() function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_14) target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE -Wno-deprecated-literal-operator) target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") endfunction() diff --git a/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc b/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc index d0e7f79..85a2413 100644 --- a/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc +++ b/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake b/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake index b29e9ba..62e3ed5 100644 --- a/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake +++ b/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage_linux ) diff --git a/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift b/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift index 37af1fe..b52fe80 100644 --- a/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,11 +5,15 @@ import FlutterMacOS import Foundation +import file_picker +import file_selector_macos import flutter_secure_storage_macos import path_provider_foundation import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/hesabixUI/hesabix_ui/pubspec.lock b/hesabixUI/hesabix_ui/pubspec.lock index 9798cef..b4a1b55 100644 --- a/hesabixUI/hesabix_ui/pubspec.lock +++ b/hesabixUI/hesabix_ui/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -41,6 +49,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.4+2" crypto: dependency: transitive description: @@ -65,6 +81,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.6.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.11" dio: dependency: "direct main" description: @@ -105,6 +129,78 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f2d9f173c2c14635cc0e9b14c143c49ef30b4934e8d1d274d6206fcb0086a06f + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.3.3" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: "5f1d15a7f17115038f433d1b0ea57513cc9e29a9d5338d166cb0bef3fa90a7a0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.4" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "4be8ae7374c81daf88e49084a1d68dfe68466ef38a6a3d711cc0b83d53e22465" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.1+16" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: fe9f52123af16bba4ad65bd7e03defbbb4b172a38a8e6aaa2a869a0c56a5f5fb + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.3+2" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.4+4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.2" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: c4c0ea4224d97a60a7067eca0c8fd419e708ff830e0c83b11a48faf566cec3e7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.4+2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.3+4" fixnum: dependency: transitive description: @@ -131,6 +227,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.30" flutter_secure_storage: dependency: "direct main" description: @@ -197,6 +301,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "16.2.2" + http: + dependency: transitive + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.0" http_parser: dependency: transitive description: @@ -357,6 +469,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.2.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" platform: dependency: transitive description: @@ -554,6 +674,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.6.1" sdks: dart: ">=3.9.2 <4.0.0" flutter: ">=3.29.0" diff --git a/hesabixUI/hesabix_ui/pubspec.yaml b/hesabixUI/hesabix_ui/pubspec.yaml index fad5cfb..1c4d92b 100644 --- a/hesabixUI/hesabix_ui/pubspec.yaml +++ b/hesabixUI/hesabix_ui/pubspec.yaml @@ -47,6 +47,8 @@ dependencies: shamsi_date: ^1.1.1 intl: ^0.20.0 data_table_2: ^2.5.12 + file_picker: ^10.3.3 + file_selector: ^1.0.4 dev_dependencies: flutter_test: diff --git a/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc b/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc index 0c50753..b53f20e 100644 --- a/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc +++ b/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake b/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake index 4fc759c..2b9f993 100644 --- a/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake +++ b/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows flutter_secure_storage_windows )