From 798dd63627dd7d8a5c62c1b7596ba8789c0ef859 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Mon, 22 Sep 2025 21:21:46 +0330 Subject: [PATCH] progress in email servie and business dashboard --- docs/EMAIL_SERVICE_README.md | 210 ++++++ .../adapters/api/v1/admin/email_config.py | 349 ++++++++++ .../adapters/api/v1/admin/file_storage.py | 43 +- .../adapters/api/v1/business_dashboard.py | 194 ++++++ hesabixAPI/adapters/api/v1/businesses.py | 62 +- .../adapters/api/v1/schema_models/email.py | 59 ++ hesabixAPI/adapters/db/models/__init__.py | 5 +- hesabixAPI/adapters/db/models/email_config.py | 27 + .../adapters/db/repositories/base_repo.py | 9 + .../repositories/email_config_repository.py | 85 +++ hesabixAPI/app/core/auth_dependency.py | 8 +- hesabixAPI/app/core/i18n.py | 6 + hesabixAPI/app/core/permissions.py | 18 +- hesabixAPI/app/main.py | 4 + .../services/business_dashboard_service.py | 194 ++++++ hesabixAPI/app/services/business_service.py | 4 +- hesabixAPI/app/services/email_service.py | 143 ++++ hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 7 + hesabixAPI/locales/en/LC_MESSAGES/messages.mo | Bin 5007 -> 5915 bytes hesabixAPI/locales/en/LC_MESSAGES/messages.po | 41 ++ hesabixAPI/locales/fa/LC_MESSAGES/messages.mo | Bin 6324 -> 7457 bytes hesabixAPI/locales/fa/LC_MESSAGES/messages.po | 41 ++ .../20250117_000008_add_email_config_table.py | 45 ++ ...7_000009_add_is_default_to_email_config.py | 26 + .../hesabix_ui/lib/core/referral_store.dart | 1 - .../lib/core/splash_controller.dart | 76 +++ hesabixUI/hesabix_ui/lib/l10n/app_en.arb | 72 +- hesabixUI/hesabix_ui/lib/l10n/app_fa.arb | 72 +- .../lib/l10n/app_localizations.dart | 432 +++++++++++- .../lib/l10n/app_localizations_en.dart | 223 +++++- .../lib/l10n/app_localizations_fa.dart | 221 +++++- hesabixUI/hesabix_ui/lib/main.dart | 337 ++++----- .../lib/models/business_dashboard_models.dart | 245 +++++++ .../hesabix_ui/lib/models/email_models.dart | 306 +++++++++ .../lib/pages/admin/email_settings_page.dart | 644 ++++++++++++++++++ .../admin/file_storage_settings_page.dart | 1 - .../pages/admin/storage_management_page.dart | 2 +- .../admin/system_configuration_page.dart | 4 +- .../lib/pages/admin/system_logs_page.dart | 14 +- .../lib/pages/admin/user_management_page.dart | 10 +- .../lib/pages/business/business_shell.dart | 173 +++++ .../dashboard/business_dashboard_page.dart | 438 ++++++++++++ hesabixUI/hesabix_ui/lib/pages/home_page.dart | 1 - .../hesabix_ui/lib/pages/login_page.dart | 5 +- .../lib/pages/profile/businesses_page.dart | 431 +++++++++++- .../lib/pages/profile/create_ticket_page.dart | 30 +- .../lib/pages/profile/marketing_page.dart | 2 +- .../lib/pages/profile/new_business_page.dart | 4 +- .../pages/profile/profile_dashboard_page.dart | 17 +- .../lib/pages/profile/profile_shell.dart | 5 +- .../lib/pages/system_settings_page.dart | 90 +-- .../services/business_dashboard_service.dart | 139 ++++ .../lib/services/email_service.dart | 248 +++++++ .../lib/theme/theme_controller.dart | 2 +- .../file_storage/storage_config_card.dart | 24 +- .../storage_config_form_dialog.dart | 54 +- .../storage_config_list_widget.dart | 16 +- .../file_storage/storage_management_page.dart | 2 +- .../data_table/data_table_search_dialog.dart | 98 +-- .../widgets/data_table/data_table_widget.dart | 40 +- .../lib/widgets/data_table/example_usage.dart | 4 +- .../helpers/column_settings_service.dart | 7 +- .../hesabix_ui/lib/widgets/error_notice.dart | 2 +- .../lib/widgets/progress_splash_screen.dart | 296 ++++++++ .../lib/widgets/simple_splash_screen.dart | 263 +++++++ .../hesabix_ui/lib/widgets/splash_screen.dart | 242 +++++++ .../lib/widgets/support/message_bubble.dart | 10 +- .../widgets/support/priority_indicator.dart | 4 +- .../lib/widgets/support/ticket_card.dart | 24 +- .../support/ticket_details_dialog.dart | 4 +- .../widgets/support/ticket_status_chip.dart | 4 +- hesabixUI/hesabix_ui/pubspec.lock | 6 +- hesabixUI/hesabix_ui/pubspec.yaml | 2 + 73 files changed, 6369 insertions(+), 558 deletions(-) create mode 100644 docs/EMAIL_SERVICE_README.md create mode 100644 hesabixAPI/adapters/api/v1/admin/email_config.py create mode 100644 hesabixAPI/adapters/api/v1/business_dashboard.py create mode 100644 hesabixAPI/adapters/api/v1/schema_models/email.py create mode 100644 hesabixAPI/adapters/db/models/email_config.py create mode 100644 hesabixAPI/adapters/db/repositories/email_config_repository.py create mode 100644 hesabixAPI/app/services/business_dashboard_service.py create mode 100644 hesabixAPI/app/services/email_service.py create mode 100644 hesabixAPI/migrations/versions/20250117_000008_add_email_config_table.py create mode 100644 hesabixAPI/migrations/versions/20250117_000009_add_is_default_to_email_config.py create mode 100644 hesabixUI/hesabix_ui/lib/core/splash_controller.dart create mode 100644 hesabixUI/hesabix_ui/lib/models/business_dashboard_models.dart create mode 100644 hesabixUI/hesabix_ui/lib/models/email_models.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/admin/email_settings_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/dashboard/business_dashboard_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/email_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/progress_splash_screen.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/simple_splash_screen.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/splash_screen.dart diff --git a/docs/EMAIL_SERVICE_README.md b/docs/EMAIL_SERVICE_README.md new file mode 100644 index 0000000..db3213b --- /dev/null +++ b/docs/EMAIL_SERVICE_README.md @@ -0,0 +1,210 @@ +# سرویس ایمیل حسابیکس + +## نمای کلی + +سرویس ایمیل حسابیکس یک سیستم داخلی برای ارسال ایمیل است که توسعه‌دهندگان می‌توانند به راحتی از آن استفاده کنند. این سرویس از SMTP استفاده می‌کند و تنظیمات اتصال در دیتابیس ذخیره می‌شود. + +## ویژگی‌ها + +- ✅ ارسال ایمیل با SMTP +- ✅ پشتیبانی از TLS و SSL +- ✅ ذخیره تنظیمات در دیتابیس +- ✅ مدیریت چندین پیکربندی +- ✅ تست اتصال +- ✅ رابط کاربری برای مدیریت +- ✅ پشتیبانی از چندزبانه (فارسی/انگلیسی) +- ✅ امنیت و رمزگذاری + +## ساختار فایل‌ها + +### Backend + +``` +hesabixAPI/ +├── adapters/db/models/email_config.py # مدل دیتابیس +├── adapters/db/repositories/email_config_repository.py # Repository +├── adapters/api/v1/schema_models/email.py # Schema models +├── adapters/api/v1/admin/email_config.py # API endpoints +├── app/services/email_service.py # سرویس اصلی +└── locales/ + ├── fa/LC_MESSAGES/messages.po # ترجمه‌های فارسی + └── en/LC_MESSAGES/messages.po # ترجمه‌های انگلیسی +``` + +### Frontend + +``` +hesabixUI/hesabix_ui/lib/ +├── models/email_models.dart # مدل‌های Flutter +├── services/email_service.dart # سرویس Flutter +├── pages/admin/email_settings_page.dart # صفحه مدیریت +└── l10n/ + ├── app_fa.arb # ترجمه‌های فارسی + └── app_en.arb # ترجمه‌های انگلیسی +``` + +## استفاده برای توسعه‌دهندگان + +### 1. ارسال ایمیل ساده + +```dart +import 'package:hesabix_ui/services/email_service.dart'; + +final emailService = EmailService(); + +// ارسال ایمیل سفارشی +await emailService.sendCustomEmail( + to: 'user@example.com', + subject: 'عنوان ایمیل', + body: 'متن ایمیل', + htmlBody: '

عنوان

متن

', +); +``` + +### 2. ارسال ایمیل خوش‌آمدگویی + +```dart +await emailService.sendWelcomeEmail( + 'user@example.com', + 'نام کاربر', +); +``` + +### 3. ارسال ایمیل بازیابی رمز عبور + +```dart +await emailService.sendPasswordResetEmail( + 'user@example.com', + 'https://example.com/reset?token=abc123', +); +``` + +### 4. ارسال ایمیل اطلاع‌رسانی + +```dart +await emailService.sendNotificationEmail( + 'user@example.com', + 'عنوان اطلاع‌رسانی', + 'پیام اطلاع‌رسانی', +); +``` + +## مدیریت تنظیمات + +### 1. دسترسی به صفحه تنظیمات + +1. وارد بخش "تنظیمات سیستم" شوید +2. روی "تنظیمات ایمیل" کلیک کنید + +### 2. افزودن پیکربندی جدید + +1. فرم را پر کنید: + - **نام پیکربندی**: نام منحصر به فرد + - **میزبان SMTP**: آدرس سرور SMTP + - **پورت SMTP**: پورت سرور (معمولاً 587 یا 465) + - **نام کاربری**: نام کاربری SMTP + - **رمز عبور**: رمز عبور SMTP + - **ایمیل فرستنده**: آدرس ایمیل فرستنده + - **نام فرستنده**: نام نمایشی فرستنده + - **TLS/SSL**: نوع رمزگذاری + +2. روی "ذخیره پیکربندی" کلیک کنید + +### 3. تست اتصال + +1. پیکربندی مورد نظر را انتخاب کنید +2. روی "تست اتصال" کلیک کنید +3. وضعیت اتصال نمایش داده می‌شود + +### 4. ارسال ایمیل تست + +1. پیکربندی مورد نظر را انتخاب کنید +2. روی "ارسال ایمیل تست" کلیک کنید +3. ایمیل تست به آدرس "ایمیل فرستنده" ارسال می‌شود + +## API Endpoints + +### مدیریت پیکربندی‌ها + +- `GET /api/v1/admin/email/configs` - دریافت لیست پیکربندی‌ها +- `GET /api/v1/admin/email/configs/{id}` - دریافت پیکربندی خاص +- `POST /api/v1/admin/email/configs` - ایجاد پیکربندی جدید +- `PUT /api/v1/admin/email/configs/{id}` - بروزرسانی پیکربندی +- `DELETE /api/v1/admin/email/configs/{id}` - حذف پیکربندی + +### تست و ارسال + +- `POST /api/v1/admin/email/configs/{id}/test` - تست اتصال +- `POST /api/v1/admin/email/configs/{id}/activate` - فعال‌سازی پیکربندی +- `POST /api/v1/admin/email/send` - ارسال ایمیل + +## امنیت + +- رمزهای عبور SMTP در دیتابیس ذخیره می‌شوند (باید رمزگذاری شوند) +- تمام endpoint ها نیاز به احراز هویت دارند +- تست اتصال قبل از فعال‌سازی انجام می‌شود + +## چندزبانه + +### فارسی +- تمام متن‌ها به فارسی ترجمه شده‌اند +- پشتیبانی از RTL +- فرمت تاریخ شمسی + +### انگلیسی +- پشتیبانی کامل از انگلیسی +- فرمت تاریخ میلادی + +## عیب‌یابی + +### مشکلات رایج + +1. **خطا در اتصال SMTP** + - بررسی صحت آدرس میزبان و پورت + - بررسی نام کاربری و رمز عبور + - بررسی تنظیمات TLS/SSL + +2. **ایمیل ارسال نمی‌شود** + - بررسی پیکربندی فعال + - تست اتصال + - بررسی لاگ‌های سرور + +3. **خطا در رابط کاربری** + - بررسی اتصال به API + - بررسی مجوزهای کاربر + - بررسی ترجمه‌ها + +### لاگ‌ها + +- لاگ‌های ارسال ایمیل در console نمایش داده می‌شوند +- خطاهای SMTP در response API نمایش داده می‌شوند + +## توسعه آینده + +### ویژگی‌های پیشنهادی + +- [ ] سیستم قالب‌های ایمیل +- [ ] صف ارسال ایمیل +- [ ] آمار ارسال +- [ ] لاگ‌گیری کامل +- [ ] رمزگذاری رمزهای عبور +- [ ] پشتیبانی از چندین ارائه‌دهنده SMTP +- [ ] تست خودکار اتصال + +### بهبودهای فنی + +- [ ] Cache کردن پیکربندی‌ها +- [ ] Connection pooling +- [ ] Retry mechanism +- [ ] Rate limiting +- [ ] Monitoring و alerting + +## پشتیبانی + +برای گزارش مشکلات یا درخواست ویژگی‌های جدید، لطفاً با تیم توسعه تماس بگیرید. + +--- + +**نسخه**: 1.0.0 +**تاریخ**: 2025-01-17 +**نویسنده**: تیم توسعه حسابیکس diff --git a/hesabixAPI/adapters/api/v1/admin/email_config.py b/hesabixAPI/adapters/api/v1/admin/email_config.py new file mode 100644 index 0000000..fe79901 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/admin/email_config.py @@ -0,0 +1,349 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session +from typing import List + +from adapters.db.session import get_db +from adapters.db.models.email_config import EmailConfig +from adapters.db.repositories.email_config_repository import EmailConfigRepository +from adapters.api.v1.schema_models.email import ( + EmailConfigCreate, + EmailConfigUpdate, + EmailConfigResponse, + SendEmailRequest, + TestConnectionRequest +) +from adapters.api.v1.schemas import SuccessResponse +from app.core.responses import success_response, format_datetime_fields +from app.core.permissions import require_app_permission +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.i18n import gettext, negotiate_locale + +router = APIRouter(prefix="/admin/email", tags=["Email Configuration"]) + + +@router.get("/configs", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def get_email_configs( + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Get all email configurations""" + try: + email_repo = EmailConfigRepository(db) + configs = email_repo.get_all_configs() + + config_responses = [ + EmailConfigResponse.model_validate(config) for config in configs + ] + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(config_responses, request) + + return success_response( + data=formatted_data, + request=request + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/configs/{config_id}", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def get_email_config( + config_id: int, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Get specific email configuration""" + try: + email_repo = EmailConfigRepository(db) + config = email_repo.get_by_id(config_id) + + if not config: + locale = negotiate_locale(request.headers.get("Accept-Language")) + raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale)) + + config_response = EmailConfigResponse.model_validate(config) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(config_response.model_dump(), request) + + return success_response( + data=formatted_data, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/configs", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def create_email_config( + request_data: EmailConfigCreate, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Create new email configuration""" + try: + email_repo = EmailConfigRepository(db) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + # Check if name already exists + existing_config = email_repo.get_by_name(request_data.name) + if existing_config: + raise HTTPException(status_code=400, detail=gettext("Configuration name already exists", locale)) + + # Create new config + config = EmailConfig(**request_data.model_dump()) + email_repo.db.add(config) + email_repo.db.commit() + email_repo.db.refresh(config) + + # If this is the first config, set it as default + if not email_repo.get_default_config(): + email_repo.set_default_config(config.id) + + config_response = EmailConfigResponse.model_validate(config) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(config_response.model_dump(), request) + + return success_response( + data=formatted_data, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/configs/{config_id}", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def update_email_config( + config_id: int, + request_data: EmailConfigUpdate, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Update email configuration""" + try: + email_repo = EmailConfigRepository(db) + config = email_repo.get_by_id(config_id) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + if not config: + raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale)) + + # Check name uniqueness if name is being updated + if request_data.name and request_data.name != config.name: + existing_config = email_repo.get_by_name(request_data.name) + if existing_config: + raise HTTPException(status_code=400, detail=gettext("Configuration name already exists", locale)) + + # Update config + update_data = request_data.model_dump(exclude_unset=True) + + # Prevent changing is_default through update - use set-default endpoint instead + if 'is_default' in update_data: + del update_data['is_default'] + + for field, value in update_data.items(): + setattr(config, field, value) + + email_repo.update(config) + + config_response = EmailConfigResponse.model_validate(config) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(config_response.model_dump(), request) + + return success_response( + data=formatted_data, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/configs/{config_id}", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def delete_email_config( + config_id: int, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Delete email configuration""" + try: + email_repo = EmailConfigRepository(db) + config = email_repo.get_by_id(config_id) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + if not config: + raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale)) + + # Prevent deletion of default config + if config.is_default: + raise HTTPException(status_code=400, detail=gettext("Cannot delete default configuration", locale)) + + email_repo.delete(config) + + return success_response( + data=None, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/configs/{config_id}/test", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def test_email_config( + config_id: int, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Test email configuration connection""" + try: + email_repo = EmailConfigRepository(db) + config = email_repo.get_by_id(config_id) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + if not config: + raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale)) + + is_connected = email_repo.test_connection(config) + + return success_response( + data={"connected": is_connected}, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/configs/{config_id}/activate", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def activate_email_config( + config_id: int, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Activate email configuration""" + try: + email_repo = EmailConfigRepository(db) + config = email_repo.get_by_id(config_id) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + if not config: + raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale)) + + success = email_repo.set_active_config(config_id) + + if not success: + raise HTTPException(status_code=500, detail=gettext("Failed to activate configuration", locale)) + + return success_response( + data=None, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/configs/{config_id}/set-default", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def set_default_email_config( + config_id: int, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Set email configuration as default""" + try: + email_repo = EmailConfigRepository(db) + config = email_repo.get_by_id(config_id) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + if not config: + raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale)) + + success = email_repo.set_default_config(config_id) + + if not success: + raise HTTPException(status_code=500, detail=gettext("Failed to set default configuration", locale)) + + return success_response( + data=None, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/send", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def send_email( + request_data: SendEmailRequest, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Send email using configured SMTP""" + try: + from app.services.email_service import EmailService + + email_service = EmailService(db) + success = email_service.send_email( + to=request_data.to, + subject=request_data.subject, + body=request_data.body, + html_body=request_data.html_body, + config_id=request_data.config_id + ) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + if not success: + raise HTTPException(status_code=500, detail=gettext("Failed to send email", locale)) + + return success_response( + data={"sent": True}, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/hesabixAPI/adapters/api/v1/admin/file_storage.py b/hesabixAPI/adapters/api/v1/admin/file_storage.py index 462df1e..906242c 100644 --- a/hesabixAPI/adapters/api/v1/admin/file_storage.py +++ b/hesabixAPI/adapters/api/v1/admin/file_storage.py @@ -6,14 +6,14 @@ from sqlalchemy import and_ from adapters.db.session import get_db from app.core.auth_dependency import get_current_user, AuthContext -from app.core.permissions import require_permission +from app.core.permissions import require_app_permission from app.core.responses import success_response from app.core.responses import ApiError from app.core.i18n import locale_dependency from app.services.file_storage_service import FileStorageService from adapters.db.repositories.file_storage_repository import StorageConfigRepository, FileStorageRepository from adapters.db.models.user import User -from adapters.db.models.file_storage import StorageConfig +from adapters.db.models.file_storage import StorageConfig, FileStorage from adapters.api.v1.schema_models.file_storage import ( StorageConfigCreateRequest, StorageConfigUpdateRequest, @@ -30,6 +30,7 @@ router = APIRouter(prefix="/admin/files", tags=["Admin File Management"]) @router.get("/", response_model=dict) +@require_app_permission("superadmin") async def list_all_files( request: Request, page: int = Query(1, ge=1), @@ -43,15 +44,6 @@ async def list_all_files( ): """لیست تمام فایل‌ها با فیلتر""" try: - # Check permission - if not current_user.has_app_permission("admin.file.view"): - raise ApiError( - code="FORBIDDEN", - message=translator.t("FORBIDDEN", "دسترسی غیرمجاز"), - http_status=403, - translator=translator - ) - file_repo = FileStorageRepository(db) # محاسبه offset برای pagination @@ -130,6 +122,7 @@ async def list_all_files( @router.get("/unverified", response_model=dict) +@require_app_permission("superadmin") async def get_unverified_files( request: Request, db: Session = Depends(get_db), @@ -167,6 +160,7 @@ async def get_unverified_files( @router.post("/cleanup-temporary", response_model=dict) +@require_app_permission("superadmin") async def cleanup_temporary_files( request: Request, db: Session = Depends(get_db), @@ -194,6 +188,7 @@ async def cleanup_temporary_files( @router.delete("/{file_id}", response_model=dict) +@require_app_permission("superadmin") async def force_delete_file( file_id: UUID, request: Request, @@ -228,6 +223,7 @@ async def force_delete_file( @router.put("/{file_id}/restore", response_model=dict) +@require_app_permission("superadmin") async def restore_file( file_id: UUID, request: Request, @@ -262,6 +258,7 @@ async def restore_file( @router.get("/statistics", response_model=dict) +@require_app_permission("superadmin") async def get_file_statistics( request: Request, db: Session = Depends(get_db), @@ -285,6 +282,7 @@ async def get_file_statistics( # Storage Configuration Management @router.get("/storage-configs/", response_model=dict) +@require_app_permission("superadmin") async def get_storage_configs( request: Request, db: Session = Depends(get_db), @@ -293,15 +291,6 @@ async def get_storage_configs( ): """لیست تنظیمات ذخیره‌سازی""" try: - # Check permission - if not current_user.has_app_permission("admin.storage.view"): - raise ApiError( - code="FORBIDDEN", - message=translator.t("FORBIDDEN", "دسترسی غیرمجاز"), - http_status=403, - translator=translator - ) - config_repo = StorageConfigRepository(db) configs = config_repo.get_all_configs() @@ -331,6 +320,7 @@ async def get_storage_configs( @router.post("/storage-configs/", response_model=dict) +@require_app_permission("superadmin") async def create_storage_config( request: Request, config_request: StorageConfigCreateRequest, @@ -367,6 +357,7 @@ async def create_storage_config( @router.put("/storage-configs/{config_id}", response_model=dict) +@require_app_permission("superadmin") async def update_storage_config( config_id: UUID, request: Request, @@ -392,6 +383,7 @@ async def update_storage_config( @router.put("/storage-configs/{config_id}/set-default", response_model=dict) +@require_app_permission("superadmin") async def set_default_storage_config( config_id: UUID, request: Request, @@ -426,6 +418,7 @@ async def set_default_storage_config( @router.delete("/storage-configs/{config_id}", response_model=dict) +@require_app_permission("superadmin") async def delete_storage_config( config_id: str, request: Request, @@ -435,15 +428,6 @@ async def delete_storage_config( ): """حذف تنظیمات ذخیره‌سازی""" try: - # Check permission - if not current_user.has_app_permission("admin.storage.delete"): - raise ApiError( - code="FORBIDDEN", - message=translator.t("FORBIDDEN", "دسترسی غیرمجاز"), - http_status=403, - translator=translator - ) - config_repo = StorageConfigRepository(db) # بررسی وجود فایل‌ها قبل از حذف @@ -480,6 +464,7 @@ async def delete_storage_config( @router.post("/storage-configs/{config_id}/test", response_model=dict) +@require_app_permission("superadmin") async def test_storage_config( config_id: str, request: Request, diff --git a/hesabixAPI/adapters/api/v1/business_dashboard.py b/hesabixAPI/adapters/api/v1/business_dashboard.py new file mode 100644 index 0000000..ebd0ed9 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/business_dashboard.py @@ -0,0 +1,194 @@ +# Removed __future__ annotations to fix OpenAPI schema generation + +from fastapi import APIRouter, Depends, Request, HTTPException +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.api.v1.schemas import SuccessResponse +from app.core.responses import success_response, format_datetime_fields +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_access +from app.services.business_dashboard_service import ( + get_business_dashboard_data, get_business_members, get_business_statistics +) + +router = APIRouter(prefix="/business", tags=["business-dashboard"]) + + +@router.post("/{business_id}/dashboard", + summary="دریافت داشبورد کسب و کار", + description="دریافت اطلاعات کلی و آمار کسب و کار", + response_model=SuccessResponse, + responses={ + 200: { + "description": "داشبورد کسب و کار با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "داشبورد کسب و کار دریافت شد", + "data": { + "business_info": { + "id": 1, + "name": "شرکت نمونه", + "business_type": "شرکت", + "business_field": "تولیدی", + "owner_id": 1, + "created_at": "1403/01/01 00:00:00", + "member_count": 5 + }, + "statistics": { + "total_sales": 1000000.0, + "total_purchases": 500000.0, + "active_members": 5, + "recent_transactions": 25 + }, + "recent_activities": [ + { + "id": 1, + "title": "فروش جدید", + "description": "فروش محصول A به مبلغ 100,000 تومان", + "icon": "sell", + "time_ago": "2 ساعت پیش" + } + ] + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز به کسب و کار" + }, + 404: { + "description": "کسب و کار یافت نشد" + } + } +) +@require_business_access("business_id") +def get_business_dashboard( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """دریافت داشبورد کسب و کار""" + dashboard_data = get_business_dashboard_data(db, business_id, ctx) + formatted_data = format_datetime_fields(dashboard_data, request) + return success_response(formatted_data, request) + + +@router.post("/{business_id}/members", + summary="لیست اعضای کسب و کار", + description="دریافت لیست اعضای کسب و کار", + response_model=SuccessResponse, + responses={ + 200: { + "description": "لیست اعضا با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "لیست اعضا دریافت شد", + "data": { + "items": [ + { + "id": 1, + "user_id": 2, + "first_name": "احمد", + "last_name": "احمدی", + "email": "ahmad@example.com", + "role": "مدیر فروش", + "permissions": { + "sales": {"write": True, "delete": True}, + "reports": {"export": True} + }, + "joined_at": "1403/01/01 00:00:00" + } + ], + "pagination": { + "total": 1, + "page": 1, + "per_page": 10, + "total_pages": 1, + "has_next": False, + "has_prev": False + } + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز به کسب و کار" + } + } +) +@require_business_access("business_id") +def get_business_members( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """لیست اعضای کسب و کار""" + members_data = get_business_members(db, business_id, ctx) + formatted_data = format_datetime_fields(members_data, request) + return success_response(formatted_data, request) + + +@router.post("/{business_id}/statistics", + summary="آمار کسب و کار", + description="دریافت آمار تفصیلی کسب و کار", + response_model=SuccessResponse, + responses={ + 200: { + "description": "آمار با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "آمار دریافت شد", + "data": { + "sales_by_month": [ + {"month": "1403/01", "amount": 500000}, + {"month": "1403/02", "amount": 750000} + ], + "top_products": [ + {"name": "محصول A", "sales_count": 100, "revenue": 500000} + ], + "member_activity": { + "active_today": 3, + "active_this_week": 5, + "total_members": 8 + } + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز به کسب و کار" + } + } +) +@require_business_access("business_id") +def get_business_statistics( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """آمار کسب و کار""" + stats_data = get_business_statistics(db, business_id, ctx) + formatted_data = format_datetime_fields(stats_data, request) + return success_response(formatted_data, request) diff --git a/hesabixAPI/adapters/api/v1/businesses.py b/hesabixAPI/adapters/api/v1/businesses.py index d6c3f23..d186a15 100644 --- a/hesabixAPI/adapters/api/v1/businesses.py +++ b/hesabixAPI/adapters/api/v1/businesses.py @@ -6,8 +6,7 @@ from sqlalchemy.orm import Session from adapters.db.session import get_db from adapters.api.v1.schemas import ( BusinessCreateRequest, BusinessUpdateRequest, BusinessResponse, - BusinessListResponse, BusinessSummaryResponse, SuccessResponse, - QueryInfo + BusinessListResponse, BusinessSummaryResponse, SuccessResponse ) from app.core.responses import success_response, format_datetime_fields from app.core.auth_dependency import get_current_user, AuthContext @@ -63,10 +62,10 @@ def create_new_business( owner_id = ctx.get_user_id() business = create_business(db, business_data, owner_id) formatted_data = format_datetime_fields(business, request) - return success_response(formatted_data, request, "کسب و کار با موفقیت ایجاد شد") + return success_response(formatted_data, request) -@router.get("", +@router.post("/list", summary="لیست کسب و کارهای کاربر", description="دریافت لیست کسب و کارهای کاربر جاری با قابلیت فیلتر و جستجو", response_model=SuccessResponse, @@ -86,7 +85,7 @@ def create_new_business( "business_type": "شرکت", "business_field": "تولیدی", "owner_id": 1, - "created_at": "2024-01-01T00:00:00Z" + "created_at": "1403/01/01 00:00:00" } ], "pagination": { @@ -109,19 +108,54 @@ def create_new_business( ) def list_user_businesses( request: Request, - query_info: QueryInfo, ctx: AuthContext = Depends(get_current_user), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + take: int = 10, + skip: int = 0, + sort_by: str = "created_at", + sort_desc: bool = True, + search: str = None ) -> dict: """لیست کسب و کارهای کاربر""" owner_id = ctx.get_user_id() - query_dict = query_info.dict() + query_dict = { + "take": take, + "skip": skip, + "sort_by": sort_by, + "sort_desc": sort_desc, + "search": search + } businesses = get_businesses_by_owner(db, owner_id, query_dict) formatted_data = format_datetime_fields(businesses, request) - return success_response(formatted_data, request, "لیست کسب و کارها دریافت شد") + + # اگر formatted_data یک dict با کلید items است، آن را استخراج کنیم + if isinstance(formatted_data, dict) and 'items' in formatted_data: + items = formatted_data['items'] + else: + items = formatted_data + + # برای حالا total را برابر با تعداد items قرار می‌دهیم + # در آینده می‌توان total را از service دریافت کرد + total = len(items) + page = (skip // take) + 1 + total_pages = (total + take - 1) // take + + response_data = { + "items": items, + "pagination": { + "total": total, + "page": page, + "per_page": take, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_prev": page > 1, + }, + "query_info": query_dict + } + return success_response(response_data, request) -@router.get("/{business_id}", +@router.post("/{business_id}/details", summary="جزئیات کسب و کار", description="دریافت جزئیات یک کسب و کار خاص", response_model=SuccessResponse, @@ -141,7 +175,7 @@ def list_user_businesses( "owner_id": 1, "address": "تهران، خیابان ولیعصر", "phone": "02112345678", - "created_at": "2024-01-01T00:00:00Z" + "created_at": "1403/01/01 00:00:00" } } } @@ -169,7 +203,7 @@ def get_business( raise HTTPException(status_code=404, detail="کسب و کار یافت نشد") formatted_data = format_datetime_fields(business, request) - return success_response(formatted_data, request, "جزئیات کسب و کار دریافت شد") + return success_response(formatted_data, request) @router.put("/{business_id}", @@ -266,7 +300,7 @@ def delete_business_info( return success_response({"ok": True}, request, "کسب و کار با موفقیت حذف شد") -@router.get("/summary/stats", +@router.post("/stats", summary="آمار کسب و کارها", description="دریافت آمار کلی کسب و کارهای کاربر", response_model=SuccessResponse, @@ -307,4 +341,4 @@ def get_business_stats( """آمار کسب و کارها""" owner_id = ctx.get_user_id() stats = get_business_summary(db, owner_id) - return success_response(stats, request, "آمار کسب و کارها دریافت شد") + return success_response(stats, request) diff --git a/hesabixAPI/adapters/api/v1/schema_models/email.py b/hesabixAPI/adapters/api/v1/schema_models/email.py new file mode 100644 index 0000000..633f6e7 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/schema_models/email.py @@ -0,0 +1,59 @@ +from pydantic import BaseModel, EmailStr, Field +from typing import Optional +from datetime import datetime + + +class EmailConfigBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100, description="Configuration name") + smtp_host: str = Field(..., min_length=1, max_length=255, description="SMTP host") + smtp_port: int = Field(..., ge=1, le=65535, description="SMTP port") + smtp_username: str = Field(..., min_length=1, max_length=255, description="SMTP username") + smtp_password: str = Field(..., min_length=1, max_length=255, description="SMTP password") + use_tls: bool = Field(default=True, description="Use TLS encryption") + use_ssl: bool = Field(default=False, description="Use SSL encryption") + from_email: EmailStr = Field(..., description="From email address") + from_name: str = Field(..., min_length=1, max_length=100, description="From name") + is_active: bool = Field(default=True, description="Is this configuration active") + is_default: bool = Field(default=False, description="Is this the default configuration") + + +class EmailConfigCreate(EmailConfigBase): + pass + + +class EmailConfigUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + smtp_host: Optional[str] = Field(None, min_length=1, max_length=255) + smtp_port: Optional[int] = Field(None, ge=1, le=65535) + smtp_username: Optional[str] = Field(None, min_length=1, max_length=255) + smtp_password: Optional[str] = Field(None, min_length=1, max_length=255) + use_tls: Optional[bool] = None + use_ssl: Optional[bool] = None + from_email: Optional[EmailStr] = None + from_name: Optional[str] = Field(None, min_length=1, max_length=100) + is_active: Optional[bool] = None + is_default: Optional[bool] = None + + +class EmailConfigResponse(EmailConfigBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class SendEmailRequest(BaseModel): + to: EmailStr = Field(..., description="Recipient email address") + subject: str = Field(..., min_length=1, max_length=255, description="Email subject") + body: str = Field(..., min_length=1, description="Email body (plain text)") + html_body: Optional[str] = Field(None, description="Email body (HTML)") + config_id: Optional[int] = Field(None, description="Specific config ID to use") + + +class TestConnectionRequest(BaseModel): + config_id: int = Field(..., description="Configuration ID to test") + + +# These response models are no longer needed as we use SuccessResponse from schemas.py diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py index bbb2d13..7e44cb1 100644 --- a/hesabixAPI/adapters/db/models/__init__.py +++ b/hesabixAPI/adapters/db/models/__init__.py @@ -12,6 +12,9 @@ from .business_permission import BusinessPermission # noqa: F401 from .support import * # noqa: F401, F403 # Import file storage models -from .file_storage import * # noqa: F401, F403 +from .file_storage import * + +# Import email config models +from .email_config import EmailConfig # noqa: F401, F403 diff --git a/hesabixAPI/adapters/db/models/email_config.py b/hesabixAPI/adapters/db/models/email_config.py new file mode 100644 index 0000000..17aa98f --- /dev/null +++ b/hesabixAPI/adapters/db/models/email_config.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, Boolean, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class EmailConfig(Base): + __tablename__ = "email_configs" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + smtp_host: Mapped[str] = mapped_column(String(255), nullable=False) + smtp_port: Mapped[int] = mapped_column(Integer, nullable=False) + smtp_username: Mapped[str] = mapped_column(String(255), nullable=False) + smtp_password: Mapped[str] = mapped_column(String(255), nullable=False) # Should be encrypted + use_tls: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + use_ssl: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + from_email: Mapped[str] = mapped_column(String(255), nullable=False) + from_name: Mapped[str] = mapped_column(String(100), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) diff --git a/hesabixAPI/adapters/db/repositories/base_repo.py b/hesabixAPI/adapters/db/repositories/base_repo.py index 2b6109f..ffb52b6 100644 --- a/hesabixAPI/adapters/db/repositories/base_repo.py +++ b/hesabixAPI/adapters/db/repositories/base_repo.py @@ -53,3 +53,12 @@ class BaseRepository(Generic[T]): stmt = stmt.where(column == value) return self.db.execute(stmt).scalars().first() is not None + + def delete(self, obj: T) -> None: + """حذف رکورد از دیتابیس""" + self.db.delete(obj) + self.db.commit() + + def update(self, obj: T) -> None: + """بروزرسانی رکورد در دیتابیس""" + self.db.commit() \ No newline at end of file diff --git a/hesabixAPI/adapters/db/repositories/email_config_repository.py b/hesabixAPI/adapters/db/repositories/email_config_repository.py new file mode 100644 index 0000000..834c467 --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/email_config_repository.py @@ -0,0 +1,85 @@ +from typing import Optional, List +from sqlalchemy.orm import Session +from sqlalchemy import and_ + +from adapters.db.models.email_config import EmailConfig +from adapters.db.repositories.base_repo import BaseRepository + + +class EmailConfigRepository(BaseRepository[EmailConfig]): + def __init__(self, db: Session): + super().__init__(db, EmailConfig) + + def get_active_config(self) -> Optional[EmailConfig]: + """Get the currently active email configuration""" + return self.db.query(self.model_class).filter(self.model_class.is_active == True).first() + + def get_default_config(self) -> Optional[EmailConfig]: + """Get the default email configuration""" + return self.db.query(self.model_class).filter(self.model_class.is_default == True).first() + + def set_default_config(self, config_id: int) -> bool: + """Set a configuration as default (removes default from others)""" + try: + # First check if the config exists + config = self.get_by_id(config_id) + if not config: + return False + + # Remove default from all configs + self.db.query(self.model_class).update({self.model_class.is_default: False}) + + # Set the specified config as default + config.is_default = True + self.db.commit() + return True + except Exception as e: + self.db.rollback() + print(f"Error in set_default_config: {e}") # Debug log + return False + + def get_by_name(self, name: str) -> Optional[EmailConfig]: + """Get email configuration by name""" + return self.db.query(self.model_class).filter(self.model_class.name == name).first() + + def get_all_configs(self) -> List[EmailConfig]: + """Get all email configurations""" + return self.db.query(self.model_class).order_by(self.model_class.created_at.desc()).all() + + def set_active_config(self, config_id: int) -> bool: + """Set a specific configuration as active and deactivate others""" + try: + # Deactivate all configs + self.db.query(self.model_class).update({self.model_class.is_active: False}) + + # Activate the specified config + config = self.get_by_id(config_id) + if config: + config.is_active = True + self.db.commit() + return True + return False + except Exception: + self.db.rollback() + return False + + def test_connection(self, config: EmailConfig) -> bool: + """Test SMTP connection for a configuration""" + try: + import smtplib + from email.mime.text import MIMEText + + # Create SMTP connection + if config.use_ssl: + server = smtplib.SMTP_SSL(config.smtp_host, config.smtp_port) + else: + server = smtplib.SMTP(config.smtp_host, config.smtp_port) + if config.use_tls: + server.starttls() + + # Login + server.login(config.smtp_username, config.smtp_password) + server.quit() + return True + except Exception: + return False diff --git a/hesabixAPI/app/core/auth_dependency.py b/hesabixAPI/app/core/auth_dependency.py index cd91eda..37c205f 100644 --- a/hesabixAPI/app/core/auth_dependency.py +++ b/hesabixAPI/app/core/auth_dependency.py @@ -249,14 +249,16 @@ class AuthContext: def get_current_user( request: Request, - authorization: Optional[str] = Header(default=None), db: Session = Depends(get_db) ) -> AuthContext: """دریافت اطلاعات کامل کاربر کنونی و تنظیمات از درخواست""" - if not authorization or not authorization.startswith("ApiKey "): + # Get authorization from request headers + auth_header = request.headers.get("Authorization") + + if not auth_header or not auth_header.startswith("ApiKey "): raise ApiError("UNAUTHORIZED", "Missing or invalid API key", http_status=401) - api_key = authorization[len("ApiKey ") :].strip() + api_key = auth_header[len("ApiKey ") :].strip() key_hash = hash_api_key(api_key) repo = ApiKeyRepository(db) obj = repo.get_by_hash(key_hash) diff --git a/hesabixAPI/app/core/i18n.py b/hesabixAPI/app/core/i18n.py index 54d59ce..574ca12 100644 --- a/hesabixAPI/app/core/i18n.py +++ b/hesabixAPI/app/core/i18n.py @@ -51,3 +51,9 @@ def get_translator(locale: str = "fa") -> Translator: return Translator(locale) +def gettext(key: str, locale: str = "fa") -> str: + """Get translation for a key using gettext""" + translator = get_translator(locale) + return translator.t(key) + + diff --git a/hesabixAPI/app/core/permissions.py b/hesabixAPI/app/core/permissions.py index 1750002..f00d098 100644 --- a/hesabixAPI/app/core/permissions.py +++ b/hesabixAPI/app/core/permissions.py @@ -74,7 +74,23 @@ def require_business_access(business_id_param: str = "business_id"): def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs) -> Any: - ctx = get_current_user() + # Find request in args or kwargs + request = None + for arg in args: + if hasattr(arg, 'headers'): # Check if it's a Request object + request = arg + break + + if not request and 'request' in kwargs: + request = kwargs['request'] + + if not request: + raise ApiError("INTERNAL_ERROR", "Request not found", http_status=500) + + # Get database session + from adapters.db.session import get_db + db = next(get_db()) + ctx = get_current_user(request, db) business_id = kwargs.get(business_id_param) if business_id and not ctx.can_access_business(business_id): raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403) diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index 9b8cb32..83315ca 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -7,12 +7,14 @@ from adapters.api.v1.health import router as health_router from adapters.api.v1.auth import router as auth_router from adapters.api.v1.users import router as users_router from adapters.api.v1.businesses import router as businesses_router +from adapters.api.v1.business_dashboard import router as business_dashboard_router from adapters.api.v1.support.tickets import router as support_tickets_router from adapters.api.v1.support.operator import router as support_operator_router from adapters.api.v1.support.categories import router as support_categories_router from adapters.api.v1.support.priorities import router as support_priorities_router from adapters.api.v1.support.statuses import router as support_statuses_router from adapters.api.v1.admin.file_storage import router as admin_file_storage_router +from adapters.api.v1.admin.email_config import router as admin_email_config_router from app.core.i18n import negotiate_locale, Translator from app.core.error_handlers import register_error_handlers from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig @@ -268,6 +270,7 @@ def create_app() -> FastAPI: application.include_router(auth_router, prefix=settings.api_v1_prefix) application.include_router(users_router, prefix=settings.api_v1_prefix) application.include_router(businesses_router, prefix=settings.api_v1_prefix) + application.include_router(business_dashboard_router, prefix=settings.api_v1_prefix) # Support endpoints application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support") @@ -278,6 +281,7 @@ def create_app() -> FastAPI: # Admin endpoints application.include_router(admin_file_storage_router, prefix=settings.api_v1_prefix) + application.include_router(admin_email_config_router, prefix=settings.api_v1_prefix) register_error_handlers(application) diff --git a/hesabixAPI/app/services/business_dashboard_service.py b/hesabixAPI/app/services/business_dashboard_service.py new file mode 100644 index 0000000..dd720a6 --- /dev/null +++ b/hesabixAPI/app/services/business_dashboard_service.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from typing import List, Optional, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import select, and_, func +from datetime import datetime, timedelta + +from adapters.db.repositories.business_repo import BusinessRepository +from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository +from adapters.db.repositories.user_repo import UserRepository +from adapters.db.models.business import Business +from adapters.db.models.business_permission import BusinessPermission +from adapters.db.models.user import User +from app.core.auth_dependency import AuthContext + + +def get_business_dashboard_data(db: Session, business_id: int, ctx: AuthContext) -> Dict[str, Any]: + """دریافت داده‌های داشبورد کسب و کار""" + business_repo = BusinessRepository(db) + business = business_repo.get_by_id(business_id) + + if not business: + raise ValueError("کسب و کار یافت نشد") + + # بررسی دسترسی کاربر + if not ctx.can_access_business(business_id): + raise ValueError("دسترسی غیرمجاز") + + # دریافت اطلاعات کسب و کار + business_info = _get_business_info(business, db) + + # دریافت آمار + statistics = _get_business_statistics(business_id, db) + + # دریافت فعالیت‌های اخیر + recent_activities = _get_recent_activities(business_id, db) + + return { + "business_info": business_info, + "statistics": statistics, + "recent_activities": recent_activities + } + + +def get_business_members(db: Session, business_id: int, ctx: AuthContext) -> Dict[str, Any]: + """دریافت لیست اعضای کسب و کار""" + if not ctx.can_access_business(business_id): + raise ValueError("دسترسی غیرمجاز") + + permission_repo = BusinessPermissionRepository(db) + user_repo = UserRepository(db) + + # دریافت دسترسی‌های کسب و کار + permissions = permission_repo.get_business_users(business_id) + + members = [] + for permission in permissions: + user = user_repo.get_by_id(permission.user_id) + if user: + members.append({ + "id": permission.id, + "user_id": user.id, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + "mobile": user.mobile, + "role": _get_user_role(permission.business_permissions), + "permissions": permission.business_permissions or {}, + "joined_at": permission.created_at.isoformat() + }) + + return { + "items": members, + "pagination": { + "total": len(members), + "page": 1, + "per_page": len(members), + "total_pages": 1, + "has_next": False, + "has_prev": False + } + } + + +def get_business_statistics(db: Session, business_id: int, ctx: AuthContext) -> Dict[str, Any]: + """دریافت آمار تفصیلی کسب و کار""" + if not ctx.can_access_business(business_id): + raise ValueError("دسترسی غیرمجاز") + + # آمار فروش ماهانه (نمونه) + sales_by_month = [ + {"month": "2024-01", "amount": 500000}, + {"month": "2024-02", "amount": 750000}, + {"month": "2024-03", "amount": 600000} + ] + + # پرفروش‌ترین محصولات (نمونه) + top_products = [ + {"name": "محصول A", "sales_count": 100, "revenue": 500000}, + {"name": "محصول B", "sales_count": 80, "revenue": 400000}, + {"name": "محصول C", "sales_count": 60, "revenue": 300000} + ] + + # آمار فعالیت اعضا + permission_repo = BusinessPermissionRepository(db) + members = permission_repo.get_business_users(business_id) + + member_activity = { + "active_today": len([m for m in members if m.created_at.date() == datetime.now().date()]), + "active_this_week": len([m for m in members if m.created_at >= datetime.now() - timedelta(days=7)]), + "total_members": len(members) + } + + return { + "sales_by_month": sales_by_month, + "top_products": top_products, + "member_activity": member_activity + } + + +def _get_business_info(business: Business, db: Session) -> Dict[str, Any]: + """دریافت اطلاعات کسب و کار""" + permission_repo = BusinessPermissionRepository(db) + member_count = len(permission_repo.get_business_users(business.id)) + + return { + "id": business.id, + "name": business.name, + "business_type": business.business_type.value, + "business_field": business.business_field.value, + "owner_id": business.owner_id, + "address": business.address, + "phone": business.phone, + "mobile": business.mobile, + "created_at": business.created_at.isoformat(), + "member_count": member_count + } + + +def _get_business_statistics(business_id: int, db: Session) -> Dict[str, Any]: + """دریافت آمار کلی کسب و کار""" + # در اینجا می‌توانید آمار واقعی را از جداول مربوطه دریافت کنید + # فعلاً داده‌های نمونه برمی‌گردانیم + return { + "total_sales": 1000000.0, + "total_purchases": 500000.0, + "active_members": 5, + "recent_transactions": 25 + } + + +def _get_recent_activities(business_id: int, db: Session) -> List[Dict[str, Any]]: + """دریافت فعالیت‌های اخیر""" + # در اینجا می‌توانید فعالیت‌های واقعی را از جداول مربوطه دریافت کنید + # فعلاً داده‌های نمونه برمی‌گردانیم + return [ + { + "id": 1, + "title": "فروش جدید", + "description": "فروش محصول A به مبلغ 100,000 تومان", + "icon": "sell", + "time_ago": "2 ساعت پیش" + }, + { + "id": 2, + "title": "عضو جدید", + "description": "احمد احمدی به تیم اضافه شد", + "icon": "person_add", + "time_ago": "5 ساعت پیش" + }, + { + "id": 3, + "title": "گزارش ماهانه", + "description": "گزارش فروش ماه ژانویه تولید شد", + "icon": "assessment", + "time_ago": "1 روز پیش" + } + ] + + +def _get_user_role(permissions: Optional[Dict[str, Any]]) -> str: + """تعیین نقش کاربر بر اساس دسترسی‌ها""" + if not permissions: + return "عضو" + + # بررسی دسترسی‌های مختلف برای تعیین نقش + if permissions.get("settings", {}).get("manage_users"): + return "مدیر" + elif permissions.get("sales", {}).get("write"): + return "مدیر فروش" + elif permissions.get("accounting", {}).get("write"): + return "حسابدار" + else: + return "عضو" diff --git a/hesabixAPI/app/services/business_service.py b/hesabixAPI/app/services/business_service.py index 75a11f3..e70d2d3 100644 --- a/hesabixAPI/app/services/business_service.py +++ b/hesabixAPI/app/services/business_service.py @@ -181,6 +181,6 @@ def _business_to_dict(business: Business) -> Dict[str, Any]: "province": business.province, "city": business.city, "postal_code": business.postal_code, - "created_at": business.created_at.isoformat(), - "updated_at": business.updated_at.isoformat() + "created_at": business.created_at, # datetime object بماند + "updated_at": business.updated_at # datetime object بماند } diff --git a/hesabixAPI/app/services/email_service.py b/hesabixAPI/app/services/email_service.py new file mode 100644 index 0000000..f1a2399 --- /dev/null +++ b/hesabixAPI/app/services/email_service.py @@ -0,0 +1,143 @@ +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from typing import Optional, List +from sqlalchemy.orm import Session + +from adapters.db.models.email_config import EmailConfig +from adapters.db.repositories.email_config_repository import EmailConfigRepository + + +class EmailService: + def __init__(self, db: Session): + self.db = db + self.email_repo = EmailConfigRepository(db) + + def send_email( + self, + to: str, + subject: str, + body: str, + html_body: Optional[str] = None, + config_id: Optional[int] = None + ) -> bool: + """ + Send email using SMTP configuration + + Args: + to: Recipient email address + subject: Email subject + body: Plain text body + html_body: HTML body (optional) + config_id: Specific config ID to use (optional) + + Returns: + bool: True if email sent successfully, False otherwise + """ + try: + # Get email configuration - prioritize default config + if config_id: + config = self.email_repo.get_by_id(config_id) + else: + # First try to get default config + config = self.email_repo.get_default_config() + if not config: + # Fallback to active config + config = self.email_repo.get_active_config() + + if not config: + return False + + # Create message + msg = MIMEMultipart('alternative') + msg['From'] = f"{config.from_name} <{config.from_email}>" + msg['To'] = to + msg['Subject'] = subject + + # Add plain text part + text_part = MIMEText(body, 'plain', 'utf-8') + msg.attach(text_part) + + # Add HTML part if provided + if html_body: + html_part = MIMEText(html_body, 'html', 'utf-8') + msg.attach(html_part) + + # Send email + return self._send_smtp_email(config, msg) + + except Exception as e: + print(f"Error sending email: {e}") + return False + + def send_template_email( + self, + template_name: str, + to: str, + context: dict, + config_id: Optional[int] = None + ) -> bool: + """ + Send email using a template (placeholder for future template system) + + Args: + template_name: Name of the template + to: Recipient email address + context: Template context variables + config_id: Specific config ID to use (optional) + + Returns: + bool: True if email sent successfully, False otherwise + """ + # For now, just use basic template substitution + # This can be extended with a proper template engine later + subject = context.get('subject', 'Email from Hesabix') + body = context.get('body', '') + html_body = context.get('html_body') + + return self.send_email(to, subject, body, html_body, config_id) + + def test_connection(self, config_id: int) -> bool: + """ + Test SMTP connection for a specific configuration + + Args: + config_id: Configuration ID to test + + Returns: + bool: True if connection successful, False otherwise + """ + config = self.email_repo.get_by_id(config_id) + if not config: + return False + + return self.email_repo.test_connection(config) + + def get_active_config(self) -> Optional[EmailConfig]: + """Get the currently active email configuration""" + return self.email_repo.get_active_config() + + def get_all_configs(self) -> List[EmailConfig]: + """Get all email configurations""" + return self.email_repo.get_all_configs() + + def _send_smtp_email(self, config: EmailConfig, msg: MIMEMultipart) -> bool: + """Internal method to send email via SMTP""" + try: + # Create SMTP connection + if config.use_ssl: + server = smtplib.SMTP_SSL(config.smtp_host, config.smtp_port) + else: + server = smtplib.SMTP(config.smtp_host, config.smtp_port) + if config.use_tls: + server.starttls() + + # Login and send + server.login(config.smtp_username, config.smtp_password) + server.send_message(msg) + server.quit() + + return True + except Exception as e: + print(f"SMTP error: {e}") + return False diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index f9b73d8..9f623f1 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -8,8 +8,10 @@ adapters/api/v1/businesses.py adapters/api/v1/health.py adapters/api/v1/schemas.py adapters/api/v1/users.py +adapters/api/v1/admin/email_config.py adapters/api/v1/admin/file_storage.py adapters/api/v1/schema_models/__init__.py +adapters/api/v1/schema_models/email.py adapters/api/v1/schema_models/file_storage.py adapters/api/v1/support/__init__.py adapters/api/v1/support/categories.py @@ -25,6 +27,7 @@ adapters/db/models/api_key.py adapters/db/models/business.py adapters/db/models/business_permission.py adapters/db/models/captcha.py +adapters/db/models/email_config.py adapters/db/models/file_storage.py adapters/db/models/password_reset.py adapters/db/models/user.py @@ -38,6 +41,7 @@ adapters/db/repositories/api_key_repo.py adapters/db/repositories/base_repo.py adapters/db/repositories/business_permission_repo.py adapters/db/repositories/business_repo.py +adapters/db/repositories/email_config_repository.py adapters/db/repositories/file_storage_repository.py adapters/db/repositories/password_reset_repo.py adapters/db/repositories/user_repo.py @@ -66,6 +70,7 @@ app/services/api_key_service.py app/services/auth_service.py app/services/business_service.py app/services/captcha_service.py +app/services/email_service.py app/services/file_storage_service.py app/services/query_service.py app/services/pdf/__init__.py @@ -84,6 +89,8 @@ migrations/versions/20250117_000004_add_business_contact_fields.py migrations/versions/20250117_000005_add_business_geographic_fields.py migrations/versions/20250117_000006_add_app_permissions_to_users.py migrations/versions/20250117_000007_create_business_permissions_table.py +migrations/versions/20250117_000008_add_email_config_table.py +migrations/versions/20250117_000009_add_is_default_to_email_config.py migrations/versions/20250915_000001_init_auth_tables.py migrations/versions/20250916_000002_add_referral_fields.py migrations/versions/5553f8745c6e_add_support_tables.py diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.mo b/hesabixAPI/locales/en/LC_MESSAGES/messages.mo index 4beb188931e5a83643954be61217d20678167b82..be0573564055296cecbc2da003a7dd24a7552875 100644 GIT binary patch delta 2280 zcmdVaPi&M$7{~Fa(%r4lg|!sgMXN8`N>fN}fW`xu7*-oSxX7`j0baLn>BjAD(|xx} z#H=D{d@O%>wnV{%nk!rRd&;NzCF6IN*kx9=ZW^6D=yy(F8~r!t2H1>y{sXS&d0T^73*L{5(Z^;S z#U(h2N?o{y|)hhf#4)V676U`Ik}g;tNTP^{ttPO5BUexDORzz*$BmvIjfxHC%-=sDv+} z=GQHn+j$de{u)%tGpP65Q3;Qt5_klwBx92_l=&1=6?+~vVH#ujCTiSUr~vP~{%KU8 zPf*|VbJVB#78Oqq`D(TR6?c)d8Ohn&8mYemck)0xS&PiIO>RIIwP3&N@4$Nc4UHI%D$Xh7*Jk*m#LqTTe=Ycx8}L19!cVB3*D*>akVlrpt#xKA zP=~C-L7-_<*o`lvZo$Xcju%{iDZA77UR;ZtQDivFPji*tizlhp-fSb@^0=XauZ^Z@G6J%K9qOX%S%sIzb!yYUp>j+gLO-TzkVpuih&JwAxH;Uwzx z9!8z^GpL<^=WJ!tE%Y?sUO-~o31p~!h8}){`ZNt>t%`SHO80*=4Q0Fowey{* zLo|-c{7Ka5eF`apJ&QUMuc9h;05$&zYTi4@SFn?)GxI6xQ=CJ^`x$jBe#Hje|9a}C zKuOeLYH|GzRG>TD^SeYGCxj3 z6F)>%U=}suJnH>L)WSbv?N)FwYPSM4FWD09k8RtSDHVtEJ1b*;m@gH*qCXONeqk)| z2X}eFqxo`JHbxYKT14D-sB zTrMb=hbo1_E~^cVhEwgaxmNUN>w{E!-aunPIF=8_=MR)fc5M3l;$`ojCVAzc7+#ZA zv?bmZ9f)t}?PDJ-6qdXz>0i4myE=d%gI=Ju-#!;l$20$>koN>bex(pbUp6j}mL-Sh aEyI~4PiFsRJ$n{!kNp8bihMEv delta 1547 zcmYM!Ye-Z<7{>9@ysWo$wba$DEh|&WN-fBs5Y$Q}V@nhZWla;sv=63&$!ZWpS`te{ zlu<-f1a>iqsK72?3XCATKo<2O2@8ryQX=aA;6%%Delv5i|+U)%< z3}k-OOC_EgM^S+eaPpc;0U!a~JL%sR9^$Tj;cdQIB<`3#j zRnn_zE-8!Rza*$VFvnBf4=1DwF$B z89RdvF%MA-9!1t}-XObSCXj{u%p?^ZwkhN?0Yukc#-UQ?LIp@g4a`6VUXARMDMD?< zHdH3|pbqg~RNM|!oE~I1Os{R9#29`5=k1M~s0kjR0*#;!+cVTD9zzBAVEu|(@Nd*} z(WFBeNv4>ED^Ls1L5<5pWndHP`EuRYKZspaG;t&9&G%V5Q3HG8{COc&{?U+1zt@qT zTu@rRrKB{!pt{iGDfO75%_W7^w$I<`C?5LdNDT3pMj!NtI3cFD}N<@jg6< z8h-^fE-}fNR9uEd#zf2-3WYS(x$eO{>K{!FFr#k!Ra{Mb-aW?TU=4cFhgsN)>DY}* z;4m)2LA3BJYTWNQAOFT=);EdNGHF#&lCq<_C~dF^5nSj$k65M2#Cl1^Cjfe~SwA1L{u4 zP*?K@DxMm-H8U3#H^nsz$=T$ka{dZjNP~9rFf!L{atCZdEm-H)U&eXVUq^mTtLvMn zg!iKYA3;63K2-eAP;t&6zh;zwO8k5(=dT4Xx&tOq6Rx3lp1>%Xgfy8ntW7Xx39h9c z;2~(CRyR>);W50^3lZjv=habEvCX8zJpSDLjMaxDPe)46et2@NukQmDSjR z3V0kh<2Y7hAup>Kx1&DB?_mLcfh9PO6_~|~T!UNjF^qIlSViG9YKOn!a-6_I%;Ym8 zD{v#)r~v(F;dy)(uet5jykOn+4txqb-THTK{U_807V|>skC-|ND`+@|b$Aw)`Te{k z9a$CXBwoWR?88lX2}|)IJ{m8gjkVU+QQs%ExB<6gC4Lfb=l-u!P{tWc zranGPk18)5&>X8kguHY1MJLX4J;=iIE?@BJ7=VOY| z%lf9A0-G^4sEIz*#NDXO-$7;mF>2vUs6c^KN2&MP)G1+S5`&#WD*Wq*N^PxlQh!<2-0ZTI_%+J zJUcx(vAxm$Shp4XYOLqNiRh{5hcpb1^*AL93zwVdKy)}dU`6{We9E{kD>`uDM67$P zN5zropz}i7MD~oS45z=_8T97l$KHybo|?yCGjoyYk+}uTiJoMk!Fb%+hB~{wDV28! z6;F&v{VY6Sv6b1EU6pf(I3%Cz98Jr)v!Rg$K60h$!*W1qt4*$1d=SlB# rPI6|lbJ@Fhg?kbN(1_>|A!aV_98Ay7x*ya!x4cgQog7tQVi<*!vl@&`JDf`U!Jq>{QEh9kM8ge!>5Yh zQhuQ@MgKon=Nhw+uoGjjALH<`jo)B0aR`^;lr8r}%;sldH2Gd!fPP$rwHR+qz|@mi zOhzllVizXjU7P<3qliDD3%?)@%`a5_sClz-A|?>8MorX*ZrqI;_%PDM)Z6k?7{>Uf zjl>cPE}}Z_L9JvE)xj{T!>^c%)2JOuikzKzHmZG*wF)(nI$VJ*n1TJM34cJfAIC(- zH@``!gE*F{t;|GKC_&A*0yTks$k!a@p;J16G&K#V`i;o$n@h;oT;`$n-8R03>Sq9T zBu_EmA@Q1oIvhh5XC_b`f3r@bwmyspNfU!wi3jIn2J$tzJTzdTjlCF7T!tF3!g>%j z;W`)lPueu{&`Z^Z8lV%^Q7@`tKWgTWPy@cSV9f>hAHexcP)LS$anV=Km^;%XZo!p+3DZ9IwT z#Cg2Qd$0;M@d4Dt13?l6BxcZu8@K{nuo3g{IacEoYRh(WdDXBB)$ub-!Er3aaPD$3 z7Nhc;Q4{PzUF&|NEAs+5%76*k3KOX7@*O!YFo zdf6T#r)8cXr(j;AUh)vC-A`1zI5Jao|IKP<)Hp*Cd!5?I ztmKl?%3V84i%Rx;{r*zF@on4b-EZ@plabr+%|tdtI5$19&S>l>=epbP?hT#`HU(RT f@1zD#5BH8-2sSxo@hQ#$x7!(YU%U4?z98x!^wX+V diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po index f47a8b4..ec270ea 100644 --- a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po +++ b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po @@ -33,6 +33,47 @@ msgstr "فیلد الزامی است" msgid "INVALID_EMAIL" msgstr "ایمیل نامعتبر است" +# Email Configuration +msgid "Email configurations retrieved successfully" +msgstr "پیکربندی‌های ایمیل با موفقیت دریافت شد" + +msgid "Email configuration retrieved successfully" +msgstr "پیکربندی ایمیل با موفقیت دریافت شد" + +msgid "Email configuration created successfully" +msgstr "پیکربندی ایمیل با موفقیت ایجاد شد" + +msgid "Email configuration updated successfully" +msgstr "پیکربندی ایمیل با موفقیت بروزرسانی شد" + +msgid "Email configuration deleted successfully" +msgstr "پیکربندی ایمیل با موفقیت حذف شد" + +msgid "Email configuration not found" +msgstr "پیکربندی ایمیل یافت نشد" + +msgid "Configuration name already exists" +msgstr "نام پیکربندی قبلاً استفاده شده است" + +msgid "Connection test completed" +msgstr "تست اتصال تکمیل شد" + +msgid "Email configuration activated successfully" +msgstr "پیکربندی ایمیل با موفقیت فعال شد" + +msgid "Failed to activate configuration" +msgstr "فعال‌سازی پیکربندی ناموفق بود" + +msgid "Email sent successfully" +msgstr "ایمیل با موفقیت ارسال شد" +msgid "Cannot delete default configuration" +msgstr "نمی‌توان پیکربندی پیشفرض را حذف کرد" +msgid "Failed to set default configuration" +msgstr "تنظیم پیکربندی پیشفرض ناموفق بود" + +msgid "Failed to send email" +msgstr "ارسال ایمیل ناموفق بود" + # Auth msgid "INVALID_CAPTCHA" msgstr "کد امنیتی نامعتبر است." diff --git a/hesabixAPI/migrations/versions/20250117_000008_add_email_config_table.py b/hesabixAPI/migrations/versions/20250117_000008_add_email_config_table.py new file mode 100644 index 0000000..e04e8a8 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250117_000008_add_email_config_table.py @@ -0,0 +1,45 @@ +"""add_email_config_table + +Revision ID: 20250117_000008 +Revises: 5553f8745c6e +Create Date: 2025-01-17 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250117_000008' +down_revision = '5553f8745c6e' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('email_configs', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('smtp_host', sa.String(length=255), nullable=False), + sa.Column('smtp_port', sa.Integer(), nullable=False), + sa.Column('smtp_username', sa.String(length=255), nullable=False), + sa.Column('smtp_password', sa.String(length=255), nullable=False), + sa.Column('use_tls', sa.Boolean(), nullable=False), + sa.Column('use_ssl', sa.Boolean(), nullable=False), + sa.Column('from_email', sa.String(length=255), nullable=False), + sa.Column('from_name', sa.String(length=100), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_email_configs_name'), 'email_configs', ['name'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_email_configs_name'), table_name='email_configs') + op.drop_table('email_configs') + # ### end Alembic commands ### diff --git a/hesabixAPI/migrations/versions/20250117_000009_add_is_default_to_email_config.py b/hesabixAPI/migrations/versions/20250117_000009_add_is_default_to_email_config.py new file mode 100644 index 0000000..c604f48 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250117_000009_add_is_default_to_email_config.py @@ -0,0 +1,26 @@ +"""add is_default to email_config + +Revision ID: 20250117_000009 +Revises: 20250117_000008 +Create Date: 2025-01-17 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250117_000009' +down_revision = '20250117_000008' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add is_default column to email_configs table + op.add_column('email_configs', sa.Column('is_default', sa.Boolean(), nullable=False, server_default='0')) + + +def downgrade(): + # Remove is_default column from email_configs table + op.drop_column('email_configs', 'is_default') diff --git a/hesabixUI/hesabix_ui/lib/core/referral_store.dart b/hesabixUI/hesabix_ui/lib/core/referral_store.dart index 1aff516..5050e91 100644 --- a/hesabixUI/hesabix_ui/lib/core/referral_store.dart +++ b/hesabixUI/hesabix_ui/lib/core/referral_store.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; class ReferralStore { diff --git a/hesabixUI/hesabix_ui/lib/core/splash_controller.dart b/hesabixUI/hesabix_ui/lib/core/splash_controller.dart new file mode 100644 index 0000000..55d7b8f --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/core/splash_controller.dart @@ -0,0 +1,76 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +class SplashController extends ChangeNotifier { + static const Duration _minimumSplashDuration = Duration(seconds: 2); + + bool _isLoading = true; + DateTime? _startTime; + Timer? _minimumDurationTimer; + Completer? _loadingCompleter; + + bool get isLoading => _isLoading; + + SplashController() { + _startTime = DateTime.now(); + } + + /// شروع loading با حداقل زمان نمایش + Future startLoading() async { + _isLoading = true; + _loadingCompleter = Completer(); + notifyListeners(); + + // شروع تایمر برای حداقل زمان نمایش + _minimumDurationTimer = Timer(_minimumSplashDuration, () { + if (_loadingCompleter != null && !_loadingCompleter!.isCompleted) { + _loadingCompleter!.complete(); + } + }); + + return _loadingCompleter!.future; + } + + /// اتمام loading (فقط اگر حداقل زمان گذشته باشد) + void finishLoading() { + if (_minimumDurationTimer != null && _minimumDurationTimer!.isActive) { + // اگر هنوز حداقل زمان نگذشته، منتظر بمان + _minimumDurationTimer!.cancel(); + _minimumDurationTimer = Timer( + _minimumSplashDuration - DateTime.now().difference(_startTime!), + () { + _completeLoading(); + }, + ); + } else { + _completeLoading(); + } + } + + void _completeLoading() { + if (_isLoading) { + _isLoading = false; + notifyListeners(); + } + } + + /// بررسی اینکه آیا حداقل زمان گذشته یا نه + bool get hasMinimumTimePassed { + if (_startTime == null) return true; + return DateTime.now().difference(_startTime!) >= _minimumSplashDuration; + } + + /// دریافت زمان باقی‌مانده تا اتمام حداقل زمان + Duration get remainingTime { + if (_startTime == null) return Duration.zero; + final elapsed = DateTime.now().difference(_startTime!); + final remaining = _minimumSplashDuration - elapsed; + return remaining.isNegative ? Duration.zero : remaining; + } + + @override + void dispose() { + _minimumDurationTimer?.cancel(); + super.dispose(); + } +} diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index a0623f8..aa584d8 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -48,6 +48,30 @@ , "systemSettings": "System Settings", "adminTools": "Admin Tools", + "emailSettings": "Email Settings", + "emailSettingsDescription": "Configure SMTP settings for email sending", + "emailConfigurations": "Email Configurations", + "noEmailConfigurations": "No email configurations found", + "addEmailConfiguration": "Add Email Configuration", + "configurationName": "Configuration Name", + "smtpHost": "SMTP Host", + "smtpPort": "SMTP Port", + "smtpUsername": "SMTP Username", + "smtpPassword": "SMTP Password", + "fromEmail": "From Email", + "fromName": "From Name", + "useTls": "Use TLS", + "useSsl": "Use SSL", + "isActive": "Active", + "active": "Active", + "testConnection": "Test Connection", + "sendTestEmail": "Send Test Email", + "saveConfiguration": "Save Configuration", + "deleteConfiguration": "Delete Configuration", + "deleteConfigurationConfirm": "Are you sure you want to delete this configuration?", + "delete": "Delete", + "invalidPort": "Invalid port", + "invalidEmail": "Invalid email", "ok": "OK", "cancel": "Cancel", "columnSettings": "Column Settings", @@ -202,6 +226,8 @@ "trading": "Trading", "service": "Service", "other": "Other", + "owner": "Owner", + "member": "Member", "support": "Support", "newTicket": "New Ticket", "ticketTitle": "Ticket Title", @@ -340,6 +366,32 @@ "connectionSuccessful": "Connection Successful", "connectionFailed": "Connection Failed", "setAsDefault": "Set as Default", + "defaultConfiguration": "Default Configuration", + "setDefaultConfirm": "Are you sure you want to set this configuration as default?", + "defaultSetSuccessfully": "Default configuration set successfully", + "defaultSetFailed": "Failed to set default configuration", + "cannotDeleteDefault": "Cannot delete default configuration", + "defaultConfigurationNote": "Default configuration is used for sending emails and cannot be deleted", + "setAsDefaultEmail": "Set as Default Email", + "defaultEmailServer": "Default Email Server", + "changeDefaultEmail": "Change Default Email", + "currentDefault": "Current Default", + "makeDefault": "Make Default", + "defaultEmailNote": "Emails are sent from the default server", + "noDefaultSet": "No default email server is set", + "selectDefaultServer": "Select Default Server", + "defaultServerChanged": "Default server changed", + "defaultServerChangeFailed": "Failed to change default server", + "emailConfigSavedSuccessfully": "Email configuration saved successfully", + "emailConfigUpdatedSuccessfully": "Email configuration updated successfully", + "editEmailConfiguration": "Edit Email Configuration", + "updateConfiguration": "Update Configuration", + "testEmailSubject": "Test Email", + "testEmailBody": "This is a test email.", + "testEmailSentSuccessfully": "Test email sent successfully", + "emailConfigDeletedSuccessfully": "Configuration deleted successfully", + "confirm": "Confirm", + "cancel": "Cancel", "fileStatistics": "File Statistics", "totalFiles": "Total Files", "totalSize": "Total Size", @@ -413,6 +465,24 @@ "systemAdministration": "System Administration", "generalSettings": "General Settings", "securitySettings": "Security Settings", - "maintenanceSettings": "Maintenance Settings" + "maintenanceSettings": "Maintenance Settings", + "initializing": "Initializing...", + "loadingLanguageSettings": "Loading language settings...", + "loadingCalendarSettings": "Loading calendar settings...", + "loadingThemeSettings": "Loading theme settings...", + "loadingAuthentication": "Loading authentication...", + "businessManagementPlatform": "Business Management Platform", + "businessDashboard": "Business Dashboard", + "businessStatistics": "Business Statistics", + "recentActivities": "Recent Activities", + "sales": "Sales", + "accounting": "Accounting", + "inventory": "Inventory", + "reports": "Reports", + "members": "Members", + "backToProfile": "Back to Profile", + "noBusinessesFound": "No businesses found", + "createFirstBusiness": "Create your first business", + "accessDenied": "Access denied" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index d84148b..d44225b 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -53,6 +53,30 @@ "marketing": "بازاریابی", "systemSettings": "تنظیمات سیستم", "adminTools": "ابزارهای مدیریتی", + "emailSettings": "تنظیمات ایمیل", + "emailSettingsDescription": "پیکربندی تنظیمات SMTP برای ارسال ایمیل", + "emailConfigurations": "پیکربندی‌های ایمیل", + "noEmailConfigurations": "هیچ پیکربندی ایمیلی وجود ندارد", + "addEmailConfiguration": "افزودن پیکربندی ایمیل", + "configurationName": "نام پیکربندی", + "smtpHost": "میزبان SMTP", + "smtpPort": "پورت SMTP", + "smtpUsername": "نام کاربری SMTP", + "smtpPassword": "رمز عبور SMTP", + "fromEmail": "ایمیل فرستنده", + "fromName": "نام فرستنده", + "useTls": "استفاده از TLS", + "useSsl": "استفاده از SSL", + "isActive": "فعال", + "active": "فعال", + "testConnection": "تست اتصال", + "sendTestEmail": "ارسال ایمیل تست", + "saveConfiguration": "ذخیره پیکربندی", + "deleteConfiguration": "حذف پیکربندی", + "deleteConfigurationConfirm": "آیا مطمئن هستید که می‌خواهید این پیکربندی را حذف کنید؟", + "delete": "حذف", + "invalidPort": "پورت نامعتبر است", + "invalidEmail": "ایمیل نامعتبر است", "ok": "تایید", "cancel": "انصراف", "columnSettings": "تنظیمات ستون‌ها", @@ -201,6 +225,8 @@ "trading": "بازرگانی", "service": "خدماتی", "other": "سایر", + "owner": "مالک", + "member": "عضو", "support": "پشتیبانی", "newTicket": "تیکت جدید", "ticketTitle": "عنوان تیکت", @@ -339,6 +365,32 @@ "connectionSuccessful": "اتصال موفقیت‌آمیز", "connectionFailed": "اتصال ناموفق", "setAsDefault": "تنظیم به عنوان پیش‌فرض", + "defaultConfiguration": "پیکربندی پیشفرض", + "setDefaultConfirm": "آیا مطمئن هستید که می‌خواهید این پیکربندی را به عنوان پیشفرض تنظیم کنید؟", + "defaultSetSuccessfully": "پیکربندی پیشفرض با موفقیت تنظیم شد", + "defaultSetFailed": "تنظیم پیکربندی پیشفرض ناموفق بود", + "cannotDeleteDefault": "نمی‌توان پیکربندی پیشفرض را حذف کرد", + "defaultConfigurationNote": "پیکربندی پیشفرض برای ارسال ایمیل‌ها استفاده می‌شود و قابل حذف نیست", + "setAsDefaultEmail": "تنظیم به عنوان ایمیل پیشفرض", + "defaultEmailServer": "سرور ایمیل پیشفرض", + "changeDefaultEmail": "تغییر ایمیل پیشفرض", + "currentDefault": "پیشفرض فعلی", + "makeDefault": "پیشفرض کردن", + "defaultEmailNote": "ایمیل‌ها از سرور پیشفرض ارسال می‌شوند", + "noDefaultSet": "هیچ سرور ایمیل پیشفرضی تنظیم نشده است", + "selectDefaultServer": "انتخاب سرور پیشفرض", + "defaultServerChanged": "سرور پیشفرض تغییر کرد", + "defaultServerChangeFailed": "تغییر سرور پیشفرض ناموفق بود", + "emailConfigSavedSuccessfully": "تنظیمات ایمیل با موفقیت ذخیره شد", + "emailConfigUpdatedSuccessfully": "تنظیمات ایمیل با موفقیت به‌روزرسانی شد", + "editEmailConfiguration": "ویرایش تنظیمات ایمیل", + "updateConfiguration": "به‌روزرسانی تنظیمات", + "testEmailSubject": "تست ایمیل", + "testEmailBody": "این یک ایمیل تست است.", + "testEmailSentSuccessfully": "ایمیل تست با موفقیت ارسال شد", + "emailConfigDeletedSuccessfully": "تنظیمات حذف شد", + "confirm": "تایید", + "cancel": "لغو", "fileStatistics": "آمار فایل‌ها", "totalFiles": "کل فایل‌ها", "totalSize": "حجم کل", @@ -412,6 +464,24 @@ "systemAdministration": "مدیریت سیستم", "generalSettings": "تنظیمات عمومی", "securitySettings": "تنظیمات امنیتی", - "maintenanceSettings": "تنظیمات نگهداری" + "maintenanceSettings": "تنظیمات نگهداری", + "initializing": "در حال راه‌اندازی...", + "loadingLanguageSettings": "در حال بارگذاری تنظیمات زبان...", + "loadingCalendarSettings": "در حال بارگذاری تنظیمات تقویم...", + "loadingThemeSettings": "در حال بارگذاری تنظیمات تم...", + "loadingAuthentication": "در حال بارگذاری احراز هویت...", + "businessManagementPlatform": "پلتفرم مدیریت کسب‌وکار", + "businessDashboard": "داشبورد کسب و کار", + "businessStatistics": "آمار کسب و کار", + "recentActivities": "فعالیت‌های اخیر", + "sales": "فروش", + "accounting": "حسابداری", + "inventory": "موجودی", + "reports": "گزارش‌ها", + "members": "اعضا", + "backToProfile": "بازگشت به پروفایل", + "noBusinessesFound": "هیچ کسب و کاری یافت نشد", + "createFirstBusiness": "اولین کسب و کار خود را ایجاد کنید", + "accessDenied": "دسترسی غیرمجاز" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index b8e92a5..c4951bc 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -350,6 +350,150 @@ abstract class AppLocalizations { /// **'Admin Tools'** String get adminTools; + /// No description provided for @emailSettings. + /// + /// In en, this message translates to: + /// **'Email Settings'** + String get emailSettings; + + /// No description provided for @emailSettingsDescription. + /// + /// In en, this message translates to: + /// **'Configure SMTP settings for email sending'** + String get emailSettingsDescription; + + /// No description provided for @emailConfigurations. + /// + /// In en, this message translates to: + /// **'Email Configurations'** + String get emailConfigurations; + + /// No description provided for @noEmailConfigurations. + /// + /// In en, this message translates to: + /// **'No email configurations found'** + String get noEmailConfigurations; + + /// No description provided for @addEmailConfiguration. + /// + /// In en, this message translates to: + /// **'Add Email Configuration'** + String get addEmailConfiguration; + + /// No description provided for @configurationName. + /// + /// In en, this message translates to: + /// **'Configuration Name'** + String get configurationName; + + /// No description provided for @smtpHost. + /// + /// In en, this message translates to: + /// **'SMTP Host'** + String get smtpHost; + + /// No description provided for @smtpPort. + /// + /// In en, this message translates to: + /// **'SMTP Port'** + String get smtpPort; + + /// No description provided for @smtpUsername. + /// + /// In en, this message translates to: + /// **'SMTP Username'** + String get smtpUsername; + + /// No description provided for @smtpPassword. + /// + /// In en, this message translates to: + /// **'SMTP Password'** + String get smtpPassword; + + /// No description provided for @fromEmail. + /// + /// In en, this message translates to: + /// **'From Email'** + String get fromEmail; + + /// No description provided for @fromName. + /// + /// In en, this message translates to: + /// **'From Name'** + String get fromName; + + /// No description provided for @useTls. + /// + /// In en, this message translates to: + /// **'Use TLS'** + String get useTls; + + /// No description provided for @useSsl. + /// + /// In en, this message translates to: + /// **'Use SSL'** + String get useSsl; + + /// No description provided for @isActive. + /// + /// In en, this message translates to: + /// **'Active'** + String get isActive; + + /// No description provided for @active. + /// + /// In en, this message translates to: + /// **'Active'** + String get active; + + /// No description provided for @testConnection. + /// + /// In en, this message translates to: + /// **'Test Connection'** + String get testConnection; + + /// No description provided for @sendTestEmail. + /// + /// In en, this message translates to: + /// **'Send Test Email'** + String get sendTestEmail; + + /// No description provided for @saveConfiguration. + /// + /// In en, this message translates to: + /// **'Save Configuration'** + String get saveConfiguration; + + /// No description provided for @deleteConfiguration. + /// + /// In en, this message translates to: + /// **'Delete Configuration'** + String get deleteConfiguration; + + /// No description provided for @deleteConfigurationConfirm. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete this configuration?'** + String get deleteConfigurationConfirm; + + /// No description provided for @delete. + /// + /// In en, this message translates to: + /// **'Delete'** + String get delete; + + /// No description provided for @invalidPort. + /// + /// In en, this message translates to: + /// **'Invalid port'** + String get invalidPort; + + /// No description provided for @invalidEmail. + /// + /// In en, this message translates to: + /// **'Invalid email'** + String get invalidEmail; + /// No description provided for @ok. /// /// In en, this message translates to: @@ -1208,6 +1352,18 @@ abstract class AppLocalizations { /// **'Other'** String get other; + /// No description provided for @owner. + /// + /// In en, this message translates to: + /// **'Owner'** + String get owner; + + /// No description provided for @member. + /// + /// In en, this message translates to: + /// **'Member'** + String get member; + /// No description provided for @newTicket. /// /// In en, this message translates to: @@ -1844,12 +2000,6 @@ abstract class AppLocalizations { /// **'Default'** String get isDefault; - /// No description provided for @isActive. - /// - /// In en, this message translates to: - /// **'Active'** - String get isActive; - /// No description provided for @configData. /// /// In en, this message translates to: @@ -1892,12 +2042,6 @@ abstract class AppLocalizations { /// **'FTP Directory'** String get ftpDirectory; - /// No description provided for @testConnection. - /// - /// In en, this message translates to: - /// **'Test Connection'** - String get testConnection; - /// No description provided for @connectionSuccessful. /// /// In en, this message translates to: @@ -1916,6 +2060,156 @@ abstract class AppLocalizations { /// **'Set as Default'** String get setAsDefault; + /// No description provided for @defaultConfiguration. + /// + /// In en, this message translates to: + /// **'Default Configuration'** + String get defaultConfiguration; + + /// No description provided for @setDefaultConfirm. + /// + /// In en, this message translates to: + /// **'Are you sure you want to set this configuration as default?'** + String get setDefaultConfirm; + + /// No description provided for @defaultSetSuccessfully. + /// + /// In en, this message translates to: + /// **'Default configuration set successfully'** + String get defaultSetSuccessfully; + + /// No description provided for @defaultSetFailed. + /// + /// In en, this message translates to: + /// **'Failed to set default configuration'** + String get defaultSetFailed; + + /// No description provided for @cannotDeleteDefault. + /// + /// In en, this message translates to: + /// **'Cannot delete default configuration'** + String get cannotDeleteDefault; + + /// No description provided for @defaultConfigurationNote. + /// + /// In en, this message translates to: + /// **'Default configuration is used for sending emails and cannot be deleted'** + String get defaultConfigurationNote; + + /// No description provided for @setAsDefaultEmail. + /// + /// In en, this message translates to: + /// **'Set as Default Email'** + String get setAsDefaultEmail; + + /// No description provided for @defaultEmailServer. + /// + /// In en, this message translates to: + /// **'Default Email Server'** + String get defaultEmailServer; + + /// No description provided for @changeDefaultEmail. + /// + /// In en, this message translates to: + /// **'Change Default Email'** + String get changeDefaultEmail; + + /// No description provided for @currentDefault. + /// + /// In en, this message translates to: + /// **'Current Default'** + String get currentDefault; + + /// No description provided for @makeDefault. + /// + /// In en, this message translates to: + /// **'Make Default'** + String get makeDefault; + + /// No description provided for @defaultEmailNote. + /// + /// In en, this message translates to: + /// **'Emails are sent from the default server'** + String get defaultEmailNote; + + /// No description provided for @noDefaultSet. + /// + /// In en, this message translates to: + /// **'No default email server is set'** + String get noDefaultSet; + + /// No description provided for @selectDefaultServer. + /// + /// In en, this message translates to: + /// **'Select Default Server'** + String get selectDefaultServer; + + /// No description provided for @defaultServerChanged. + /// + /// In en, this message translates to: + /// **'Default server changed'** + String get defaultServerChanged; + + /// No description provided for @defaultServerChangeFailed. + /// + /// In en, this message translates to: + /// **'Failed to change default server'** + String get defaultServerChangeFailed; + + /// No description provided for @emailConfigSavedSuccessfully. + /// + /// In en, this message translates to: + /// **'Email configuration saved successfully'** + String get emailConfigSavedSuccessfully; + + /// No description provided for @emailConfigUpdatedSuccessfully. + /// + /// In en, this message translates to: + /// **'Email configuration updated successfully'** + String get emailConfigUpdatedSuccessfully; + + /// No description provided for @editEmailConfiguration. + /// + /// In en, this message translates to: + /// **'Edit Email Configuration'** + String get editEmailConfiguration; + + /// No description provided for @updateConfiguration. + /// + /// In en, this message translates to: + /// **'Update Configuration'** + String get updateConfiguration; + + /// No description provided for @testEmailSubject. + /// + /// In en, this message translates to: + /// **'Test Email'** + String get testEmailSubject; + + /// No description provided for @testEmailBody. + /// + /// In en, this message translates to: + /// **'This is a test email.'** + String get testEmailBody; + + /// No description provided for @testEmailSentSuccessfully. + /// + /// In en, this message translates to: + /// **'Test email sent successfully'** + String get testEmailSentSuccessfully; + + /// No description provided for @emailConfigDeletedSuccessfully. + /// + /// In en, this message translates to: + /// **'Configuration deleted successfully'** + String get emailConfigDeletedSuccessfully; + + /// No description provided for @confirm. + /// + /// In en, this message translates to: + /// **'Confirm'** + String get confirm; + /// No description provided for @fileStatistics. /// /// In en, this message translates to: @@ -2162,12 +2456,6 @@ abstract class AppLocalizations { /// **'Edit'** String get edit; - /// No description provided for @delete. - /// - /// In en, this message translates to: - /// **'Delete'** - String get delete; - /// No description provided for @actions. /// /// In en, this message translates to: @@ -2317,6 +2605,114 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Maintenance Settings'** String get maintenanceSettings; + + /// No description provided for @initializing. + /// + /// In en, this message translates to: + /// **'Initializing...'** + String get initializing; + + /// No description provided for @loadingLanguageSettings. + /// + /// In en, this message translates to: + /// **'Loading language settings...'** + String get loadingLanguageSettings; + + /// No description provided for @loadingCalendarSettings. + /// + /// In en, this message translates to: + /// **'Loading calendar settings...'** + String get loadingCalendarSettings; + + /// No description provided for @loadingThemeSettings. + /// + /// In en, this message translates to: + /// **'Loading theme settings...'** + String get loadingThemeSettings; + + /// No description provided for @loadingAuthentication. + /// + /// In en, this message translates to: + /// **'Loading authentication...'** + String get loadingAuthentication; + + /// No description provided for @businessManagementPlatform. + /// + /// In en, this message translates to: + /// **'Business Management Platform'** + String get businessManagementPlatform; + + /// No description provided for @businessDashboard. + /// + /// In en, this message translates to: + /// **'Business Dashboard'** + String get businessDashboard; + + /// No description provided for @businessStatistics. + /// + /// In en, this message translates to: + /// **'Business Statistics'** + String get businessStatistics; + + /// No description provided for @recentActivities. + /// + /// In en, this message translates to: + /// **'Recent Activities'** + String get recentActivities; + + /// No description provided for @sales. + /// + /// In en, this message translates to: + /// **'Sales'** + String get sales; + + /// No description provided for @accounting. + /// + /// In en, this message translates to: + /// **'Accounting'** + String get accounting; + + /// No description provided for @inventory. + /// + /// In en, this message translates to: + /// **'Inventory'** + String get inventory; + + /// No description provided for @reports. + /// + /// In en, this message translates to: + /// **'Reports'** + String get reports; + + /// No description provided for @members. + /// + /// In en, this message translates to: + /// **'Members'** + String get members; + + /// No description provided for @backToProfile. + /// + /// In en, this message translates to: + /// **'Back to Profile'** + String get backToProfile; + + /// No description provided for @noBusinessesFound. + /// + /// In en, this message translates to: + /// **'No businesses found'** + String get noBusinessesFound; + + /// No description provided for @createFirstBusiness. + /// + /// In en, this message translates to: + /// **'Create your first business'** + String get createFirstBusiness; + + /// No description provided for @accessDenied. + /// + /// In en, this message translates to: + /// **'Access denied'** + String get accessDenied; } class _AppLocalizationsDelegate diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart index 620e769..5087499 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -136,6 +136,80 @@ class AppLocalizationsEn extends AppLocalizations { @override String get adminTools => 'Admin Tools'; + @override + String get emailSettings => 'Email Settings'; + + @override + String get emailSettingsDescription => + 'Configure SMTP settings for email sending'; + + @override + String get emailConfigurations => 'Email Configurations'; + + @override + String get noEmailConfigurations => 'No email configurations found'; + + @override + String get addEmailConfiguration => 'Add Email Configuration'; + + @override + String get configurationName => 'Configuration Name'; + + @override + String get smtpHost => 'SMTP Host'; + + @override + String get smtpPort => 'SMTP Port'; + + @override + String get smtpUsername => 'SMTP Username'; + + @override + String get smtpPassword => 'SMTP Password'; + + @override + String get fromEmail => 'From Email'; + + @override + String get fromName => 'From Name'; + + @override + String get useTls => 'Use TLS'; + + @override + String get useSsl => 'Use SSL'; + + @override + String get isActive => 'Active'; + + @override + String get active => 'Active'; + + @override + String get testConnection => 'Test Connection'; + + @override + String get sendTestEmail => 'Send Test Email'; + + @override + String get saveConfiguration => 'Save Configuration'; + + @override + String get deleteConfiguration => 'Delete Configuration'; + + @override + String get deleteConfigurationConfirm => + 'Are you sure you want to delete this configuration?'; + + @override + String get delete => 'Delete'; + + @override + String get invalidPort => 'Invalid port'; + + @override + String get invalidEmail => 'Invalid email'; + @override String get ok => 'OK'; @@ -577,6 +651,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get other => 'Other'; + @override + String get owner => 'Owner'; + + @override + String get member => 'Member'; + @override String get newTicket => 'New Ticket'; @@ -908,9 +988,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get isDefault => 'Default'; - @override - String get isActive => 'Active'; - @override String get configData => 'Configuration Data'; @@ -932,9 +1009,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get ftpDirectory => 'FTP Directory'; - @override - String get testConnection => 'Test Connection'; - @override String get connectionSuccessful => 'Connection Successful'; @@ -944,6 +1018,86 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setAsDefault => 'Set as Default'; + @override + String get defaultConfiguration => 'Default Configuration'; + + @override + String get setDefaultConfirm => + 'Are you sure you want to set this configuration as default?'; + + @override + String get defaultSetSuccessfully => 'Default configuration set successfully'; + + @override + String get defaultSetFailed => 'Failed to set default configuration'; + + @override + String get cannotDeleteDefault => 'Cannot delete default configuration'; + + @override + String get defaultConfigurationNote => + 'Default configuration is used for sending emails and cannot be deleted'; + + @override + String get setAsDefaultEmail => 'Set as Default Email'; + + @override + String get defaultEmailServer => 'Default Email Server'; + + @override + String get changeDefaultEmail => 'Change Default Email'; + + @override + String get currentDefault => 'Current Default'; + + @override + String get makeDefault => 'Make Default'; + + @override + String get defaultEmailNote => 'Emails are sent from the default server'; + + @override + String get noDefaultSet => 'No default email server is set'; + + @override + String get selectDefaultServer => 'Select Default Server'; + + @override + String get defaultServerChanged => 'Default server changed'; + + @override + String get defaultServerChangeFailed => 'Failed to change default server'; + + @override + String get emailConfigSavedSuccessfully => + 'Email configuration saved successfully'; + + @override + String get emailConfigUpdatedSuccessfully => + 'Email configuration updated successfully'; + + @override + String get editEmailConfiguration => 'Edit Email Configuration'; + + @override + String get updateConfiguration => 'Update Configuration'; + + @override + String get testEmailSubject => 'Test Email'; + + @override + String get testEmailBody => 'This is a test email.'; + + @override + String get testEmailSentSuccessfully => 'Test email sent successfully'; + + @override + String get emailConfigDeletedSuccessfully => + 'Configuration deleted successfully'; + + @override + String get confirm => 'Confirm'; + @override String get fileStatistics => 'File Statistics'; @@ -1071,9 +1225,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get edit => 'Edit'; - @override - String get delete => 'Delete'; - @override String get actions => 'Actions'; @@ -1151,4 +1302,58 @@ class AppLocalizationsEn extends AppLocalizations { @override String get maintenanceSettings => 'Maintenance Settings'; + + @override + String get initializing => 'Initializing...'; + + @override + String get loadingLanguageSettings => 'Loading language settings...'; + + @override + String get loadingCalendarSettings => 'Loading calendar settings...'; + + @override + String get loadingThemeSettings => 'Loading theme settings...'; + + @override + String get loadingAuthentication => 'Loading authentication...'; + + @override + String get businessManagementPlatform => 'Business Management Platform'; + + @override + String get businessDashboard => 'Business Dashboard'; + + @override + String get businessStatistics => 'Business Statistics'; + + @override + String get recentActivities => 'Recent Activities'; + + @override + String get sales => 'Sales'; + + @override + String get accounting => 'Accounting'; + + @override + String get inventory => 'Inventory'; + + @override + String get reports => 'Reports'; + + @override + String get members => 'Members'; + + @override + String get backToProfile => 'Back to Profile'; + + @override + String get noBusinessesFound => 'No businesses found'; + + @override + String get createFirstBusiness => 'Create your first business'; + + @override + String get accessDenied => 'Access denied'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index 80b4154..7bb21b6 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -136,6 +136,80 @@ class AppLocalizationsFa extends AppLocalizations { @override String get adminTools => 'ابزارهای مدیریتی'; + @override + String get emailSettings => 'تنظیمات ایمیل'; + + @override + String get emailSettingsDescription => + 'پیکربندی تنظیمات SMTP برای ارسال ایمیل'; + + @override + String get emailConfigurations => 'پیکربندی‌های ایمیل'; + + @override + String get noEmailConfigurations => 'هیچ پیکربندی ایمیلی وجود ندارد'; + + @override + String get addEmailConfiguration => 'افزودن پیکربندی ایمیل'; + + @override + String get configurationName => 'نام پیکربندی'; + + @override + String get smtpHost => 'میزبان SMTP'; + + @override + String get smtpPort => 'پورت SMTP'; + + @override + String get smtpUsername => 'نام کاربری SMTP'; + + @override + String get smtpPassword => 'رمز عبور SMTP'; + + @override + String get fromEmail => 'ایمیل فرستنده'; + + @override + String get fromName => 'نام فرستنده'; + + @override + String get useTls => 'استفاده از TLS'; + + @override + String get useSsl => 'استفاده از SSL'; + + @override + String get isActive => 'فعال'; + + @override + String get active => 'فعال'; + + @override + String get testConnection => 'تست اتصال'; + + @override + String get sendTestEmail => 'ارسال ایمیل تست'; + + @override + String get saveConfiguration => 'ذخیره پیکربندی'; + + @override + String get deleteConfiguration => 'حذف پیکربندی'; + + @override + String get deleteConfigurationConfirm => + 'آیا مطمئن هستید که می‌خواهید این پیکربندی را حذف کنید؟'; + + @override + String get delete => 'حذف'; + + @override + String get invalidPort => 'پورت نامعتبر است'; + + @override + String get invalidEmail => 'ایمیل نامعتبر است'; + @override String get ok => 'تایید'; @@ -575,6 +649,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String get other => 'سایر'; + @override + String get owner => 'مالک'; + + @override + String get member => 'عضو'; + @override String get newTicket => 'تیکت جدید'; @@ -904,9 +984,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String get isDefault => 'پیش‌فرض'; - @override - String get isActive => 'فعال'; - @override String get configData => 'داده‌های پیکربندی'; @@ -928,9 +1005,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String get ftpDirectory => 'پوشه FTP'; - @override - String get testConnection => 'تست اتصال'; - @override String get connectionSuccessful => 'اتصال موفقیت‌آمیز'; @@ -940,6 +1014,84 @@ class AppLocalizationsFa extends AppLocalizations { @override String get setAsDefault => 'تنظیم به عنوان پیش‌فرض'; + @override + String get defaultConfiguration => 'پیکربندی پیشفرض'; + + @override + String get setDefaultConfirm => + 'آیا مطمئن هستید که می‌خواهید این پیکربندی را به عنوان پیشفرض تنظیم کنید؟'; + + @override + String get defaultSetSuccessfully => 'پیکربندی پیشفرض با موفقیت تنظیم شد'; + + @override + String get defaultSetFailed => 'تنظیم پیکربندی پیشفرض ناموفق بود'; + + @override + String get cannotDeleteDefault => 'نمی‌توان پیکربندی پیشفرض را حذف کرد'; + + @override + String get defaultConfigurationNote => + 'پیکربندی پیشفرض برای ارسال ایمیل‌ها استفاده می‌شود و قابل حذف نیست'; + + @override + String get setAsDefaultEmail => 'تنظیم به عنوان ایمیل پیشفرض'; + + @override + String get defaultEmailServer => 'سرور ایمیل پیشفرض'; + + @override + String get changeDefaultEmail => 'تغییر ایمیل پیشفرض'; + + @override + String get currentDefault => 'پیشفرض فعلی'; + + @override + String get makeDefault => 'پیشفرض کردن'; + + @override + String get defaultEmailNote => 'ایمیل‌ها از سرور پیشفرض ارسال می‌شوند'; + + @override + String get noDefaultSet => 'هیچ سرور ایمیل پیشفرضی تنظیم نشده است'; + + @override + String get selectDefaultServer => 'انتخاب سرور پیشفرض'; + + @override + String get defaultServerChanged => 'سرور پیشفرض تغییر کرد'; + + @override + String get defaultServerChangeFailed => 'تغییر سرور پیشفرض ناموفق بود'; + + @override + String get emailConfigSavedSuccessfully => 'تنظیمات ایمیل با موفقیت ذخیره شد'; + + @override + String get emailConfigUpdatedSuccessfully => + 'تنظیمات ایمیل با موفقیت به‌روزرسانی شد'; + + @override + String get editEmailConfiguration => 'ویرایش تنظیمات ایمیل'; + + @override + String get updateConfiguration => 'به‌روزرسانی تنظیمات'; + + @override + String get testEmailSubject => 'تست ایمیل'; + + @override + String get testEmailBody => 'این یک ایمیل تست است.'; + + @override + String get testEmailSentSuccessfully => 'ایمیل تست با موفقیت ارسال شد'; + + @override + String get emailConfigDeletedSuccessfully => 'تنظیمات حذف شد'; + + @override + String get confirm => 'تایید'; + @override String get fileStatistics => 'آمار فایل‌ها'; @@ -1065,9 +1217,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String get edit => 'ویرایش'; - @override - String get delete => 'حذف'; - @override String get actions => 'عملیات'; @@ -1143,4 +1292,58 @@ class AppLocalizationsFa extends AppLocalizations { @override String get maintenanceSettings => 'تنظیمات نگهداری'; + + @override + String get initializing => 'در حال راه‌اندازی...'; + + @override + String get loadingLanguageSettings => 'در حال بارگذاری تنظیمات زبان...'; + + @override + String get loadingCalendarSettings => 'در حال بارگذاری تنظیمات تقویم...'; + + @override + String get loadingThemeSettings => 'در حال بارگذاری تنظیمات تم...'; + + @override + String get loadingAuthentication => 'در حال بارگذاری احراز هویت...'; + + @override + String get businessManagementPlatform => 'پلتفرم مدیریت کسب‌وکار'; + + @override + String get businessDashboard => 'داشبورد کسب و کار'; + + @override + String get businessStatistics => 'آمار کسب و کار'; + + @override + String get recentActivities => 'فعالیت‌های اخیر'; + + @override + String get sales => 'فروش'; + + @override + String get accounting => 'حسابداری'; + + @override + String get inventory => 'موجودی'; + + @override + String get reports => 'گزارش‌ها'; + + @override + String get members => 'اعضا'; + + @override + String get backToProfile => 'بازگشت به پروفایل'; + + @override + String get noBusinessesFound => 'هیچ کسب و کاری یافت نشد'; + + @override + String get createFirstBusiness => 'اولین کسب و کار خود را ایجاد کنید'; + + @override + String get accessDenied => 'دسترسی غیرمجاز'; } diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index 2f87463..5cb3947 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -17,6 +17,9 @@ import 'pages/admin/storage_management_page.dart'; import 'pages/admin/system_configuration_page.dart'; import 'pages/admin/user_management_page.dart'; import 'pages/admin/system_logs_page.dart'; +import 'pages/admin/email_settings_page.dart'; +import 'pages/business/business_shell.dart'; +import 'pages/business/dashboard/business_dashboard_page.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'core/locale_controller.dart'; import 'core/calendar_controller.dart'; @@ -25,6 +28,7 @@ import 'theme/theme_controller.dart'; import 'theme/app_theme.dart'; import 'core/auth_store.dart'; import 'core/permission_guard.dart'; +import 'widgets/simple_splash_screen.dart'; void main() { // Use path-based routing instead of hash routing @@ -44,216 +48,131 @@ class _MyAppState extends State { CalendarController? _calendarController; ThemeController? _themeController; AuthStore? _authStore; + bool _isLoading = true; + DateTime? _loadStartTime; @override void initState() { super.initState(); - LocaleController.load().then((c) { - setState(() { - _controller = c - ..addListener(() { - // Update ApiClient language header on change - ApiClient.setCurrentLocale(c.locale); - setState(() {}); - }); - ApiClient.setCurrentLocale(c.locale); - }); - }); + _loadStartTime = DateTime.now(); + _loadControllers(); + } - CalendarController.load().then((cc) { - setState(() { - _calendarController = cc - ..addListener(() { - setState(() {}); - }); - ApiClient.bindCalendarController(cc); - }); + Future _loadControllers() async { + // بارگذاری تمام کنترلرها + final localeController = await LocaleController.load(); + final calendarController = await CalendarController.load(); + final themeController = ThemeController(); + await themeController.load(); + final authStore = AuthStore(); + await authStore.load(); + + // تنظیم کنترلرها + setState(() { + _controller = localeController; + _calendarController = calendarController; + _themeController = themeController; + _authStore = authStore; }); - - final tc = ThemeController(); - tc.load().then((_) { - setState(() { - _themeController = tc - ..addListener(() { - setState(() {}); - }); - }); + + // اضافه کردن listeners + _controller!.addListener(() { + ApiClient.setCurrentLocale(_controller!.locale); + setState(() {}); }); - - final store = AuthStore(); - store.load().then((_) { - setState(() { - _authStore = store - ..addListener(() { - setState(() {}); - }); - ApiClient.bindAuthStore(store); - }); + + _calendarController!.addListener(() { + setState(() {}); }); + + _themeController!.addListener(() { + setState(() {}); + }); + + _authStore!.addListener(() { + setState(() {}); + }); + + // تنظیم API Client + ApiClient.setCurrentLocale(_controller!.locale); + ApiClient.bindCalendarController(_calendarController!); + ApiClient.bindAuthStore(_authStore!); + + // اطمینان از حداقل 4 ثانیه نمایش splash screen + final elapsed = DateTime.now().difference(_loadStartTime!); + const minimumDuration = Duration(seconds: 4); + if (elapsed < minimumDuration) { + await Future.delayed(minimumDuration - elapsed); + } + + // اتمام loading + if (mounted) { + setState(() { + _isLoading = false; + }); + } } // Root of application with GoRouter @override Widget build(BuildContext context) { - // اگر هنوز loading است، یک router ساده با loading page بساز - if (_controller == null || _calendarController == null || _themeController == null || _authStore == null) { + // اگر هنوز loading است، splash screen نمایش بده + if (_isLoading || + _controller == null || + _calendarController == null || + _themeController == null || + _authStore == null) { final loadingRouter = GoRouter( redirect: (context, state) { // در حین loading، هیچ redirect نکن - URL را حفظ کن return null; }, routes: [ - GoRoute( - path: '/', - builder: (context, state) => const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading...'), - ], - ), - ), - ), - ), - // برای سایر مسیرها هم loading page نمایش بده - GoRoute( - path: '/login', - builder: (context, state) => const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading...'), - ], - ), - ), - ), - ), - GoRoute( - path: '/user/profile/dashboard', - builder: (context, state) => const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading...'), - ], - ), - ), - ), - ), - GoRoute( - path: '/user/profile/marketing', - builder: (context, state) => const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading...'), - ], - ), - ), - ), - ), - GoRoute( - path: '/user/profile/new-business', - builder: (context, state) => const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading...'), - ], - ), - ), - ), - ), - GoRoute( - path: '/user/profile/businesses', - builder: (context, state) => const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading...'), - ], - ), - ), - ), - ), - GoRoute( - path: '/user/profile/support', - builder: (context, state) => const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading...'), - ], - ), - ), - ), - ), - GoRoute( - path: '/user/profile/change-password', - builder: (context, state) => const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading...'), - ], - ), - ), - ), - ), - GoRoute( - path: '/user/profile/system-settings', - builder: (context, state) => const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading...'), - ], - ), - ), - ), - ), - // Catch-all route برای هر URL دیگر + // برای تمام مسیرها splash screen نمایش بده GoRoute( path: '/:path(.*)', - builder: (context, state) => const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading...'), - ], - ), - ), - ), + builder: (context, state) { + // تشخیص نوع loading بر اساس controller های موجود + String loadingMessage = 'Initializing...'; + if (_controller == null) { + loadingMessage = 'Loading language settings...'; + } else if (_calendarController == null) { + loadingMessage = 'Loading calendar settings...'; + } else if (_themeController == null) { + loadingMessage = 'Loading theme settings...'; + } else if (_authStore == null) { + loadingMessage = 'Loading authentication...'; + } + + // اگر controller موجود است، از locale آن استفاده کن + if (_controller != null) { + final isFa = _controller!.locale.languageCode == 'fa'; + if (isFa) { + if (_controller == null) { + loadingMessage = 'در حال بارگذاری تنظیمات زبان...'; + } else if (_calendarController == null) { + loadingMessage = 'در حال بارگذاری تنظیمات تقویم...'; + } else if (_themeController == null) { + loadingMessage = 'در حال بارگذاری تنظیمات تم...'; + } else if (_authStore == null) { + loadingMessage = 'در حال بارگذاری احراز هویت...'; + } else { + loadingMessage = 'در حال راه‌اندازی...'; + } + } + } + + return SimpleSplashScreen( + message: loadingMessage, + showLogo: true, + displayDuration: const Duration(seconds: 4), + locale: _controller?.locale, + onComplete: () { + // این callback زمانی فراخوانی می‌شود که splash screen تمام شود + // اما ما از splash controller استفاده می‌کنیم + }, + ); + }, ), ], ); @@ -264,6 +183,7 @@ class _MyAppState extends State { locale: const Locale('en'), supportedLocales: const [Locale('en'), Locale('fa')], localizationsDelegates: const [ + AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, GlobalWidgetsLocalizations.delegate, @@ -431,10 +351,49 @@ class _MyAppState extends State { return const SystemLogsPage(); }, ), + GoRoute( + path: 'email', + name: 'system_settings_email', + builder: (context, state) { + if (_authStore == null || !_authStore!.isSuperAdmin) { + return PermissionGuard.buildAccessDeniedPage(); + } + return const EmailSettingsPage(); + }, + ), ], ), ], ), + GoRoute( + path: '/business/:business_id', + name: 'business_shell', + builder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + return BusinessShell( + businessId: businessId, + authStore: _authStore!, + calendarController: _calendarController!, + child: const SizedBox.shrink(), // Will be replaced by child routes + ); + }, + routes: [ + GoRoute( + path: 'dashboard', + name: 'business_dashboard', + builder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + return BusinessShell( + businessId: businessId, + authStore: _authStore!, + calendarController: _calendarController!, + child: BusinessDashboardPage(businessId: businessId), + ); + }, + ), + // TODO: Add other business routes (sales, accounting, etc.) + ], + ), ], ); diff --git a/hesabixUI/hesabix_ui/lib/models/business_dashboard_models.dart b/hesabixUI/hesabix_ui/lib/models/business_dashboard_models.dart new file mode 100644 index 0000000..2e268a5 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/business_dashboard_models.dart @@ -0,0 +1,245 @@ +class BusinessInfo { + final int id; + final String name; + final String businessType; + final String businessField; + final int ownerId; + final String? address; + final String? phone; + final String? mobile; + final String createdAt; + final int memberCount; + + BusinessInfo({ + required this.id, + required this.name, + required this.businessType, + required this.businessField, + required this.ownerId, + this.address, + this.phone, + this.mobile, + required this.createdAt, + required this.memberCount, + }); + + factory BusinessInfo.fromJson(Map json) { + // Handle both string and object formats for created_at + String createdAt; + if (json['created_at'] is String) { + createdAt = json['created_at']; + } else if (json['created_at'] is Map) { + createdAt = json['created_at']['formatted'] ?? json['created_at']['date_only'] ?? ''; + } else { + createdAt = ''; + } + + return BusinessInfo( + id: json['id'], + name: json['name'], + businessType: json['business_type'], + businessField: json['business_field'], + ownerId: json['owner_id'], + address: json['address'], + phone: json['phone'], + mobile: json['mobile'], + createdAt: createdAt, + memberCount: json['member_count'], + ); + } +} + +class BusinessStatistics { + final double totalSales; + final double totalPurchases; + final int activeMembers; + final int recentTransactions; + + BusinessStatistics({ + required this.totalSales, + required this.totalPurchases, + required this.activeMembers, + required this.recentTransactions, + }); + + factory BusinessStatistics.fromJson(Map json) { + return BusinessStatistics( + totalSales: (json['total_sales'] ?? 0).toDouble(), + totalPurchases: (json['total_purchases'] ?? 0).toDouble(), + activeMembers: json['active_members'] ?? 0, + recentTransactions: json['recent_transactions'] ?? 0, + ); + } +} + +class Activity { + final int id; + final String title; + final String description; + final String icon; + final String timeAgo; + + Activity({ + required this.id, + required this.title, + required this.description, + required this.icon, + required this.timeAgo, + }); + + factory Activity.fromJson(Map json) { + return Activity( + id: json['id'], + title: json['title'], + description: json['description'], + icon: json['icon'], + timeAgo: json['time_ago'], + ); + } +} + +class BusinessDashboardResponse { + final BusinessInfo businessInfo; + final BusinessStatistics statistics; + final List recentActivities; + + BusinessDashboardResponse({ + required this.businessInfo, + required this.statistics, + required this.recentActivities, + }); + + factory BusinessDashboardResponse.fromJson(Map json) { + return BusinessDashboardResponse( + businessInfo: BusinessInfo.fromJson(json['business_info']), + statistics: BusinessStatistics.fromJson(json['statistics']), + recentActivities: (json['recent_activities'] as List) + .map((activity) => Activity.fromJson(activity)) + .toList(), + ); + } +} + +class BusinessMember { + final int id; + final int userId; + final String firstName; + final String lastName; + final String email; + final String? mobile; + final String role; + final Map permissions; + final String joinedAt; + + BusinessMember({ + required this.id, + required this.userId, + required this.firstName, + required this.lastName, + required this.email, + this.mobile, + required this.role, + required this.permissions, + required this.joinedAt, + }); + + factory BusinessMember.fromJson(Map json) { + // Handle both string and object formats for joined_at + String joinedAt; + if (json['joined_at'] is String) { + joinedAt = json['joined_at']; + } else if (json['joined_at'] is Map) { + joinedAt = json['joined_at']['formatted'] ?? json['joined_at']['date_only'] ?? ''; + } else { + joinedAt = ''; + } + + return BusinessMember( + id: json['id'], + userId: json['user_id'], + firstName: json['first_name'] ?? '', + lastName: json['last_name'] ?? '', + email: json['email'] ?? '', + mobile: json['mobile'], + role: json['role'] ?? 'عضو', + permissions: Map.from(json['permissions'] ?? {}), + joinedAt: joinedAt, + ); + } +} + +class BusinessMembersResponse { + final List items; + final Map pagination; + + BusinessMembersResponse({ + required this.items, + required this.pagination, + }); + + factory BusinessMembersResponse.fromJson(Map json) { + return BusinessMembersResponse( + items: (json['items'] as List) + .map((member) => BusinessMember.fromJson(member)) + .toList(), + pagination: json['pagination'], + ); + } +} + +class BusinessWithPermission { + final int id; + final String name; + final String businessType; + final String businessField; + final int ownerId; + final String? address; + final String? phone; + final String? mobile; + final String createdAt; + final bool isOwner; + final String role; + final Map permissions; + + BusinessWithPermission({ + required this.id, + required this.name, + required this.businessType, + required this.businessField, + required this.ownerId, + this.address, + this.phone, + this.mobile, + required this.createdAt, + required this.isOwner, + required this.role, + required this.permissions, + }); + + factory BusinessWithPermission.fromJson(Map json) { + // Handle both string and object formats for created_at + String createdAt; + if (json['created_at'] is String) { + createdAt = json['created_at']; + } else if (json['created_at'] is Map) { + createdAt = json['created_at']['formatted'] ?? json['created_at']['date_only'] ?? ''; + } else { + createdAt = ''; + } + + return BusinessWithPermission( + id: json['id'], + name: json['name'], + businessType: json['business_type'], + businessField: json['business_field'], + ownerId: json['owner_id'], + address: json['address'], + phone: json['phone'], + mobile: json['mobile'], + createdAt: createdAt, + isOwner: json['is_owner'] ?? false, + role: json['role'] ?? 'عضو', + permissions: Map.from(json['permissions'] ?? {}), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/models/email_models.dart b/hesabixUI/hesabix_ui/lib/models/email_models.dart new file mode 100644 index 0000000..c99ff2b --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/email_models.dart @@ -0,0 +1,306 @@ +class EmailConfig { + final int id; + final String name; + final String smtpHost; + final int smtpPort; + final String smtpUsername; + final bool useTls; + final bool useSsl; + final String fromEmail; + final String fromName; + final bool isActive; + final bool isDefault; + final DateTime createdAt; + final DateTime updatedAt; + + EmailConfig({ + required this.id, + required this.name, + required this.smtpHost, + required this.smtpPort, + required this.smtpUsername, + required this.useTls, + required this.useSsl, + required this.fromEmail, + required this.fromName, + required this.isActive, + required this.isDefault, + required this.createdAt, + required this.updatedAt, + }); + + factory EmailConfig.fromJson(Map json) { + return EmailConfig( + id: json['id'] as int, + name: json['name'] as String, + smtpHost: json['smtp_host'] as String, + smtpPort: json['smtp_port'] as int, + smtpUsername: json['smtp_username'] as String, + useTls: json['use_tls'] as bool, + useSsl: json['use_ssl'] as bool, + fromEmail: json['from_email'] as String, + fromName: json['from_name'] as String, + isActive: json['is_active'] as bool, + isDefault: json['is_default'] as bool? ?? false, + createdAt: _parseDateTime(json['created_at']), + updatedAt: _parseDateTime(json['updated_at']), + ); + } + + static DateTime _parseDateTime(dynamic dateValue) { + if (dateValue == null) { + return DateTime.now(); + } + + if (dateValue is String) { + return DateTime.parse(dateValue); + } + + if (dateValue is Map) { + // Handle formatted date object + final formatted = dateValue['formatted'] as String?; + if (formatted != null) { + return DateTime.parse(formatted); + } + } + + return DateTime.now(); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'smtp_host': smtpHost, + 'smtp_port': smtpPort, + 'smtp_username': smtpUsername, + 'use_tls': useTls, + 'use_ssl': useSsl, + 'from_email': fromEmail, + 'from_name': fromName, + 'is_active': isActive, + 'is_default': isDefault, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } + + EmailConfig copyWith({ + int? id, + String? name, + String? smtpHost, + int? smtpPort, + String? smtpUsername, + bool? useTls, + bool? useSsl, + String? fromEmail, + String? fromName, + bool? isActive, + bool? isDefault, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return EmailConfig( + id: id ?? this.id, + name: name ?? this.name, + smtpHost: smtpHost ?? this.smtpHost, + smtpPort: smtpPort ?? this.smtpPort, + smtpUsername: smtpUsername ?? this.smtpUsername, + useTls: useTls ?? this.useTls, + useSsl: useSsl ?? this.useSsl, + fromEmail: fromEmail ?? this.fromEmail, + fromName: fromName ?? this.fromName, + isActive: isActive ?? this.isActive, + isDefault: isDefault ?? this.isDefault, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +class CreateEmailConfigRequest { + final String name; + final String smtpHost; + final int smtpPort; + final String smtpUsername; + final String smtpPassword; + final bool useTls; + final bool useSsl; + final String fromEmail; + final String fromName; + final bool isActive; + + CreateEmailConfigRequest({ + required this.name, + required this.smtpHost, + required this.smtpPort, + required this.smtpUsername, + required this.smtpPassword, + required this.useTls, + required this.useSsl, + required this.fromEmail, + required this.fromName, + this.isActive = true, + }); + + Map toJson() { + return { + 'name': name, + 'smtp_host': smtpHost, + 'smtp_port': smtpPort, + 'smtp_username': smtpUsername, + 'smtp_password': smtpPassword, + 'use_tls': useTls, + 'use_ssl': useSsl, + 'from_email': fromEmail, + 'from_name': fromName, + 'is_active': isActive, + }; + } +} + +class UpdateEmailConfigRequest { + final String? name; + final String? smtpHost; + final int? smtpPort; + final String? smtpUsername; + final String? smtpPassword; + final bool? useTls; + final bool? useSsl; + final String? fromEmail; + final String? fromName; + final bool? isActive; + + UpdateEmailConfigRequest({ + this.name, + this.smtpHost, + this.smtpPort, + this.smtpUsername, + this.smtpPassword, + this.useTls, + this.useSsl, + this.fromEmail, + this.fromName, + this.isActive, + }); + + Map toJson() { + final Map json = {}; + if (name != null) json['name'] = name; + if (smtpHost != null) json['smtp_host'] = smtpHost; + if (smtpPort != null) json['smtp_port'] = smtpPort; + if (smtpUsername != null) json['smtp_username'] = smtpUsername; + if (smtpPassword != null) json['smtp_password'] = smtpPassword; + if (useTls != null) json['use_tls'] = useTls; + if (useSsl != null) json['use_ssl'] = useSsl; + if (fromEmail != null) json['from_email'] = fromEmail; + if (fromName != null) json['from_name'] = fromName; + if (isActive != null) json['is_active'] = isActive; + return json; + } +} + +class SendEmailRequest { + final String to; + final String subject; + final String body; + final String? htmlBody; + final int? configId; + + SendEmailRequest({ + required this.to, + required this.subject, + required this.body, + this.htmlBody, + this.configId, + }); + + Map toJson() { + return { + 'to': to, + 'subject': subject, + 'body': body, + if (htmlBody != null) 'html_body': htmlBody, + if (configId != null) 'config_id': configId, + }; + } +} + +class EmailConfigListResponse { + final bool success; + final List data; + final String message; + + EmailConfigListResponse({ + required this.success, + required this.data, + required this.message, + }); + + factory EmailConfigListResponse.fromJson(Map json) { + return EmailConfigListResponse( + success: json['success'] as bool? ?? true, + data: (json['data'] as List? ?? []) + .map((item) => EmailConfig.fromJson(item as Map)) + .toList(), + message: json['message'] as String? ?? '', + ); + } +} + +class EmailConfigResponse { + final bool success; + final EmailConfig data; + final String message; + + EmailConfigResponse({ + required this.success, + required this.data, + required this.message, + }); + + factory EmailConfigResponse.fromJson(Map json) { + return EmailConfigResponse( + success: json['success'] as bool? ?? true, + data: EmailConfig.fromJson(json['data'] as Map), + message: json['message'] as String? ?? '', + ); + } +} + +class SendEmailResponse { + final bool success; + final String message; + + SendEmailResponse({ + required this.success, + required this.message, + }); + + factory SendEmailResponse.fromJson(Map json) { + return SendEmailResponse( + success: json['success'] as bool? ?? true, + message: json['message'] as String? ?? '', + ); + } +} + +class TestConnectionResponse { + final bool success; + final String message; + final bool connected; + + TestConnectionResponse({ + required this.success, + required this.message, + required this.connected, + }); + + factory TestConnectionResponse.fromJson(Map json) { + return TestConnectionResponse( + success: json['success'] as bool? ?? true, + message: json['message'] as String? ?? '', + connected: json['connected'] as bool? ?? false, + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/admin/email_settings_page.dart b/hesabixUI/hesabix_ui/lib/pages/admin/email_settings_page.dart new file mode 100644 index 0000000..076e0cd --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/admin/email_settings_page.dart @@ -0,0 +1,644 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:hesabix_ui/models/email_models.dart'; +import 'package:hesabix_ui/services/email_service.dart'; + +class EmailSettingsPage extends StatefulWidget { + const EmailSettingsPage({super.key}); + + @override + State createState() => _EmailSettingsPageState(); +} + +class _EmailSettingsPageState extends State { + final EmailService _emailService = EmailService(); + final _formKey = GlobalKey(); + bool _isLoading = false; + bool _isTesting = false; + List _configs = []; + EmailConfig? _selectedConfig; + + // Form controllers + final _nameController = TextEditingController(); + final _smtpHostController = TextEditingController(); + final _smtpPortController = TextEditingController(); + final _smtpUsernameController = TextEditingController(); + final _smtpPasswordController = TextEditingController(); + final _fromEmailController = TextEditingController(); + final _fromNameController = TextEditingController(); + bool _useTls = true; + bool _useSsl = false; + bool _isActive = true; + bool _isEditing = false; + + @override + void initState() { + super.initState(); + _initializeApiClient(); + _loadConfigs(); + } + + void _initializeApiClient() { + // Initialize ApiClient - it will get AuthStore from global state + // AuthStore should be bound in main.dart or app initialization + } + + @override + void dispose() { + _nameController.dispose(); + _smtpHostController.dispose(); + _smtpPortController.dispose(); + _smtpUsernameController.dispose(); + _smtpPasswordController.dispose(); + _fromEmailController.dispose(); + _fromNameController.dispose(); + super.dispose(); + } + + Future _loadConfigs() async { + setState(() => _isLoading = true); + try { + final response = await _emailService.getEmailConfigs(); + setState(() { + _configs = response.data; + // Select the default config, or first one if no default + _selectedConfig = _configs.where((config) => config.isDefault).isNotEmpty + ? _configs.where((config) => config.isDefault).first + : (_configs.isNotEmpty ? _configs.first : null); + }); + } catch (e) { + _showErrorSnackBar(e.toString()); + } finally { + setState(() => _isLoading = false); + } + } + + Future _saveConfig() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + try { + final t = AppLocalizations.of(context); + + if (_isEditing && _selectedConfig != null) { + // Update existing config + final request = UpdateEmailConfigRequest( + name: _nameController.text, + smtpHost: _smtpHostController.text, + smtpPort: int.parse(_smtpPortController.text), + smtpUsername: _smtpUsernameController.text, + smtpPassword: _smtpPasswordController.text, + useTls: _useTls, + useSsl: _useSsl, + fromEmail: _fromEmailController.text, + fromName: _fromNameController.text, + isActive: _isActive, + ); + + await _emailService.updateEmailConfig(_selectedConfig!.id, request); + _showSuccessSnackBar(t.emailConfigUpdatedSuccessfully); + } else { + // Create new config + final request = CreateEmailConfigRequest( + name: _nameController.text, + smtpHost: _smtpHostController.text, + smtpPort: int.parse(_smtpPortController.text), + smtpUsername: _smtpUsernameController.text, + smtpPassword: _smtpPasswordController.text, + useTls: _useTls, + useSsl: _useSsl, + fromEmail: _fromEmailController.text, + fromName: _fromNameController.text, + isActive: _isActive, + ); + + await _emailService.createEmailConfig(request); + _showSuccessSnackBar(t.emailConfigSavedSuccessfully); + } + + if (!mounted) return; + _loadConfigs(); + _clearForm(); + } catch (e) { + _showErrorSnackBar(e.toString()); + } finally { + setState(() => _isLoading = false); + } + } + + Future _testConnection() async { + if (_selectedConfig == null) return; + final config = _selectedConfig!; + + setState(() => _isTesting = true); + try { + final response = await _emailService.testEmailConfig(config.id); + if (!mounted) return; + final t = AppLocalizations.of(context); + if (response.connected) { + _showSuccessSnackBar(t.connectionSuccessful); + } else { + _showErrorSnackBar(t.connectionFailed); + } + } catch (e) { + _showErrorSnackBar(e.toString()); + } finally { + setState(() => _isTesting = false); + } + } + + Future _setAsDefault(EmailConfig config) async { + final t = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.setDefaultConfirm), + content: Text(t.setDefaultConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(t.confirm), + ), + ], + ), + ); + + if (confirmed != true) return; + + setState(() => _isLoading = true); + try { + await _emailService.setDefaultEmailConfig(config.id); + _showSuccessSnackBar(t.defaultSetSuccessfully); + // Force refresh the configs and update selected config + await _loadConfigs(); + // Update selected config to the one that was just set as default + _selectedConfig = _configs.firstWhere((c) => c.id == config.id); + } catch (e) { + _showErrorSnackBar(t.defaultSetFailed); + } finally { + setState(() => _isLoading = false); + } + } + + Future _sendTestEmail() async { + if (_selectedConfig == null) return; + + try { + final t = AppLocalizations.of(context); + await _emailService.sendCustomEmail( + to: _fromEmailController.text, + subject: t.testEmailSubject, + body: t.testEmailBody, + configId: _selectedConfig!.id, + ); + _showSuccessSnackBar(t.testEmailSentSuccessfully); + } catch (e) { + _showErrorSnackBar(e.toString()); + } + } + + void _clearForm() { + _nameController.clear(); + _smtpHostController.clear(); + _smtpPortController.clear(); + _smtpUsernameController.clear(); + _smtpPasswordController.clear(); + _fromEmailController.clear(); + _fromNameController.clear(); + _useTls = true; + _useSsl = false; + _isActive = true; + _isEditing = false; + _selectedConfig = null; + } + + void _showErrorSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ), + ); + } + + void _showSuccessSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.green, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final t = AppLocalizations.of(context); + + return Scaffold( + backgroundColor: colorScheme.surface, + appBar: AppBar( + title: Text(t.emailSettings), + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + elevation: 0, + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildConfigList(theme, colorScheme, t), + const SizedBox(height: 24), + _buildConfigForm(theme, colorScheme, t), + ], + ), + ), + ); + } + + Widget _buildConfigList(ThemeData theme, ColorScheme colorScheme, AppLocalizations t) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.emailConfigurations, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + if (_configs.isEmpty) + Text( + t.noEmailConfigurations, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ) + else + ..._configs.map((config) => _buildConfigItem(config, theme, colorScheme, t)), + ], + ), + ), + ); + } + + Widget _buildConfigItem(EmailConfig config, ThemeData theme, ColorScheme colorScheme, AppLocalizations t) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: Icon( + config.isActive ? Icons.email : Icons.email_outlined, + color: config.isActive ? Colors.green : colorScheme.onSurface.withValues(alpha: 0.6), + ), + title: Row( + children: [ + Text(config.name), + if (config.isDefault) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + t.currentDefault, + style: TextStyle( + color: Colors.blue, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + subtitle: Text('${config.smtpHost}:${config.smtpPort}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (config.isActive) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + t.active, + style: TextStyle( + color: Colors.green, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => _editConfig(config), + tooltip: t.edit, + ), + if (!config.isDefault) + IconButton( + icon: const Icon(Icons.star_outline), + onPressed: () => _setAsDefault(config), + tooltip: t.makeDefault, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: config.isDefault ? null : () => _deleteConfig(config.id), + tooltip: config.isDefault ? t.cannotDeleteDefault : t.delete, + ), + ], + ), + onTap: () => _selectConfig(config), + ), + ); + } + + Widget _buildConfigForm(ThemeData theme, ColorScheme colorScheme, AppLocalizations t) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _isEditing ? t.editEmailConfiguration : t.addEmailConfiguration, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: t.configurationName, + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return t.requiredField; + } + return null; + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _smtpHostController, + decoration: InputDecoration( + labelText: t.smtpHost, + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return t.requiredField; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _smtpPortController, + decoration: InputDecoration( + labelText: t.smtpPort, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return t.requiredField; + } + if (int.tryParse(value) == null) { + return t.invalidPort; + } + return null; + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _smtpUsernameController, + decoration: InputDecoration( + labelText: t.smtpUsername, + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return t.requiredField; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _smtpPasswordController, + decoration: InputDecoration( + labelText: t.smtpPassword, + border: const OutlineInputBorder(), + ), + obscureText: true, + validator: (value) { + if (value == null || value.isEmpty) { + return t.requiredField; + } + return null; + }, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _fromEmailController, + decoration: InputDecoration( + labelText: t.fromEmail, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) { + return t.requiredField; + } + if (!value.contains('@')) { + return t.invalidEmail; + } + return null; + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _fromNameController, + decoration: InputDecoration( + labelText: t.fromName, + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return t.requiredField; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Checkbox( + value: _useTls, + onChanged: (value) => setState(() => _useTls = value ?? false), + ), + Text(t.useTls), + const SizedBox(width: 24), + Checkbox( + value: _useSsl, + onChanged: (value) => setState(() => _useSsl = value ?? false), + ), + Text(t.useSsl), + const SizedBox(width: 24), + Checkbox( + value: _isActive, + onChanged: (value) => setState(() => _isActive = value ?? false), + ), + Text(t.isActive), + ], + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: _isLoading ? null : _saveConfig, + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(_isEditing ? t.updateConfiguration : t.saveConfiguration), + ), + ), + const SizedBox(width: 16), + if (_selectedConfig != null) ...[ + Expanded( + child: ElevatedButton( + onPressed: _isTesting ? null : _testConnection, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + ), + child: _isTesting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(t.testConnection), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: _sendTestEmail, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + ), + child: Text(t.sendTestEmail), + ), + ), + ], + ], + ), + ], + ), + ), + ), + ); + } + + Future _selectConfig(EmailConfig config) async { + setState(() => _selectedConfig = config); + } + + Future _editConfig(EmailConfig config) async { + setState(() { + _selectedConfig = config; + _isEditing = true; + _nameController.text = config.name; + _smtpHostController.text = config.smtpHost; + _smtpPortController.text = config.smtpPort.toString(); + _smtpUsernameController.text = config.smtpUsername; + _smtpPasswordController.clear(); // Password is not returned for security + _fromEmailController.text = config.fromEmail; + _fromNameController.text = config.fromName; + _useTls = config.useTls; + _useSsl = config.useSsl; + _isActive = config.isActive; + }); + } + + Future _deleteConfig(int configId) async { + final t = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.deleteConfiguration), + content: Text(t.deleteConfigurationConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(t.delete), + ), + ], + ), + ); + + if (confirmed == true) { + try { + await _emailService.deleteEmailConfig(configId); + if (!mounted) return; + final t = AppLocalizations.of(context); + _showSuccessSnackBar(t.emailConfigDeletedSuccessfully); + _loadConfigs(); + } catch (e) { + _showErrorSnackBar(e.toString()); + } + } + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/admin/file_storage_settings_page.dart b/hesabixUI/hesabix_ui/lib/pages/admin/file_storage_settings_page.dart index 2396c82..1c7d70b 100644 --- a/hesabixUI/hesabix_ui/lib/pages/admin/file_storage_settings_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/admin/file_storage_settings_page.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/widgets/admin/file_storage/storage_management_page.dart'; class FileStorageSettingsPage extends StatelessWidget { diff --git a/hesabixUI/hesabix_ui/lib/pages/admin/storage_management_page.dart b/hesabixUI/hesabix_ui/lib/pages/admin/storage_management_page.dart index 1c18c7a..5dda982 100644 --- a/hesabixUI/hesabix_ui/lib/pages/admin/storage_management_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/admin/storage_management_page.dart @@ -42,7 +42,7 @@ class _AdminStorageManagementPageState extends State begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - theme.colorScheme.primary.withOpacity(0.1), + theme.colorScheme.primary.withValues(alpha: 0.1), theme.colorScheme.surface, ], ), diff --git a/hesabixUI/hesabix_ui/lib/pages/admin/system_configuration_page.dart b/hesabixUI/hesabix_ui/lib/pages/admin/system_configuration_page.dart index 246ea9e..fc5180d 100644 --- a/hesabixUI/hesabix_ui/lib/pages/admin/system_configuration_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/admin/system_configuration_page.dart @@ -65,7 +65,7 @@ class _SystemConfigurationPageState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - theme.colorScheme.primary.withOpacity(0.1), + theme.colorScheme.primary.withValues(alpha: 0.1), theme.colorScheme.surface, ], ), @@ -232,7 +232,7 @@ class _SystemConfigurationPageState extends State { return Padding( padding: const EdgeInsets.only(bottom: 16), child: DropdownButtonFormField( - value: value, + initialValue: value, decoration: InputDecoration( labelText: label, border: const OutlineInputBorder(), diff --git a/hesabixUI/hesabix_ui/lib/pages/admin/system_logs_page.dart b/hesabixUI/hesabix_ui/lib/pages/admin/system_logs_page.dart index 765ebf1..35275eb 100644 --- a/hesabixUI/hesabix_ui/lib/pages/admin/system_logs_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/admin/system_logs_page.dart @@ -115,7 +115,7 @@ class _SystemLogsPageState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - theme.colorScheme.primary.withOpacity(0.1), + theme.colorScheme.primary.withValues(alpha: 0.1), theme.colorScheme.surface, ], ), @@ -162,7 +162,7 @@ class _SystemLogsPageState extends State { children: [ Expanded( child: DropdownButtonFormField( - value: _selectedLevel, + initialValue: _selectedLevel, decoration: const InputDecoration( labelText: 'Log Level', border: OutlineInputBorder(), @@ -180,7 +180,7 @@ class _SystemLogsPageState extends State { const SizedBox(width: 16), Expanded( child: DropdownButtonFormField( - value: _selectedDateRange, + initialValue: _selectedDateRange, decoration: const InputDecoration( labelText: 'Date Range', border: OutlineInputBorder(), @@ -212,13 +212,13 @@ class _SystemLogsPageState extends State { Icon( Icons.analytics_outlined, size: 64, - color: theme.colorScheme.onSurface.withOpacity(0.5), + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), ), const SizedBox(height: 16), Text( 'No logs found', style: theme.textTheme.titleLarge?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.7), + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ), ], @@ -248,7 +248,7 @@ class _SystemLogsPageState extends State { subtitle: Text( '${log['timestamp']} • ${log['module']}', style: TextStyle( - color: theme.colorScheme.onSurface.withOpacity(0.7), + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), fontSize: 12, ), ), @@ -270,7 +270,7 @@ class _SystemLogsPageState extends State { width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.5), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8), ), child: Text( diff --git a/hesabixUI/hesabix_ui/lib/pages/admin/user_management_page.dart b/hesabixUI/hesabix_ui/lib/pages/admin/user_management_page.dart index 7450e32..7161347 100644 --- a/hesabixUI/hesabix_ui/lib/pages/admin/user_management_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/admin/user_management_page.dart @@ -96,7 +96,7 @@ class _UserManagementPageState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - theme.colorScheme.primary.withOpacity(0.1), + theme.colorScheme.primary.withValues(alpha: 0.1), theme.colorScheme.surface, ], ), @@ -150,7 +150,7 @@ class _UserManagementPageState extends State { children: [ Expanded( child: DropdownButtonFormField( - value: _selectedFilter, + initialValue: _selectedFilter, decoration: const InputDecoration( labelText: 'Filter', border: OutlineInputBorder(), @@ -190,13 +190,13 @@ class _UserManagementPageState extends State { Icon( Icons.people_outline, size: 64, - color: theme.colorScheme.onSurface.withOpacity(0.5), + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), ), const SizedBox(height: 16), Text( 'No users found', style: theme.textTheme.titleLarge?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.7), + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ), ], @@ -301,7 +301,7 @@ class _UserManagementPageState extends State { role.toUpperCase(), style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold), ), - backgroundColor: _getRoleColor(role).withOpacity(0.2), + backgroundColor: _getRoleColor(role).withValues(alpha: 0.2), labelStyle: TextStyle(color: _getRoleColor(role)), ); } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart new file mode 100644 index 0000000..a08f94b --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../core/auth_store.dart'; +import '../../core/calendar_controller.dart'; + +class BusinessShell extends StatefulWidget { + final int businessId; + final AuthStore authStore; + final CalendarController calendarController; + final Widget child; + + const BusinessShell({ + super.key, + required this.businessId, + required this.authStore, + required this.calendarController, + required this.child, + }); + + @override + State createState() => _BusinessShellState(); +} + +class _BusinessShellState extends State { + + @override + void initState() { + super.initState(); + // اضافه کردن listener برای AuthStore + widget.authStore.addListener(() { + if (mounted) { + setState(() {}); + } + }); + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + final bool useRail = width >= 700; + final bool railExtended = width >= 1100; + final ColorScheme scheme = Theme.of(context).colorScheme; + final String location = GoRouterState.of(context).uri.toString(); + final bool isDark = Theme.of(context).brightness == Brightness.dark; + final String logoAsset = isDark + ? 'assets/images/logo-light.png' + : 'assets/images/logo-light.png'; + + final t = AppLocalizations.of(context); + final destinations = <_Dest>[ + _Dest(t.businessDashboard, Icons.dashboard_outlined, Icons.dashboard, '/business/${widget.businessId}/dashboard'), + _Dest(t.sales, Icons.sell, Icons.sell, '/business/${widget.businessId}/sales'), + _Dest(t.accounting, Icons.account_balance, Icons.account_balance, '/business/${widget.businessId}/accounting'), + _Dest(t.inventory, Icons.inventory, Icons.inventory, '/business/${widget.businessId}/inventory'), + _Dest(t.reports, Icons.assessment, Icons.assessment, '/business/${widget.businessId}/reports'), + _Dest(t.members, Icons.people, Icons.people, '/business/${widget.businessId}/members'), + _Dest(t.settings, Icons.settings, Icons.settings, '/business/${widget.businessId}/settings'), + ]; + + int selectedIndex = 0; + for (int i = 0; i < destinations.length; i++) { + if (location.startsWith(destinations[i].path)) { + selectedIndex = i; + break; + } + } + + Future onSelect(int index) async { + final path = destinations[index].path; + if (GoRouterState.of(context).uri.toString() != path) { + context.go(path); + } + } + + Future onBackToProfile() async { + context.go('/user/profile/businesses'); + } + + // Brand top bar with contrast color + final Color appBarBg = Theme.of(context).brightness == Brightness.dark + ? scheme.surfaceContainerHighest + : scheme.primary; + final Color appBarFg = Theme.of(context).brightness == Brightness.dark + ? scheme.onSurfaceVariant + : scheme.onPrimary; + + final appBar = AppBar( + backgroundColor: appBarBg, + foregroundColor: appBarFg, + elevation: 0, + title: Row( + children: [ + Image.asset( + logoAsset, + height: 32, + width: 32, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.business, + color: appBarFg, + size: 32, + ), + ), + const SizedBox(width: 12), + Text( + 'Hesabix', + style: TextStyle( + color: appBarFg, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + ], + ), + actions: [ + IconButton( + icon: Icon(Icons.arrow_back, color: appBarFg), + onPressed: onBackToProfile, + tooltip: t.backToProfile, + ), + const SizedBox(width: 8), + ], + ); + + if (useRail) { + return Scaffold( + appBar: appBar, + body: Row( + children: [ + NavigationRail( + selectedIndex: selectedIndex, + onDestinationSelected: onSelect, + labelType: railExtended ? NavigationRailLabelType.selected : NavigationRailLabelType.all, + extended: railExtended, + destinations: destinations.map((dest) => NavigationRailDestination( + icon: Icon(dest.icon), + selectedIcon: Icon(dest.selectedIcon), + label: Text(dest.label), + )).toList(), + ), + const VerticalDivider(thickness: 1, width: 1), + Expanded( + child: widget.child, + ), + ], + ), + ); + } else { + return Scaffold( + appBar: appBar, + body: widget.child, + bottomNavigationBar: NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: onSelect, + destinations: destinations.map((dest) => NavigationDestination( + icon: Icon(dest.icon), + selectedIcon: Icon(dest.selectedIcon), + label: dest.label, + )).toList(), + ), + ); + } + } +} + +class _Dest { + final String label; + final IconData icon; + final IconData selectedIcon; + final String path; + + const _Dest(this.label, this.icon, this.selectedIcon, this.path); +} diff --git a/hesabixUI/hesabix_ui/lib/pages/business/dashboard/business_dashboard_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/dashboard/business_dashboard_page.dart new file mode 100644 index 0000000..8542ae7 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/dashboard/business_dashboard_page.dart @@ -0,0 +1,438 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../../services/business_dashboard_service.dart'; +import '../../../core/api_client.dart'; +import '../../../models/business_dashboard_models.dart'; + +class BusinessDashboardPage extends StatefulWidget { + final int businessId; + + const BusinessDashboardPage({super.key, required this.businessId}); + + @override + State createState() => _BusinessDashboardPageState(); +} + +class _BusinessDashboardPageState extends State { + final BusinessDashboardService _service = BusinessDashboardService(ApiClient()); + BusinessDashboardResponse? _dashboardData; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadDashboard(); + } + + Future _loadDashboard() async { + try { + setState(() { + _loading = true; + _error = null; + }); + + final data = await _service.getDashboard(widget.businessId); + + if (mounted) { + setState(() { + _dashboardData = data; + _loading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error, size: 64, color: Colors.red), + const SizedBox(height: 16), + Text( + _error!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadDashboard, + child: Text(t.retry), + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.businessDashboard, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 16), + if (_dashboardData != null) ...[ + _buildBusinessInfo(_dashboardData!.businessInfo), + const SizedBox(height: 24), + _buildStatistics(_dashboardData!.statistics), + const SizedBox(height: 24), + _buildRecentActivities(_dashboardData!.recentActivities), + ], + ], + ), + ); + } + + Widget _buildBusinessInfo(BusinessInfo info) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.business, size: 32, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.name, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 4), + Text( + '${info.businessType} - ${info.businessField}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + _buildInfoChip( + Icons.people, + '${info.memberCount} عضو', + Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + _buildInfoChip( + Icons.calendar_today, + 'تأسیس: ${_formatDate(info.createdAt)}', + Theme.of(context).colorScheme.secondary, + ), + ], + ), + if (info.address != null) ...[ + const SizedBox(height: 12), + Row( + children: [ + Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Expanded( + child: Text( + info.address!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], + if (info.phone != null || info.mobile != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.phone, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + info.phone ?? info.mobile!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ], + ), + ), + ); + } + + Widget _buildInfoChip(IconData icon, String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 4), + Text( + text, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildStatistics(BusinessStatistics stats) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context).businessStatistics, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildStatCard( + 'فروش کل', + _formatCurrency(stats.totalSales), + Icons.trending_up, + Colors.green, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildStatCard( + 'خرید کل', + _formatCurrency(stats.totalPurchases), + Icons.trending_down, + Colors.orange, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildStatCard( + 'اعضای فعال', + stats.activeMembers.toString(), + Icons.people, + Colors.blue, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildStatCard( + 'تراکنش‌های اخیر', + stats.recentTransactions.toString(), + Icons.receipt, + Colors.purple, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildStatCard(String title, String value, IconData icon, Color color) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + value, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } + + Widget _buildRecentActivities(List activities) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context).recentActivities, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + if (activities.isEmpty) + Center( + child: Column( + children: [ + Icon( + Icons.inbox, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 8), + Text( + 'هیچ فعالیتی یافت نشد', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ) + else + ...activities.map((activity) => _buildActivityItem(activity)), + ], + ), + ), + ); + } + + Widget _buildActivityItem(Activity activity) { + IconData activityIcon; + Color iconColor; + + switch (activity.icon) { + case 'sell': + activityIcon = Icons.sell; + iconColor = Colors.green; + break; + case 'person_add': + activityIcon = Icons.person_add; + iconColor = Colors.blue; + break; + case 'assessment': + activityIcon = Icons.assessment; + iconColor = Colors.orange; + break; + default: + activityIcon = Icons.info; + iconColor = Colors.grey; + } + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(activityIcon, color: iconColor, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + activity.title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + activity.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Text( + activity.timeAgo, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + String _formatCurrency(double amount) { + if (amount >= 1000000) { + return '${(amount / 1000000).toStringAsFixed(1)}M تومان'; + } else if (amount >= 1000) { + return '${(amount / 1000).toStringAsFixed(1)}K تومان'; + } else { + return '${amount.toStringAsFixed(0)} تومان'; + } + } + + String _formatDate(String dateString) { + try { + final date = DateTime.parse(dateString); + return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}'; + } catch (e) { + return dateString; + } + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/home_page.dart b/hesabixUI/hesabix_ui/lib/pages/home_page.dart index 05945af..9c442a4 100644 --- a/hesabixUI/hesabix_ui/lib/pages/home_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/home_page.dart @@ -43,7 +43,6 @@ class _ThemeMenu extends StatelessWidget { Widget build(BuildContext context) { return PopupMenuButton( icon: const Icon(Icons.color_lens_outlined), - initialValue: controller.mode, onSelected: (mode) => controller.setMode(mode), itemBuilder: (context) => const [ PopupMenuItem(value: ThemeMode.system, child: Text('System')), diff --git a/hesabixUI/hesabix_ui/lib/pages/login_page.dart b/hesabixUI/hesabix_ui/lib/pages/login_page.dart index 368bb6c..371a06d 100644 --- a/hesabixUI/hesabix_ui/lib/pages/login_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/login_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; @@ -352,7 +351,9 @@ class _LoginPageState extends State with SingleTickerProviderStateMix _showSnack(t.registerSuccess); // پاکسازی کد معرف پس از ثبت‌نام موفق unawaited(ReferralStore.clearReferrer()); - context.go('/user/profile/dashboard'); + if (mounted) { + context.go('/user/profile/dashboard'); + } } catch (e) { if (!mounted) return; final msg = _extractErrorMessage(e, AppLocalizations.of(context)); diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/businesses_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/businesses_page.dart index 10a6557..47586b8 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/businesses_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/businesses_page.dart @@ -1,24 +1,447 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../services/business_dashboard_service.dart'; +import '../../core/api_client.dart'; +import '../../models/business_dashboard_models.dart'; -class BusinessesPage extends StatelessWidget { +class BusinessesPage extends StatefulWidget { const BusinessesPage({super.key}); + @override + State createState() => _BusinessesPageState(); +} + +class _BusinessesPageState extends State { + final BusinessDashboardService _service = BusinessDashboardService(ApiClient()); + List _businesses = []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadBusinesses(); + } + + Future _loadBusinesses() async { + try { + setState(() { + _loading = true; + _error = null; + }); + + final businesses = await _service.getUserBusinesses(); + + if (mounted) { + setState(() { + _businesses = businesses; + _loading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _loading = false; + _error = e.toString(); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در بارگذاری کسب و کارها: $e')), + ); + } + } + } + + void _navigateToBusiness(int businessId) { + context.go('/business/$businessId/dashboard'); + } + @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); + return Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(t.businesses, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 8), - Text('${t.businesses} - sample page'), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + t.businesses, + style: Theme.of(context).textTheme.headlineMedium, + ), + ElevatedButton.icon( + onPressed: () => context.go('/user/profile/new-business'), + icon: const Icon(Icons.add), + label: Text(t.newBusiness), + ), + ], + ), + const SizedBox(height: 16), + if (_loading) + const Center(child: CircularProgressIndicator()) + else if (_error != null) + Center( + child: Column( + children: [ + Icon(Icons.error, size: 64, color: Colors.red), + const SizedBox(height: 16), + Text(_error!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadBusinesses, + child: Text(t.retry), + ), + ], + ), + ) + else if (_businesses.isEmpty) + Center( + child: Column( + children: [ + Icon(Icons.business, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(t.noBusinessesFound), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => context.go('/user/profile/new-business'), + child: Text(t.createFirstBusiness), + ), + ], + ), + ) + else + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + // Responsive grid based on screen width + int crossAxisCount; + if (constraints.maxWidth > 1200) { + crossAxisCount = 4; + } else if (constraints.maxWidth > 900) { + crossAxisCount = 3; + } else if (constraints.maxWidth > 600) { + crossAxisCount = 2; + } else { + crossAxisCount = 1; + } + + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + childAspectRatio: crossAxisCount == 1 ? 4.0 : 1.3, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: _businesses.length, + itemBuilder: (context, index) { + final business = _businesses[index]; + return _BusinessCard( + business: business, + onTap: () => _navigateToBusiness(business.id), + isCompact: crossAxisCount > 1, + ); + }, + ); + }, + ), + ), ], ), ); } } +class _BusinessCard extends StatelessWidget { + final BusinessWithPermission business; + final VoidCallback onTap; + final bool isCompact; + + const _BusinessCard({ + required this.business, + required this.onTap, + this.isCompact = true, + }); + + @override + Widget build(BuildContext context) { + if (isCompact) { + return _buildCompactCard(context); + } else { + return _buildWideCard(context); + } + } + + Widget _buildCompactCard(BuildContext context) { + return Card( + elevation: 1, + margin: EdgeInsets.zero, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Header with icon and role badge + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: business.isOwner + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) + : Theme.of(context).colorScheme.secondary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + business.isOwner ? Icons.business : Icons.business_outlined, + color: business.isOwner + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.secondary, + size: 20, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: business.isOwner + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) + : Theme.of(context).colorScheme.secondary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + business.isOwner ? AppLocalizations.of(context).owner : AppLocalizations.of(context).member, + style: TextStyle( + color: business.isOwner + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.secondary, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + + const SizedBox(height: 6), + + // Business name + Text( + business.name, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 3), + + // Business type and field + Text( + '${_translateBusinessType(business.businessType, context)} • ${_translateBusinessField(business.businessField, context)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 6), + + // Footer with date and arrow + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + _formatDate(business.createdAt), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 10, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildWideCard(BuildContext context) { + return Card( + elevation: 1, + margin: EdgeInsets.zero, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + // Icon + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: business.isOwner + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) + : Theme.of(context).colorScheme.secondary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + business.isOwner ? Icons.business : Icons.business_outlined, + color: business.isOwner + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.secondary, + size: 24, + ), + ), + + const SizedBox(width: 16), + + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + business.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: business.isOwner + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) + : Theme.of(context).colorScheme.secondary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + business.isOwner ? AppLocalizations.of(context).owner : AppLocalizations.of(context).member, + style: TextStyle( + color: business.isOwner + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.secondary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + + const SizedBox(height: 4), + + Text( + '${_translateBusinessType(business.businessType, context)} • ${_translateBusinessField(business.businessField, context)}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 8), + + Text( + 'تأسیس: ${_formatDate(business.createdAt)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + + const SizedBox(width: 16), + + // Arrow + Icon( + Icons.arrow_forward_ios, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } + + String _formatDate(String dateString) { + try { + final date = DateTime.parse(dateString); + return '${date.year}/${date.month}/${date.day}'; + } catch (e) { + return dateString; + } + } + + String _translateBusinessType(String type, BuildContext context) { + final l10n = AppLocalizations.of(context); + switch (type) { + case 'شرکت': + return l10n.company; + case 'مغازه': + return l10n.shop; + case 'فروشگاه': + return l10n.store; + case 'اتحادیه': + return l10n.union; + case 'باشگاه': + return l10n.club; + case 'موسسه': + return l10n.institute; + case 'شخصی': + return l10n.individual; + default: + return type; + } + } + + String _translateBusinessField(String field, BuildContext context) { + final l10n = AppLocalizations.of(context); + switch (field) { + case 'تولیدی': + return l10n.manufacturing; + case 'بازرگانی': + return l10n.trading; + case 'خدماتی': + return l10n.service; + case 'سایر': + return l10n.other; + default: + return field; + } + } +} + diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart index 78fee57..428ef13 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart @@ -59,7 +59,7 @@ class _CreateTicketPageState extends State { borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.2), + color: Colors.black.withValues(alpha: 0.2), blurRadius: 8, offset: const Offset(0, 4), ), @@ -189,8 +189,8 @@ class _CreateTicketPageState extends State { decoration: BoxDecoration( gradient: LinearGradient( colors: [ - theme.colorScheme.primary.withOpacity(0.1), - theme.colorScheme.primary.withOpacity(0.05), + theme.colorScheme.primary.withValues(alpha: 0.1), + theme.colorScheme.primary.withValues(alpha: 0.05), ], ), borderRadius: const BorderRadius.only( @@ -228,7 +228,7 @@ class _CreateTicketPageState extends State { Text( t.descriptionHint, style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.7), + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ), ], @@ -268,7 +268,7 @@ class _CreateTicketPageState extends State { Text( t.loadingData, style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.7), + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ), ], @@ -286,13 +286,13 @@ class _CreateTicketPageState extends State { Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( - color: Colors.red.withOpacity(0.1), + color: Colors.red.withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon( Icons.error_outline, size: 48, - color: Colors.red.withOpacity(0.8), + color: Colors.red.withValues(alpha: 0.8), ), ), const SizedBox(height: 24), @@ -397,7 +397,7 @@ class _CreateTicketPageState extends State { enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: theme.colorScheme.outline.withOpacity(0.3), + color: theme.colorScheme.outline.withValues(alpha: 0.3), ), ), focusedBorder: OutlineInputBorder( @@ -437,7 +437,7 @@ class _CreateTicketPageState extends State { ), const SizedBox(height: 8), DropdownButtonFormField( - value: _selectedCategory, + initialValue: _selectedCategory, decoration: InputDecoration( border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), @@ -445,7 +445,7 @@ class _CreateTicketPageState extends State { enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: theme.colorScheme.outline.withOpacity(0.3), + color: theme.colorScheme.outline.withValues(alpha: 0.3), ), ), focusedBorder: OutlineInputBorder( @@ -493,7 +493,7 @@ class _CreateTicketPageState extends State { ), const SizedBox(height: 8), DropdownButtonFormField( - value: _selectedPriority, + initialValue: _selectedPriority, decoration: InputDecoration( border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), @@ -501,7 +501,7 @@ class _CreateTicketPageState extends State { enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: theme.colorScheme.outline.withOpacity(0.3), + color: theme.colorScheme.outline.withValues(alpha: 0.3), ), ), focusedBorder: OutlineInputBorder( @@ -573,7 +573,7 @@ class _CreateTicketPageState extends State { enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: theme.colorScheme.outline.withOpacity(0.3), + color: theme.colorScheme.outline.withValues(alpha: 0.3), ), ), focusedBorder: OutlineInputBorder( @@ -610,7 +610,7 @@ class _CreateTicketPageState extends State { color: theme.colorScheme.surface, border: Border( top: BorderSide( - color: theme.colorScheme.outline.withOpacity(0.2), + color: theme.colorScheme.outline.withValues(alpha: 0.2), width: 1, ), ), @@ -636,7 +636,7 @@ class _CreateTicketPageState extends State { foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), elevation: 4, - shadowColor: theme.colorScheme.primary.withOpacity(0.3), + shadowColor: theme.colorScheme.primary.withValues(alpha: 0.3), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/marketing_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/marketing_page.dart index 27f74f4..afaa626 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/marketing_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/marketing_page.dart @@ -189,9 +189,9 @@ class _MarketingPageState extends State { const SizedBox(width: 8), FilledButton.icon( onPressed: () async { + final messenger = ScaffoldMessenger.of(context); await Clipboard.setData(ClipboardData(text: inviteLink)); if (!mounted) return; - final messenger = ScaffoldMessenger.of(context); messenger ..hideCurrentSnackBar() ..showSnackBar( diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart index 674f45c..1427457 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart @@ -633,7 +633,7 @@ class _NewBusinessPageState extends State { ), ), ), - value: _businessData.businessType, + initialValue: _businessData.businessType, items: BusinessType.values.map((type) { return DropdownMenuItem( value: type, @@ -675,7 +675,7 @@ class _NewBusinessPageState extends State { ), ), ), - value: _businessData.businessField, + initialValue: _businessData.businessField, items: BusinessField.values.map((field) { return DropdownMenuItem( value: field, diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/profile_dashboard_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/profile_dashboard_page.dart index 9ae4387..c335f04 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/profile_dashboard_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/profile_dashboard_page.dart @@ -5,16 +5,13 @@ class ProfileDashboardPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text('User Profile Dashboard', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), - SizedBox(height: 12), - Text('Summary and quick actions will appear here.'), - ], - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('User Profile Dashboard', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + SizedBox(height: 12), + Text('Summary and quick actions will appear here.'), + ], ); } } diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/profile_shell.dart b/hesabixUI/hesabix_ui/lib/pages/profile/profile_shell.dart index d4dab00..858ea42 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/profile_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/profile_shell.dart @@ -152,10 +152,7 @@ class _ProfileShellState extends State { final content = Container( color: scheme.surface, child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: widget.child, - ), + child: widget.child, ), ); diff --git a/hesabixUI/hesabix_ui/lib/pages/system_settings_page.dart b/hesabixUI/hesabix_ui/lib/pages/system_settings_page.dart index 5c5f83b..0011243 100644 --- a/hesabixUI/hesabix_ui/lib/pages/system_settings_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/system_settings_page.dart @@ -44,6 +44,13 @@ class _SystemSettingsPageState extends State { color: const Color(0xFF9C27B0), route: '/user/profile/system-settings/logs', ), + SettingsItem( + title: 'emailSettings', + description: 'emailSettingsDescription', + icon: Icons.email_outlined, + color: const Color(0xFFE91E63), + route: '/user/profile/system-settings/email', + ), ]; } @@ -55,25 +62,6 @@ class _SystemSettingsPageState extends State { return Scaffold( backgroundColor: colorScheme.surface, - appBar: AppBar( - title: Text( - t.systemSettingsWelcome, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 20, - ), - ), - backgroundColor: Colors.transparent, - elevation: 0, - centerTitle: true, - actions: [ - IconButton( - onPressed: () => _showHelpDialog(context), - icon: const Icon(Icons.help_outline), - tooltip: t.systemSettingsWelcome, - ), - ], - ), body: SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( @@ -97,13 +85,13 @@ class _SystemSettingsPageState extends State { begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - colorScheme.primary.withOpacity(0.1), - colorScheme.primaryContainer.withOpacity(0.3), + colorScheme.primary.withValues(alpha: 0.1), + colorScheme.primaryContainer.withValues(alpha: 0.3), ], ), borderRadius: BorderRadius.circular(16), border: Border.all( - color: colorScheme.primary.withOpacity(0.2), + color: colorScheme.primary.withValues(alpha: 0.2), width: 1, ), ), @@ -117,13 +105,13 @@ class _SystemSettingsPageState extends State { end: Alignment.bottomRight, colors: [ colorScheme.primary, - colorScheme.primary.withOpacity(0.8), + colorScheme.primary.withValues(alpha: 0.8), ], ), borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: colorScheme.primary.withOpacity(0.3), + color: colorScheme.primary.withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 2), ), @@ -152,7 +140,7 @@ class _SystemSettingsPageState extends State { Text( t.systemSettingsDescription, style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface.withOpacity(0.7), + color: colorScheme.onSurface.withValues(alpha: 0.7), fontSize: 14, ), ), @@ -162,7 +150,7 @@ class _SystemSettingsPageState extends State { Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: colorScheme.primary.withOpacity(0.1), + color: colorScheme.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), ), child: Text( @@ -196,7 +184,7 @@ class _SystemSettingsPageState extends State { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: colorScheme.primary.withOpacity(0.1), + color: colorScheme.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Text( @@ -246,7 +234,7 @@ class _SystemSettingsPageState extends State { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide( - color: colorScheme.outline.withOpacity(0.2), + color: colorScheme.outline.withValues(alpha: 0.2), width: 1, ), ), @@ -255,8 +243,8 @@ class _SystemSettingsPageState extends State { child: InkWell( onTap: () => context.go(item.route!), borderRadius: BorderRadius.circular(12), - hoverColor: item.color.withOpacity(0.05), - splashColor: item.color.withOpacity(0.1), + hoverColor: item.color.withValues(alpha: 0.05), + splashColor: item.color.withValues(alpha: 0.1), child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.all(16), @@ -267,11 +255,11 @@ class _SystemSettingsPageState extends State { duration: const Duration(milliseconds: 200), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: item.color.withOpacity(0.1), + color: item.color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( - color: item.color.withOpacity(0.1), + color: item.color.withValues(alpha: 0.1), blurRadius: 4, offset: const Offset(0, 2), ), @@ -299,7 +287,7 @@ class _SystemSettingsPageState extends State { Text( _getLocalizedText(t, item.description), style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface.withOpacity(0.6), + color: colorScheme.onSurface.withValues(alpha: 0.6), fontSize: 11, ), textAlign: TextAlign.center, @@ -311,7 +299,7 @@ class _SystemSettingsPageState extends State { duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: item.color.withOpacity(0.1), + color: item.color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon( @@ -346,41 +334,15 @@ class _SystemSettingsPageState extends State { return t.systemLogs; case 'systemLogsDescription': return t.systemLogsDescription; + case 'emailSettings': + return t.emailSettings; + case 'emailSettingsDescription': + return t.emailSettingsDescription; default: return key; } } - void _showHelpDialog(BuildContext context) { - final t = AppLocalizations.of(context); - showDialog( - context: context, - builder: (context) => AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - title: Row( - children: [ - Icon( - Icons.help_outline, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Text(t.systemSettingsWelcome), - ], - ), - content: Text( - t.systemSettingsDescription, - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(t.ok), - ), - ], - ), - ); - } } class SettingsItem { diff --git a/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart b/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart new file mode 100644 index 0000000..c46cd2d --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart @@ -0,0 +1,139 @@ +import 'package:dio/dio.dart'; +import '../core/api_client.dart'; +import '../models/business_dashboard_models.dart'; + +class BusinessDashboardService { + final ApiClient _apiClient; + + BusinessDashboardService(this._apiClient); + + /// دریافت داشبورد کسب و کار + Future getDashboard(int businessId) async { + try { + final response = await _apiClient.post>( + '/api/v1/business/$businessId/dashboard', + ); + + if (response.data?['success'] == true) { + return BusinessDashboardResponse.fromJson(response.data!['data']); + } else { + throw Exception('Failed to load dashboard: ${response.data?['message']}'); + } + } on DioException catch (e) { + if (e.response?.statusCode == 403) { + throw Exception('دسترسی غیرمجاز به این کسب و کار'); + } else if (e.response?.statusCode == 404) { + throw Exception('کسب و کار یافت نشد'); + } else { + throw Exception('خطا در بارگذاری داشبورد: ${e.message}'); + } + } catch (e) { + throw Exception('خطا در بارگذاری داشبورد: $e'); + } + } + + /// دریافت لیست اعضای کسب و کار + Future getMembers(int businessId) async { + try { + final response = await _apiClient.post>( + '/api/v1/business/$businessId/members', + ); + + if (response.data?['success'] == true) { + return BusinessMembersResponse.fromJson(response.data!['data']); + } else { + throw Exception('Failed to load members: ${response.data?['message']}'); + } + } on DioException catch (e) { + if (e.response?.statusCode == 403) { + throw Exception('دسترسی غیرمجاز به این کسب و کار'); + } else if (e.response?.statusCode == 404) { + throw Exception('کسب و کار یافت نشد'); + } else { + throw Exception('خطا در بارگذاری اعضا: ${e.message}'); + } + } catch (e) { + throw Exception('خطا در بارگذاری اعضا: $e'); + } + } + + /// دریافت آمار کسب و کار + Future> getStatistics(int businessId) async { + try { + final response = await _apiClient.post>( + '/api/v1/business/$businessId/statistics', + ); + + if (response.data?['success'] == true) { + return response.data!['data']; + } else { + throw Exception('Failed to load statistics: ${response.data?['message']}'); + } + } on DioException catch (e) { + if (e.response?.statusCode == 403) { + throw Exception('دسترسی غیرمجاز به این کسب و کار'); + } else if (e.response?.statusCode == 404) { + throw Exception('کسب و کار یافت نشد'); + } else { + throw Exception('خطا در بارگذاری آمار: ${e.message}'); + } + } catch (e) { + throw Exception('خطا در بارگذاری آمار: $e'); + } + } + + /// دریافت لیست کسب و کارهای کاربر (مالک + عضو) + Future> getUserBusinesses() async { + try { + // دریافت کسب و کارهای مالک با POST request + final ownedResponse = await _apiClient.post>( + '/api/v1/businesses/list', + data: { + 'take': 100, + 'skip': 0, + 'sort_by': 'created_at', + 'sort_desc': true, + 'search': null, + }, + ); + + List businesses = []; + + if (ownedResponse.data?['success'] == true) { + final ownedItems = ownedResponse.data!['data']['items'] as List; + businesses.addAll( + ownedItems.map((item) { + final business = BusinessWithPermission.fromJson(item); + return BusinessWithPermission( + id: business.id, + name: business.name, + businessType: business.businessType, + businessField: business.businessField, + ownerId: business.ownerId, + address: business.address, + phone: business.phone, + mobile: business.mobile, + createdAt: business.createdAt, + isOwner: true, + role: 'مالک', + permissions: {}, + ); + }), + ); + } + + // TODO: در آینده می‌توان کسب و کارهای عضو را نیز اضافه کرد + // از API endpoint جدید برای کسب و کارهای عضو + + return businesses; + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw Exception('لطفاً ابتدا وارد شوید'); + } else { + throw Exception('خطا در بارگذاری کسب و کارها: ${e.message}'); + } + } catch (e) { + throw Exception('خطا در بارگذاری کسب و کارها: $e'); + } + } +} diff --git a/hesabixUI/hesabix_ui/lib/services/email_service.dart b/hesabixUI/hesabix_ui/lib/services/email_service.dart new file mode 100644 index 0000000..258e1e1 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/email_service.dart @@ -0,0 +1,248 @@ +import 'package:dio/dio.dart'; +import '../core/api_client.dart'; +import '../models/email_models.dart'; + +class EmailService { + static final EmailService _instance = EmailService._internal(); + factory EmailService() => _instance; + EmailService._internal(); + + late final ApiClient _apiClient; + + void _initializeApiClient() { + _apiClient = ApiClient(); + } + + void _ensureApiClientInitialized() { + try { + _apiClient; + } catch (e) { + _initializeApiClient(); + } + } + + /// Send email using configured SMTP + Future sendEmail(SendEmailRequest request) async { + _ensureApiClientInitialized(); + try { + final response = await _apiClient.post>( + '/api/v1/admin/email/send', + data: request.toJson(), + ); + + return SendEmailResponse.fromJson(response.data!); + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Send welcome email to new user + Future sendWelcomeEmail(String userEmail, String userName) async { + _ensureApiClientInitialized(); + return sendEmail(SendEmailRequest( + to: userEmail, + subject: 'خوش آمدید به حسابیکس', + body: 'سلام $userName،\n\nبه حسابیکس خوش آمدید! امیدواریم تجربه خوبی داشته باشید.\n\nبا احترام\nتیم حسابیکس', + htmlBody: ''' +

خوش آمدید به حسابیکس

+

سلام $userName،

+

به حسابیکس خوش آمدید! امیدواریم تجربه خوبی داشته باشید.

+

با احترام
تیم حسابیکس

+ ''', + )); + } + + /// Send password reset email + Future sendPasswordResetEmail(String userEmail, String resetLink) async { + _ensureApiClientInitialized(); + return sendEmail(SendEmailRequest( + to: userEmail, + subject: 'بازیابی رمز عبور', + body: 'برای بازیابی رمز عبور روی لینک زیر کلیک کنید:\n\n$resetLink\n\nاین لینک تا 1 ساعت معتبر است.', + htmlBody: ''' +

بازیابی رمز عبور

+

برای بازیابی رمز عبور روی لینک زیر کلیک کنید:

+

بازیابی رمز عبور

+

این لینک تا 1 ساعت معتبر است.

+ ''', + )); + } + + /// Send notification email + Future sendNotificationEmail(String userEmail, String title, String message) async { + _ensureApiClientInitialized(); + return sendEmail(SendEmailRequest( + to: userEmail, + subject: title, + body: message, + htmlBody: ''' +

$title

+

$message

+ ''', + )); + } + + /// Send custom email + Future sendCustomEmail({ + required String to, + required String subject, + required String body, + String? htmlBody, + int? configId, + }) async { + _ensureApiClientInitialized(); + return sendEmail(SendEmailRequest( + to: to, + subject: subject, + body: body, + htmlBody: htmlBody, + configId: configId, + )); + } + + /// Get all email configurations + Future getEmailConfigs() async { + _ensureApiClientInitialized(); + try { + final response = await _apiClient.get>( + '/api/v1/admin/email/configs', + ); + + final data = response.data!; + return EmailConfigListResponse( + success: data['success'] as bool? ?? true, + data: (data['data'] as List? ?? []) + .map((item) => EmailConfig.fromJson(item as Map)) + .toList(), + message: data['message'] as String? ?? '', + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Get specific email configuration + Future getEmailConfig(int configId) async { + _ensureApiClientInitialized(); + try { + final response = await _apiClient.get>( + '/api/v1/admin/email/configs/$configId', + ); + + final data = response.data!; + return EmailConfigResponse( + success: data['success'] as bool? ?? true, + data: EmailConfig.fromJson(data['data'] as Map), + message: data['message'] as String? ?? '', + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Create new email configuration + Future createEmailConfig(CreateEmailConfigRequest request) async { + _ensureApiClientInitialized(); + try { + final response = await _apiClient.post>( + '/api/v1/admin/email/configs', + data: request.toJson(), + ); + + final data = response.data!; + return EmailConfigResponse( + success: data['success'] as bool? ?? true, + data: EmailConfig.fromJson(data['data'] as Map), + message: data['message'] as String? ?? '', + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Update email configuration + Future updateEmailConfig(int configId, UpdateEmailConfigRequest request) async { + _ensureApiClientInitialized(); + try { + final response = await _apiClient.put>( + '/api/v1/admin/email/configs/$configId', + data: request.toJson(), + ); + + final data = response.data!; + return EmailConfigResponse( + success: data['success'] as bool? ?? true, + data: EmailConfig.fromJson(data['data'] as Map), + message: data['message'] as String? ?? '', + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Delete email configuration + Future deleteEmailConfig(int configId) async { + _ensureApiClientInitialized(); + try { + await _apiClient.delete('/api/v1/admin/email/configs/$configId'); + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Test email configuration connection + Future testEmailConfig(int configId) async { + _ensureApiClientInitialized(); + try { + final response = await _apiClient.post>( + '/api/v1/admin/email/configs/$configId/test', + ); + + final data = response.data!; + return TestConnectionResponse( + success: data['success'] as bool? ?? true, + message: data['message'] as String? ?? '', + connected: data['data']?['connected'] as bool? ?? false, + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Activate email configuration + Future activateEmailConfig(int configId) async { + _ensureApiClientInitialized(); + try { + await _apiClient.post('/api/v1/admin/email/configs/$configId/activate'); + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Set email configuration as default + Future setDefaultEmailConfig(int configId) async { + _ensureApiClientInitialized(); + try { + await _apiClient.post('/api/v1/admin/email/configs/$configId/set-default'); + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Handle API errors + String _handleError(DioException e) { + if (e.response != null) { + final data = e.response!.data; + if (data is Map && data.containsKey('detail')) { + return data['detail'] as String; + } + return 'خطا در ارتباط با سرور: ${e.response!.statusCode}'; + } else if (e.type == DioExceptionType.connectionTimeout) { + return 'خطا در اتصال به سرور - لطفاً اتصال اینترنت خود را بررسی کنید'; + } else if (e.type == DioExceptionType.receiveTimeout) { + return 'زمان دریافت پاسخ از سرور به پایان رسید'; + } else { + return 'خطای نامشخص: ${e.message}'; + } + } +} diff --git a/hesabixUI/hesabix_ui/lib/theme/theme_controller.dart b/hesabixUI/hesabix_ui/lib/theme/theme_controller.dart index 8f9e3d8..80b55c7 100644 --- a/hesabixUI/hesabix_ui/lib/theme/theme_controller.dart +++ b/hesabixUI/hesabix_ui/lib/theme/theme_controller.dart @@ -36,7 +36,7 @@ class ThemeController extends ChangeNotifier { _seed = c; notifyListeners(); final p = await SharedPreferences.getInstance(); - await p.setInt(_seedKey, c.value); + await p.setInt(_seedKey, c.toARGB32()); } } diff --git a/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_card.dart b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_card.dart index d7c44dc..5cb2f62 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_card.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_card.dart @@ -41,8 +41,8 @@ class StorageConfigCard extends StatelessWidget { begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - theme.colorScheme.primary.withOpacity(0.05), - theme.colorScheme.primary.withOpacity(0.02), + theme.colorScheme.primary.withValues(alpha: 0.05), + theme.colorScheme.primary.withValues(alpha: 0.02), ], ) : null, @@ -58,7 +58,7 @@ class StorageConfigCard extends StatelessWidget { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: _getStorageColor(storageType).withOpacity(0.1), + color: _getStorageColor(storageType).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon( @@ -255,13 +255,13 @@ class StorageConfigCard extends StatelessWidget { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.5), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8), ), child: Text( 'نوع ذخیره‌سازی نامشخص', style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.6), + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), ), ), ); @@ -276,10 +276,10 @@ class StorageConfigCard extends StatelessWidget { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.3), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withOpacity(0.2), + color: theme.colorScheme.outline.withValues(alpha: 0.2), ), ), child: Column( @@ -306,7 +306,7 @@ class StorageConfigCard extends StatelessWidget { Text( l10n.basePath, style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.6), + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), ), ), const SizedBox(height: 4), @@ -316,7 +316,7 @@ class StorageConfigCard extends StatelessWidget { color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(6), border: Border.all( - color: theme.colorScheme.outline.withOpacity(0.3), + color: theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Row( @@ -355,10 +355,10 @@ class StorageConfigCard extends StatelessWidget { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.3), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withOpacity(0.2), + color: theme.colorScheme.outline.withValues(alpha: 0.2), ), ), child: Column( @@ -435,7 +435,7 @@ class StorageConfigCard extends StatelessWidget { Text( '$label: ', style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.6), + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), ), ), Expanded( diff --git a/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_form_dialog.dart index 6027d94..12e3d25 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_form_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_form_dialog.dart @@ -252,43 +252,33 @@ class _StorageConfigFormDialogState extends State { ), ), const SizedBox(height: 8), - Row( + Column( children: [ - Expanded( - child: RadioListTile( - title: Row( - children: [ - Icon(Icons.storage, size: 20), - const SizedBox(width: 8), - Text(l10n.localStorage), - ], - ), - value: 'local', + ListTile( + leading: Radio( + value: 'local', + // ignore: deprecated_member_use groupValue: _selectedStorageType, - onChanged: (value) { - setState(() { - _selectedStorageType = value!; - }); - }, + // ignore: deprecated_member_use + onChanged: (value) => setState(() => _selectedStorageType = value!), ), + title: Text(l10n.localStorage), + trailing: Icon(Icons.storage, size: 20), + onTap: () => setState(() => _selectedStorageType = 'local'), + contentPadding: EdgeInsets.zero, ), - Expanded( - child: RadioListTile( - title: Row( - children: [ - Icon(Icons.cloud_upload, size: 20), - const SizedBox(width: 8), - Text('سرور FTP'), - ], - ), + ListTile( + leading: Radio( value: 'ftp', + // ignore: deprecated_member_use groupValue: _selectedStorageType, - onChanged: (value) { - setState(() { - _selectedStorageType = value!; - }); - }, - ), + // ignore: deprecated_member_use + onChanged: (value) => setState(() => _selectedStorageType = value!), + ), + title: Text('سرور FTP'), + trailing: Icon(Icons.cloud_upload, size: 20), + onTap: () => setState(() => _selectedStorageType = 'ftp'), + contentPadding: EdgeInsets.zero, ), ], ), @@ -344,7 +334,7 @@ class _StorageConfigFormDialogState extends State { Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.3), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20), diff --git a/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_list_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_list_widget.dart index 3feea46..d4d037b 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_list_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_list_widget.dart @@ -58,6 +58,7 @@ class StorageConfigListWidgetState extends State { final api = ApiClient(); final response = await api.post('/api/v1/admin/files/storage-configs/$configId/test'); + if (!mounted) return; if (response.data != null && response.data['success'] == true) { final testResult = response.data['data']['test_result']; if (testResult['success'] == true) { @@ -79,6 +80,7 @@ class StorageConfigListWidgetState extends State { throw Exception(response.data?['message'] ?? 'خطا در تست اتصال'); } } catch (e) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('اتصال ناموفق: $e'), @@ -112,6 +114,7 @@ class StorageConfigListWidgetState extends State { final api = ApiClient(); final response = await api.delete('/api/v1/admin/files/storage-configs/$configId'); + if (!mounted) return; if (response.data != null && response.data['success'] == true) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -142,6 +145,7 @@ class StorageConfigListWidgetState extends State { errorMessage = e.toString().replaceFirst('Exception: ', ''); } + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(errorMessage), @@ -158,6 +162,7 @@ class StorageConfigListWidgetState extends State { final api = ApiClient(); final response = await api.put('/api/v1/admin/files/storage-configs/$configId/set-default'); + if (!mounted) return; if (response.data != null && response.data['success'] == true) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -172,6 +177,7 @@ class StorageConfigListWidgetState extends State { throw Exception(response.data?['message'] ?? 'خطا در تنظیم به عنوان پیش‌فرض'); } } catch (e) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('خطا در تنظیم به عنوان پیش‌فرض: $e'), @@ -264,20 +270,20 @@ class StorageConfigListWidgetState extends State { Icon( Icons.storage_outlined, size: 64, - color: theme.colorScheme.primary.withOpacity(0.5), + color: theme.colorScheme.primary.withValues(alpha: 0.5), ), const SizedBox(height: 16), Text( 'هیچ پیکربندی ذخیره‌سازی وجود ندارد', style: theme.textTheme.headlineSmall?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.6), + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), ), ), const SizedBox(height: 8), Text( 'اولین پیکربندی ذخیره‌سازی را ایجاد کنید', style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.6), + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), ), textAlign: TextAlign.center, ), @@ -285,7 +291,7 @@ class StorageConfigListWidgetState extends State { Text( 'از دکمه + در پایین صفحه استفاده کنید', style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.5), + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), fontStyle: FontStyle.italic, ), ), @@ -321,7 +327,7 @@ class StorageConfigListWidgetState extends State { Text( '${_storageConfigs.length} پیکربندی', style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.6), + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), ), ), ], diff --git a/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_management_page.dart b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_management_page.dart index 4a56e22..2558d2a 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_management_page.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_management_page.dart @@ -35,7 +35,7 @@ class _StorageManagementPageState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - theme.colorScheme.primary.withOpacity(0.1), + theme.colorScheme.primary.withValues(alpha: 0.1), theme.colorScheme.surface, ], ), diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_search_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_search_dialog.dart index e1d590e..a4694eb 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_search_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_search_dialog.dart @@ -43,7 +43,7 @@ class DataTableSearchDialog extends StatefulWidget { class _DataTableSearchDialogState extends State { late TextEditingController _controller; late String _selectedType; - Set _selectedValues = {}; + final Set _selectedValues = {}; DateTime? _fromDate; DateTime? _toDate; @@ -118,7 +118,7 @@ class _DataTableSearchDialogState extends State { return [ // Search type dropdown DropdownButtonFormField( - value: _selectedType, + initialValue: _selectedType, decoration: InputDecoration( labelText: t.searchType, border: const OutlineInputBorder(), @@ -163,27 +163,7 @@ class _DataTableSearchDialogState extends State { subtitle: Text(_fromDate != null ? HesabixDateUtils.formatForDisplay(_fromDate!, isJalali) : t.selectDate), - onTap: () async { - final date = isJalali - ? await showJalaliDatePicker( - context: context, - initialDate: _fromDate ?? DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime.now().add(const Duration(days: 365)), - helpText: t.dateFrom, - ) - : await showDatePicker( - context: context, - initialDate: _fromDate ?? DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (date != null) { - setState(() { - _fromDate = date; - }); - } - }, + onTap: () => _selectFromDate(t, isJalali), ), // To date ListTile( @@ -192,27 +172,7 @@ class _DataTableSearchDialogState extends State { subtitle: Text(_toDate != null ? HesabixDateUtils.formatForDisplay(_toDate!, isJalali) : t.selectDate), - onTap: () async { - final date = isJalali - ? await showJalaliDatePicker( - context: context, - initialDate: _toDate ?? _fromDate ?? DateTime.now(), - firstDate: _fromDate ?? DateTime(2000), - lastDate: DateTime.now().add(const Duration(days: 365)), - helpText: t.dateTo, - ) - : await showDatePicker( - context: context, - initialDate: _toDate ?? _fromDate ?? DateTime.now(), - firstDate: _fromDate ?? DateTime(2000), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (date != null) { - setState(() { - _toDate = date; - }); - } - }, + onTap: () => _selectToDate(t, isJalali), ), ]; } @@ -344,6 +304,56 @@ class _DataTableSearchDialogState extends State { } Navigator.of(context).pop(); } + + Future _selectFromDate(AppLocalizations t, bool isJalali) async { + final currentContext = context; + final date = isJalali + ? await showJalaliDatePicker( + // ignore: use_build_context_synchronously + context: currentContext, + initialDate: _fromDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + helpText: t.dateFrom, + ) + : await showDatePicker( + // ignore: use_build_context_synchronously + context: currentContext, + initialDate: _fromDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (date != null && mounted) { + setState(() { + _fromDate = date; + }); + } + } + + Future _selectToDate(AppLocalizations t, bool isJalali) async { + final currentContext = context; + final date = isJalali + ? await showJalaliDatePicker( + // ignore: use_build_context_synchronously + context: currentContext, + initialDate: _toDate ?? _fromDate ?? DateTime.now(), + firstDate: _fromDate ?? DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + helpText: t.dateTo, + ) + : await showDatePicker( + // ignore: use_build_context_synchronously + context: currentContext, + initialDate: _toDate ?? _fromDate ?? DateTime.now(), + firstDate: _fromDate ?? DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (date != null && mounted) { + setState(() { + _toDate = date; + }); + } + } } /// Dialog for date range filter 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 2038829..b2c7ae4 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,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; // import 'dart:html' as html; // Not available on Linux import 'package:flutter/material.dart'; import 'package:data_table_2/data_table_2.dart'; @@ -63,7 +62,7 @@ class _DataTableWidgetState extends State> { bool _sortDesc = false; // Row selection state - Set _selectedRows = {}; + final Set _selectedRows = {}; bool _isExporting = false; // Column settings state @@ -144,7 +143,7 @@ class _DataTableWidgetState extends State> { _visibleColumns = _getVisibleColumnsFromSettings(effectiveSettings); }); } catch (e) { - print('Error loading column settings: $e'); + debugPrint('Error loading column settings: $e'); setState(() { _visibleColumns = List.from(widget.config.columns); }); @@ -466,7 +465,7 @@ class _DataTableWidgetState extends State> { widget.config.onColumnSettingsChanged!(validatedSettings); } } catch (e) { - print('Error saving column settings: $e'); + debugPrint('Error saving column settings: $e'); if (mounted) { final t = Localizations.of(context, AppLocalizations)!; final messenger = ScaffoldMessenger.of(context); @@ -624,43 +623,16 @@ class _DataTableWidgetState extends State> { // Platform-specific download functions for Linux Future _downloadPdf(dynamic data, String filename) async { // For Linux desktop, we'll save to Downloads folder - print('Download PDF: $filename (Linux desktop - save to Downloads folder)'); + debugPrint('Download PDF: $filename (Linux desktop - save to Downloads folder)'); // TODO: Implement proper file saving for Linux } Future _downloadExcel(dynamic data, String filename) async { // For Linux desktop, we'll save to Downloads folder - print('Download Excel: $filename (Linux desktop - save to Downloads folder)'); + debugPrint('Download Excel: $filename (Linux desktop - save to Downloads folder)'); // TODO: Implement proper file saving for Linux } - String _convertToCsv(List data) { - if (data.isEmpty) return ''; - - // Get headers from first item - final firstItem = data.first as Map; - final headers = firstItem.keys.toList(); - - // Create CSV content - final csvLines = []; - - // Add headers - csvLines.add(headers.join(',')); - - // Add data rows - for (final item in data) { - final row = []; - for (final header in headers) { - final value = item[header]?.toString() ?? ''; - // Escape commas and quotes - final escapedValue = value.replaceAll('"', '""'); - row.add('"$escapedValue"'); - } - csvLines.add(row.join(',')); - } - - return csvLines.join('\n'); - } @override Widget build(BuildContext context) { @@ -1451,7 +1423,7 @@ class _ColumnHeaderWithSearch extends StatelessWidget { onTap: enabled ? () => onSort(sortBy) : null, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Container( + child: SizedBox( width: double.infinity, child: Row( mainAxisSize: MainAxisSize.max, diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/example_usage.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/example_usage.dart index 2bfc4f9..b3f32be 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/example_usage.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/example_usage.dart @@ -21,7 +21,7 @@ class DataTableExampleUsage { DataTableAction( icon: Icons.edit, label: 'ویرایش', - onTap: (user) => print('Edit user: ${user.id}'), + onTap: (user) => debugPrint('Edit user: ${user.id}'), ), ]), ], @@ -51,7 +51,7 @@ class DataTableExampleUsage { DataTableAction( icon: Icons.visibility, label: 'مشاهده', - onTap: (order) => print('View order: ${order.id}'), + onTap: (order) => debugPrint('View order: ${order.id}'), ), ]), ], diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart index 32337ad..a706659 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Column settings for a specific table @@ -58,7 +59,7 @@ class ColumnSettingsService { final json = jsonDecode(jsonString) as Map; return ColumnSettings.fromJson(json); } catch (e) { - print('Error loading column settings: $e'); + debugPrint('Error loading column settings: $e'); return null; } } @@ -71,7 +72,7 @@ class ColumnSettingsService { final jsonString = jsonEncode(settings.toJson()); await prefs.setString(key, jsonString); } catch (e) { - print('Error saving column settings: $e'); + debugPrint('Error saving column settings: $e'); } } @@ -82,7 +83,7 @@ class ColumnSettingsService { final key = '$_keyPrefix$tableId'; await prefs.remove(key); } catch (e) { - print('Error clearing column settings: $e'); + debugPrint('Error clearing column settings: $e'); } } diff --git a/hesabixUI/hesabix_ui/lib/widgets/error_notice.dart b/hesabixUI/hesabix_ui/lib/widgets/error_notice.dart index 4b9115c..f31541d 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/error_notice.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/error_notice.dart @@ -19,7 +19,7 @@ class ErrorNotice extends StatelessWidget { decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(8), - border: Border.all(color: cs.error.withOpacity(0.4)), + border: Border.all(color: cs.error.withValues(alpha: 0.4)), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( diff --git a/hesabixUI/hesabix_ui/lib/widgets/progress_splash_screen.dart b/hesabixUI/hesabix_ui/lib/widgets/progress_splash_screen.dart new file mode 100644 index 0000000..89e2776 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/progress_splash_screen.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'dart:async'; + +class ProgressSplashScreen extends StatefulWidget { + final String? message; + final bool showLogo; + final Color? backgroundColor; + final Color? primaryColor; + final Duration minimumDisplayDuration; + final VoidCallback? onComplete; + + const ProgressSplashScreen({ + super.key, + this.message, + this.showLogo = true, + this.backgroundColor, + this.primaryColor, + this.minimumDisplayDuration = const Duration(seconds: 2), + this.onComplete, + }); + + @override + State createState() => _ProgressSplashScreenState(); +} + +class _ProgressSplashScreenState extends State + with TickerProviderStateMixin { + late AnimationController _fadeController; + late AnimationController _scaleController; + late AnimationController _progressController; + late Animation _fadeAnimation; + late Animation _scaleAnimation; + late Animation _progressAnimation; + + Timer? _countdownTimer; + int _remainingSeconds = 0; + + @override + void initState() { + super.initState(); + + _remainingSeconds = widget.minimumDisplayDuration.inSeconds; + + _fadeController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + + _scaleController = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + ); + + _progressController = AnimationController( + duration: widget.minimumDisplayDuration, + vsync: this, + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _fadeController, + curve: Curves.easeInOut, + )); + + _scaleAnimation = Tween( + begin: 0.8, + end: 1.0, + ).animate(CurvedAnimation( + parent: _scaleController, + curve: Curves.elasticOut, + )); + + _progressAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _progressController, + curve: Curves.easeInOut, + )); + + // Start animations + _fadeController.forward(); + _scaleController.forward(); + _progressController.forward(); + + // Start countdown timer + _startCountdown(); + } + + void _startCountdown() { + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) { + setState(() { + _remainingSeconds = widget.minimumDisplayDuration.inSeconds - timer.tick; + if (_remainingSeconds <= 0) { + timer.cancel(); + widget.onComplete?.call(); + } + }); + } + }); + } + + @override + void dispose() { + _fadeController.dispose(); + _scaleController.dispose(); + _progressController.dispose(); + _countdownTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + + final bgColor = widget.backgroundColor ?? colorScheme.surface; + final primary = widget.primaryColor ?? colorScheme.primary; + + return Scaffold( + backgroundColor: bgColor, + body: Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: isDark + ? [ + bgColor, + bgColor.withOpacity(0.95), + ] + : [ + bgColor, + bgColor.withOpacity(0.98), + ], + ), + ), + child: AnimatedBuilder( + animation: Listenable.merge([_fadeAnimation, _scaleAnimation, _progressAnimation]), + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Opacity( + opacity: _fadeAnimation.value, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo Section + if (widget.showLogo) ...[ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: primary.withOpacity(0.2), + blurRadius: 20, + spreadRadius: 2, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.asset( + isDark ? 'assets/images/logo-light.png' : 'assets/images/logo-blue.png', + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Container( + decoration: BoxDecoration( + color: primary, + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + Icons.account_balance, + size: 60, + color: colorScheme.onPrimary, + ), + ); + }, + ), + ), + ), + const SizedBox(height: 32), + ], + + // App Name + Text( + 'Hesabix', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 8), + + // Subtitle + Text( + AppLocalizations.of(context)?.businessManagementPlatform ?? 'Business Management Platform', + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 48), + + // Loading Indicator with Progress + Column( + children: [ + SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(primary), + ), + ), + const SizedBox(height: 24), + + // Progress Bar + Container( + width: 200, + height: 4, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: colorScheme.surfaceContainerHighest, + ), + child: AnimatedBuilder( + animation: _progressAnimation, + builder: (context, child) { + return FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: _progressAnimation.value, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + gradient: LinearGradient( + colors: [primary, primary.withOpacity(0.8)], + ), + ), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + + // Loading Message + Text( + widget.message ?? AppLocalizations.of(context)?.loading ?? 'Loading...', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + + // Countdown Timer + if (_remainingSeconds > 0) + Text( + '${_remainingSeconds}s', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant.withOpacity(0.7), + fontWeight: FontWeight.w400, + ), + ), + ], + ), + + const SizedBox(height: 80), + + // Version Info + Text( + 'Version 1.0.0', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant.withOpacity(0.6), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/simple_splash_screen.dart b/hesabixUI/hesabix_ui/lib/widgets/simple_splash_screen.dart new file mode 100644 index 0000000..8499ade --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/simple_splash_screen.dart @@ -0,0 +1,263 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; + +class SimpleSplashScreen extends StatefulWidget { + final String? message; + final bool showLogo; + final Color? backgroundColor; + final Color? primaryColor; + final Duration displayDuration; + final VoidCallback? onComplete; + final Locale? locale; + + const SimpleSplashScreen({ + super.key, + this.message, + this.showLogo = true, + this.backgroundColor, + this.primaryColor, + this.displayDuration = const Duration(seconds: 4), + this.onComplete, + this.locale, + }); + + @override + State createState() => _SimpleSplashScreenState(); +} + +class _SimpleSplashScreenState extends State + with TickerProviderStateMixin { + late AnimationController _fadeController; + late AnimationController _scaleController; + late Animation _fadeAnimation; + late Animation _scaleAnimation; + + Timer? _displayTimer; + + @override + void initState() { + super.initState(); + + _fadeController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + + _scaleController = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _fadeController, + curve: Curves.easeInOut, + )); + + _scaleAnimation = Tween( + begin: 0.8, + end: 1.0, + ).animate(CurvedAnimation( + parent: _scaleController, + curve: Curves.elasticOut, + )); + + // Start animations + _fadeController.forward(); + _scaleController.forward(); + + // Start display timer + _displayTimer = Timer(widget.displayDuration, () { + widget.onComplete?.call(); + }); + } + + @override + void dispose() { + _fadeController.dispose(); + _scaleController.dispose(); + _displayTimer?.cancel(); + super.dispose(); + } + + String _getAppName() { + if (widget.locale != null && widget.locale!.languageCode == 'fa') { + return 'حسابیکس'; + } + return 'Hesabix'; + } + + + String _getLoadingMessage() { + if (widget.locale != null && widget.locale!.languageCode == 'fa') { + return 'در حال بارگذاری...'; + } + return 'Loading...'; + } + + String _getVersionInfo() { + if (widget.locale != null && widget.locale!.languageCode == 'fa') { + return 'نسخه 1.0.0'; + } + return 'Version 1.0.0'; + } + + String _getMotto() { + if (widget.locale != null && widget.locale!.languageCode == 'fa') { + return 'جهان با تعاون زیبا می‌شود'; + } + return 'The world becomes beautiful through cooperation'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + + final bgColor = widget.backgroundColor ?? colorScheme.surface; + final primary = widget.primaryColor ?? colorScheme.primary; + + return Scaffold( + backgroundColor: bgColor, + body: Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: isDark + ? [ + bgColor, + bgColor.withValues(alpha: 0.95), + ] + : [ + bgColor, + bgColor.withValues(alpha: 0.98), + ], + ), + ), + child: AnimatedBuilder( + animation: Listenable.merge([_fadeAnimation, _scaleAnimation]), + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Opacity( + opacity: _fadeAnimation.value, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo Section - Simple and Clean + if (widget.showLogo) ...[ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: primary.withValues(alpha: 0.2), + blurRadius: 20, + spreadRadius: 2, + offset: const Offset(0, 5), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.asset( + isDark ? 'assets/images/logo-light.png' : 'assets/images/logo-blue.png', + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Container( + decoration: BoxDecoration( + color: primary, + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + Icons.account_balance, + size: 50, + color: colorScheme.onPrimary, + ), + ); + }, + ), + ), + ), + const SizedBox(height: 32), + ], + + // App Name - Simple and Clean + Text( + _getAppName(), + style: theme.textTheme.headlineLarge?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 24), + + // Motto/Slogan - Simple Design + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + _getMotto(), + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w400, + fontStyle: FontStyle.italic, + letterSpacing: 0.5, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 48), + + // Loading Section - Simple and Clean + Column( + children: [ + // Simple Loading Indicator + SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(primary), + ), + ), + const SizedBox(height: 24), + + // Simple Loading Message + Text( + widget.message ?? _getLoadingMessage(), + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + + const SizedBox(height: 60), + + // Simple Version Info + Text( + _getVersionInfo(), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/splash_screen.dart b/hesabixUI/hesabix_ui/lib/widgets/splash_screen.dart new file mode 100644 index 0000000..fb3d163 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/splash_screen.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; + +class SplashScreen extends StatelessWidget { + final String? message; + final bool showLogo; + final Color? backgroundColor; + final Color? primaryColor; + + const SplashScreen({ + super.key, + this.message, + this.showLogo = true, + this.backgroundColor, + this.primaryColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + + final bgColor = backgroundColor ?? colorScheme.surface; + final primary = primaryColor ?? colorScheme.primary; + + return Scaffold( + backgroundColor: bgColor, + body: Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: isDark + ? [ + bgColor, + bgColor.withOpacity(0.95), + ] + : [ + bgColor, + bgColor.withOpacity(0.98), + ], + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo Section + if (showLogo) ...[ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: primary.withOpacity(0.2), + blurRadius: 20, + spreadRadius: 2, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.asset( + isDark ? 'assets/images/logo-light.png' : 'assets/images/logo-blue.png', + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Container( + decoration: BoxDecoration( + color: primary, + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + Icons.account_balance, + size: 60, + color: colorScheme.onPrimary, + ), + ); + }, + ), + ), + ), + const SizedBox(height: 32), + ], + + // App Name + Text( + 'Hesabix', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 8), + + // Subtitle + Text( + AppLocalizations.of(context)?.businessManagementPlatform ?? 'Business Management Platform', + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 48), + + // Loading Indicator + Column( + children: [ + SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(primary), + ), + ), + const SizedBox(height: 24), + + // Loading Message + Text( + message ?? AppLocalizations.of(context)?.loading ?? 'Loading...', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + + const SizedBox(height: 80), + + // Version Info (Optional) + Text( + 'Version 1.0.0', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant.withOpacity(0.6), + ), + ), + ], + ), + ), + ); + } +} + +// Animated Splash Screen with fade effects +class AnimatedSplashScreen extends StatefulWidget { + final String? message; + final bool showLogo; + final Color? backgroundColor; + final Color? primaryColor; + final Duration animationDuration; + final Duration minimumDisplayDuration; + + const AnimatedSplashScreen({ + super.key, + this.message, + this.showLogo = true, + this.backgroundColor, + this.primaryColor, + this.animationDuration = const Duration(milliseconds: 1500), + this.minimumDisplayDuration = const Duration(seconds: 2), + }); + + @override + State createState() => _AnimatedSplashScreenState(); +} + +class _AnimatedSplashScreenState extends State + with TickerProviderStateMixin { + late AnimationController _fadeController; + late AnimationController _scaleController; + late Animation _fadeAnimation; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + + _fadeController = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + + _scaleController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _fadeController, + curve: Curves.easeInOut, + )); + + _scaleAnimation = Tween( + begin: 0.8, + end: 1.0, + ).animate(CurvedAnimation( + parent: _scaleController, + curve: Curves.elasticOut, + )); + + // Start animations + _fadeController.forward(); + _scaleController.forward(); + } + + @override + void dispose() { + _fadeController.dispose(); + _scaleController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: Listenable.merge([_fadeAnimation, _scaleAnimation]), + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Opacity( + opacity: _fadeAnimation.value, + child: SplashScreen( + message: widget.message, + showLogo: widget.showLogo, + backgroundColor: widget.backgroundColor, + primaryColor: widget.primaryColor, + ), + ), + ); + }, + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart b/hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart index 9502603..16e043d 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart @@ -141,9 +141,9 @@ class MessageBubble extends StatelessWidget { Color _getBorderColor(ThemeData theme, bool isUser) { if (isUser) { - return theme.colorScheme.primary.withOpacity(0.3); + return theme.colorScheme.primary.withValues(alpha: 0.3); } else { - return theme.colorScheme.outline.withOpacity(0.3); + return theme.colorScheme.outline.withValues(alpha: 0.3); } } @@ -157,7 +157,7 @@ class MessageBubble extends StatelessWidget { Color _getSenderTextColor(ThemeData theme, bool isUser) { if (isUser) { - return Colors.white.withOpacity(0.8); + return Colors.white.withValues(alpha: 0.8); } else { return theme.colorScheme.primary; } @@ -165,9 +165,9 @@ class MessageBubble extends StatelessWidget { Color _getTimeColor(ThemeData theme, bool isUser) { if (isUser) { - return Colors.white.withOpacity(0.7); + return Colors.white.withValues(alpha: 0.7); } else { - return theme.colorScheme.onSurface.withOpacity(0.6); + return theme.colorScheme.onSurface.withValues(alpha: 0.6); } } diff --git a/hesabixUI/hesabix_ui/lib/widgets/support/priority_indicator.dart b/hesabixUI/hesabix_ui/lib/widgets/support/priority_indicator.dart index 5e4b633..1e2287f 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/support/priority_indicator.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/support/priority_indicator.dart @@ -35,10 +35,10 @@ class PriorityIndicator extends StatelessWidget { vertical: isSmall ? 4 : 6, ), decoration: BoxDecoration( - color: color.withOpacity(0.1), + color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(isSmall ? 12 : 16), border: Border.all( - color: color.withOpacity(0.3), + color: color.withValues(alpha: 0.3), width: 1, ), ), diff --git a/hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart index 8c3a3a2..bc434d0 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart @@ -29,7 +29,7 @@ class TicketCard extends StatelessWidget { borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: theme.colorScheme.shadow.withOpacity(0.08), + color: theme.colorScheme.shadow.withValues(alpha: 0.08), blurRadius: 8, offset: const Offset(0, 2), ), @@ -46,7 +46,7 @@ class TicketCard extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), border: Border.all( - color: theme.colorScheme.outline.withOpacity(0.1), + color: theme.colorScheme.outline.withValues(alpha: 0.1), width: 1, ), ), @@ -78,7 +78,7 @@ class TicketCard extends StatelessWidget { Text( ticket.description, style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.8), + color: theme.colorScheme.onSurface.withValues(alpha: 0.8), height: 1.4, ), maxLines: 3, @@ -129,7 +129,7 @@ class TicketCard extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.5), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -138,13 +138,13 @@ class TicketCard extends StatelessWidget { Icon( Icons.access_time, size: 12, - color: theme.colorScheme.onSurfaceVariant.withOpacity(0.7), + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), ), const SizedBox(width: 4), Text( _formatDate(dateTime, l10n), style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant.withOpacity(0.8), + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.8), fontWeight: FontWeight.w500, ), ), @@ -160,14 +160,14 @@ class TicketCard extends StatelessWidget { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.3), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ CircleAvatar( radius: 16, - backgroundColor: theme.colorScheme.primary.withOpacity(0.1), + backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1), child: Icon( Icons.person, size: 16, @@ -182,7 +182,7 @@ class TicketCard extends StatelessWidget { Text( l10n.createdBy, style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant.withOpacity(0.7), + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), fontWeight: FontWeight.w500, ), ), @@ -202,7 +202,7 @@ class TicketCard extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: theme.colorScheme.secondary.withOpacity(0.1), + color: theme.colorScheme.secondary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -239,10 +239,10 @@ class TicketCard extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( - color: color.withOpacity(0.1), + color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), border: Border.all( - color: color.withOpacity(0.2), + color: color.withValues(alpha: 0.2), width: 1, ), ), diff --git a/hesabixUI/hesabix_ui/lib/widgets/support/ticket_details_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_details_dialog.dart index 89200f0..48724d1 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/support/ticket_details_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_details_dialog.dart @@ -62,7 +62,7 @@ class _TicketDetailsDialogState extends State { borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.2), + color: Colors.black.withValues(alpha: 0.2), blurRadius: 8, offset: const Offset(0, 4), ), @@ -275,7 +275,7 @@ class _TicketDetailsDialogState extends State { Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( - color: theme.primaryColor.withOpacity(0.1), + color: theme.primaryColor.withValues(alpha: 0.1), borderRadius: const BorderRadius.only( topLeft: Radius.circular(20), topRight: Radius.circular(20), diff --git a/hesabixUI/hesabix_ui/lib/widgets/support/ticket_status_chip.dart b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_status_chip.dart index f930fcf..7c94771 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/support/ticket_status_chip.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_status_chip.dart @@ -22,10 +22,10 @@ class TicketStatusChip extends StatelessWidget { vertical: isSmall ? 4 : 6, ), decoration: BoxDecoration( - color: color.withOpacity(0.1), + color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(isSmall ? 12 : 16), border: Border.all( - color: color.withOpacity(0.3), + color: color.withValues(alpha: 0.3), width: 1, ), ), diff --git a/hesabixUI/hesabix_ui/pubspec.lock b/hesabixUI/hesabix_ui/pubspec.lock index 6dbca59..9798cef 100644 --- a/hesabixUI/hesabix_ui/pubspec.lock +++ b/hesabixUI/hesabix_ui/pubspec.lock @@ -185,7 +185,7 @@ packages: source: sdk version: "0.0.0" flutter_web_plugins: - dependency: transitive + dependency: "direct main" description: flutter source: sdk version: "0.0.0" @@ -193,10 +193,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: eb059dfe59f08546e9787f895bd01652076f996bcbf485a8609ef990419ad227 + sha256: b1488741c9ce37b72e026377c69a59c47378493156fc38efb5a54f6def3f92a3 url: "https://pub.flutter-io.cn" source: hosted - version: "16.2.1" + version: "16.2.2" http_parser: dependency: transitive description: diff --git a/hesabixUI/hesabix_ui/pubspec.yaml b/hesabixUI/hesabix_ui/pubspec.yaml index 0d73a67..fad5cfb 100644 --- a/hesabixUI/hesabix_ui/pubspec.yaml +++ b/hesabixUI/hesabix_ui/pubspec.yaml @@ -32,6 +32,8 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + flutter_web_plugins: + sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.